diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor index cb5d3771..25a1ec15 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor @@ -210,6 +210,7 @@ + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminRepairPanel.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminRepairPanel.razor new file mode 100644 index 00000000..8ca58dd1 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminRepairPanel.razor @@ -0,0 +1,244 @@ +@using CodeBeam.UltimateAuth.IdentityLab.Authorization +@using CodeBeam.UltimateAuth.IdentityLab.Contracts +@using CodeBeam.UltimateAuth.IdentityLab.Services + +@inject LabRepairService RepairService +@inject LabAccessContextProvider ContextProvider +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + + + + + Provisioning & Repair + + + Detect incomplete accounts and orphaned sandboxes. + + + + + Scan + + + + @if (_busy) + { + + } + + @if (!string.IsNullOrEmpty(_error)) + { + + @_error + + } + + @if (_snapshot is not null) + { + @if (!_snapshot.UserScanComplete) + { + + User scan was limited. Results may be incomplete. + + } + + + Unlinked Users (@_snapshot.OrphanUsers.Count) + + + + + User + Email + Role + Recovery + + + + + @context.DisplayName + + + + @context.Email + + + + @context.Role + + + + @if (context.Role == LabRoles.Owner) + { + + Repair Owner + + } + else + { + + Manual review required + + } + + + + + + + + + + + Empty Sandboxes (@_snapshot.EmptySandboxes.Count) + + + + + Sandbox + Owner Key + Status + Action + + + + + @context.Name + + + + @context.OwnerUserKey.Value + + + + @context.Status + + + + + Delete + + + + + + + + } + + + +@code { + private RepairSnapshot? _snapshot; + private bool _busy; + private string? _error; + + private async Task + GetContextAsync() + { + return await ContextProvider.CreateAsync( + "identitylab.repair.admin", + "identitylab.repair"); + } + + private async Task LoadAsync() + { + _busy = true; + _error = null; + + try + { + var context = await GetContextAsync(); + _snapshot = await RepairService.GetSnapshotAsync(context); + } + catch (Exception) + { + _error = "Unable to load repair information."; + } + finally + { + _busy = false; + } + } + + private async Task RepairOwnerAsync(OrphanUserItem user) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Repair Owner", + $"Repair the Sandbox membership for {user.DisplayName}?", + yesText: "Repair", + cancelText: "Cancel"); + + if (confirm != true) + return; + + await ExecuteAsync(async () => + { + var context = await GetContextAsync(); + return await RepairService.RepairOwnerAsync(context, user.UserKey); + }); + } + + private async Task DeleteSandboxAsync(EmptySandboxItem sandbox) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Delete Empty Sandbox", + $"Permanently delete '{sandbox.Name}'? This cannot be undone.", + yesText: "Delete", + cancelText: "Cancel"); + + if (confirm != true) + return; + + await ExecuteAsync(async () => + { + var context = await GetContextAsync(); + return await RepairService.DeleteEmptySandboxAsync(context, sandbox.SandboxId); + }); + } + + private async Task ExecuteAsync(Func> operation) + { + _busy = true; + _error = null; + + try + { + var result = await operation(); + Snackbar.Add(result.Message, result.Success ? Severity.Success : Severity.Warning); + } + catch (Exception) + { + Snackbar.Add("Repair operation failed. No further changes should be made until the state is reviewed.", Severity.Error); + } + finally + { + _busy = false; + } + + await LoadAsync(); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor index da663b83..29ff6dfd 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor @@ -21,6 +21,14 @@

Activity

Review identity, session, and authorization activity within your sandbox.

+ + + Refresh +
@@ -46,6 +54,15 @@
+ @if (!string.IsNullOrWhiteSpace(_error)) + { + + @_error + + } +
@@ -170,8 +187,35 @@ private bool _loading; private string? _error; + private UserKey? _loadedUserKey; + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + var userKey = AuthState?.Identity?.UserKey; + + if (userKey is null) + { + _loadedUserKey = null; + _events.Clear(); + _error = null; + return; + } + + if (_loadedUserKey == userKey) + return; + + _loadedUserKey = userKey; + + await InitializeActivityAsync(userKey.Value); + } + private async Task InitializeActivityAsync(UserKey currentUserKey) { + _events.Clear(); + _error = null; + var sandbox = await SandboxService.GetByUserAsync(currentUserKey); if (sandbox is null) @@ -205,6 +249,8 @@ } catch (Exception) { + _events.Clear(); + _loadedUserKey = null; _error = "Unable to load activity records."; } finally @@ -213,6 +259,16 @@ } } + private async Task RefreshActivityAsync() + { + var userKey = AuthState?.Identity?.UserKey; + + if (userKey is null) + return; + + await InitializeActivityAsync(userKey.Value); + } + private IEnumerable FilteredEvents => _events .Where(x => diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor index 6af62350..c07ff9fd 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor @@ -7,6 +7,7 @@ @layout DashboardLayout @inject SandboxUserService SandboxUserService @inject IDialogService DialogService +@inject ISnackbar Snackbar @attribute [UAuthAuthorize(Roles = "Ladmin,Owner")] @@ -179,7 +180,14 @@ Size="Size.Small" Disabled="@context.IsOwner" OnClick="@(() => OpenUserDetailsAsync(context))" /> + + +
@@ -368,4 +376,49 @@ return await SandboxUserService.CreateAsync(actorUserKey.Value, request); } + + private async Task DeleteUserAsync(SandboxUserItem user) + { + if (user.IsOwner) + return; + + var confirmed = await DialogService.ShowMessageBoxAsync( + "Permanently delete user?", + $"Delete {user.Username} permanently? This action cannot be undone.", + yesText: "Delete permanently", + cancelText: "Cancel"); + + if (confirmed != true) + return; + + var actorUserKey = AuthState?.Identity?.UserKey; + + if (actorUserKey is null) + return; + + _loading = true; + _error = null; + + try + { + var result = await SandboxUserService.DeleteAsync(actorUserKey.Value, user.UserKey); + + if (!result.Success) + { + Snackbar.Add("Unable to delete user. Check server logs.", Severity.Error); + _error = result.Message; + return; + } + + await LoadUsersAsync(actorUserKey.Value); + } + catch (Exception) + { + Snackbar.Add("Unable to delete user. Check server logs.", Severity.Error); + } + finally + { + _loading = false; + } + } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor index fff8c308..8b4c155a 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor @@ -9,6 +9,8 @@ @attribute [UAuthAuthorize(Roles = "Ladmin,Owner,User")] @inject LabDashboardService DashboardService +@inject LabOwnerRepairService OwnerRepairService +@inject LabAccessContextProvider ContextProvider @inject ISnackbar Snackbar Overview · Identity Lab @@ -30,9 +32,46 @@ } else if (_dashboard is null) { - - Your sandbox overview is currently unavailable. - + @if (_repairStatus?.State == OwnerRepairState.CanRepair) + { + + + + @_repairStatus.Message + + + + Your account is active, but your sandbox setup is incomplete. + You can safely complete the setup without creating another account. + + + + @if (_repairing) + { + + } + Complete Sandbox Setup + + + + } + else + { + + Your sandbox overview is currently unavailable. + @if (_repairStatus?.State == OwnerRepairState.NotEligible) + { + Please contact the administrator if the problem persists. + } + + } } else { @@ -408,6 +447,9 @@ private string CurrentUserIdentifier => AuthState?.Identity?.UserKey.Value ?? "Unavailable"; + private OwnerRepairStatus? _repairStatus; + private bool _repairing; + private bool ShowManagement => AuthState?.IsInRole(LabRoles.Admin) == true || (AuthState?.IsInRole(LabRoles.Owner) == true && @@ -433,21 +475,64 @@ try { _dashboard = await DashboardService.GetAsync(userKey.Value); + _repairStatus = null; + + if (_dashboard is null) + { + var context = await ContextProvider.CreateAsync("identitylab.owner.repair.status", "identitylab.sandbox"); + _repairStatus = await OwnerRepairService.GetStatusAsync(context); + } - if (_dashboard is not null) - _loadedUserKey = userKey; + _loadedUserKey = userKey; } catch (Exception) { _dashboard = null; + _repairStatus = null; + + Snackbar.Add("Unable to load your dashboard.", Severity.Error); + } + finally + { + _loading = false; + } + } + private async Task RepairSandboxAsync() + { + if (_repairing) + return; + + _repairing = true; + + try + { + var context = await ContextProvider.CreateAsync("identitylab.owner.repair", "identitylab.sandbox"); + var result = await OwnerRepairService.RepairAsync(context); + Snackbar.Add(result.Message, result.Success ? Severity.Success : Severity.Warning); + + if (result.Success) + { + _repairStatus = null; + + var userKey = AuthState?.Identity?.UserKey; + + if (userKey is not null) + { + _dashboard = await DashboardService.GetAsync(userKey.Value); + _loadedUserKey = userKey; + } + } + } + catch (Exception) + { Snackbar.Add( - "Unable to load your dashboard.", + "Unable to complete sandbox setup. Please contact the administrator.", Severity.Error); } finally { - _loading = false; + _repairing = false; } } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRepairContracts.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRepairContracts.cs new file mode 100644 index 00000000..5844a2e0 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRepairContracts.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Domain; + +namespace CodeBeam.UltimateAuth.IdentityLab.Contracts; + +public sealed record OrphanUserItem(UserKey UserKey, string DisplayName, string? Email, string Role, DateTimeOffset CreatedAt, Guid? OwnedSandboxId); + +public sealed record EmptySandboxItem(Guid SandboxId, string Name, UserKey OwnerUserKey, SandboxStatus Status, DateTimeOffset CreatedAt); + +public sealed record RepairSnapshot(IReadOnlyList OrphanUsers, IReadOnlyList EmptySandboxes, bool UserScanComplete); + +public sealed record RepairOperationResult(bool Success, string Message) +{ + public static RepairOperationResult Ok(string message) + => new(true, message); + + public static RepairOperationResult Fail(string message) + => new(false, message); +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/OwnerRepairResult.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/OwnerRepairResult.cs new file mode 100644 index 00000000..ee40f9f8 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/OwnerRepairResult.cs @@ -0,0 +1,19 @@ +namespace CodeBeam.UltimateAuth.IdentityLab.Contracts; + +public enum OwnerRepairState +{ + NotRequired, + CanRepair, + NotEligible +} + +public sealed record OwnerRepairStatus(OwnerRepairState State, string Message); + +public sealed record OwnerRepairResult(bool Success, string Message) +{ + public static OwnerRepairResult Ok(string message) + => new(true, message); + + public static OwnerRepairResult Fail(string message) + => new(false, message); +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs index 6eb114e4..0b26df59 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs @@ -20,8 +20,14 @@ public async ValueTask DecideAsync(AccessContext context, Cancel var action = context.Action; - // Authentication recovery and registration require - // their own trusted, target-aware protection. + // Authentication recovery and registration require their own trusted, target-aware protection. + if (action == UAuthActions.Users.CreateAnonymous) + { + return LabProvisioningGate.IsTrusted(context.Attributes) + ? AccessDecision.Allow() + : AccessDecision.Deny("lab_registration_required"); + } + if (action.EndsWith(".anonymous", StringComparison.Ordinal)) return AccessDecision.Allow(); @@ -32,8 +38,13 @@ public async ValueTask DecideAsync(AccessContext context, Cancel if (context.IsSystemActor) return AccessDecision.Allow(); - if (!context.IsAuthenticated || - context.ActorUserKey is not UserKey actorKey) + if (action == UAuthActions.Users.CreateAdmin) + { + if (!LabProvisioningGate.IsTrusted(context.Attributes)) + return AccessDecision.Deny("sandbox_provisioning_required"); + } + + if (!context.IsAuthenticated || context.ActorUserKey is not UserKey actorKey) { return AccessDecision.Deny("sandbox_actor_required"); } @@ -62,6 +73,11 @@ public async ValueTask DecideAsync(AccessContext context, Cancel if (actor.Status == SandboxStatus.Suspended) return AccessDecision.Deny("sandbox_suspended"); + if (action == UAuthActions.Users.DeleteSelf && actor.Role == SandboxMemberRole.Owner) + { + return AccessDecision.Deny("sandbox_owner_cannot_delete_self"); + } + var operation = ClassifyAction(action); if (operation == SandboxOperation.Denied) @@ -107,6 +123,20 @@ public async ValueTask DecideAsync(AccessContext context, Cancel .AsNoTracking() .AnyAsync(x => x.UserKey == targetKey && x.SandboxId == actor.SandboxId, cancellationToken); + if (action == UAuthActions.Users.DeleteAdmin) + { + var targetIsOwner = await db.SandboxMembers + .AsNoTracking() + .AnyAsync(x => + x.SandboxId == actor.SandboxId && + x.UserKey == targetKey && + x.Role == SandboxMemberRole.Owner, + cancellationToken); + + if (targetIsOwner) + return AccessDecision.Deny("sandbox_owner_cannot_be_deleted"); + } + if (!belongsToSandbox) return AccessDecision.Deny("sandbox_target_out_of_scope"); } @@ -142,29 +172,27 @@ private static bool RequiresTarget(string action) || action is UAuthActions.Flows.LogoutDeviceAdmin or UAuthActions.Flows.LogoutOthersAdmin or - UAuthActions.Flows.LogoutAllAdmin; + UAuthActions.Flows.LogoutAllAdmin + || action == UAuthActions.Authorization.Roles.GetAdmin; } private static SandboxOperation ClassifyAction(string action) { - // User creation requires a controlled sandbox - // creation workflow, including membership and capacity. - if (action is UAuthActions.Users.CreateAdmin or UAuthActions.Users.QueryAdmin) - { + if (action == UAuthActions.Users.CreateAdmin) + return SandboxOperation.Manage; + + if (action == UAuthActions.Users.QueryAdmin) return SandboxOperation.Denied; - } - // Global role definitions and assignments are - // handled separately by LabApplicationPolicy. if (action.StartsWith("authorization.roles.", StringComparison.Ordinal)) - { - return action is - UAuthActions.Authorization.Roles.GetSelf or - UAuthActions.Authorization.Roles.GetAdmin or - UAuthActions.Authorization.Roles.QueryAdmin - ? SandboxOperation.Read - : SandboxOperation.Denied; - } + { + return action is + UAuthActions.Authorization.Roles.GetSelf or + UAuthActions.Authorization.Roles.GetAdmin or + UAuthActions.Authorization.Roles.QueryAdmin + ? SandboxOperation.Read + : SandboxOperation.Denied; + } if (IsReadAction(action)) return SandboxOperation.Read; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs index 50a134c8..8260d525 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Client.Blazor.Extensions; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.Options; @@ -45,6 +46,8 @@ builder.Services.AddUltimateAuthServer(o => { + // Identity Lab uses its own trusted creation align with Sandbox management. + o.Endpoints.DisabledActions.Add(UAuthActions.Users.CreateAnonymous); o.Diagnostics.EnableRefreshDetails = true; //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); //o.Session.Lifetime = TimeSpan.FromSeconds(32); @@ -55,8 +58,7 @@ o.Login.MaxFailedAttempts = 5; o.Login.LockoutDuration = TimeSpan.FromMinutes(1); o.Identifiers.Behavior.AllowMultipleUsernames = true; - // TODO: Remove it back after fix - o.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveAll; + o.Events.OnUserLoggedIn = context => activityEventBridge.RecordAsync(context.UserKey, ActivityEvents.Login); o.Events.OnUserLoggedOut = context => activityEventBridge.RecordAsync(context.UserKey, ActivityEvents.Logout); @@ -95,6 +97,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -105,6 +110,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddLogging(); var app = builder.Build(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabOwnerRepairService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabOwnerRepairService.cs new file mode 100644 index 00000000..26210aff --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabOwnerRepairService.cs @@ -0,0 +1,228 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed class LabOwnerRepairService( + IDbContextFactory dbFactory, + IUserLifecycleStoreFactory lifecycleStoreFactory, + IRoleStoreFactory roleStoreFactory, + IUserRoleStoreFactory userRoleStoreFactory, + ILogger logger) +{ + public async Task GetStatusAsync( + AccessContext context, + CancellationToken ct = default) + { + var ownerKey = await GetEligibleOwnerAsync(context, ct); + + if (ownerKey is null) + { + return new OwnerRepairStatus( + OwnerRepairState.NotEligible, + "Your account is not eligible for automatic sandbox recovery."); + } + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var membership = await db.SandboxMembers + .AsNoTracking() + .FirstOrDefaultAsync(x => x.UserKey == ownerKey.Value, ct); + + if (membership is not null) + { + return new OwnerRepairStatus( + OwnerRepairState.NotRequired, + "Your account already has a sandbox membership."); + } + + var ownedSandbox = await db.Sandboxes + .AsNoTracking() + .FirstOrDefaultAsync(x => x.OwnerUserKey == ownerKey.Value, ct); + + if (ownedSandbox is not null) + { + var otherOwnerExists = await db.SandboxMembers.AnyAsync( + x => x.SandboxId == ownedSandbox.Id && + x.Role == SandboxMemberRole.Owner, + ct); + + if (otherOwnerExists) + { + return new OwnerRepairStatus( + OwnerRepairState.NotEligible, + "Sandbox ownership requires administrator review."); + } + } + + return new OwnerRepairStatus( + OwnerRepairState.CanRepair, + ownedSandbox is null + ? "Your account was created, but sandbox setup was not completed." + : "Your sandbox exists, but your owner membership needs repair."); + } + + public async Task RepairAsync( + AccessContext context, + CancellationToken ct = default) + { + var ownerKey = await GetEligibleOwnerAsync(context, ct); + + if (ownerKey is null) + { + return OwnerRepairResult.Fail( + "Your account is not eligible for automatic recovery."); + } + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + await using var transaction = await db.Database + .BeginTransactionAsync(IsolationLevel.Serializable, ct); + + // Recheck under the write transaction. + var membershipExists = await db.SandboxMembers + .AnyAsync(x => x.UserKey == ownerKey.Value, ct); + + if (membershipExists) + { + return OwnerRepairResult.Fail( + "Your account already belongs to a sandbox."); + } + + var sandbox = await db.Sandboxes + .Include(x => x.Settings) + .FirstOrDefaultAsync( + x => x.OwnerUserKey == ownerKey.Value, + ct); + + if (sandbox is null) + { + sandbox = new Sandbox + { + Id = Guid.NewGuid(), + Name = "My Sandbox", + OwnerUserKey = ownerKey.Value, + Status = SandboxStatus.Active, + CreatedAt = DateTimeOffset.UtcNow, + Settings = new SandboxSettings + { + MaxUsers = SandboxLimits.DefaultMaxUsers + } + }; + + db.Sandboxes.Add(sandbox); + } + else + { + var existingOwner = await db.SandboxMembers + .AnyAsync(x => + x.SandboxId == sandbox.Id && + x.Role == SandboxMemberRole.Owner, + ct); + + if (existingOwner) + { + return OwnerRepairResult.Fail( + "Sandbox ownership requires administrator review."); + } + + var memberCount = await db.SandboxMembers + .CountAsync(x => x.SandboxId == sandbox.Id, ct); + + var capacity = Math.Min( + sandbox.Settings.MaxUsers, + SandboxLimits.AbsoluteMaxUsers); + + if (memberCount >= capacity) + { + return OwnerRepairResult.Fail( + "Sandbox capacity is full. Contact the administrator."); + } + } + + db.SandboxMembers.Add(new SandboxMember + { + Id = Guid.NewGuid(), + SandboxId = sandbox.Id, + UserKey = ownerKey.Value, + Role = SandboxMemberRole.Owner, + JoinedAt = DateTimeOffset.UtcNow + }); + + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + logger.LogWarning( + "Owner self-repair completed for {OwnerUserKey}, Sandbox {SandboxId}", + ownerKey.Value.Value, + sandbox.Id); + + return OwnerRepairResult.Ok( + "Your sandbox setup has been completed successfully."); + } + + private async Task GetEligibleOwnerAsync( + AccessContext context, + CancellationToken ct) + { + if (!context.IsAuthenticated || + context.IsSystemActor || + context.ActorUserKey is not UserKey userKey || + context.ActorTenant.IsSystem || + context.ActorTenant.IsUnresolved || + context.IsCrossTenant || + context.ActorTenant != TenantKeys.Single) + { + return null; + } + + var lifecycleStore = lifecycleStoreFactory.Create(context.ActorTenant); + + var lifecycle = await lifecycleStore.GetAsync( + new UserLifecycleKey(context.ActorTenant, userKey), + ct); + + if (lifecycle is null || + lifecycle.Status != UserStatus.Active) + { + return null; + } + + var roleStore = roleStoreFactory.Create(context.ActorTenant); + var userRoleStore = userRoleStoreFactory.Create(context.ActorTenant); + + var ownerRole = await roleStore.GetByNameAsync( + LabRoles.Owner.ToUpperInvariant(), ct); + + var adminRole = await roleStore.GetByNameAsync( + LabRoles.Admin.ToUpperInvariant(), ct); + + if (ownerRole is null || ownerRole.IsDeleted) + return null; + + var assignments = await userRoleStore.GetAssignmentsAsync( + userKey, ct); + + if (!assignments.Any(x => x.RoleId == ownerRole.Id)) + return null; + + // Platform admins must not provision their own sandbox + // through the ordinary Owner recovery workflow. + if (adminRole is not null && + assignments.Any(x => x.RoleId == adminRole.Id)) + { + return null; + } + + return userKey; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabPartialProvisioningException.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabPartialProvisioningException.cs new file mode 100644 index 00000000..19c8d4d8 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabPartialProvisioningException.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed class LabPartialProvisioningException : Exception +{ + public string CreatedUserKey { get; } + + public LabPartialProvisioningException(string createdUserKey, Exception innerException) + : base("The user was created but sandbox provisioning was incomplete.", innerException) + { + CreatedUserKey = createdUserKey; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabProvisioningGate.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabProvisioningGate.cs new file mode 100644 index 00000000..7659b19c --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabProvisioningGate.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +internal static class LabProvisioningGate +{ + internal const string AttributeKey = "identitylab.internal.provisioning"; + + // Reference identity is intentional. + // A string, boolean or client-supplied value cannot reproduce this token. + internal static readonly object Token = new(); + + internal static bool IsTrusted(IReadOnlyDictionary attributes) + { + return attributes.TryGetValue(AttributeKey, out var value) && ReferenceEquals(value, Token); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs index 96776f5d..19c3a3a9 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs @@ -1,16 +1,18 @@ using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.IdentityLab.Contracts; using CodeBeam.UltimateAuth.IdentityLab.Domain; using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.IdentityLab.Services; -public sealed class LabRegistrationService(IUAuthClient uAuthClient, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, - SandboxService sandboxService, ActivityLogService activityLogService) +public sealed class LabRegistrationService(LabAccessContextProvider contextProvider, IUserApplicationService userApplicationService, IRoleStoreFactory roleStoreFactory, + IUserRoleStoreFactory userRoleStoreFactory, SandboxService sandboxService, ActivityLogService activityLogService) { public async Task RegisterAsync(LabRegistrationRequest request, CancellationToken ct = default) { @@ -21,24 +23,27 @@ public async Task RegisterAsync(LabRegistrationRequest re return LabRegistrationResult.Failed("Username, email and password are required."); } - var createResult = await uAuthClient.Users.CreateAsync( + var context = await contextProvider.CreateAsync(UAuthActions.Users.CreateAnonymous, "users", ct); + + var trustedContext = context.WithAttribute(LabProvisioningGate.AttributeKey, LabProvisioningGate.Token); + + var createResult = await userApplicationService.CreateUserAsync( + trustedContext, new CreateUserRequest { UserName = request.Username.Trim(), Email = request.Email.Trim(), Password = request.Password - }); + }, + ct); - if (!createResult.IsSuccess || createResult.Value is not - { - IsSuccess: true, - UserKey: { Length: > 0 } createdUserKey - }) + if (!createResult.IsSuccess || + string.IsNullOrWhiteSpace(createResult.UserKey)) { - return LabRegistrationResult.Failed(createResult.Value?.FailureReason ?? "Unable to create your account."); + return LabRegistrationResult.Failed(createResult.FailureReason ?? "Unable to create your account."); } - var userKey = UserKey.FromString(createdUserKey); + var userKey = UserKey.FromString(createResult.UserKey); // TODO(Part 3): Replace direct role-store provisioning with // a trusted UltimateAuth registration/provisioning extension. diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRepairService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRepairService.cs new file mode 100644 index 00000000..034f5d7c --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRepairService.cs @@ -0,0 +1,294 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed class LabRepairService( + IDbContextFactory dbFactory, + IUserSummaryQueryStoreFactory userQueryFactory, + IRoleStoreFactory roleStoreFactory, + IUserRoleStoreFactory userRoleStoreFactory, + ILabAdminGuard adminGuard, + ILogger logger) +{ + private const int PageSize = 100; + private const int MaxScan = 5000; + + public async Task GetSnapshotAsync(AccessContext context, CancellationToken ct = default) + { + await adminGuard.EnsureAdminAsync(context, ct); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var memberKeys = (await db.SandboxMembers + .AsNoTracking() + .Select(x => x.UserKey) + .ToListAsync(ct)) + .ToHashSet(); + + var owned = await db.Sandboxes + .AsNoTracking() + .Select(x => new { x.OwnerUserKey, x.Id }) + .ToListAsync(ct); + + var ownerMap = owned.ToDictionary( + x => x.OwnerUserKey, + x => x.Id); + + var emptySandboxes = await db.Sandboxes + .AsNoTracking() + .Where(x => !x.Members.Any()) + .OrderBy(x => x.CreatedAt) + .Select(x => new EmptySandboxItem( + x.Id, + x.Name, + x.OwnerUserKey, + x.Status, + x.CreatedAt)) + .ToListAsync(ct); + + var userStore = userQueryFactory.Create(TenantKeys.Single); + var roleStore = roleStoreFactory.Create(TenantKeys.Single); + var assignmentStore = userRoleStoreFactory.Create(TenantKeys.Single); + + var ownerRole = await roleStore.GetByNameAsync( + LabRoles.Owner.ToUpperInvariant(), ct); + + var userRole = await roleStore.GetByNameAsync( + LabRoles.User.ToUpperInvariant(), ct); + + var adminRole = await roleStore.GetByNameAsync( + LabRoles.Admin.ToUpperInvariant(), ct); + + var orphanUsers = new List(); + var scanned = 0; + var page = 1; + var complete = false; + + while (scanned < MaxScan) + { + var users = await userStore.QueryAsync( + new UserQuery + { + PageNumber = page, + PageSize = PageSize, + IncludeDeleted = false + }, + ct); + + if (users.Items.Count == 0) + { + complete = true; + break; + } + + foreach (var user in users.Items) + { + if (memberKeys.Contains(user.UserKey)) + continue; + + var assignments = await assignmentStore + .GetAssignmentsAsync(user.UserKey, ct); + + var roleIds = assignments.Select(x => x.RoleId).ToHashSet(); + + // Never offer platform admins for sandbox repair. + if (adminRole is not null && roleIds.Contains(adminRole.Id)) + continue; + + var role = ownerRole is not null && roleIds.Contains(ownerRole.Id) + ? LabRoles.Owner + : userRole is not null && roleIds.Contains(userRole.Id) + ? LabRoles.User + : "Unknown"; + + ownerMap.TryGetValue(user.UserKey, out var ownedId); + + orphanUsers.Add(new OrphanUserItem( + user.UserKey, + user.DisplayName ?? user.UserName ?? user.UserKey.Value, + user.PrimaryEmail, + role, + user.CreatedAt, + ownedId == Guid.Empty ? null : ownedId)); + } + + scanned += users.Items.Count; + + if (scanned >= users.TotalCount) + { + complete = true; + break; + } + + page++; + } + + return new RepairSnapshot( + orphanUsers, + emptySandboxes, + complete); + } + + public async Task RepairOwnerAsync(AccessContext context, UserKey ownerUserKey, CancellationToken ct = default) + { + await adminGuard.EnsureAdminAsync(context, ct); + + if (context.ActorUserKey is null || context.ActorUserKey == ownerUserKey) + { + return RepairOperationResult.Fail("Cannot repair the current administrator account."); + } + + var roleStore = roleStoreFactory.Create(TenantKeys.Single); + var assignmentStore = userRoleStoreFactory.Create(TenantKeys.Single); + var userStore = userQueryFactory.Create(TenantKeys.Single); + + var ownerRole = await roleStore.GetByNameAsync( + LabRoles.Owner.ToUpperInvariant(), ct); + + var adminRole = await roleStore.GetByNameAsync( + LabRoles.Admin.ToUpperInvariant(), ct); + + if (ownerRole is null) + return RepairOperationResult.Fail("Owner role is missing."); + + var users = await userStore.GetByKeysAsync([ownerUserKey], ct); + + if (users.Count != 1) + return RepairOperationResult.Fail("User not found."); + + var assignments = await assignmentStore.GetAssignmentsAsync( + ownerUserKey, ct); + + if (!assignments.Any(x => x.RoleId == ownerRole.Id) || + (adminRole is not null && + assignments.Any(x => x.RoleId == adminRole.Id))) + { + return RepairOperationResult.Fail( + "User is not an eligible Sandbox Owner."); + } + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + await using var transaction = await db.Database + .BeginTransactionAsync(IsolationLevel.Serializable, ct); + + var existingMembership = await db.SandboxMembers + .AnyAsync(x => x.UserKey == ownerUserKey, ct); + + if (existingMembership) + return RepairOperationResult.Fail( + "User already has a Sandbox membership."); + + var sandbox = await db.Sandboxes + .Include(x => x.Settings) + .FirstOrDefaultAsync(x => x.OwnerUserKey == ownerUserKey, ct); + + if (sandbox is null) + { + var name = users[0].DisplayName + ?? users[0].UserName + ?? "Recovered"; + + if (name.Length > 120) + name = name[..120]; + + sandbox = new Sandbox + { + Id = Guid.NewGuid(), + Name = $"{name}'s Sandbox", + OwnerUserKey = ownerUserKey, + Status = SandboxStatus.Passive, + Settings = new SandboxSettings + { + MaxUsers = SandboxLimits.DefaultMaxUsers + } + }; + + db.Sandboxes.Add(sandbox); + } + + // An existing sandbox may have other members. + // Never exceed its configured capacity. + var memberCount = await db.SandboxMembers + .CountAsync(x => x.SandboxId == sandbox.Id, ct); + + if (memberCount >= sandbox.Settings.MaxUsers) + return RepairOperationResult.Fail( + "Sandbox capacity is full."); + + db.SandboxMembers.Add(new SandboxMember + { + Id = Guid.NewGuid(), + SandboxId = sandbox.Id, + UserKey = ownerUserKey, + Role = SandboxMemberRole.Owner, + JoinedAt = DateTimeOffset.UtcNow + }); + + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + logger.LogWarning( + "Ladmin {Actor} repaired owner {Owner} in sandbox {Sandbox}", + context.ActorUserKey.Value.Value, + ownerUserKey.Value, + sandbox.Id); + + return RepairOperationResult.Ok( + "Owner membership repaired. Review the sandbox status before activation."); + } + + public async Task DeleteEmptySandboxAsync( + AccessContext context, + Guid sandboxId, + CancellationToken ct = default) + { + await adminGuard.EnsureAdminAsync(context, ct); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + await using var transaction = await db.Database + .BeginTransactionAsync(IsolationLevel.Serializable, ct); + + var sandbox = await db.Sandboxes + .Include(x => x.Settings) + .FirstOrDefaultAsync(x => x.Id == sandboxId, ct); + + if (sandbox is null) + return RepairOperationResult.Fail("Sandbox not found."); + + if (await db.SandboxMembers.AnyAsync(x => x.SandboxId == sandboxId, ct)) + { + return RepairOperationResult.Fail("Sandbox is not empty."); + } + + // Activity logs may reference the sandbox. + // Do not cascade-delete forensic records. + if (await db.ActivityLogs.AnyAsync(x => x.SandboxId == sandboxId, ct)) + { + return RepairOperationResult.Fail("Sandbox has activity history and cannot be deleted automatically."); + } + + db.SandboxSettings.Remove(sandbox.Settings); + db.Sandboxes.Remove(sandbox); + + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + logger.LogWarning("Ladmin {Actor} deleted empty sandbox {Sandbox}", + context.ActorUserKey!.Value.Value, + sandboxId); + + return RepairOperationResult.Ok("Empty sandbox deleted."); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs new file mode 100644 index 00000000..91547940 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs @@ -0,0 +1,125 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.AspNetCore.Identity; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed class LabUserProvisioningService( + LabAccessContextProvider contextProvider, + SandboxService sandboxService, + SandboxAccessService accessService, + IUserApplicationService userApplicationService, + IRoleStoreFactory roleStoreFactory, + IUserRoleStoreFactory userRoleStoreFactory, + ILogger logger) +{ + public async Task CreateMemberAsync( + UserKey actorUserKey, + CreateUserRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + // Obtain the actor from the actual authenticated session. + var context = await contextProvider.CreateAsync( + UAuthActions.Users.CreateAdmin, + "users", + ct); + + if (!context.IsAuthenticated || + context.IsSystemActor || + context.ActorUserKey is not UserKey authenticatedUserKey || + authenticatedUserKey != actorUserKey) + { + throw new UnauthorizedAccessException( + "Authenticated actor mismatch."); + } + + var sandbox = await sandboxService.GetByUserAsync( + authenticatedUserKey, ct); + + if (sandbox is null) + throw new UnauthorizedAccessException("Sandbox not found."); + + var access = await accessService.CanManageAsync( + authenticatedUserKey, + sandbox.Id, + ct); + + if (!access.Allowed) + throw new UnauthorizedAccessException( + "Sandbox management permission required."); + + var remaining = await sandboxService.GetRemainingCapacityAsync( + sandbox.Id, + ct); + + if (remaining <= 0) + throw new InvalidOperationException( + "Sandbox user limit reached."); + + // Fail before account creation if the required role is absent. + var roleStore = roleStoreFactory.Create( + CodeBeam.UltimateAuth.Core.MultiTenancy.TenantKeys.Single); + + var role = await roleStore.GetByNameAsync("USER", ct); + + if (role is null || role.IsDeleted) + throw new InvalidOperationException( + "Identity Lab User role is not configured."); + + // Only this server-side path can attach the object-identity token. + var trustedContext = context.WithAttribute( + LabProvisioningGate.AttributeKey, + LabProvisioningGate.Token); + + var result = await userApplicationService.CreateUserAsync(trustedContext, request, ct); + + if (!result.IsSuccess || string.IsNullOrWhiteSpace(result.UserKey)) + { + return result; + } + + var newUserKey = UserKey.FromString(result.UserKey); + + try + { + var userRoleStore = userRoleStoreFactory.Create( + trustedContext.ResourceTenant); + + var assignments = await userRoleStore.GetAssignmentsAsync( + newUserKey, ct); + + if (!assignments.Any(x => x.RoleId == role.Id)) + { + await userRoleStore.AssignAsync( + newUserKey, + role.Id, + DateTimeOffset.UtcNow, + ct); + } + + await sandboxService.AddMemberAsync( + authenticatedUserKey, + newUserKey, + ct); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Partial provisioning failure for user {UserKey}", + newUserKey.Value); + + throw new LabPartialProvisioningException( + newUserKey.Value, + ex); + } + + return result; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs index c5f6c135..8d598784 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs @@ -250,4 +250,47 @@ public async Task GetRemainingCapacityAsync(Guid sandboxId, CancellationTok return Math.Max(0, settings.MaxUsers - count); } + + public async Task RemoveMemberAsync(UserKey actorUserKey, UserKey targetUserKey, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + await using var transaction = await db.Database + .BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + + var sandbox = await db.Sandboxes + .FirstOrDefaultAsync(x => x.OwnerUserKey == actorUserKey, ct); + + if (sandbox is null) + throw new UnauthorizedAccessException( + "Only the sandbox owner can remove members."); + + if (sandbox.Status != SandboxStatus.Active) + throw new InvalidOperationException("Sandbox is not active."); + + if (targetUserKey == sandbox.OwnerUserKey) + throw new InvalidOperationException( + "Sandbox owner cannot be removed."); + + var member = await db.SandboxMembers + .FirstOrDefaultAsync( + x => x.SandboxId == sandbox.Id && + x.UserKey == targetUserKey, + ct); + + if (member is null) + return false; + + if (member.Role == SandboxMemberRole.Owner) + throw new InvalidOperationException( + "Owner membership cannot be removed."); + + db.SandboxMembers.Remove(member); + + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + return true; + } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs index ab67ecf7..fa24dae4 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs @@ -1,17 +1,21 @@ using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.IdentityLab.Contracts; using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; using CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.IdentityLab.Services; -public sealed class SandboxUserService(SandboxService sandboxService, SandboxAccessService accessService, IUAuthClient uAuthClient, - IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, IUserSummaryQueryStoreFactory userSummaryQueryStoreFactory, - ActivityLogService activityLogService) +public sealed class SandboxUserService(SandboxService sandboxService, IUAuthClient uAuthClient, IDbContextFactory dbFactory, SandboxAccessService accessService, LabUserProvisioningService provisioningService, + IUserSummaryQueryStoreFactory userSummaryQueryStoreFactory, ActivityLogService activityLogService, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, ILogger logger) { public async Task GetUsersAsync(UserKey actorUserKey, CancellationToken ct = default) { @@ -72,57 +76,177 @@ public async Task CreateAsync(UserKey actorUserKey, Crea if (sandbox is null) return CreateUserDialogResult.Failure("Sandbox not found."); - var access = await accessService.CanManageAsync(actorUserKey, sandbox.Id, ct); + try + { + var result = await provisioningService.CreateMemberAsync( + actorUserKey, + request, + ct); - if (!access.Allowed) - return CreateUserDialogResult.Failure("You are not allowed to manage this sandbox."); + if (!result.IsSuccess || + string.IsNullOrWhiteSpace(result.UserKey)) + { + return CreateUserDialogResult.Failure( + result.FailureReason ?? "User creation failed."); + } + + await activityLogService.TryRecordAsync( + sandbox.Id, + actorUserKey.Value, + ActivityEvents.UserCreated, + result.UserKey, + ct); + + return CreateUserDialogResult.Success(); + } + catch (LabPartialProvisioningException) + { + return CreateUserDialogResult.Failure( + "The account was created, but sandbox setup could not be completed. " + + "Please contact the administrator for repair."); + } + catch (UnauthorizedAccessException) + { + return CreateUserDialogResult.Failure( + "You are not allowed to manage this sandbox."); + } + catch (UAuthValidationException ex) + { + return CreateUserDialogResult.Failure( + FormatValidationError(ex.Message)); + } + catch (InvalidOperationException ex) + when (ex.Message == "Sandbox user limit reached.") + { + return CreateUserDialogResult.Failure( + "Sandbox user limit reached. Remove a member before adding another."); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to create sandbox user."); - var remaining = await sandboxService.GetRemainingCapacityAsync(sandbox.Id, ct); + return CreateUserDialogResult.Failure( + "Unable to create the user. Please try again."); + } + } - if (remaining <= 0) - return CreateUserDialogResult.Failure("Sandbox user limit reached."); + public async Task<(bool Success, string Message)> DeleteAsync( + UserKey actorUserKey, + UserKey targetUserKey, + CancellationToken ct = default) + { + if (actorUserKey == targetUserKey) + return (false, "You cannot delete your own account."); - var result = await uAuthClient.Users.CreateAsAdminAsync(request); + await using var db = await dbFactory.CreateDbContextAsync(ct); - if (!result.IsSuccess || result.Value is null || result.Value.UserKey is null) - return CreateUserDialogResult.Failure(result.ErrorText ?? "User creation failed."); + var sandbox = await db.Sandboxes + .AsNoTracking() + .FirstOrDefaultAsync( + x => x.OwnerUserKey == actorUserKey && + x.Status == SandboxStatus.Active, + ct); - var newUserKey = UserKey.FromString(result.Value.UserKey); + if (sandbox is null) + return (false, "You are not the owner of an active sandbox."); - try + var owner = await db.SandboxMembers + .AsNoTracking() + .AnyAsync(x => + x.SandboxId == sandbox.Id && + x.UserKey == actorUserKey && + x.Role == SandboxMemberRole.Owner, ct); + + if (!owner) + return (false, "Owner membership is required."); + + var member = await db.SandboxMembers + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.SandboxId == sandbox.Id && + x.UserKey == targetUserKey, ct); + + if (member is null || member.Role != SandboxMemberRole.Member) + return (false, "The target is not a regular member of your sandbox."); + + // Prevent deleting an account with elevated global roles. + var roleStore = roleStoreFactory.Create(TenantKeys.Single); + var userRoleStore = userRoleStoreFactory.Create(TenantKeys.Single); + var assignments = await userRoleStore.GetAssignmentsAsync(targetUserKey, ct); + + var ownerRole = await roleStore.GetByNameAsync(LabRoles.Owner.ToUpperInvariant(), ct); + var adminRole = await roleStore.GetByNameAsync(LabRoles.Admin.ToUpperInvariant(), ct); + + if (ownerRole is null || adminRole is null) + return (false, "Required roles are not configured."); + + if (assignments.Any(x => + x.RoleId == ownerRole.Id || x.RoleId == adminRole.Id)) { - await AssignUserRoleAsync(newUserKey, ct); - await sandboxService.AddMemberAsync(actorUserKey, newUserKey, ct); + return (false, "Privileged accounts cannot be deleted."); } - catch (Exception) + + try { - return CreateUserDialogResult.Failure( - "User was created, but sandbox membership failed. " + - "Manual repair is required."); - } + var result = await uAuthClient.Users.DeleteUserAsync( + targetUserKey, + new DeleteUserRequest() { Mode = DeleteMode.Hard }); - await activityLogService.TryRecordAsync(sandbox.Id, actorUserKey.Value, ActivityEvents.UserCreated, newUserKey.Value, ct); + if (!result.IsSuccess) + return (false, result.ErrorText ?? "User deletion failed."); - return CreateUserDialogResult.Success(); - } + // Remove sandbox membership only after framework deletion succeeds. + var currentMember = await db.SandboxMembers + .SingleOrDefaultAsync(x => + x.SandboxId == sandbox.Id && + x.UserKey == targetUserKey, ct); - private async Task AssignUserRoleAsync(UserKey userKey, CancellationToken ct) - { - var tenant = TenantKeys.Single; + if (currentMember is not null) + { + db.SandboxMembers.Remove(currentMember); + await db.SaveChangesAsync(ct); + } - var roleStore = roleStoreFactory.Create(tenant); - var userRoleStore = userRoleStoreFactory.Create(tenant); + await activityLogService.TryRecordAsync( + sandbox.Id, + actorUserKey.Value, + ActivityEvents.UserDeleted, + targetUserKey.Value, + ct); - var role = await roleStore.GetByNameAsync("USER", ct); + return (true, "User permanently deleted."); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to delete sandbox user {TargetUserKey} by {ActorUserKey}", + targetUserKey.Value, + actorUserKey.Value); - if (role is null) - throw new InvalidOperationException("Identity Lab User role is not configured."); + return (false, "User deletion failed. Check server logs before retrying."); + } + } - var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct); + private static string FormatValidationError(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + return "Invalid user information."; - if (assignments.Any(x => x.RoleId == role.Id)) - return; + if (message.Contains("username_unavailable", StringComparison.OrdinalIgnoreCase)) + { + return "This username is already taken."; + } + + if (message.Contains("email_unavailable", StringComparison.OrdinalIgnoreCase)) + { + return "This email address is already registered."; + } + + if (message.Contains("phone_unavailable", StringComparison.OrdinalIgnoreCase)) + { + return "This phone number is already registered."; + } - await userRoleStore.AssignAsync(userKey, role.Id, DateTimeOffset.UtcNow, ct); + return "Please check the user information and try again."; } }