mirror of
https://github.com/bitwarden/server.git
synced 2025-06-30 15:42:48 -05:00
[AC-292] Public Api - allow configuration of custom permissions (#4022)
* Also refactor OrganizationService user invite methods
This commit is contained in:
@ -225,7 +225,7 @@ public class OrganizationUsersController : Controller
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
await _organizationService.InviteUsersAsync(orgId, userId.Value,
|
||||
await _organizationService.InviteUsersAsync(orgId, userId.Value, systemUser: null,
|
||||
new (OrganizationUserInvite, string)[] { (new OrganizationUserInvite(model.ToData()), null) });
|
||||
}
|
||||
|
||||
|
@ -127,10 +127,11 @@ public class MembersController : Controller
|
||||
public async Task<IActionResult> Post([FromBody] MemberCreateRequestModel model)
|
||||
{
|
||||
var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(_currentContext.OrganizationId.Value);
|
||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(flexibleCollectionsIsEnabled)).ToList();
|
||||
var invite = model.ToOrganizationUserInvite(flexibleCollectionsIsEnabled);
|
||||
|
||||
var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null,
|
||||
model.Email, model.Type.Value, model.AccessAll.Value, model.ExternalId, associations, model.Groups);
|
||||
var response = new MemberResponseModel(user, associations, flexibleCollectionsIsEnabled);
|
||||
systemUser: null, invite, model.ExternalId);
|
||||
var response = new MemberResponseModel(user, invite.Collections, flexibleCollectionsIsEnabled);
|
||||
return new JsonResult(response);
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
#nullable enable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
@ -21,6 +23,11 @@ public abstract class MemberBaseModel
|
||||
AccessAll = user.AccessAll;
|
||||
ExternalId = user.ExternalId;
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
|
||||
if (Type == OrganizationUserType.Custom)
|
||||
{
|
||||
Permissions = new PermissionsModel(user.GetPermissions());
|
||||
}
|
||||
}
|
||||
|
||||
public MemberBaseModel(OrganizationUserUserDetails user, bool flexibleCollectionsEnabled)
|
||||
@ -34,6 +41,11 @@ public abstract class MemberBaseModel
|
||||
AccessAll = user.AccessAll;
|
||||
ExternalId = user.ExternalId;
|
||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||
|
||||
if (Type == OrganizationUserType.Custom)
|
||||
{
|
||||
Permissions = new PermissionsModel(user.GetPermissions());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -59,6 +71,11 @@ public abstract class MemberBaseModel
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool ResetPasswordEnrolled { get; set; }
|
||||
/// <summary>
|
||||
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
||||
/// default to false.
|
||||
/// </summary>
|
||||
public PermissionsModel? Permissions { get; set; }
|
||||
|
||||
// TODO: AC-2188 - Remove this method when the custom users with no other permissions than 'Edit/Delete Assigned Collections' are migrated
|
||||
private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions)
|
||||
|
67
src/Api/AdminConsole/Public/Models/PermissionsModel.cs
Normal file
67
src/Api/AdminConsole/Public/Models/PermissionsModel.cs
Normal file
@ -0,0 +1,67 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Public.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a member's custom permissions if the member has a Custom role.
|
||||
/// </summary>
|
||||
public class PermissionsModel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public PermissionsModel() { }
|
||||
public PermissionsModel(Permissions? data)
|
||||
{
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AccessEventLogs = data.AccessEventLogs;
|
||||
AccessImportExport = data.AccessImportExport;
|
||||
AccessReports = data.AccessReports;
|
||||
CreateNewCollections = data.CreateNewCollections;
|
||||
EditAnyCollection = data.EditAnyCollection;
|
||||
DeleteAnyCollection = data.DeleteAnyCollection;
|
||||
ManageGroups = data.ManageGroups;
|
||||
ManagePolicies = data.ManagePolicies;
|
||||
ManageSso = data.ManageSso;
|
||||
ManageUsers = data.ManageUsers;
|
||||
ManageResetPassword = data.ManageResetPassword;
|
||||
ManageScim = data.ManageScim;
|
||||
}
|
||||
|
||||
public bool AccessEventLogs { get; set; }
|
||||
public bool AccessImportExport { get; set; }
|
||||
public bool AccessReports { get; set; }
|
||||
public bool CreateNewCollections { get; set; }
|
||||
public bool EditAnyCollection { get; set; }
|
||||
public bool DeleteAnyCollection { get; set; }
|
||||
public bool ManageGroups { get; set; }
|
||||
public bool ManagePolicies { get; set; }
|
||||
public bool ManageSso { get; set; }
|
||||
public bool ManageUsers { get; set; }
|
||||
public bool ManageResetPassword { get; set; }
|
||||
public bool ManageScim { get; set; }
|
||||
|
||||
public Permissions ToData()
|
||||
{
|
||||
return new Permissions
|
||||
{
|
||||
AccessEventLogs = AccessEventLogs,
|
||||
AccessImportExport = AccessImportExport,
|
||||
AccessReports = AccessReports,
|
||||
CreateNewCollections = CreateNewCollections,
|
||||
EditAnyCollection = EditAnyCollection,
|
||||
DeleteAnyCollection = DeleteAnyCollection,
|
||||
ManageGroups = ManageGroups,
|
||||
ManagePolicies = ManagePolicies,
|
||||
ManageSso = ManageSso,
|
||||
ManageUsers = ManageUsers,
|
||||
ManageResetPassword = ManageResetPassword,
|
||||
ManageScim = ManageScim
|
||||
};
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Public.Models.Request;
|
||||
@ -19,4 +21,24 @@ public class MemberCreateRequestModel : MemberUpdateRequestModel
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public OrganizationUserInvite ToOrganizationUserInvite(bool flexibleCollectionsIsEnabled)
|
||||
{
|
||||
var invite = new OrganizationUserInvite
|
||||
{
|
||||
Emails = new[] { Email },
|
||||
Type = Type.Value,
|
||||
AccessAll = AccessAll.Value,
|
||||
Collections = Collections?.Select(c => c.ToCollectionAccessSelection(flexibleCollectionsIsEnabled)).ToList(),
|
||||
Groups = Groups
|
||||
};
|
||||
|
||||
// Permissions property is optional for backwards compatibility with existing usage
|
||||
if (Type is OrganizationUserType.Custom && Permissions is not null)
|
||||
{
|
||||
invite.Permissions = Permissions.ToData();
|
||||
}
|
||||
|
||||
return invite;
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
using Bit.Core.Entities;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Public.Models.Request;
|
||||
|
||||
public class MemberUpdateRequestModel : MemberBaseModel
|
||||
public class MemberUpdateRequestModel : MemberBaseModel, IValidatableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The associated collections that this member can access.
|
||||
@ -19,6 +21,21 @@ public class MemberUpdateRequestModel : MemberBaseModel
|
||||
existingUser.Type = Type.Value;
|
||||
existingUser.AccessAll = AccessAll.Value;
|
||||
existingUser.ExternalId = ExternalId;
|
||||
|
||||
// Permissions property is optional for backwards compatibility with existing usage
|
||||
if (existingUser.Type is OrganizationUserType.Custom && Permissions is not null)
|
||||
{
|
||||
existingUser.SetPermissions(Permissions.ToData());
|
||||
}
|
||||
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (Type is not OrganizationUserType.Custom && Permissions is not null)
|
||||
{
|
||||
yield return new ValidationResult("Only users with the Custom role may use custom permissions.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -12,6 +13,9 @@ namespace Bit.Api.AdminConsole.Public.Models.Response;
|
||||
/// </summary>
|
||||
public class MemberResponseModel : MemberBaseModel, IResponseModel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public MemberResponseModel() { }
|
||||
|
||||
public MemberResponseModel(OrganizationUser user, IEnumerable<CollectionAccessSelection> collections,
|
||||
bool flexibleCollectionsEnabled)
|
||||
: base(user, flexibleCollectionsEnabled)
|
||||
|
@ -35,4 +35,9 @@ public class OrganizationUser : ITableObject<Guid>, IExternal
|
||||
return string.IsNullOrWhiteSpace(Permissions) ? null
|
||||
: CoreHelpers.LoadClassFromJsonData<Permissions>(Permissions);
|
||||
}
|
||||
|
||||
public void SetPermissions(Permissions permissions)
|
||||
{
|
||||
Permissions = CoreHelpers.ClassToJsonData(permissions);
|
||||
}
|
||||
}
|
||||
|
@ -43,14 +43,10 @@ public interface IOrganizationService
|
||||
Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated);
|
||||
Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type);
|
||||
Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type);
|
||||
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId,
|
||||
Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
|
||||
OrganizationUserInvite invite, string externalId);
|
||||
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
|
||||
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, EventSystemUser systemUser,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
|
||||
Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, ICollection<CollectionAccessSelection> collections, IEnumerable<Guid> groups);
|
||||
Task<OrganizationUser> InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups, bool accessSecretsManager);
|
||||
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
|
||||
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false);
|
||||
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
|
@ -957,12 +957,53 @@ public class OrganizationService : IOrganizationService
|
||||
await UpdateAsync(organization);
|
||||
}
|
||||
|
||||
public async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId,
|
||||
public async Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
|
||||
OrganizationUserInvite invite, string externalId)
|
||||
{
|
||||
// Ideally OrganizationUserInvite should represent a single user so that this doesn't have to be a runtime check
|
||||
if (invite.Emails.Count() > 1)
|
||||
{
|
||||
throw new BadRequestException("This method can only be used to invite a single user.");
|
||||
}
|
||||
|
||||
// Validate Collection associations if org is using latest collection enhancements
|
||||
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
|
||||
if (organizationAbility?.FlexibleCollections ?? false)
|
||||
{
|
||||
var invalidAssociations = invite.Collections?.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords));
|
||||
if (invalidAssociations?.Any() ?? false)
|
||||
{
|
||||
throw new BadRequestException("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.");
|
||||
}
|
||||
}
|
||||
|
||||
var results = await InviteUsersAsync(organizationId, invitingUserId, systemUser,
|
||||
new (OrganizationUserInvite, string)[] { (invite, externalId) });
|
||||
|
||||
var result = results.FirstOrDefault();
|
||||
if (result == null)
|
||||
{
|
||||
throw new BadRequestException("This user has already been invited.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invite users to an organization.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The organization Id</param>
|
||||
/// <param name="invitingUserId">The current authenticated user who is sending the invite. Only used when inviting via a client app; null if using SCIM or Public API.</param>
|
||||
/// <param name="systemUser">The system user which is sending the invite. Only used when inviting via SCIM; null if using a client app or Public API</param>
|
||||
/// <param name="invites">Details about the users being invited</param>
|
||||
/// <returns></returns>
|
||||
public async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites)
|
||||
{
|
||||
var inviteTypes = new HashSet<OrganizationUserType>(invites.Where(i => i.invite.Type.HasValue)
|
||||
.Select(i => i.invite.Type.Value));
|
||||
|
||||
// If authenticating via a client app, verify the inviting user has permissions
|
||||
// cf. SCIM and Public API have superuser permissions here
|
||||
if (invitingUserId.HasValue && inviteTypes.Count > 0)
|
||||
{
|
||||
foreach (var (invite, _) in invites)
|
||||
@ -972,25 +1013,24 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
}
|
||||
|
||||
var (organizationUsers, events) = await SaveUsersSendInvitesAsync(organizationId, invites, systemUser: null);
|
||||
var (organizationUsers, events) = await SaveUsersSendInvitesAsync(organizationId, invites);
|
||||
|
||||
await _eventService.LogOrganizationUserEventsAsync(events);
|
||||
|
||||
return organizationUsers;
|
||||
}
|
||||
|
||||
public async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, EventSystemUser systemUser,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites)
|
||||
{
|
||||
var (organizationUsers, events) = await SaveUsersSendInvitesAsync(organizationId, invites, systemUser);
|
||||
|
||||
await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.Item1, e.Item2, systemUser, e.Item3)));
|
||||
if (systemUser.HasValue)
|
||||
{
|
||||
// Log SCIM event
|
||||
await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.Item1, e.Item2, systemUser.Value, e.Item3)));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Log client app or Public Api event
|
||||
await _eventService.LogOrganizationUserEventsAsync(events);
|
||||
}
|
||||
|
||||
return organizationUsers;
|
||||
}
|
||||
|
||||
private async Task<(List<OrganizationUser> organizationUsers, List<(OrganizationUser, EventType, DateTime?)> events)> SaveUsersSendInvitesAsync(Guid organizationId,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, EventSystemUser? systemUser)
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites)
|
||||
{
|
||||
var organization = await GetOrgById(organizationId);
|
||||
var initialSeatCount = organization.Seats;
|
||||
@ -1087,9 +1127,9 @@ public class OrganizationService : IOrganizationService
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
if (invite.Permissions != null)
|
||||
if (invite.Type == OrganizationUserType.Custom)
|
||||
{
|
||||
orgUser.Permissions = JsonSerializer.Serialize(invite.Permissions, JsonHelpers.CamelCase);
|
||||
orgUser.SetPermissions(invite.Permissions ?? new Permissions());
|
||||
}
|
||||
|
||||
if (!orgUser.AccessAll && invite.Collections.Any())
|
||||
@ -1667,55 +1707,6 @@ public class OrganizationService : IOrganizationService
|
||||
EventType.OrganizationUser_ResetPassword_Enroll : EventType.OrganizationUser_ResetPassword_Withdraw);
|
||||
}
|
||||
|
||||
public async Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, ICollection<CollectionAccessSelection> collections,
|
||||
IEnumerable<Guid> groups)
|
||||
{
|
||||
// Validate Collection associations if org is using latest collection enhancements
|
||||
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
|
||||
if (organizationAbility?.FlexibleCollections ?? false)
|
||||
{
|
||||
var invalidAssociations = collections?.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords));
|
||||
if (invalidAssociations?.Any() ?? false)
|
||||
{
|
||||
throw new BadRequestException("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.");
|
||||
}
|
||||
}
|
||||
|
||||
return await SaveUserSendInviteAsync(organizationId, invitingUserId, systemUser: null, email, type, accessAll, externalId, collections, groups);
|
||||
}
|
||||
|
||||
public async Task<OrganizationUser> InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections,
|
||||
IEnumerable<Guid> groups, bool accessSecretsManager)
|
||||
{
|
||||
// Collection associations validation not required as they are always an empty list - created via system user (scim)
|
||||
return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups, accessSecretsManager);
|
||||
}
|
||||
|
||||
private async Task<OrganizationUser> SaveUserSendInviteAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, string email,
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups, bool accessSecretsManager = false)
|
||||
{
|
||||
var invite = new OrganizationUserInvite()
|
||||
{
|
||||
Emails = new List<string> { email },
|
||||
Type = type,
|
||||
AccessAll = accessAll,
|
||||
Collections = collections,
|
||||
Groups = groups,
|
||||
AccessSecretsManager = accessSecretsManager
|
||||
};
|
||||
var results = systemUser.HasValue ? await InviteUsersAsync(organizationId, systemUser.Value,
|
||||
new (OrganizationUserInvite, string)[] { (invite, externalId) }) : await InviteUsersAsync(organizationId, invitingUserId,
|
||||
new (OrganizationUserInvite, string)[] { (invite, externalId) });
|
||||
var result = results.FirstOrDefault();
|
||||
if (result == null)
|
||||
{
|
||||
throw new BadRequestException("This user has already been invited.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task ImportAsync(Guid organizationId,
|
||||
Guid? importingUserId,
|
||||
IEnumerable<ImportedGroup> groups,
|
||||
@ -1831,7 +1822,7 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
}
|
||||
|
||||
var invitedUsers = await InviteUsersAsync(organizationId, importingUserId, userInvites);
|
||||
var invitedUsers = await InviteUsersAsync(organizationId, importingUserId, systemUser: null, userInvites);
|
||||
foreach (var invitedUser in invitedUsers)
|
||||
{
|
||||
existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id);
|
||||
|
Reference in New Issue
Block a user