Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-10319] - Revoke Non Complaint Users for 2FA and Single Org Policy Enablement #5037

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0a4c88a
Revoking users when enabling single org and 2fa policies. Fixing tests.
jrmccannon Nov 7, 2024
ff61b83
Added migration.
jrmccannon Nov 7, 2024
49620c7
Wrote tests and fixed bugs found.
jrmccannon Nov 11, 2024
e7a9a4f
Patch build process
withinfocus Nov 11, 2024
8b534b0
Fixing tests.
jrmccannon Nov 11, 2024
6a36767
Added unit test around disabling the feature flag.
jrmccannon Nov 12, 2024
f2f2a62
Updated error message to be public and added test for validating the โ€ฆ
jrmccannon Nov 12, 2024
f009db0
formatting
jrmccannon Nov 12, 2024
595e4b9
Added some tests for single org policy validator.
jrmccannon Nov 12, 2024
d662fff
Merge branch 'main' into ac/jmccannon/pm-10319-revoke-nc-users
jrmccannon Nov 12, 2024
d64032c
Fix issues from merge.
jrmccannon Nov 12, 2024
abbd4f5
Added sending emails to revoked non-compliant users.
jrmccannon Nov 12, 2024
3917114
Fixing name. Adding two factor policy email.
jrmccannon Nov 13, 2024
da02c89
Send email when user has been revoked.
jrmccannon Nov 13, 2024
1c3e4d8
Correcting migration name.
jrmccannon Nov 13, 2024
35643c1
Fixing templates and logic issue in Revoke command.
jrmccannon Nov 13, 2024
d3172c0
Moving interface into its own file.
jrmccannon Nov 13, 2024
fe5af90
Correcting namespaces for email templates.
jrmccannon Nov 13, 2024
95bb1f4
correcting logic that would not allow normal users to revoke non owners.
jrmccannon Nov 13, 2024
eb693a8
Actually correcting the test and logic.
jrmccannon Nov 13, 2024
58702d2
dotnet format. Added exec to bottom of bulk sproc
jrmccannon Nov 13, 2024
47cc545
Merge branch 'main' into ac/jmccannon/pm-10319-revoke-nc-users
jrmccannon Nov 14, 2024
9427b9f
Update src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Rโ€ฆ
jrmccannon Nov 15, 2024
99f98f2
Updated OrgIds to be a json string
jrmccannon Nov 15, 2024
78cd03d
Fixing errors.
jrmccannon Nov 15, 2024
436bebd
Updating test
jrmccannon Nov 15, 2024
8b89ad6
Moving command result.
jrmccannon Nov 15, 2024
0014d5c
Formatting and request rename
jrmccannon Nov 15, 2024
82b65e0
Realized this would throw a null error from the system domain verificโ€ฆ
jrmccannon Nov 15, 2024
51d0b12
Code review changes
jrmccannon Nov 19, 2024
d4bd369
Removing todos
jrmccannon Nov 19, 2024
1d77345
Corrected test name.
jrmccannon Nov 19, 2024
37ca7ff
Syncing filename to record name.
jrmccannon Nov 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Core/AdminConsole/Enums/EventSystemUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

public enum EventSystemUser : byte
{
Unknown = 0,
SCIM = 1,
DomainVerification = 2,
PublicApi = 3,
Expand Down
10 changes: 10 additions & 0 deletions src/Core/AdminConsole/Models/Data/IActingUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
๏ปฟusing Bit.Core.Enums;

namespace Bit.Core.AdminConsole.Models.Data;

public interface IActingUser
{
Guid? UserId { get; }
bool IsOrganizationOwnerOrProvider { get; }
EventSystemUser? SystemUserType { get; }
}
16 changes: 16 additions & 0 deletions src/Core/AdminConsole/Models/Data/StandardUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
๏ปฟusing Bit.Core.Enums;

namespace Bit.Core.AdminConsole.Models.Data;

public class StandardUser : IActingUser
{
public StandardUser(Guid userId, bool isOrganizationOwner)
{
UserId = userId;
IsOrganizationOwnerOrProvider = isOrganizationOwner;
}

public Guid? UserId { get; }
public bool IsOrganizationOwnerOrProvider { get; }
public EventSystemUser? SystemUserType => throw new Exception($"{nameof(StandardUser)} does not have a {nameof(SystemUserType)}");

Check warning on line 15 in src/Core/AdminConsole/Models/Data/StandardUser.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/Models/Data/StandardUser.cs#L15

Added line #L15 was not covered by tests
}
16 changes: 16 additions & 0 deletions src/Core/AdminConsole/Models/Data/SystemUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
๏ปฟusing Bit.Core.Enums;

namespace Bit.Core.AdminConsole.Models.Data;

public class SystemUser : IActingUser
{
public SystemUser(EventSystemUser systemUser)
{
SystemUserType = systemUser;
}

public Guid? UserId => throw new Exception($"{nameof(SystemUserType)} does not have a {nameof(UserId)}.");

Check warning on line 12 in src/Core/AdminConsole/Models/Data/SystemUser.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/Models/Data/SystemUser.cs#L12

Added line #L12 was not covered by tests

public bool IsOrganizationOwnerOrProvider => false;
public EventSystemUser? SystemUserType { get; }

Check warning on line 15 in src/Core/AdminConsole/Models/Data/SystemUser.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/Models/Data/SystemUser.cs#L14-L15

Added lines #L14 - L15 were not covered by tests
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
๏ปฟusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
Expand All @@ -12,124 +14,114 @@

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;

public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
public class VerifyOrganizationDomainCommand(
IOrganizationDomainRepository organizationDomainRepository,
IDnsResolverService dnsResolverService,
IEventService eventService,
IGlobalSettings globalSettings,
IPolicyService policyService,
IFeatureService featureService,
ICurrentContext currentContext,
ILogger<VerifyOrganizationDomainCommand> logger)
: IVerifyOrganizationDomainCommand
{
private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IDnsResolverService _dnsResolverService;
private readonly IEventService _eventService;
private readonly IGlobalSettings _globalSettings;
private readonly IPolicyService _policyService;
private readonly IFeatureService _featureService;
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;

public VerifyOrganizationDomainCommand(
IOrganizationDomainRepository organizationDomainRepository,
IDnsResolverService dnsResolverService,
IEventService eventService,
IGlobalSettings globalSettings,
IPolicyService policyService,
IFeatureService featureService,
ILogger<VerifyOrganizationDomainCommand> logger)
{
_organizationDomainRepository = organizationDomainRepository;
_dnsResolverService = dnsResolverService;
_eventService = eventService;
_globalSettings = globalSettings;
_policyService = policyService;
_featureService = featureService;
_logger = logger;
}


public async Task<OrganizationDomain> UserVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain)
{
var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain);
var actingUser = new StandardUser(currentContext.UserId ?? Guid.Empty, await currentContext.OrganizationOwner(organizationDomain.OrganizationId));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why pass an empty Guid and not the nullable Guid in the first parameter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not have null. I could throw at this point instead of using Guid.Empty. I was just defaulting to Guid.Empty in the event that a caller hasn't initialized the CurrentContext.

Do we know which applications would call this method without the CurrentContext populated with a user?


await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain, actingUser);

await eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
domainVerificationResult.VerifiedDate != null
? EventType.OrganizationDomain_Verified
: EventType.OrganizationDomain_NotVerified);

await _organizationDomainRepository.ReplaceAsync(domainVerificationResult);
await organizationDomainRepository.ReplaceAsync(domainVerificationResult);

return domainVerificationResult;
}

public async Task<OrganizationDomain> SystemVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain)
{
var actingUser = new SystemUser(EventSystemUser.DomainVerification);

organizationDomain.SetJobRunCount();

var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain);
var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain, actingUser);

if (domainVerificationResult.VerifiedDate is not null)
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain");
logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain");

Check warning on line 56 in src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs#L56

Added line #L56 was not covered by tests

await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
await eventService.LogOrganizationDomainEventAsync(domainVerificationResult,

Check warning on line 58 in src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs#L58

Added line #L58 was not covered by tests
EventType.OrganizationDomain_Verified,
EventSystemUser.DomainVerification);
}
else
{
domainVerificationResult.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval);
domainVerificationResult.SetNextRunDate(globalSettings.DomainVerification.VerificationInterval);

await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
await eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
EventType.OrganizationDomain_NotVerified,
EventSystemUser.DomainVerification);

_logger.LogInformation(Constants.BypassFiltersEventId,
logger.LogInformation(Constants.BypassFiltersEventId,
"Verification for organization {OrgId} with domain {Domain} failed",
domainVerificationResult.OrganizationId, domainVerificationResult.DomainName);
}

await _organizationDomainRepository.ReplaceAsync(domainVerificationResult);
await organizationDomainRepository.ReplaceAsync(domainVerificationResult);

return domainVerificationResult;
}

private async Task<OrganizationDomain> VerifyOrganizationDomainAsync(OrganizationDomain domain)
private async Task<OrganizationDomain> VerifyOrganizationDomainAsync(OrganizationDomain domain, IActingUser actingUser)
{
domain.SetLastCheckedDate();

if (domain.VerifiedDate is not null)
{
await _organizationDomainRepository.ReplaceAsync(domain);
await organizationDomainRepository.ReplaceAsync(domain);
throw new ConflictException("Domain has already been verified.");
}

var claimedDomain =
await _organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName);
await organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName);

if (claimedDomain.Count > 0)
{
await _organizationDomainRepository.ReplaceAsync(domain);
await organizationDomainRepository.ReplaceAsync(domain);
throw new ConflictException("The domain is not available to be claimed.");
}

try
{
if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
if (await dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
{
domain.SetVerifiedDate();

await EnableSingleOrganizationPolicyAsync(domain.OrganizationId);
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser);
}
}
catch (Exception e)
{
_logger.LogError("Error verifying Organization domain: {domain}. {errorMessage}",
logger.LogError("Error verifying Organization domain: {domain}. {errorMessage}",

Check warning on line 110 in src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs#L110

Added line #L110 was not covered by tests
domain.DomainName, e.Message);
}

return domain;
}

private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId)
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
await _policyService.SaveAsync(
new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true }, null);
await policyService.SaveAsync(
new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true },
savingUserId: actingUser is StandardUser standardUser ? standardUser.UserId : null,
eventSystemUser: actingUser is SystemUser systemUser ? systemUser.SystemUserType : null);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
๏ปฟusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.Models.Commands;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;

public interface IRevokeNonCompliantOrganizationUserCommand
{
Task<CommandResult> RevokeNonCompliantOrganizationUsersAsync(RevokeOrganizationUsersRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
๏ปฟusing Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;

public record RevokeOrganizationUsersRequest(
Guid OrganizationId,
IEnumerable<OrganizationUserUserDetails> OrganizationUsers,
IActingUser ActionPerformedBy)
{
public RevokeOrganizationUsersRequest(Guid organizationId, OrganizationUserUserDetails organizationUser, IActingUser actionPerformedBy)
: this(organizationId, [organizationUser], actionPerformedBy) { }
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anything in here that is specific to users being revoked for non-compliance with an org policy?

Users can also be revoked for other reasons (including because you looked at an admin funny). If this command is a bit more general, it can be used in all cases and can replace the other (various, confusing) OrganizationService implementations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could, but I would want to do that change in a separate PR. I also don't want to change the name until then so we know its safe to be the de facto RevokeOrganizationUserCommand

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's cool, can you please make a follow-up tech debt ticket to look into this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
๏ปฟusing Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.Enums;
using Bit.Core.Models.Commands;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;

public class RevokeNonCompliantOrganizationUserCommand(IOrganizationUserRepository organizationUserRepository,
IEventService eventService,
IHasConfirmedOwnersExceptQuery confirmedOwnersExceptQuery,
TimeProvider timeProvider) : IRevokeNonCompliantOrganizationUserCommand
{
public const string ErrorCannotRevokeSelf = "You cannot revoke yourself.";
public const string ErrorOnlyOwnersCanRevokeOtherOwners = "Only owners can revoke other owners.";
public const string ErrorUserAlreadyRevoked = "User is already revoked.";
public const string ErrorOrgMustHaveAtLeastOneOwner = "Organization must have at least one confirmed owner.";
public const string ErrorInvalidUsers = "Invalid users.";
public const string ErrorRequestedByWasNotValid = "Action was performed by an unexpected type.";

public async Task<CommandResult> RevokeNonCompliantOrganizationUsersAsync(RevokeOrganizationUsersRequest request)
{
var validationResult = await ValidateAsync(request);

if (validationResult.HasErrors)
{
return validationResult;
}

await organizationUserRepository.RevokeOrganizationUserAsync(request.OrganizationUsers.Select(x => x.Id));

var now = timeProvider.GetUtcNow();

switch (request.ActionPerformedBy)
{
case StandardUser:
await eventService.LogOrganizationUserEventsAsync(
request.OrganizationUsers.Select(x => GetRevokedUserEventTuple(x, now)));
break;
case SystemUser { SystemUserType: not null } loggableSystem:
await eventService.LogOrganizationUserEventsAsync(
request.OrganizationUsers.Select(x =>
GetRevokedUserEventBySystemUserTuple(x, loggableSystem.SystemUserType.Value, now)));
break;

Check warning on line 47 in src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs#L44-L47

Added lines #L44 - L47 were not covered by tests
}

return validationResult;
}

private static (OrganizationUserUserDetails organizationUser, EventType eventType, DateTime? time) GetRevokedUserEventTuple(
OrganizationUserUserDetails organizationUser, DateTimeOffset dateTimeOffset) =>
new(organizationUser, EventType.OrganizationUser_Revoked, dateTimeOffset.UtcDateTime);

Check warning on line 55 in src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs#L55

Added line #L55 was not covered by tests

private static (OrganizationUserUserDetails organizationUser, EventType eventType, EventSystemUser eventSystemUser, DateTime? time) GetRevokedUserEventBySystemUserTuple(
OrganizationUserUserDetails organizationUser, EventSystemUser systemUser, DateTimeOffset dateTimeOffset) => new(organizationUser,
EventType.OrganizationUser_Revoked, systemUser, dateTimeOffset.UtcDateTime);

Check warning on line 59 in src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs#L58-L59

Added lines #L58 - L59 were not covered by tests

private async Task<CommandResult> ValidateAsync(RevokeOrganizationUsersRequest request)
{
if (!PerformedByIsAnExpectedType(request.ActionPerformedBy))
{
return new CommandResult(ErrorRequestedByWasNotValid);
}

if (request.ActionPerformedBy is StandardUser user
&& request.OrganizationUsers.Any(x => x.UserId == user.UserId))
{
return new CommandResult(ErrorCannotRevokeSelf);
}

if (request.OrganizationUsers.Any(x => x.OrganizationId != request.OrganizationId))
{
return new CommandResult(ErrorInvalidUsers);
}

if (!await confirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(
request.OrganizationId,
request.OrganizationUsers.Select(x => x.Id)))
{
return new CommandResult(ErrorOrgMustHaveAtLeastOneOwner);
}

return request.OrganizationUsers.Aggregate(new CommandResult(), (result, userToRevoke) =>
{
if (IsAlreadyRevoked(userToRevoke))
{
result.ErrorMessages.Add($"{ErrorUserAlreadyRevoked} Id: {userToRevoke.Id}");
return result;
}

if (NonOwnersCannotRevokeOwners(userToRevoke, request.ActionPerformedBy))
{
result.ErrorMessages.Add($"{ErrorOnlyOwnersCanRevokeOtherOwners}");
return result;
}

return result;
});
}

private static bool PerformedByIsAnExpectedType(IActingUser entity) => entity is SystemUser or StandardUser;

private static bool IsAlreadyRevoked(OrganizationUserUserDetails organizationUser) =>
organizationUser is { Status: OrganizationUserStatusType.Revoked };

private static bool NonOwnersCannotRevokeOwners(OrganizationUserUserDetails organizationUser,
IActingUser actingUser) =>
actingUser is StandardUser { IsOrganizationOwnerOrProvider: false } && organizationUser.Type == OrganizationUserType.Owner;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
๏ปฟ#nullable enable

using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Utilities;

Expand All @@ -15,6 +16,7 @@ public record PolicyUpdate
public PolicyType Type { get; set; }
public string? Data { get; set; }
public bool Enabled { get; set; }
public IActingUser? PerformedBy { get; set; }

public T GetDataModel<T>() where T : IPolicyDataModel, new()
{
Expand Down
Loading
Loading