diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 22bac9f62014..d1550bf3e091 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -444,6 +444,7 @@ public async Task Put([BindOrganization] Organization organization, Gui model.Type.Value, model.Permissions, model.AccessSecretsManager, + model.AccessPam, collectionAccessToSave, groupsToSave, model.Email, @@ -799,6 +800,38 @@ public async Task BulkEnableSecretsManagerAsync(Guid orgId, await _organizationUserRepository.ReplaceManyAsync(orgUsers); } + /// + /// Grants PAM access to the specified members. A plain field write: PAM has no seats, so there is no + /// autoscale or billing step. Members who already have access are skipped. + /// + [HttpPut("enable-pam")] + [Authorize] + public async Task BulkEnablePamAsync(Guid orgId, + [FromBody] OrganizationUserBulkRequestModel model) + { + var orgUsers = (await _organizationUserRepository.GetManyAsync(model.Ids)) + .Where(ou => ou.OrganizationId == orgId && !ou.AccessPam).ToList(); + if (orgUsers.Count == 0) + { + throw new BadRequestException("Users invalid."); + } + + // Granting access on an organization without PAM would be inert: claim emission ANDs AccessPam with the + // organization's UsePam. + var organization = await _organizationRepository.GetByIdAsync(orgId); + if (organization is not { UsePam: true }) + { + throw new BadRequestException("To grant PAM access the organization must have PAM enabled."); + } + + foreach (var orgUser in orgUsers) + { + orgUser.AccessPam = true; + } + + await _organizationUserRepository.ReplaceManyAsync(orgUsers); + } + [HttpPost("{id}/auto-confirm")] [Authorize] public async Task AutomaticallyConfirmOrganizationUserAsync([FromRoute] Guid orgId, diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index 357647dfeb74..cfa2c0b109c5 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -98,6 +98,7 @@ public class OrganizationUserUpdateRequestModel [EnumDataType(typeof(OrganizationUserType))] public OrganizationUserType? Type { get; set; } public bool AccessSecretsManager { get; set; } + public bool AccessPam { get; set; } public Permissions Permissions { get; set; } public IEnumerable Collections { get; set; } public IEnumerable Groups { get; set; } @@ -118,6 +119,7 @@ public OrganizationUser ToOrganizationUser(OrganizationUser existingUser) existingUser.Type = Type.Value; existingUser.Permissions = CoreHelpers.ClassToJsonData(Permissions); existingUser.AccessSecretsManager = AccessSecretsManager; + existingUser.AccessPam = AccessPam; return existingUser; } } diff --git a/src/Core/AdminConsole/Entities/OrganizationUser.cs b/src/Core/AdminConsole/Entities/OrganizationUser.cs index b490d5eebfeb..963783202cd6 100644 --- a/src/Core/AdminConsole/Entities/OrganizationUser.cs +++ b/src/Core/AdminConsole/Entities/OrganizationUser.cs @@ -160,6 +160,7 @@ public void SetPermissions(Permissions permissions) public OrganizationUser UpdateOrganizationUser(OrganizationUserType organizationUserType, Permissions? permissions, bool accessSecretsManager, + bool accessPam, TimeProvider timeProvider) { if (permissions is not null) @@ -168,6 +169,7 @@ public OrganizationUser UpdateOrganizationUser(OrganizationUserType organization } Type = organizationUserType; AccessSecretsManager = accessSecretsManager; + AccessPam = accessPam; RevisionDate = timeProvider.GetUtcNow().UtcDateTime; return this; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs index e31bf890224f..8b65720770d4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs @@ -133,6 +133,14 @@ public async Task UpdateUserAsync(OrganizationUser organizationUser, Organizatio } } + // Granting PAM access to a member of an organization without PAM would be inert: claim emission ANDs + // AccessPam with the organization's UsePam. Reject so the admin gets an actionable error instead. + // Only the grant is gated — revoking access stays possible on an organization whose entitlement has lapsed. + if (!originalOrganizationUser.AccessPam && organizationUser.AccessPam && !organization.UsePam) + { + throw new BadRequestException("To grant PAM access the organization must have PAM enabled."); + } + // Only autoscale (if required) after all validation has passed so that we know it's a valid request before // updating Stripe if (!originalOrganizationUser.AccessSecretsManager && organizationUser.AccessSecretsManager) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs index 8eca1c7d672f..ead6d6486afd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs @@ -12,6 +12,7 @@ public record MustHaveConfirmedOwner() : BadRequestError("Organization must have public record CustomPermissionsNotEnabled() : BadRequestError("To enable custom permissions the organization must be on an Enterprise plan."); public record CannotAssignDefaultCollection() : BadRequestError("Default collections cannot be assigned to a member."); public record CannotAutoscaleSecretsManagerSeatsOnSelfHost() : BadRequestError("Cannot autoscale on a self-hosted instance."); +public record PamNotEnabled() : BadRequestError("To grant PAM access the organization must have PAM enabled."); public record CouldNotIncreaseSeatsOfSecretManager(string Message) : BadRequestError(Message); public abstract record EmailValidationError(string Message, string Type) : BadRequestError(Message), IValidationError diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs index 8d879f3aacfa..a7fb045d09c3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -54,6 +54,7 @@ public async Task UpdateUserAsync(UpdateOrganizationUserRequest r var organizationUser = request.OrganizationUserToUpdate.UpdateOrganizationUser(request.NewType, request.NewPermissions, request.NewAccessSecretsManager, + request.NewAccessPam, timeProvider); if (request.IsEnablingSecretsManager()) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserRequest.cs index 85e7e9e6cc20..4b3360b85a79 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserRequest.cs @@ -23,6 +23,7 @@ public record UpdateOrganizationUserRequest( OrganizationUserType NewType, Permissions? NewPermissions, bool NewAccessSecretsManager, + bool NewAccessPam, List? CollectionsToSave, IEnumerable? NewGroups, string? NewEmail, @@ -37,6 +38,12 @@ _existingOrganizationUserType is OrganizationUserType.Admin or OrganizationUserT public bool IsEnablingSecretsManager() => !_existingAccessSecretsManager && NewAccessSecretsManager; + /// + /// Only a false → true transition is a grant. Revoking access stays possible on an organization whose PAM + /// entitlement has lapsed, so is not checked when disabling. + /// + public bool IsEnablingPam() => !_existingAccessPam && NewAccessPam; + public bool IsEmailChanged() => !string.IsNullOrWhiteSpace(NewEmail) && UserToUpdate is not null @@ -54,4 +61,5 @@ public bool IsNameChanged() => private readonly OrganizationUserType _existingOrganizationUserType = OrganizationUserToUpdate.Type; private readonly bool _existingAccessSecretsManager = OrganizationUserToUpdate.AccessSecretsManager; + private readonly bool _existingAccessPam = OrganizationUserToUpdate.AccessPam; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidator.cs index bbb4445b7d14..b9e2fef104e7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidator.cs @@ -81,6 +81,13 @@ public async Task> ValidateAsync return Invalid(request, new CustomPermissionsNotEnabled()); } + // Granting PAM access to a member of an organization without PAM would be inert: claim emission ANDs + // AccessPam with the organization's UsePam. Reject so the admin gets an actionable error instead. + if (request.IsEnablingPam() && !request.Organization.UsePam) + { + return Invalid(request, new PamNotEnabled()); + } + if (request.NewType != OrganizationUserType.Owner && !await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId, [organizationUser.Id])) diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index ef035e3a91f4..ab009ff4ff00 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -47,6 +47,103 @@ namespace Bit.Api.Test.AdminConsole.Controllers; [SutProviderCustomize] public class OrganizationUsersControllerTests { + [Theory] + [BitAutoData] + public async Task BulkEnablePam_GrantsAccessToMembersWithoutIt(Guid orgId, + OrganizationUserBulkRequestModel model, Organization organization, List orgUsers, + SutProvider sutProvider) + { + organization.UsePam = true; + foreach (var orgUser in orgUsers) + { + orgUser.OrganizationId = orgId; + orgUser.AccessPam = false; + } + + sutProvider.GetDependency().GetManyAsync(model.Ids).Returns(orgUsers); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + + await sutProvider.Sut.BulkEnablePamAsync(orgId, model); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceManyAsync(Arg.Is>(users => users.All(u => u.AccessPam))); + } + + [Theory] + [BitAutoData] + public async Task BulkEnablePam_SkipsMembersOfOtherOrganizationsAndThoseWithAccess(Guid orgId, + OrganizationUserBulkRequestModel model, Organization organization, OrganizationUser targetUser, + OrganizationUser alreadyEnabledUser, OrganizationUser otherOrgUser, + SutProvider sutProvider) + { + organization.UsePam = true; + targetUser.OrganizationId = alreadyEnabledUser.OrganizationId = orgId; + targetUser.AccessPam = false; + alreadyEnabledUser.AccessPam = true; + otherOrgUser.AccessPam = false; + + sutProvider.GetDependency().GetManyAsync(model.Ids) + .Returns([targetUser, alreadyEnabledUser, otherOrgUser]); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + + await sutProvider.Sut.BulkEnablePamAsync(orgId, model); + + Assert.False(otherOrgUser.AccessPam); + await sutProvider.GetDependency() + .Received(1) + .ReplaceManyAsync(Arg.Is>(users => + users.Count() == 1 && users.Single().Id == targetUser.Id && users.Single().AccessPam)); + } + + [Theory] + [BitAutoData] + public async Task BulkEnablePam_WhenOrganizationDoesNotUsePam_Throws(Guid orgId, + OrganizationUserBulkRequestModel model, Organization organization, List orgUsers, + SutProvider sutProvider) + { + organization.UsePam = false; + foreach (var orgUser in orgUsers) + { + orgUser.OrganizationId = orgId; + orgUser.AccessPam = false; + } + + sutProvider.GetDependency().GetManyAsync(model.Ids).Returns(orgUsers); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.BulkEnablePamAsync(orgId, model)); + + Assert.Contains("must have PAM enabled", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceManyAsync(default); + } + + [Theory] + [BitAutoData] + public async Task BulkEnablePam_WhenNoMembersNeedAccess_Throws(Guid orgId, + OrganizationUserBulkRequestModel model, List orgUsers, + SutProvider sutProvider) + { + foreach (var orgUser in orgUsers) + { + orgUser.OrganizationId = orgId; + orgUser.AccessPam = true; + } + + sutProvider.GetDependency().GetManyAsync(model.Ids).Returns(orgUsers); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.BulkEnablePamAsync(orgId, model)); + + Assert.Equal("Users invalid.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceManyAsync(default); + } + [Theory] [BitAutoData] public async Task PutResetPasswordEnrollment_InvitedUser_AcceptsInvite(Guid orgId, Guid userId, OrganizationUserResetPasswordEnrollmentRequestModel model, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommandTests.cs index dbcbafe32759..23fdffe88f5a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommandTests.cs @@ -197,6 +197,77 @@ await sutProvider.GetDependency().Received(1).Ha Arg.Is>(i => i.Contains(newUserData.Id))); } + [Theory, BitAutoData] + public async Task UpdateUserAsync_WhenGrantingPam_AndOrganizationDoesNotUsePam_Throws( + Organization organization, + OrganizationUser oldUserData, + OrganizationUser newUserData, + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser savingUser, + SutProvider sutProvider) + { + Setup(sutProvider, organization, newUserData, oldUserData); + organization.UsePam = false; + newUserData.Permissions = null; + oldUserData.AccessPam = false; + newUserData.AccessPam = true; + newUserData.Type = OrganizationUserType.User; + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateUserAsync(newUserData, OrganizationUserType.User, savingUser.UserId, null, null)); + + Assert.Contains("must have PAM enabled", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(default, default(IEnumerable)); + } + + [Theory, BitAutoData] + public async Task UpdateUserAsync_WhenGrantingPam_AndOrganizationUsesPam_Persists( + Organization organization, + OrganizationUser oldUserData, + OrganizationUser newUserData, + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser savingUser, + SutProvider sutProvider) + { + Setup(sutProvider, organization, newUserData, oldUserData); + organization.UsePam = true; + newUserData.Permissions = null; + oldUserData.AccessPam = false; + newUserData.AccessPam = true; + newUserData.Type = OrganizationUserType.User; + + await sutProvider.Sut.UpdateUserAsync(newUserData, OrganizationUserType.User, savingUser.UserId, null, null); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => ou.AccessPam), + Arg.Any>()); + } + + [Theory, BitAutoData] + public async Task UpdateUserAsync_WhenRevokingPam_AndOrganizationDoesNotUsePam_Persists( + Organization organization, + OrganizationUser oldUserData, + OrganizationUser newUserData, + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser savingUser, + SutProvider sutProvider) + { + // Revoking access must stay possible on an organization whose PAM entitlement has lapsed. + Setup(sutProvider, organization, newUserData, oldUserData); + organization.UsePam = false; + newUserData.Permissions = null; + oldUserData.AccessPam = true; + newUserData.AccessPam = false; + newUserData.Type = OrganizationUserType.User; + + await sutProvider.Sut.UpdateUserAsync(newUserData, OrganizationUserType.User, savingUser.UserId, null, null); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => !ou.AccessPam), + Arg.Any>()); + } + [Theory] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index 54f2108b927c..0d64a9e251ea 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -64,6 +64,44 @@ await sutProvider.GetDependency() .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated); } + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenGrantingPam_PersistsAccessPam( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = false; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => ou.AccessPam), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenRevokingPam_PersistsAccessPamAsFalse( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = true; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: false); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => !ou.AccessPam), + Arg.Any>()); + } + [Theory] [BitAutoData] public async Task UpdateUserAsync_WhenEmailChanged_NotifiesMemberAtPreviousEmail( @@ -429,7 +467,8 @@ private static UpdateOrganizationUserRequest Setup( bool targetAccessSecretsManager = false, string defaultUserCollectionName = null, string newEmail = null, - string newName = null) + string newName = null, + bool targetAccessPam = false) { organization.PlanType = PlanType.EnterpriseAnnually; organizationUser.OrganizationId = organization.Id; @@ -446,6 +485,7 @@ private static UpdateOrganizationUserRequest Setup( type, null, targetAccessSecretsManager, + targetAccessPam, collections, groups, newEmail, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidatorTests.cs index 93565467c194..1b8fb46fd118 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidatorTests.cs @@ -166,6 +166,73 @@ public async Task ValidateAsync_WhenCustomTypeAndCustomPermissionsDisabled_Retur Assert.IsType(result.AsError); } + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenGrantingPamAndOrganizationDoesNotUsePam_ReturnsPamNotEnabled( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + orgUser.AccessPam = false; + var organization = CreateOrganization(orgUser.OrganizationId, PlanType.EnterpriseAnnually, usePam: false); + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, organization: organization, + newAccessPam: true); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenGrantingPamAndOrganizationUsesPam_ReturnsValid( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + orgUser.AccessPam = false; + var organization = CreateOrganization(orgUser.OrganizationId, PlanType.EnterpriseAnnually, usePam: true); + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, organization: organization, + newAccessPam: true); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenRevokingPamAndOrganizationDoesNotUsePam_ReturnsValid( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + // Revoking access must stay possible on an organization whose PAM entitlement has lapsed. + orgUser.AccessPam = true; + var organization = CreateOrganization(orgUser.OrganizationId, PlanType.EnterpriseAnnually, usePam: false); + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, organization: organization, + newAccessPam: false); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenMemberAlreadyHasPamAndOrganizationDoesNotUsePam_ReturnsValid( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + // Not a grant, so an unrelated edit to a member who already has access is not blocked. + orgUser.AccessPam = true; + var organization = CreateOrganization(orgUser.OrganizationId, PlanType.EnterpriseAnnually, usePam: false); + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, organization: organization, + newAccessPam: true); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsValid); + } + [Theory] [BitAutoData] public async Task ValidateAsync_WhenRemovingLastConfirmedOwner_ReturnsMustHaveConfirmedOwner( @@ -580,7 +647,8 @@ private static UpdateOrganizationUserRequest CreateRequest( Permissions newPermissions = null, string newEmail = null, string newName = null, - User userToUpdate = null) + User userToUpdate = null, + bool newAccessPam = false) { sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId, Arg.Any>()) @@ -609,6 +677,7 @@ private static UpdateOrganizationUserRequest CreateRequest( newType, newPermissions, false, + newAccessPam, collectionAccess, groups, newEmail, @@ -618,6 +687,7 @@ private static UpdateOrganizationUserRequest CreateRequest( userToUpdate); } - private static Organization CreateOrganization(Guid id, PlanType planType, bool useCustomPermissions = true) => - new() { Id = id, PlanType = planType, UseCustomPermissions = useCustomPermissions }; + private static Organization CreateOrganization(Guid id, PlanType planType, bool useCustomPermissions = true, + bool usePam = false) => + new() { Id = id, PlanType = planType, UseCustomPermissions = useCustomPermissions, UsePam = usePam }; }