diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index b6b49e93e9dc..c65a88c7228c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -23,6 +24,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IGetOrganizationUserQuery _getOrganizationUserQuery; private readonly IUserRepository _userRepository; private readonly IEventService _eventService; private readonly IMailService _mailService; @@ -39,6 +41,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, + IGetOrganizationUserQuery getOrganizationUserQuery, IUserRepository userRepository, IEventService eventService, IMailService mailService, @@ -54,6 +57,7 @@ public ConfirmOrganizationUserCommand( { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; + _getOrganizationUserQuery = getOrganizationUserQuery; _userRepository = userRepository; _eventService = eventService; _mailService = mailService; @@ -117,9 +121,11 @@ public async Task>> ConfirmUsersAsync(Guid private async Task>> SaveChangesToDatabaseAsync(Guid organizationId, Dictionary keys, Guid confirmingUserId) { - var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys); + // Use the query to fetch strongly-typed models instead of raw entities + var selectedOrganizationUsers = await _getOrganizationUserQuery.GetManyOrganizationUsersAsync(keys.Keys); var validSelectedOrganizationUsers = selectedOrganizationUsers - .Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null) + .OfType() + .Where(ou => !ou.Revoked) .ToList(); if (!validSelectedOrganizationUsers.Any()) @@ -127,7 +133,7 @@ private async Task>> SaveChangesToDatabaseA return new List>(); } - var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList(); + var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId).ToList(); var organization = await _organizationRepository.GetByIdAsync(organizationId); var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds); @@ -135,7 +141,7 @@ private async Task>> SaveChangesToDatabaseA var users = await _userRepository.GetManyAsync(validSelectedUserIds); var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds); - var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u); + var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId, u => u); var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value) .ToDictionary(u => u.Key, u => u.ToList()); @@ -165,18 +171,21 @@ private async Task>> SaveChangesToDatabaseA var userTwoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; await CheckPoliciesAsync(organizationId, user, orgUsers, userTwoFactorEnabled); - orgUser.Status = OrganizationUserStatusType.Confirmed; - orgUser.Key = keys[orgUser.Id]; - orgUser.Email = null; - - await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); - succeededUsers.Add(orgUser); - result.Add(Tuple.Create(orgUser, "")); + + // Use the strongly-typed model to transition from Accepted to Confirmed state + var confirmedOrgUser = orgUser.ToConfirmed(keys[orgUser.Id]); + + // Ideally these would accept an interface, but for now we'll convert it back + var confirmedEntity = confirmedOrgUser.ToEntity(); + await _eventService.LogOrganizationUserEventAsync(confirmedEntity, EventType.OrganizationUser_Confirmed); + await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, confirmedOrgUser.AccessSecretsManager); + + succeededUsers.Add(confirmedEntity); + result.Add(Tuple.Create(confirmedEntity, "")); } catch (BadRequestException e) { - result.Add(Tuple.Create(orgUser, e.Message)); + result.Add(Tuple.Create(orgUser.ToEntity(), e.Message)); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUserQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUserQuery.cs new file mode 100644 index 000000000000..02e3c1480682 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUserQuery.cs @@ -0,0 +1,50 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class GetOrganizationUserQuery(IOrganizationUserRepository organizationUserRepository) + : IGetOrganizationUserQuery +{ + public async Task GetOrganizationUserAsync(Guid organizationUserId) + { + var organizationUser = await organizationUserRepository.GetByIdAsync(organizationUserId); + + if (organizationUser == null) + { + return null; + } + + return ConvertToStronglyTypedModel(organizationUser); + } + + public async Task> GetManyOrganizationUsersAsync(IEnumerable organizationUserIds) + { + var organizationUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); + + return organizationUsers + .Select(ConvertToStronglyTypedModel) + .ToList(); + } + + private static ITypedOrganizationUser ConvertToStronglyTypedModel(OrganizationUser organizationUser) + { + // Determine the appropriate model type based on the status + // For revoked users, use GetPriorActiveOrganizationUserStatusType to determine the underlying status + var effectiveStatus = organizationUser.Status == OrganizationUserStatusType.Revoked + ? OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser) + : organizationUser.Status; + + return effectiveStatus switch + { + OrganizationUserStatusType.Invited => InvitedOrganizationUser.FromEntity(organizationUser), + OrganizationUserStatusType.Accepted => AcceptedOrganizationUser.FromEntity(organizationUser), + OrganizationUserStatusType.Confirmed => ConfirmedOrganizationUser.FromEntity(organizationUser), + _ => throw new InvalidOperationException($"Unsupported organization user status: {organizationUser.Status}") + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUserQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUserQuery.cs new file mode 100644 index 000000000000..993c53161728 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUserQuery.cs @@ -0,0 +1,18 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IGetOrganizationUserQuery +{ + /// + /// Retrieves an organization user by their ID and returns the appropriate strongly-typed model + /// based on their status (Invited, Accepted, Confirmed, or Revoked). + /// + /// The ID of the organization user to retrieve. + Task GetOrganizationUserAsync(Guid organizationUserId); + + /// + /// Retrieves multiple organization users by their IDs and returns the appropriate strongly-typed models + /// based on their status (Invited, Accepted, Confirmed, or Revoked). + /// + /// The IDs of the organization users to retrieve. + Task> GetManyOrganizationUsersAsync(IEnumerable organizationUserIds); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserPermissions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserPermissions.cs new file mode 100644 index 000000000000..906aeed589c9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserPermissions.cs @@ -0,0 +1,51 @@ +#nullable enable + +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +/// +/// Represents an entity that has organization user permissions stored as a JSON string. +/// +public interface IOrganizationUserPermissions +{ + /// + /// A json blob representing the of the OrganizationUser if they + /// are a Custom user role. MAY be NULL if they are not a custom user, but this is not guaranteed; + /// do not use this to determine their role. + /// + /// + /// Avoid using this property directly - instead use the extension methods + /// and + /// . + /// + string? Permissions { get; set; } +} + +/// +/// Extension methods for working with implementations. +/// +public static class OrganizationUserPermissionsExtensions +{ + /// + /// Deserializes the Permissions JSON string into a object. + /// + /// The organization user with permissions. + /// A object if the JSON is valid, otherwise null. + public static Permissions? GetPermissions(this IOrganizationUserPermissions organizationUser) + { + return string.IsNullOrWhiteSpace(organizationUser.Permissions) ? null + : CoreHelpers.LoadClassFromJsonData(organizationUser.Permissions); + } + + /// + /// Serializes a object into a JSON string and stores it. + /// + /// The organization user to set permissions on. + /// The permissions object to serialize. + public static void SetPermissions(this IOrganizationUserPermissions organizationUser, Permissions permissions) + { + organizationUser.Permissions = CoreHelpers.ClassToJsonData(permissions); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/ITypedOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/ITypedOrganizationUser.cs new file mode 100644 index 000000000000..3879aa976823 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/ITypedOrganizationUser.cs @@ -0,0 +1,48 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +/// +/// Represents common properties shared by all typed organization user models. +/// +public interface ITypedOrganizationUser : IExternal, IOrganizationUserPermissions +{ + /// + /// A unique identifier for the organization user. + /// + Guid Id { get; set; } + + /// + /// The ID of the Organization. + /// + Guid OrganizationId { get; set; } + + /// + /// The User's role in the Organization. + /// + OrganizationUserType Type { get; set; } + + /// + /// The date the OrganizationUser was created. + /// + DateTime CreationDate { get; } + + /// + /// The last date the OrganizationUser entry was updated. + /// + DateTime RevisionDate { get; } + + /// + /// True if the User has access to Secrets Manager for this Organization, false otherwise. + /// + bool AccessSecretsManager { get; set; } + + /// + /// True if the user's access has been revoked, false otherwise. + /// + bool Revoked { get; set; } + + public OrganizationUser ToEntity(); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/AcceptedOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/AcceptedOrganizationUser.cs new file mode 100644 index 000000000000..94c358dc0b52 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/AcceptedOrganizationUser.cs @@ -0,0 +1,170 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Models; + +/// +/// Represents a user who has accepted their invitation to join an organization but has not yet been confirmed +/// by an organization administrator. At this stage, the user is linked to a User account but does not yet have +/// access to encrypted organization data. +/// +public class AcceptedOrganizationUser : ITypedOrganizationUser +{ + /// + /// A unique identifier for the organization user. + /// + public Guid Id { get; set; } + + /// + /// The ID of the Organization that the user has accepted membership to. + /// + public Guid OrganizationId { get; set; } + + /// + /// The ID of the User who accepted the invitation. This is now linked to a specific User account. + /// + public Guid UserId { get; set; } + + /// + /// The User's role in the Organization. + /// + public OrganizationUserType Type { get; set; } + + /// + /// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector + /// and SCIM. + /// + public string? ExternalId { get; set; } + + /// + /// The date the OrganizationUser was created, i.e. when the User was first invited to the Organization. + /// + public DateTime CreationDate { get; internal set; } + + /// + /// The last date the OrganizationUser entry was updated. + /// + public DateTime RevisionDate { get; internal set; } + + /// + public string? Permissions { get; set; } + + /// + /// True if the User has access to Secrets Manager for this Organization, false otherwise. + /// + public bool AccessSecretsManager { get; set; } + + /// + /// True if the user's access has been revoked, false otherwise. + /// + public bool Revoked { get; set; } + + /// + /// Transitions this accepted user to a confirmed state when an organization admin confirms them. + /// + /// The Organization symmetric key encrypted with the User's public key. + /// A new instance. + /// Thrown if the user is revoked. + public ConfirmedOrganizationUser ToConfirmed(string key) + { + if (Revoked) + { + throw new InvalidOperationException("Cannot transition a revoked user to confirmed status"); + } + + return new ConfirmedOrganizationUser + { + Id = Id, + OrganizationId = OrganizationId, + UserId = UserId, + Key = key, + ResetPasswordKey = null, + Type = Type, + ExternalId = ExternalId, + CreationDate = CreationDate, + RevisionDate = DateTime.UtcNow, + Permissions = Permissions, + AccessSecretsManager = AccessSecretsManager, + Revoked = false + }; + } + + /// + /// Converts this model to an entity. + /// + /// An entity with Status set to Accepted or Revoked based on the Revoked flag. + public OrganizationUser ToEntity() + { + return new OrganizationUser + { + Id = Id, + OrganizationId = OrganizationId, + UserId = UserId, + Email = null, + Key = null, + ResetPasswordKey = null, + Status = Revoked ? OrganizationUserStatusType.Revoked : OrganizationUserStatusType.Accepted, + Type = Type, + ExternalId = ExternalId, + CreationDate = CreationDate, + RevisionDate = RevisionDate, + Permissions = Permissions, + AccessSecretsManager = AccessSecretsManager + }; + } + + /// + /// Creates an from an entity. + /// + /// The entity to convert from. Must have Status = Accepted or Revoked (with pre-revoked status of Accepted), and UserId must not be null. + /// A new instance. + /// Thrown if the entity status is invalid or UserId is null. + public static AcceptedOrganizationUser FromEntity(OrganizationUser entity) + { + var isRevoked = entity.Status == OrganizationUserStatusType.Revoked; + + if (!isRevoked && entity.Status != OrganizationUserStatusType.Accepted) + { + throw new InvalidOperationException($"Cannot create AcceptedOrganizationUser from entity with status {entity.Status}"); + } + + if (isRevoked) + { + // Validate that the revoked user's pre-revoked status is Accepted + var preRevokedStatus = OrganizationService.GetPriorActiveOrganizationUserStatusType(entity); + if (preRevokedStatus != OrganizationUserStatusType.Accepted) + { + throw new InvalidOperationException($"Cannot create AcceptedOrganizationUser from revoked entity with pre-revoked status {preRevokedStatus}"); + } + } + + if (!entity.UserId.HasValue) + { + throw new InvalidOperationException("Cannot create AcceptedOrganizationUser from entity with null UserId"); + } + + return new AcceptedOrganizationUser + { + Id = entity.Id, + OrganizationId = entity.OrganizationId, + UserId = entity.UserId.Value, + Type = entity.Type, + ExternalId = entity.ExternalId, + CreationDate = entity.CreationDate, + RevisionDate = entity.RevisionDate, + Permissions = entity.Permissions, + AccessSecretsManager = entity.AccessSecretsManager, + Revoked = isRevoked + }; + } + + /// + /// Implicitly converts an AcceptedOrganizationUser to an OrganizationUser entity. + /// + public static implicit operator OrganizationUser(AcceptedOrganizationUser accepted) + { + return accepted.ToEntity(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/ConfirmedOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/ConfirmedOrganizationUser.cs new file mode 100644 index 000000000000..56bdcc0ffa76 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/ConfirmedOrganizationUser.cs @@ -0,0 +1,159 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Models; + +/// +/// Represents a fully confirmed member of an organization. The user has accepted their invitation and has been +/// confirmed by an organization administrator. At this stage, the user has access to encrypted organization data +/// through the encrypted organization key. +/// +public class ConfirmedOrganizationUser : ITypedOrganizationUser +{ + /// + /// A unique identifier for the organization user. + /// + public Guid Id { get; set; } + + /// + /// The ID of the Organization that the user is a confirmed member of. + /// + public Guid OrganizationId { get; set; } + + /// + /// The ID of the User who is the confirmed member. + /// + public Guid UserId { get; set; } + + /// + /// The Organization symmetric key encrypted with the User's public key. + /// This grants the user access to the organization's encrypted data. + /// + public required string Key { get; set; } + + /// + /// The User's symmetric key encrypted with the Organization's public key. + /// NULL if the OrganizationUser is not enrolled in account recovery. + /// + public string? ResetPasswordKey { get; set; } + + /// + /// The User's role in the Organization. + /// + public OrganizationUserType Type { get; set; } + + /// + /// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector + /// and SCIM. + /// + public string? ExternalId { get; set; } + + /// + /// The date the OrganizationUser was created, i.e. when the User was first invited to the Organization. + /// + public DateTime CreationDate { get; internal set; } + + /// + /// The last date the OrganizationUser entry was updated. + /// + public DateTime RevisionDate { get; internal set; } + + /// + public string? Permissions { get; set; } + + /// + /// True if the User has access to Secrets Manager for this Organization, false otherwise. + /// + public bool AccessSecretsManager { get; set; } + + /// + /// True if the user's access has been revoked, false otherwise. + /// + public bool Revoked { get; set; } + + /// + /// Converts this model to an entity. + /// + /// An entity with Status set to Confirmed or Revoked based on the Revoked flag. + public OrganizationUser ToEntity() + { + return new OrganizationUser + { + Id = Id, + OrganizationId = OrganizationId, + UserId = UserId, + Email = null, + Key = Key, + ResetPasswordKey = ResetPasswordKey, + Status = Revoked ? OrganizationUserStatusType.Revoked : OrganizationUserStatusType.Confirmed, + Type = Type, + ExternalId = ExternalId, + CreationDate = CreationDate, + RevisionDate = RevisionDate, + Permissions = Permissions, + AccessSecretsManager = AccessSecretsManager + }; + } + + /// + /// Creates a from an entity. + /// + /// The entity to convert from. Must have Status = Confirmed or Revoked (with pre-revoked status of Confirmed), UserId and Key must not be null. + /// A new instance. + /// Thrown if the entity status is invalid, or UserId or Key is null. + public static ConfirmedOrganizationUser FromEntity(OrganizationUser entity) + { + var isRevoked = entity.Status == OrganizationUserStatusType.Revoked; + + if (!isRevoked && entity.Status != OrganizationUserStatusType.Confirmed) + { + throw new InvalidOperationException($"Cannot create ConfirmedOrganizationUser from entity with status {entity.Status}"); + } + + if (isRevoked) + { + // Validate that the revoked user's pre-revoked status is Confirmed + var preRevokedStatus = OrganizationService.GetPriorActiveOrganizationUserStatusType(entity); + if (preRevokedStatus != OrganizationUserStatusType.Confirmed) + { + throw new InvalidOperationException($"Cannot create ConfirmedOrganizationUser from revoked entity with pre-revoked status {preRevokedStatus}"); + } + } + + if (!entity.UserId.HasValue) + { + throw new InvalidOperationException("Cannot create ConfirmedOrganizationUser from entity with null UserId"); + } + + if (string.IsNullOrEmpty(entity.Key)) + { + throw new InvalidOperationException("Cannot create ConfirmedOrganizationUser from entity with null Key"); + } + + return new ConfirmedOrganizationUser + { + Id = entity.Id, + OrganizationId = entity.OrganizationId, + UserId = entity.UserId.Value, + Key = entity.Key, + ResetPasswordKey = entity.ResetPasswordKey, + Type = entity.Type, + ExternalId = entity.ExternalId, + CreationDate = entity.CreationDate, + RevisionDate = entity.RevisionDate, + Permissions = entity.Permissions, + AccessSecretsManager = entity.AccessSecretsManager, + Revoked = isRevoked + }; + } + + /// + /// Implicitly converts a ConfirmedOrganizationUser to an OrganizationUser entity. + /// + public static implicit operator OrganizationUser(ConfirmedOrganizationUser confirmed) + { + return confirmed.ToEntity(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/InvitedOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/InvitedOrganizationUser.cs new file mode 100644 index 000000000000..c0e75e4f576e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Models/InvitedOrganizationUser.cs @@ -0,0 +1,174 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Models; + +/// +/// Represents an invitation to join an organization. +/// At this stage, the invitation is sent to an email address but is not yet linked to a specific User account. +/// +public class InvitedOrganizationUser : ITypedOrganizationUser +{ + /// + /// A unique identifier for the organization user. + /// + public Guid Id { get; set; } + + /// + /// The ID of the Organization that the user is invited to join. + /// + public Guid OrganizationId { get; set; } + + /// + /// The email address of the user invited to the organization. + /// This is the primary identifier at this stage since the invitation is not yet linked to a User account. + /// + public required string Email { get; set; } + + /// + /// The role that the user will have in the Organization once they accept and are confirmed. + /// + public OrganizationUserType Type { get; set; } + + /// + /// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector + /// and SCIM. + /// + public string? ExternalId { get; set; } + + /// + /// The date the invitation was created and sent. + /// + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + + /// + /// The last date the invitation entry was updated. + /// + public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + + /// + public string? Permissions { get; set; } + + /// + /// True if the invited user will have access to Secrets Manager for this Organization once confirmed, false otherwise. + /// + public bool AccessSecretsManager { get; set; } + + /// + /// True if the user's access has been revoked, false otherwise. + /// + public bool Revoked { get; set; } + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + + /// + /// Implicitly converts an InvitedOrganizationUser to an OrganizationUser entity. + /// + public static implicit operator OrganizationUser(InvitedOrganizationUser invited) + { + return invited.ToEntity(); + } + + /// + /// Transitions this invited user to an accepted state when the user accepts the invitation. + /// + /// The ID of the User who accepted the invitation. + /// A new instance. + /// Thrown if the user is revoked. + public AcceptedOrganizationUser ToAccepted(Guid userId) + { + if (Revoked) + { + throw new InvalidOperationException("Cannot transition a revoked user to accepted status"); + } + + return new AcceptedOrganizationUser + { + Id = Id, + OrganizationId = OrganizationId, + UserId = userId, + Type = Type, + ExternalId = ExternalId, + CreationDate = CreationDate, + RevisionDate = DateTime.UtcNow, + Permissions = Permissions, + AccessSecretsManager = AccessSecretsManager, + Revoked = false + }; + } + + /// + /// Converts this model to an entity. + /// + /// An entity with Status set to Invited or Revoked based on the Revoked flag. + public OrganizationUser ToEntity() + { + return new OrganizationUser + { + Id = Id, + OrganizationId = OrganizationId, + UserId = null, + Email = Email, + Key = null, + ResetPasswordKey = null, + Status = Revoked ? OrganizationUserStatusType.Revoked : OrganizationUserStatusType.Invited, + Type = Type, + ExternalId = ExternalId, + CreationDate = CreationDate, + RevisionDate = RevisionDate, + Permissions = Permissions, + AccessSecretsManager = AccessSecretsManager + }; + } + + /// + /// Creates an from an entity. + /// + /// The entity to convert from. Must have Status = Invited or Revoked (with pre-revoked status of Invited), and Email must not be null. + /// A new instance. + /// Thrown if the entity status is invalid or Email is null. + public static InvitedOrganizationUser FromEntity(OrganizationUser entity) + { + var isRevoked = entity.Status == OrganizationUserStatusType.Revoked; + + if (!isRevoked && entity.Status != OrganizationUserStatusType.Invited) + { + throw new InvalidOperationException($"Cannot create InvitedOrganizationUser from entity with status {entity.Status}"); + } + + if (isRevoked) + { + // Validate that the revoked user's pre-revoked status is Invited + var preRevokedStatus = OrganizationService.GetPriorActiveOrganizationUserStatusType(entity); + if (preRevokedStatus != OrganizationUserStatusType.Invited) + { + throw new InvalidOperationException($"Cannot create InvitedOrganizationUser from revoked entity with pre-revoked status {preRevokedStatus}"); + } + } + + if (string.IsNullOrEmpty(entity.Email)) + { + throw new InvalidOperationException("Cannot create InvitedOrganizationUser from entity with null Email"); + } + + return new InvitedOrganizationUser + { + Id = entity.Id, + OrganizationId = entity.OrganizationId, + Email = entity.Email, + Type = entity.Type, + ExternalId = entity.ExternalId, + CreationDate = entity.CreationDate, + RevisionDate = entity.RevisionDate, + Permissions = entity.Permissions, + AccessSecretsManager = entity.AccessSecretsManager, + Revoked = isRevoked + }; + } +}