Skip to content

Commit

Permalink
Implement new roles permissions in user invite logic
Browse files Browse the repository at this point in the history
  • Loading branch information
pmachapman committed Nov 26, 2024
1 parent f2d21b7 commit da5c791
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 97 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,10 @@ await projectService.GetLinkSharingKeyAsync(
)
);
}
catch (ForbiddenException)
{
return ForbiddenError();
}
catch (DataNotFoundException dnfe)
{
return NotFoundError(dnfe.Message);
Expand All @@ -440,7 +444,7 @@ await projectService.GetLinkSharingKeyAsync(
{ "projectId", projectId },
{ "role", role },
{ "shareLinkType", shareLinkType },
{ "daysBeforeExpiration", daysBeforeExpiration.ToString() }
{ "daysBeforeExpiration", daysBeforeExpiration.ToString() },
}
);
throw;
Expand Down
151 changes: 85 additions & 66 deletions src/SIL.XForge.Scripture/Services/SFProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ Uri websiteUrl
)
{
SFProject project = await GetProjectAsync(projectId);
if (!CanUserShareRole(curUserId, project, role))
if (!CanUserShareRole(curUserId, project, role, out string userRole))
throw new ForbiddenException();

if (
Expand All @@ -642,9 +642,7 @@ await RealtimeService
}
SiteOptions siteOptions = SiteOptions.Value;

bool isProjectAdmin = IsProjectAdmin(project, curUserId);
string[] availableRoles = GetAvailableRoles(project, isProjectAdmin);

string[] availableRoles = GetAvailableRoles(project, userRole);
if (!availableRoles.Contains(role))
throw new ForbiddenException();

Expand All @@ -669,7 +667,7 @@ await ProjectSecrets.UpdateAsync(
ExpirationTime = expTime,
ProjectRole = role,
ShareLinkType = ShareLinkType.Recipient,
CreatedByAdmin = isProjectAdmin
CreatedByRole = userRole,
}
)
);
Expand Down Expand Up @@ -718,11 +716,10 @@ int daysBeforeExpiration
)
{
SFProject project = await GetProjectAsync(projectId);
if (!CanUserShareRole(curUserId, project, role))
if (!CanUserShareRole(curUserId, project, role, out string userRole))
throw new ForbiddenException();

bool isProjectAdmin = IsProjectAdmin(project, curUserId);
string[] availableRoles = GetAvailableRoles(project, isProjectAdmin);
string[] availableRoles = GetAvailableRoles(project, userRole);
if (!availableRoles.Contains(role))
throw new ForbiddenException();

Expand All @@ -740,7 +737,7 @@ await ProjectSecrets.UpdateAsync(
ProjectRole = role,
ShareLinkType = shareLinkType,
ExpirationTime = DateTime.UtcNow.AddDays(daysBeforeExpiration),
CreatedByAdmin = isProjectAdmin,
CreatedByRole = userRole,
}
)
);
Expand Down Expand Up @@ -805,9 +802,16 @@ await ProjectSecrets.UpdateAsync(
public async Task<bool> IsAlreadyInvitedAsync(string curUserId, string projectId, string? email)
{
SFProject project = await GetProjectAsync(projectId);
string[] availableRoles = GetAvailableRoles(project, isProjectAdmin: false);
if (!project.UserRoles.TryGetValue(curUserId, out string userRole))
{
throw new ForbiddenException();
}

string[] availableRoles = GetAvailableRoles(project, userRole);
bool sharingEnabled =
availableRoles.Contains(SFProjectRole.Viewer) || availableRoles.Contains(SFProjectRole.CommunityChecker);
availableRoles.Contains(SFProjectRole.CommunityChecker)
|| availableRoles.Contains(SFProjectRole.Commenter)
|| availableRoles.Contains(SFProjectRole.Viewer);
if (!IsProjectAdmin(project, curUserId) && !(IsOnProject(project, curUserId) && sharingEnabled))
throw new ForbiddenException();

Expand Down Expand Up @@ -915,11 +919,13 @@ public async Task<ValidShareKey> CheckShareKeyValidity(string shareKey)
{
throw new DataNotFoundException("role_not_found");
}

// If the key is expired
if (projectSecretShareKey.ExpirationTime < DateTime.UtcNow)
{
throw new DataNotFoundException("key_expired");
}

// If the desired role is community checker but community checking is disabled
if (
projectSecretShareKey.ProjectRole == SFProjectRole.CommunityChecker
Expand All @@ -928,10 +934,11 @@ public async Task<ValidShareKey> CheckShareKeyValidity(string shareKey)
{
throw new DataNotFoundException("role_not_found");
}

// If the link was sent by a non-admin and an admin has since disabled non-admin sharing
if (!projectSecretShareKey.CreatedByAdmin)
if (projectSecretShareKey.CreatedByRole != SFProjectRole.Administrator)
{
string[] availableRoles = GetAvailableRoles(project, isProjectAdmin: false);
string[] availableRoles = GetAvailableRoles(project, projectSecretShareKey.CreatedByRole);
if (!availableRoles.Contains(projectSecretShareKey.ProjectRole))
{
throw new DataNotFoundException("role_not_found");
Expand Down Expand Up @@ -1789,31 +1796,79 @@ private string[] GetProjectWithReferenceToSource(string projectId)
.ToArray();
}

/// <summary> Determines if a user on a project has the right to share a specific role. </summary>
private bool CanUserShareRole(string userId, SFProject project, string role)
/// <summary>
/// Determines if a user on a project has the right to share a specific role.
/// </summary>
/// <param name="userId">The user identifier.</param>
/// <param name="project">The project.</param>
/// <param name="role">The role.</param>
/// <param name="userRole">The role of the user identifier by <paramref name="userId"/>.</param>
/// <returns><c>true</c> if the user can share the specified role; otherwise, <c>false</c>.</returns>
private bool CanUserShareRole(string userId, SFProject project, string role, out string userRole)
{
if (!IsOnProject(project, userId))
// If the user is not on the project, they cannot share any roles
if (!project.UserRoles.TryGetValue(userId, out userRole))
{
userRole = string.Empty;
return false;
if (role == SFProjectRole.CommunityChecker)
return true;
}

if (role == SFProjectRole.Commenter)
// The user must have the right to invite users
if (!_projectRights.HasRight(project, userId, SFProjectDomain.UserInvites, Operation.Create))
{
// This may change if we decide that other users should be able to invite reviewers
if (IsProjectAdmin(project, userId))
return true;
return false;
}
else if (role == SFProjectRole.Viewer)

// Determine if the user's role can invite the specified role
return GetAvailableRoles(project, userRole).Contains(role);
}

/// <summary>
/// Gets the roles that are available for sharing.
/// </summary>
/// <param name="project"></param>
/// <param name="userRole">The role of the user that will be creating or did create the share link.</param>
/// <returns>An array of the available roles the user can create share invitations for.</returns>
/// <remarks>
/// If you update this function, you will need to update ShareBaseComponent.userShareableRoles in TypeScript.
/// </remarks>
private string[] GetAvailableRoles(SFProject project, string userRole)
{
bool checkUserRole = userRole is SFProjectRole.Administrator or SFProjectRole.Translator;
return new Dictionary<string, bool>
{
if (HasParatextRole(project, userId))
return true;
if (project.UserRoles.TryGetValue(userId, out string projectRole))
{
if (projectRole == SFProjectRole.Commenter || projectRole == SFProjectRole.Viewer)
return true;
}
SFProjectRole.CommunityChecker,
project.CheckingConfig.CheckingEnabled
&& _projectRights.RoleHasRight(
project,
role: checkUserRole ? userRole : SFProjectRole.CommunityChecker,
SFProjectDomain.UserInvites,
Operation.Create
)
},
{
SFProjectRole.Viewer,
_projectRights.RoleHasRight(
project,
role: checkUserRole ? userRole : SFProjectRole.Viewer,
SFProjectDomain.UserInvites,
Operation.Create
)
},
{
SFProjectRole.Commenter,
_projectRights.RoleHasRight(
project,
role: checkUserRole ? userRole : SFProjectRole.Commenter,
SFProjectDomain.UserInvites,
Operation.Create
)
},
}
return false;
.Where(entry => entry.Value)
.Select(entry => entry.Key)
.ToArray();
}

/// <summary>
Expand Down Expand Up @@ -1886,40 +1941,4 @@ await projectDoc.SubmitJson0OpAsync(op =>
}
}
}

private string[] GetAvailableRoles(SFProject project, bool isProjectAdmin) =>
new Dictionary<string, bool>
{
{
SFProjectRole.CommunityChecker,
project.CheckingConfig.CheckingEnabled
&& _projectRights.RoleHasRight(
project,
role: isProjectAdmin ? SFProjectRole.Administrator : SFProjectRole.CommunityChecker,
SFProjectDomain.UserInvites,
Operation.Create
)
},
{
SFProjectRole.Viewer,
_projectRights.RoleHasRight(
project,
role: isProjectAdmin ? SFProjectRole.Administrator : SFProjectRole.Viewer,
SFProjectDomain.UserInvites,
Operation.Create
)
},
{
SFProjectRole.Commenter,
_projectRights.RoleHasRight(
project,
role: isProjectAdmin ? SFProjectRole.Administrator : SFProjectRole.Commenter,
SFProjectDomain.UserInvites,
Operation.Create
)
},
}
.Where(entry => entry.Value)
.Select(entry => entry.Key)
.ToArray();
}
26 changes: 13 additions & 13 deletions src/SIL.XForge/Models/ShareKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ namespace SIL.XForge.Models;
/// The user can then use the share key to join the project.
/// - When a user is invited via email a share key is created and a link is sent to the user.
/// - If an invitation is sent again to the same email with a different role, the share key is updated with the new role
/// and the link is resent. Both the original and new email invitations will both work, since there is only one share
/// key.
/// and the link is resent. Both the original and new email invitations will both work, since there is only one share
/// key.
/// - When a recipient-only invitation is shared by other means (e.g. copying the link), a new share key is created as
/// soon as the user opens the dialog (i.e. *before* the user has actually shared the link) so that when the share
/// button is clicked the link can be instantly copied. What this means is that at any given time, there may be multiple
/// single-use share keys for a given project that have not yet been used (potentially as many as the number of roles
/// that are available to share). When a link is copied the expiration time is set, and the share key is marked as
/// "reserved".
//// See documentation for individual properties for more details.
/// soon as the user opens the dialog (i.e. *before* the user has actually shared the link) so that when the share
/// button is clicked the link can be instantly copied. What this means is that at any given time, there may be multiple
/// single-use share keys for a given project that have not yet been used (potentially as many as the number of roles
/// that are available to share). When a link is copied the expiration time is set, and the share key is marked as
/// "reserved".
/// See documentation for individual properties for more details.
/// </summary>
public class ShareKey
{
/// <summary>
/// Gets or set the role of the user that created this share key.
/// </summary>
public string CreatedByRole { get; set; } = string.Empty;

/// <summary>
/// Optional.
/// Specifies the email address the invitation link was sent to. This is only used if the invitation is sent via
Expand Down Expand Up @@ -68,11 +73,6 @@ public class ShareKey
/// </summary>
public bool? Reserved { get; set; }

/// <summary>
/// Flag that indicates whether the key was generated by an admin or non-admin user.
/// </summary>
public bool CreatedByAdmin { get; set; } = false;

/// <summary>
/// Used to keep track of how many Auth0 user accounts have been generated using this share key. This allows for a
/// basic rate limiting check to ensure any abuse is restricted. An optional setting on the project is available to
Expand Down
Loading

0 comments on commit da5c791

Please sign in to comment.