From 9ba3270ae46ad290c4b4993d1ceb570b83b159d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 9 Nov 2026 11:45:53 +0000 Subject: [PATCH 1/2] fix(identitylab): use authenticated HTTP context for Blazor management operations --- .../Authorization/LabAccessContextProvider.cs | 15 +- .../LabAdminAuthorizationFilter.cs | 9 +- .../LabManagementAuthorizationFilter.cs | 45 ++++++ .../Components/Pages/AdminRepairPanel.razor | 20 +-- .../Pages/Dashboard/DemoStorePage.razor | 10 +- .../Pages/Dashboard/UsersPage.razor | 10 +- .../Components/Pages/DashboardPage.razor | 32 ++-- .../Pages/Dialogs/DemoRoleDialog.razor | 12 +- .../Endpoints/LabAdminEndpointExtensions.cs | 37 +++-- .../LabManagementEndpointExtensions.cs | 47 ++++++ .../Program.cs | 3 + .../Services/LabDemoRoleService.cs | 19 +-- .../Services/LabDemoStoreService.cs | 17 +-- .../Services/LabManagementClient.cs | 19 +++ .../Services/LabUserProvisioningService.cs | 14 +- .../Services/SandboxUserService.cs | 8 +- apps/identity-lab/README.md | 41 ++++++ .../Infrastructure/ActorProvider.cs | 7 +- .../Infrastructure/LabHttpHost.cs | 88 +++++++++++ .../Infrastructure/LabRuntime.cs | 40 +++-- .../Integration/ManagementHttpTests.cs | 139 ++++++++++++++++++ .../RegistrationIntegrationTests.cs | 10 +- .../Integration/UserWorkflowTests.cs | 10 +- .../Provisioning/MemberProvisioningTests.cs | 8 +- .../Rendering/ManagementTransportTests.cs | 40 +++++ .../PageRenderContext.ApplicationPages.cs | 5 + .../Rendering/PageRenderContext.cs | 3 + .../Security/ValidatedRollbackContextTests.cs | 36 +++++ .../Services/DemoRoleServiceTests.cs | 14 +- .../Services/DemoStoreTests.cs | 10 +- 30 files changed, 633 insertions(+), 135 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabManagementAuthorizationFilter.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabManagementEndpointExtensions.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabManagementClient.cs create mode 100644 apps/identity-lab/README.md create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabHttpHost.cs create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/ManagementHttpTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/ManagementTransportTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/ValidatedRollbackContextTests.cs diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs index 081758c9..3f24258e 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs @@ -6,16 +6,25 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Authorization; public sealed class LabAccessContextProvider(IAuthFlow authFlow, IAccessContextFactory contextFactory) { + private AuthFlowContext? validatedFlow; + + // Authenticated management contexts must originate in an HTTP endpoint. public async Task CreateAsync(string action, string resource, CancellationToken cancellationToken = default) { var flow = await authFlow.BeginAsync(AuthFlowType.ValidateSession, cancellationToken); + validatedFlow = flow; return await contextFactory.CreateAsync(flow, action, resource, ct: cancellationToken); } - public async Task CreateForUserAsync(string action, string resource, UserKey targetUserKey, CancellationToken ct = default) + public Task CreateForValidatedUserAsync(AccessContext actor, string action, string resource, UserKey targetUserKey, CancellationToken ct = default) { - var flow = await authFlow.BeginAsync(AuthFlowType.ValidateSession, ct); - return await contextFactory.CreateAsync(flow, action, resource, resourceId: targetUserKey.Value, ct: ct); + // Reuse only the flow validated in this request, retaining actor, tenant and device validation. + if (validatedFlow is not { IsAuthenticated: true } flow || + !actor.IsAuthenticated || actor.IsSystemActor || + flow.UserKey != actor.ActorUserKey || flow.Tenant != actor.ActorTenant) + throw new UnauthorizedAccessException("Rollback actor mismatch."); + + return contextFactory.CreateAsync(flow, action, resource, resourceId: targetUserKey.Value, ct: ct); } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminAuthorizationFilter.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminAuthorizationFilter.cs index 220e5f06..68b3551b 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminAuthorizationFilter.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminAuthorizationFilter.cs @@ -2,15 +2,12 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Endpoints; -public sealed class LabAdminAuthorizationFilter(LabAccessContextProvider contextProvider, ILabAdminGuard adminGuard) : IEndpointFilter +public sealed class LabAdminAuthorizationFilter(ILabAdminGuard adminGuard) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext invocation, EndpointFilterDelegate next) { - var context = await contextProvider.CreateAsync( - "identitylab.admin", - "identitylab", - invocation.HttpContext.RequestAborted); + var context = LabManagementAuthorizationFilter.Context(invocation.HttpContext); if (!context.IsAuthenticated || context.ActorUserKey is null) return Results.Unauthorized(); @@ -19,7 +16,7 @@ public sealed class LabAdminAuthorizationFilter(LabAccessContextProvider context context, invocation.HttpContext.RequestAborted)) { - return Results.Forbid(); + return Results.StatusCode(StatusCodes.Status403Forbidden); } return await next(invocation); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabManagementAuthorizationFilter.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabManagementAuthorizationFilter.cs new file mode 100644 index 00000000..d1ea91eb --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabManagementAuthorizationFilter.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using Microsoft.AspNetCore.Antiforgery; + +namespace CodeBeam.UltimateAuth.IdentityLab.Endpoints; + +// A request scope owns the validated context. No component state or payload supplies the actor. +public sealed class LabManagementAuthorizationFilter( + LabAccessContextProvider contextProvider, + IAntiforgery antiforgery, + ILogger logger) : IEndpointFilter +{ + public static AccessContext Context(HttpContext http) => (AccessContext)http.Items[typeof(AccessContext)]!; + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext invocation, EndpointFilterDelegate next) + { + var http = invocation.HttpContext; + try + { + if (!HttpMethods.IsGet(http.Request.Method)) + { + try { await antiforgery.ValidateRequestAsync(http); } + catch (AntiforgeryValidationException) { return Results.BadRequest(); } + } + + var operation = http.GetEndpoint()!.Metadata.GetMetadata()!; + var context = await contextProvider.CreateAsync(operation.Action, operation.Resource, http.RequestAborted); + if (!context.IsAuthenticated || context.ActorUserKey is null) + return Results.Unauthorized(); + if (context.IsSystemActor || context.ActorTenant != TenantKeys.Single || context.IsCrossTenant) + return Results.StatusCode(StatusCodes.Status403Forbidden); + + http.Items[typeof(AccessContext)] = context; + return await next(invocation); + } + catch (UnauthorizedAccessException) { return Results.StatusCode(StatusCodes.Status403Forbidden); } + catch (OperationCanceledException) when (http.RequestAborted.IsCancellationRequested) { throw; } + catch (Exception ex) + { + logger.LogError(ex, "Identity Lab management request failed for {Path}", http.Request.Path); + return Results.Problem("Unable to complete the operation."); + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminRepairPanel.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminRepairPanel.razor index 8ca58dd1..7008841b 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminRepairPanel.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminRepairPanel.razor @@ -2,8 +2,7 @@ @using CodeBeam.UltimateAuth.IdentityLab.Contracts @using CodeBeam.UltimateAuth.IdentityLab.Services -@inject LabRepairService RepairService -@inject LabAccessContextProvider ContextProvider +@inject LabManagementClient Management @inject IDialogService DialogService @inject ISnackbar Snackbar @@ -156,14 +155,6 @@ 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; @@ -171,8 +162,7 @@ try { - var context = await GetContextAsync(); - _snapshot = await RepairService.GetSnapshotAsync(context); + _snapshot = await Management.SendAsync("/api/lab/admin/repair/scan"); } catch (Exception) { @@ -197,8 +187,7 @@ await ExecuteAsync(async () => { - var context = await GetContextAsync(); - return await RepairService.RepairOwnerAsync(context, user.UserKey); + return await Management.SendAsync($"/api/lab/admin/repair/owner/{Uri.EscapeDataString(user.UserKey.Value)}"); }); } @@ -215,8 +204,7 @@ await ExecuteAsync(async () => { - var context = await GetContextAsync(); - return await RepairService.DeleteEmptySandboxAsync(context, sandbox.SandboxId); + return await Management.SendAsync($"/api/lab/admin/repair/sandbox/{sandbox.SandboxId}/delete"); }); } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/DemoStorePage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/DemoStorePage.razor index 37102e70..a5a52cfd 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/DemoStorePage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/DemoStorePage.razor @@ -5,7 +5,7 @@ @using CodeBeam.UltimateAuth.IdentityLab.Authorization @using CodeBeam.UltimateAuth.IdentityLab.Services -@inject LabDemoStoreService DemoStoreService +@inject LabManagementClient Management @inject ISnackbar Snackbar Demo Store · Identity Lab @@ -221,9 +221,11 @@ private bool Has(string permission) => _permissions.Contains(permission); - protected override async Task OnInitializedAsync() + protected override async Task OnAfterRenderAsync(bool firstRender) { + if (!firstRender) return; await RefreshAccessAsync(); + StateHasChanged(); } private async Task RefreshAccessAsync() @@ -234,7 +236,7 @@ try { _permissions = - await DemoStoreService.GetPermissionsAsync(); + await Management.SendAsync>("/api/lab/manage/demo-permissions"); } catch (Exception) { @@ -259,7 +261,7 @@ try { - await DemoStoreService.RequireAsync(permission); + await Management.SendAsync("/api/lab/manage/demo-permissions/check", new { Permission = permission }); operation(); 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 180b806a..7be433b0 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 @@ -6,6 +6,7 @@ @using CodeBeam.UltimateAuth.Users.Contracts @layout DashboardLayout @inject SandboxUserService SandboxUserService +@inject LabManagementClient Management @inject IDialogService DialogService @inject ISnackbar Snackbar @@ -407,7 +408,14 @@ if (actorUserKey is null) return CreateUserDialogResult.Failure("You must be signed in."); - return await SandboxUserService.CreateAsync(actorUserKey.Value, request); + try + { + return await Management.SendAsync("/api/lab/manage/users", request); + } + catch (UnauthorizedAccessException) + { + return CreateUserDialogResult.Failure("You are not allowed to manage this sandbox."); + } } private async Task DeleteUserAsync(SandboxUserItem user) 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 19288fb4..8f6e2956 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor @@ -9,8 +9,7 @@ @attribute [UAuthAuthorize(Roles = "Ladmin,Owner,User")] @inject LabDashboardService DashboardService -@inject LabOwnerRepairService OwnerRepairService -@inject LabAccessContextProvider ContextProvider +@inject LabManagementClient Management @inject ISnackbar Snackbar Overview · Identity Lab @@ -482,11 +481,7 @@ if (_dashboard is null) { - var context = await ContextProvider.CreateAsync( - "identitylab.owner.repair.status", - "identitylab.sandbox"); - - _repairStatus = await OwnerRepairService.GetStatusAsync(context); + _loadRepairStatus = true; } _loadedUserKey = userKey; @@ -506,6 +501,23 @@ } } + private bool _loadRepairStatus; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!_loadRepairStatus) return; + _loadRepairStatus = false; + try + { + _repairStatus = await Management.SendAsync("/api/lab/manage/owner-repair/status"); + } + catch (Exception) + { + Snackbar.Add("Unable to load sandbox recovery status.", Severity.Error); + } + StateHasChanged(); + } + private async Task RepairSandboxAsync() { if (_repairing) @@ -515,11 +527,7 @@ try { - var context = await ContextProvider.CreateAsync( - "identitylab.owner.repair", - "identitylab.sandbox"); - - var result = await OwnerRepairService.RepairAsync(context); + var result = await Management.SendAsync("/api/lab/manage/owner-repair"); Snackbar.Add( result.Message, diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/DemoRoleDialog.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/DemoRoleDialog.razor index 2f6d9dcf..e01b0195 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/DemoRoleDialog.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/DemoRoleDialog.razor @@ -2,7 +2,7 @@ @using CodeBeam.UltimateAuth.IdentityLab.Authorization @using CodeBeam.UltimateAuth.IdentityLab.Services -@inject LabDemoRoleService DemoRoleService +@inject LabManagementClient Management @inject ISnackbar Snackbar @@ -118,9 +118,11 @@ private bool _saving; private string? _error; - protected override async Task OnInitializedAsync() + protected override async Task OnAfterRenderAsync(bool firstRender) { + if (!firstRender) return; await LoadAsync(); + StateHasChanged(); } private async Task LoadAsync() @@ -130,7 +132,7 @@ try { - var roles = await DemoRoleService.GetRolesAsync(UserKey); + var roles = await Management.SendAsync>("/api/lab/manage/demo-roles/query", new { UserKey = UserKey.Value, Role = "", Assigned = false }); _manager = roles.Contains( LabRoles.Manager, @@ -165,8 +167,8 @@ try { - await DemoRoleService.SetRoleAsync( - UserKey, role, assigned); + await Management.SendAsync("/api/lab/manage/demo-roles", + new { UserKey = UserKey.Value, Role = role, Assigned = assigned }); if (role == LabRoles.Manager) _manager = assigned; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabAdminEndpointExtensions.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabAdminEndpointExtensions.cs index f32b4b57..13ddd622 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabAdminEndpointExtensions.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabAdminEndpointExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.IdentityLab.Contracts; using CodeBeam.UltimateAuth.IdentityLab.Services; @@ -8,40 +9,48 @@ public static class LabAdminEndpointExtensions { public static IEndpointRouteBuilder MapLabAdminEndpoints(this IEndpointRouteBuilder endpoints) { - var group = endpoints.MapGroup("/api/lab/admin").AddEndpointFilter(); + var group = endpoints.MapGroup("/api/lab/admin") + .AddEndpointFilter() + .AddEndpointFilter() + .WithMetadata(new LabOperation("identitylab.admin", "identitylab")); group.MapGet("/sandboxes", async ( [AsParameters] SandboxQuery query, - LabAccessContextProvider contextProvider, + HttpContext http, SandboxAdminService adminService, CancellationToken ct) => { - var context = await contextProvider.CreateAsync( - "identitylab.sandboxes.admin", - "identitylab.sandboxes", - ct); + var context = LabManagementAuthorizationFilter.Context(http); var result = await adminService.QueryAsync( context, query, ct); return Results.Ok(result); - }); + }).WithMetadata(new LabOperation("identitylab.sandboxes.admin", "identitylab.sandboxes")); group.MapGet("/statistics", async ( - LabAccessContextProvider contextProvider, + HttpContext http, SandboxAdminService adminService, CancellationToken ct) => { - var context = await contextProvider.CreateAsync( - "identitylab.statistics.admin", - "identitylab.statistics", - ct); + var context = LabManagementAuthorizationFilter.Context(http); var result = await adminService.GetStatisticsAsync( context, ct); return Results.Ok(result); - }); + }).WithMetadata(new LabOperation("identitylab.statistics.admin", "identitylab.statistics")); + + // POST queries reuse the existing CSRF-aware JSON browser transport. + group.MapPost("/repair/scan", async (HttpContext http, LabRepairService service, CancellationToken ct) => + Results.Ok(await service.GetSnapshotAsync(LabManagementAuthorizationFilter.Context(http), ct))) + .WithMetadata(new LabOperation("identitylab.repair.admin", "identitylab.repair")); + group.MapPost("/repair/owner/{userKey}", async (string userKey, HttpContext http, LabRepairService service, CancellationToken ct) => + Results.Ok(await service.RepairOwnerAsync(LabManagementAuthorizationFilter.Context(http), UserKey.FromString(userKey), ct))) + .WithMetadata(new LabOperation("identitylab.repair.admin", "identitylab.repair")); + group.MapPost("/repair/sandbox/{sandboxId:guid}/delete", async (Guid sandboxId, HttpContext http, LabRepairService service, CancellationToken ct) => + Results.Ok(await service.DeleteEmptySandboxAsync(LabManagementAuthorizationFilter.Context(http), sandboxId, ct))) + .WithMetadata(new LabOperation("identitylab.repair.admin", "identitylab.repair")); return endpoints; } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabManagementEndpointExtensions.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabManagementEndpointExtensions.cs new file mode 100644 index 00000000..05942d16 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabManagementEndpointExtensions.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.IdentityLab.Endpoints; + +public sealed record LabOperation(string Action, string Resource); +public sealed record DemoRoleRequest(string UserKey, string Role, bool Assigned); +public sealed record DemoPermissionRequest(string Permission); + +public static class LabManagementEndpointExtensions +{ + public static IEndpointRouteBuilder MapLabManagementEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/lab/manage").AddEndpointFilter(); + group.MapPost("/users", async (CreateUserRequest request, HttpContext http, SandboxUserService service, CancellationToken ct) => + Results.Ok(await service.CreateAsync(LabManagementAuthorizationFilter.Context(http), request, ct))) + .WithMetadata(new LabOperation(UAuthActions.Users.CreateAdmin, "users")); + + group.MapPost("/owner-repair/status", async (HttpContext http, LabOwnerRepairService service, CancellationToken ct) => + Results.Ok(await service.GetStatusAsync(LabManagementAuthorizationFilter.Context(http), ct))) + .WithMetadata(new LabOperation("identitylab.owner.repair.status", "identitylab.sandbox")); + group.MapPost("/owner-repair", async (HttpContext http, LabOwnerRepairService service, CancellationToken ct) => + Results.Ok(await service.RepairAsync(LabManagementAuthorizationFilter.Context(http), ct))) + .WithMetadata(new LabOperation("identitylab.owner.repair", "identitylab.sandbox")); + + group.MapPost("/demo-roles/query", async (DemoRoleRequest request, HttpContext http, LabDemoRoleService service, CancellationToken ct) => + Results.Ok(await service.GetRolesAsync(LabManagementAuthorizationFilter.Context(http), UserKey.FromString(request.UserKey), ct))) + .WithMetadata(new LabOperation(UAuthActions.Authorization.Roles.GetAdmin, "authorization.roles")); + group.MapPost("/demo-roles", async (DemoRoleRequest request, HttpContext http, LabDemoRoleService service, CancellationToken ct) => + { + await service.SetRoleAsync(LabManagementAuthorizationFilter.Context(http), UserKey.FromString(request.UserKey), request.Role, request.Assigned, ct); + return Results.Ok(true); + }).WithMetadata(new LabOperation(UAuthActions.Authorization.Roles.AssignAdmin, "authorization.roles")); + group.MapPost("/demo-permissions", async (HttpContext http, LabDemoStoreService service, CancellationToken ct) => + Results.Ok(await service.GetPermissionsAsync(LabManagementAuthorizationFilter.Context(http), ct))) + .WithMetadata(new LabOperation(LabDemoPermissions.ProductsRead, "demo.products")); + group.MapPost("/demo-permissions/check", async (DemoPermissionRequest request, HttpContext http, LabDemoStoreService service, CancellationToken ct) => + { + await service.RequireAsync(LabManagementAuthorizationFilter.Context(http), request.Permission, ct); + return Results.Ok(true); + }).WithMetadata(new LabOperation(LabDemoPermissions.ProductsRead, "demo.products")); + return endpoints; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs index 2a34f7a9..334327ce 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -79,6 +79,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -161,6 +163,7 @@ app.MapUltimateAuthEndpoints(); app.MapLabAdminEndpoints(); +app.MapLabManagementEndpoints(); app.MapStaticAssets(); app.MapRazorComponents() diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoRoleService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoRoleService.cs index d3ffb645..f411cd45 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoRoleService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoRoleService.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; @@ -9,18 +9,16 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Services; -public sealed class LabDemoRoleService(LabAccessContextProvider contextProvider, IDbContextFactory dbFactory, +public sealed class LabDemoRoleService(IDbContextFactory dbFactory, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory) { - public async Task SetRoleAsync(UserKey targetUserKey, string roleName, bool assigned, CancellationToken ct = default) + public async Task SetRoleAsync(AccessContext context, UserKey targetUserKey, string roleName, bool assigned, CancellationToken ct = default) { // Never accept arbitrary roles. if (!LabRoles.IsDemoRole(roleName)) throw new UnauthorizedAccessException("Only demo roles can be managed here."); - var context = await contextProvider.CreateAsync(UAuthActions.Authorization.Roles.AssignAdmin, "authorization.roles", ct); - - if (!context.IsAuthenticated || + if (context.ActorTenant != TenantKeys.Single || context.IsCrossTenant || !context.IsAuthenticated || context.IsSystemActor || context.ActorUserKey is not UserKey actorKey) { @@ -85,11 +83,10 @@ public async Task SetRoleAsync(UserKey targetUserKey, string roleName, bool assi } } - public async Task> GetRolesAsync(UserKey targetUserKey, CancellationToken ct = default) + public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) { - var context = await contextProvider.CreateAsync(UAuthActions.Authorization.Roles.GetAdmin, "authorization.roles", ct); - - if (!context.IsAuthenticated || context.IsSystemActor || context.ActorUserKey is not UserKey actorKey) + if (context.ActorTenant != TenantKeys.Single || context.IsCrossTenant || !context.IsAuthenticated || + context.IsSystemActor || context.ActorUserKey is not UserKey actorKey) { throw new UnauthorizedAccessException(); } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoStoreService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoStoreService.cs index c0ba82dd..e77e3d3b 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoStoreService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoStoreService.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; @@ -10,20 +11,14 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Services; public sealed record DemoProduct(int Id, string Name, decimal Price); public sealed class LabDemoStoreService( - LabAccessContextProvider contextProvider, IDbContextFactory dbFactory, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory) { public async Task> GetPermissionsAsync( - CancellationToken ct = default) + AccessContext context, CancellationToken ct = default) { - var context = await contextProvider.CreateAsync( - LabDemoPermissions.ProductsRead, - "demo.products", - ct); - - if (!context.IsAuthenticated || + if (context.ActorTenant != TenantKeys.Single || context.IsCrossTenant || context.IsSystemActor || !context.IsAuthenticated || context.ActorUserKey is not UserKey actorKey) { return new HashSet(); @@ -95,10 +90,10 @@ public async Task> GetPermissionsAsync( } public async Task RequireAsync( - string permission, + AccessContext context, string permission, CancellationToken ct = default) { - var permissions = await GetPermissionsAsync(ct); + var permissions = await GetPermissionsAsync(context, ct); if (!permissions.Contains(permission)) throw new UnauthorizedAccessException( diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabManagementClient.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabManagementClient.cs new file mode 100644 index 00000000..b8acecfa --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabManagementClient.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Client.Infrastructure; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +// Uses UltimateAuth's browser transport: cookies, profile, X-UDID and antiforgery. +public sealed class LabManagementClient(IUAuthRequestClient requests) +{ + public async Task SendAsync(string endpoint, object? payload = null, CancellationToken ct = default) + { + var result = await requests.SendJsonAsync(endpoint, payload, ct); + if (result.Status is 401 or 403) + throw new UnauthorizedAccessException("Management permission required."); + if (!result.Ok || result.Body is not { } body) + throw new InvalidOperationException("Unable to complete the operation."); + return body.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidOperationException("Invalid management response."); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs index 91f807b1..b8ce384d 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; @@ -20,17 +21,15 @@ public sealed class LabUserProvisioningService( LabProvisioningRollbackGate rollbackGate, ILogger logger) { - public async Task CreateMemberAsync(UserKey actorUserKey, CreateUserRequest request, CancellationToken ct = default) + public async Task CreateMemberAsync(AccessContext context, 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) + context.ActorTenant != TenantKeys.Single || + context.IsCrossTenant || context.Action != UAuthActions.Users.CreateAdmin) { throw new UnauthorizedAccessException("Authenticated actor mismatch."); } @@ -56,7 +55,7 @@ context.ActorUserKey is not UserKey authenticatedUserKey || // Fail before account creation if the required role is absent. var roleStore = roleStoreFactory.Create( - CodeBeam.UltimateAuth.Core.MultiTenancy.TenantKeys.Single); + TenantKeys.Single); var role = await roleStore.GetByNameAsync("USER", ct); @@ -116,7 +115,8 @@ await sandboxService.AddMemberAsync( using var cleanupCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var deleteContext = await contextProvider.CreateForUserAsync( + var deleteContext = await contextProvider.CreateForValidatedUserAsync( + context, UAuthActions.Users.DeleteAdmin, "users", newUserKey, diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs index e18cc6f2..8de025ab 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs @@ -68,8 +68,12 @@ public sealed class SandboxUserService(SandboxService sandboxService, IUAuthClie return new SandboxUsersSnapshot(sandbox.Id, sandbox.Settings.MaxUsers, users); } - public async Task CreateAsync(UserKey actorUserKey, CreateUserRequest request, CancellationToken ct = default) + public async Task CreateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) { + if (!context.IsAuthenticated || context.IsSystemActor || context.ActorUserKey is not UserKey actorUserKey || + context.ActorTenant != TenantKeys.Single || context.IsCrossTenant) + return CreateUserDialogResult.Failure("You are not allowed to manage this sandbox."); + var sandbox = await sandboxService.GetByUserAsync(actorUserKey, ct); if (sandbox is null) @@ -78,7 +82,7 @@ public async Task CreateAsync(UserKey actorUserKey, Crea try { var result = await provisioningService.CreateMemberAsync( - actorUserKey, + context, request, ct); diff --git a/apps/identity-lab/README.md b/apps/identity-lab/README.md new file mode 100644 index 00000000..c3b0d265 --- /dev/null +++ b/apps/identity-lab/README.md @@ -0,0 +1,41 @@ +# Identity Lab management requests + +Interactive Blazor Server events do not have a fresh browser HTTP request. +`IAuthFlow.BeginAsync` uses `IHttpContextAccessor`, and session validation resolves +the device from that HTTP request. A circuit event can therefore have a session +but no current `X-UDID`, causing `DeviceMismatch` and an unauthenticated access +context. Do not construct authenticated access contexts in component events. + +Identity Lab uses `LabManagementClient` and `IUAuthRequestClient.SendJsonAsync` +to initiate same-origin browser requests. The existing UltimateAuth transport +bootstraps the device and sends cookies, client profile, device header and an +antiforgery token. Read operations also use POST to reuse this transport without +expanding the public client API. Initial browser calls run after interactive +rendering; mutations retain their UI confirmation steps. + +`LabManagementAuthorizationFilter` checks antiforgery, validates the request's +session and device, and creates a request-scoped access context. Endpoints pass +that context to the application services. Actor, tenant, roles and sandbox +ownership are never accepted from the payload. Admin routes additionally use +`LabAdminAuthorizationFilter` and the existing Ladmin guard. Services retain +ownership, capacity, target, tenant and role checks. + +Provisioning rollback reuses the flow validated in the same request to create a +delete context bound to the created user. It verifies the actor again and retains +the single-use rollback gate, independent cleanup timeout and authorization +policy. It does not start another session validation or use a system actor. + +## Context usage audit + +- **HTTP request usage:** management and admin endpoint filters. Downstream + services receive the validated context; rollback retargets the validated flow. +- **Interactive usages migrated:** member creation, admin repair scan/owner + repair/empty sandbox deletion, owner recovery status/execution, demo role + queries/updates, and demo permission queries/checks. +- **Unrelated anonymous usages:** registration and registration availability + construct contexts for anonymous creation and public identifier validation. + These do not authorize management through a session and retain their existing + provisioning and rate-limit rules. +- **Existing browser SDK paths:** sandbox member deletion already uses the + UltimateAuth browser client for the authenticated framework operation; it does + not construct an authenticated flow in a component event. diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs index db515780..f0a446ab 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs @@ -14,13 +14,16 @@ public static LabAccessContextProvider For(UserKey? actor) // ownership queries and PostgreSQL authorization decisions remain real. var flow = new Mock(); flow.Setup(x => x.BeginAsync(It.IsAny(), It.IsAny())) - .Returns(new ValueTask((AuthFlowContext)null!)); + .Returns(new ValueTask(actor.HasValue ? Contexts.AuthenticatedFlow(actor.Value) : null!)); var contexts = new Mock(); contexts.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) .Returns((AuthFlowContext _, string action, string resource, string? id, IDictionary? attributes, CancellationToken ct) => Task.FromResult(Contexts.Create(action, actor, id is null ? null : UserKey.FromString(id)))); - return new LabAccessContextProvider(flow.Object, contexts.Object); + var provider = new LabAccessContextProvider(flow.Object, contexts.Object); + if (actor.HasValue) + provider.CreateAsync("users.create.admin", "users").GetAwaiter().GetResult(); + return provider; } } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabHttpHost.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabHttpHost.cs new file mode 100644 index 00000000..d7b79c2f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabHttpHost.cs @@ -0,0 +1,88 @@ +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Endpoints; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Infrastructure; + +internal sealed class LabHttpHost(WebApplication app) : IAsyncDisposable +{ + public IServiceProvider Services => app.Services; + public static async Task CreateAsync(LabDatabase database) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + LabRuntime.ConfigureServices(builder.Services, database); + builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); + 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(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + // SandboxUserService's delete path uses the SDK; these tests never invoke it. + builder.Services.AddSingleton(Moq.Mock.Of()); + var app = builder.Build(); + app.UseDeveloperExceptionPage(); + app.UseUltimateAuthWithAspNetCore(); + app.UseAntiforgery(); + app.MapUltimateAuthEndpoints(); + app.MapLabManagementEndpoints(); + app.MapLabAdminEndpoints(); + await LabRuntime.SeedAsync(app.Services); + await app.StartAsync(); + return new(app); + } + + public HttpClient Browser(string? device = "identitylab-browser") + { + var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(app.Urls.Single()) + }; + client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", "BlazorServer"); + if (device is not null) client.DefaultRequestHeaders.Add("X-UDID", device); + return client; + } + + public static async Task PostAsync(HttpClient client, string path, object? payload = null) + { + var ct = TestContext.Current.CancellationToken; + using var tokenResponse = await client.GetAsync("/auth/antiforgery/token", ct); + Assert.True(tokenResponse.IsSuccessStatusCode, await tokenResponse.Content.ReadAsStringAsync(ct)); + var token = await tokenResponse.Content.ReadFromJsonAsync(ct); + using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(payload ?? new { }) }; + request.Headers.Add(token!.HeaderName, token.RequestToken); + return await client.SendAsync(request, ct); + } + + public static async Task LoginAsync(HttpClient client, string identifier, string secret) + { + using var response = await PostAsync(client, "/auth/login", new { identifier, secret }); + Assert.True(response.StatusCode == HttpStatusCode.Found, + $"Login returned {response.StatusCode}: {await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)}"); + var session = response.Headers.GetValues("Set-Cookie").Select(x => x.Split(';', 2)[0]).Single(x => x.StartsWith("uas=")); + // Kestrel is HTTP in this fixture; explicitly send the Secure session cookie, + // as the repository's other HTTP integration clients do. Production stays Secure. + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", session); + return session; + } + + private sealed record Token(string HeaderName, string RequestToken); + public async ValueTask DisposeAsync() { await app.StopAsync(); await app.DisposeAsync(); } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabRuntime.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabRuntime.cs index 58c5132b..3d48e329 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabRuntime.cs @@ -23,6 +23,23 @@ internal sealed class LabRuntime(ServiceProvider provider) : IAsyncDisposable public static async Task CreateAsync(LabDatabase database) { var services = new ServiceCollection(); + ConfigureServices(services, database); + var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + var runtime = new LabRuntime(provider); + try + { + await SeedAsync(provider); + return runtime; + } + catch + { + await runtime.DisposeAsync(); + throw; + } + } + + internal static void ConfigureServices(IServiceCollection services, LabDatabase database) + { services.AddLogging(); services.AddRouting(); services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); @@ -41,21 +58,14 @@ public static async Task CreateAsync(LabDatabase database) options.Email = "lab-admin@example.test"; options.Password = "Lab-admin-test-password-8!"; }); - var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); - var runtime = new LabRuntime(provider); - try - { - await using var scope = provider.CreateAsyncScope(); - await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); - await ActivatorUtilities.CreateInstance(scope.ServiceProvider).SeedAsync(TenantKeys.Single); - await ActivatorUtilities.CreateInstance(scope.ServiceProvider).SeedAsync(TenantKeys.Single); - return runtime; - } - catch - { - await runtime.DisposeAsync(); - throw; - } + } + + internal static async Task SeedAsync(IServiceProvider provider) + { + await using var scope = provider.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); + await ActivatorUtilities.CreateInstance(scope.ServiceProvider).SeedAsync(TenantKeys.Single); + await ActivatorUtilities.CreateInstance(scope.ServiceProvider).SeedAsync(TenantKeys.Single); } public ValueTask DisposeAsync() => provider.DisposeAsync(); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/ManagementHttpTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/ManagementHttpTests.cs new file mode 100644 index 00000000..67bbde8e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/ManagementHttpTests.cs @@ -0,0 +1,139 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Integration; + +public sealed class ManagementHttpTests(PostgresFixture postgres) : IClassFixture +{ + private const string Password = "Http-test-password-8!"; + + private static async Task RegisterAsync(LabHttpHost host, LabDatabase db, string name) + { + await using var scope = host.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var registration = new LabRegistrationService(ActorProvider.For(null), sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + new(db), new(db, NullLogger.Instance)); + var registered = await registration.RegisterAsync(new(name, $"{name}@example.test", Password), TestContext.Current.CancellationToken); + Assert.True(registered.IsSuccess, registered.ErrorMessage); + await using var verify = db.CreateDbContext(); + return (await verify.Sandboxes.SingleAsync(x => x.Name == $"{name}'s Sandbox")).OwnerUserKey; + } + + [Fact] + public async Task Browser_request_provisions_as_validated_owner_enforces_capacity_membership_and_target_isolation() + { + await using var db = await postgres.CreateAsync(); + await using var host = await LabHttpHost.CreateAsync(db); + var owner = await RegisterAsync(host, db, "owner-http"); + var foreign = await RegisterAsync(host, db, "foreign-http"); + using var browser = host.Browser(); + await LabHttpHost.LoginAsync(browser, "owner-http", Password); + + // Unknown actor/tenant fields are ignored: only the validated session is authoritative. + using var created = await LabHttpHost.PostAsync(browser, "/api/lab/manage/users", new + { + userName = "member-http", email = "member-http@example.test", password = Password, + actorUserKey = foreign.Value, tenant = "forged-tenant" + }); + Assert.Equal(HttpStatusCode.OK, created.StatusCode); + Assert.True((await created.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken))!.IsSuccess); + UserKey member; + await using (var verify = db.CreateDbContext()) + { + var membership = await verify.SandboxMembers.Include(x => x.Sandbox) + .SingleAsync(x => x.Role == SandboxMemberRole.Member, TestContext.Current.CancellationToken); + Assert.Equal(owner, membership.Sandbox.OwnerUserKey); + member = membership.UserKey; + var settings = await verify.SandboxSettings.SingleAsync(x => x.SandboxId == membership.SandboxId, TestContext.Current.CancellationToken); + settings.MaxUsers = 2; + await verify.SaveChangesAsync(TestContext.Current.CancellationToken); + } + await using (var scope = host.Services.CreateAsyncScope()) + { + var roles = await scope.ServiceProvider.GetRequiredService() + .Create(TenantKeys.Single).GetAssignmentsAsync(member, TestContext.Current.CancellationToken); + Assert.Contains(roles, x => x.RoleId == LabRoleIds.User); + } + using var capacity = await LabHttpHost.PostAsync(browser, "/api/lab/manage/users", new CreateUserRequest + { UserName = "over-capacity", Email = "capacity@example.test", Password = Password }); + var capacityResult = await capacity.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.False(capacityResult!.IsSuccess); + Assert.Contains("limit", capacityResult.ErrorMessage); + + using var memberBrowser = host.Browser("member-device-0001"); + await LabHttpHost.LoginAsync(memberBrowser, "member-http", Password); + using var denied = await LabHttpHost.PostAsync(memberBrowser, "/api/lab/manage/users", new CreateUserRequest + { UserName = "forbidden", Email = "forbidden@example.test", Password = Password }); + Assert.False((await denied.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken))!.IsSuccess); + using var memberRepair = await LabHttpHost.PostAsync(memberBrowser, "/api/lab/admin/repair/scan"); + Assert.Equal(HttpStatusCode.Forbidden, memberRepair.StatusCode); + using var ownerRepair = await LabHttpHost.PostAsync(browser, "/api/lab/admin/repair/scan"); + Assert.Equal(HttpStatusCode.Forbidden, ownerRepair.StatusCode); + + using var foreignBrowser = host.Browser("foreign-device-0001"); + await LabHttpHost.LoginAsync(foreignBrowser, "foreign-http", Password); + using var foreignTarget = await LabHttpHost.PostAsync(foreignBrowser, "/api/lab/manage/demo-roles", new + { userKey = member.Value, role = "Manager", assigned = true }); + Assert.Equal(HttpStatusCode.Forbidden, foreignTarget.StatusCode); + } + + [Fact] + public async Task Repair_requires_live_session_matching_device_ladmin_and_csrf() + { + await using var db = await postgres.CreateAsync(); + await using var host = await LabHttpHost.CreateAsync(db); + using var anonymous = host.Browser(); + using var anonymousResult = await LabHttpHost.PostAsync(anonymous, "/api/lab/admin/repair/scan"); + Assert.Equal(HttpStatusCode.Unauthorized, anonymousResult.StatusCode); + using var anonymousCreate = await LabHttpHost.PostAsync(anonymous, "/api/lab/manage/users", new CreateUserRequest()); + Assert.Equal(HttpStatusCode.Unauthorized, anonymousCreate.StatusCode); + + using var browser = host.Browser(); + await LabHttpHost.LoginAsync(browser, "lab-admin", "Lab-admin-test-password-8!"); + using var scan = await LabHttpHost.PostAsync(browser, "/api/lab/admin/repair/scan"); + Assert.Equal(HttpStatusCode.OK, scan.StatusCode); + Assert.NotNull(await scan.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken)); + using var noCsrf = await browser.PostAsJsonAsync("/api/lab/admin/repair/sandbox/00000000-0000-0000-0000-000000000001/delete", new { }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, noCsrf.StatusCode); + using var noCreateCsrf = await browser.PostAsJsonAsync("/api/lab/manage/users", new CreateUserRequest(), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, noCreateCsrf.StatusCode); + + browser.DefaultRequestHeaders.Remove("X-UDID"); + using var missing = await LabHttpHost.PostAsync(browser, "/api/lab/admin/repair/scan"); + Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode); + browser.DefaultRequestHeaders.Add("X-UDID", "different-device"); + using var mismatch = await LabHttpHost.PostAsync(browser, "/api/lab/admin/repair/scan"); + Assert.Equal(HttpStatusCode.Unauthorized, mismatch.StatusCode); + + browser.DefaultRequestHeaders.Remove("X-UDID"); + browser.DefaultRequestHeaders.Add("X-UDID", "identitylab-browser"); + var oldSession = await LabHttpHost.LoginAsync(browser, "lab-admin", "Lab-admin-test-password-8!"); + using var logout = await LabHttpHost.PostAsync(browser, "/auth/logout"); + Assert.True((int)logout.StatusCode is >= 200 and < 400); + using var replay = host.Browser(); + replay.DefaultRequestHeaders.Add("Cookie", oldSession); + using var revoked = await LabHttpHost.PostAsync(replay, "/api/lab/admin/repair/scan"); + Assert.Equal(HttpStatusCode.Unauthorized, revoked.StatusCode); + using var revokedCreate = await LabHttpHost.PostAsync(replay, "/api/lab/manage/users", new CreateUserRequest()); + Assert.Equal(HttpStatusCode.Unauthorized, revokedCreate.StatusCode); + using var invalid = host.Browser(); + invalid.DefaultRequestHeaders.Add("Cookie", "uas=invalid-session"); + using var invalidScan = await LabHttpHost.PostAsync(invalid, "/api/lab/admin/repair/scan"); + Assert.Equal(HttpStatusCode.Unauthorized, invalidScan.StatusCode); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs index 67f6757d..dfe29d70 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs @@ -114,13 +114,13 @@ public async Task Owner_can_provision_member_and_assign_demo_role_but_cannot_del var provider = ActorProvider.For(owner); var provisioning = new LabUserProvisioningService(provider, new(db), new(db), users, roles, assignments, sp.GetRequiredService(), NullLogger.Instance); - var result = await provisioning.CreateMemberAsync(owner, new CreateUserRequest + var result = await provisioning.CreateMemberAsync(Contexts.Create("users.create.admin", owner), new CreateUserRequest { UserName = "member", Email = "member@example.test", Password = "test-password-8!" }, TestContext.Current.CancellationToken); Assert.True(result.IsSuccess, result.FailureReason); var member = UserKey.FromString(result.UserKey!); - var demos = new LabDemoRoleService(provider, db, roles, assignments); - await demos.SetRoleAsync(member, "Manager", true, TestContext.Current.CancellationToken); - await Assert.ThrowsAsync(() => demos.SetRoleAsync(member, "Ladmin", true, ct: TestContext.Current.CancellationToken)); + var demos = new LabDemoRoleService(db, roles, assignments); + await demos.SetRoleAsync(Contexts.Create("roles.assign.admin", owner), member, "Manager", true, TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => demos.SetRoleAsync(Contexts.Create("roles.assign.admin", owner), member, "Ladmin", true, ct: TestContext.Current.CancellationToken)); var roleService = sp.GetRequiredService(); await Assert.ThrowsAsync(() => roleService.AssignAsync(Contexts.Create(CodeBeam.UltimateAuth.Core.Defaults.UAuthActions.Authorization.Roles.AssignAdmin, @@ -191,7 +191,7 @@ public async Task Provisioning_assignment_fault_hard_deletes_real_partial_accoun var provisioning = new LabUserProvisioningService(ActorProvider.For(owner), new(db), new(db), users, roles, failedFactory.Object, sp.GetRequiredService(), NullLogger.Instance); - var failure = await Assert.ThrowsAsync(() => provisioning.CreateMemberAsync(owner, + var failure = await Assert.ThrowsAsync(() => provisioning.CreateMemberAsync(Contexts.Create("users.create.admin", owner), new CreateUserRequest { UserName = "failed-member", Email = "failed-member@example.test", Password = "Member-test-password-8!" }, TestContext.Current.CancellationToken)); var partialKey = UserKey.FromString(failure.CreatedUserKey); await using var fresh = runtime.Services.CreateAsyncScope(); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs index 79926be4..67d1418b 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs @@ -53,7 +53,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou var sp = scope.ServiceProvider; var owner = await RegisterAsync(sp, db, "owner"); var foreignOwner = await RegisterAsync(sp, db, "foreign"); - var foreign = await Provisioning(sp, db, foreignOwner).CreateMemberAsync(foreignOwner, Request("foreign-member"), TestContext.Current.CancellationToken); + var foreign = await Provisioning(sp, db, foreignOwner).CreateMemberAsync(Contexts.Create("users.create.admin", foreignOwner), Request("foreign-member"), TestContext.Current.CancellationToken); Assert.True(foreign.IsSuccess, foreign.FailureReason); var foreignKey = UserKey.FromString(foreign.UserKey!); @@ -69,7 +69,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou client.SetupGet(x => x.Users).Returns(userClient.Object); var service = UserService(sp, db, owner, client.Object); - await service.CreateAsync(owner, Request("member"), TestContext.Current.CancellationToken); + await service.CreateAsync(Contexts.Create("users.create.admin", owner), Request("member"), TestContext.Current.CancellationToken); var visible = await service.GetUsersAsync(owner, TestContext.Current.CancellationToken); Assert.NotNull(visible); var member = Assert.Single(visible.Users, x => !x.IsOwner).UserKey; @@ -77,7 +77,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou Assert.DoesNotContain(visible.Users, x => x.UserKey == foreignKey); var beforeRejected = await db.SnapshotAsync(); // The facade reports failures; assert persistence rather than dialog wording. - await service.CreateAsync(owner, Request("rejected-member"), TestContext.Current.CancellationToken); + await service.CreateAsync(Contexts.Create("users.create.admin", owner), Request("rejected-member"), TestContext.Current.CancellationToken); Assert.Equal(beforeRejected, await db.SnapshotAsync()); await using (var fresh = runtime.Services.CreateAsyncScope()) { @@ -127,7 +127,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou Assert.NotNull(dashboard); Assert.Equal(1, dashboard.MemberCount); Assert.Equal(1, dashboard.RemainingCapacity); - await service.CreateAsync(owner, Request("replacement"), TestContext.Current.CancellationToken); + await service.CreateAsync(Contexts.Create("users.create.admin", owner), Request("replacement"), TestContext.Current.CancellationToken); Assert.Equal(2, (await service.GetUsersAsync(owner, TestContext.Current.CancellationToken))!.Users.Count); await using var final = db.CreateDbContext(); Assert.False(await final.SandboxMembers.AnyAsync(x => x.UserKey == member, cancellationToken: TestContext.Current.CancellationToken)); @@ -145,7 +145,7 @@ public async Task Owner_and_admin_recovery_restore_missing_membership_without_du var sp = scope.ServiceProvider; var owner = await RegisterAsync(sp, db, "repair-owner"); var other = await RegisterAsync(sp, db, "other-owner"); - var member = await Provisioning(sp, db, owner).CreateMemberAsync(owner, Request("retained-member"), TestContext.Current.CancellationToken); + var member = await Provisioning(sp, db, owner).CreateMemberAsync(Contexts.Create("users.create.admin", owner), Request("retained-member"), TestContext.Current.CancellationToken); Assert.True(member.IsSuccess, member.FailureReason); var roles = sp.GetRequiredService(); var assignments = sp.GetRequiredService(); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs index 3bb5675f..e0762e33 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs @@ -17,9 +17,9 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Provisioning; public sealed class MemberProvisioningTests(PostgresFixture postgres) : IClassFixture { [Theory] - [InlineData("owner-b")] + [InlineData(null)] [InlineData("member-a")] - public async Task Forged_actor_or_regular_member_cannot_provision(string requestedActor) + public async Task Anonymous_or_regular_member_cannot_provision(string? actor) { await using var db = await postgres.CreateAsync(); await db.SeedAsync(); @@ -31,7 +31,7 @@ public async Task Forged_actor_or_regular_member_cannot_provision(string request new(db), new(db), users.Object, roles.Object, assignments.Object, new(), NullLogger.Instance); await Assert.ThrowsAsync(() => service.CreateMemberAsync( - UserKey.FromString(requestedActor), new CreateUserRequest(), TestContext.Current.CancellationToken)); + Contexts.Create("users.create.admin", actor is null ? null : UserKey.FromString(actor)), new CreateUserRequest(), TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); users.VerifyNoOtherCalls(); roles.VerifyNoOtherCalls(); @@ -83,7 +83,7 @@ public async Task Failed_role_assignment_rolls_back_only_created_user_and_token_ var service = new LabUserProvisioningService(ActorProvider.For(LabDatabase.OwnerA), new(db), new(db), users.Object, roles.Object, assignments.Object, gate, NullLogger.Instance); await Assert.ThrowsAsync(() => service.CreateMemberAsync( - LabDatabase.OwnerA, new CreateUserRequest(), TestContext.Current.CancellationToken)); + Contexts.Create("users.create.admin", LabDatabase.OwnerA), new CreateUserRequest(), TestContext.Current.CancellationToken)); Assert.NotNull(rollbackContext); Assert.Equal(createdKey, rollbackContext!.TargetUserKey); Assert.Equal(DeleteMode.Hard, rollbackMode); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/ManagementTransportTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/ManagementTransportTests.cs new file mode 100644 index 00000000..81eb8c7b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/ManagementTransportTests.cs @@ -0,0 +1,40 @@ +using Bunit; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Components.Pages; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Moq; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering; + +public sealed class ManagementTransportTests +{ + [Fact] + public async Task Scan_uses_browser_transport_and_renders_returned_snapshot() + { + await using var context = new PageRenderContext(); + context.AddAdminServices(); + context.Requests.Setup(x => x.SendJsonAsync("/api/lab/admin/repair/scan", null, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, Status = 200, + Body = JsonSerializer.SerializeToElement(new RepairSnapshot([], [], true)) + }); + var panel = context.RenderPage(); + await panel.Find("button").ClickAsync(); + panel.WaitForAssertion(() => Assert.Contains("Unlinked Users (0)", panel.Markup)); + context.Requests.Verify(x => x.SendJsonAsync("/api/lab/admin/repair/scan", null, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Client_rejects_failed_transport_without_exposing_response_details() + { + var requests = new Mock(); + requests.Setup(x => x.SendJsonAsync(It.IsAny(), null, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 500, Body = JsonSerializer.SerializeToElement("private diagnostic") }); + var error = await Assert.ThrowsAsync(() => + new LabManagementClient(requests.Object).SendAsync("/api/lab/admin/repair/scan", ct: Xunit.TestContext.Current.CancellationToken)); + Assert.DoesNotContain("private", error.Message); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.ApplicationPages.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.ApplicationPages.cs index 5f994ee9..62e472ec 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.ApplicationPages.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.ApplicationPages.cs @@ -84,6 +84,11 @@ public void AddAdminServices() public void AddDemoStoreServices() { + Requests.Setup(x => x.SendJsonAsync("/api/lab/manage/demo-permissions", null, It.IsAny())) + .ReturnsAsync(new CodeBeam.UltimateAuth.Client.Contracts.UAuthTransportResult + { + Ok = true, Status = 200, Body = System.Text.Json.JsonSerializer.SerializeToElement(Array.Empty()) + }); AddLabServices(); var flow = new Mock(MockBehavior.Strict); flow.Setup(x => x.BeginAsync(It.IsAny(), It.IsAny())) diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.cs index 0c59e9bf..79083151 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Rendering/PageRenderContext.cs @@ -34,6 +34,7 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering; internal sealed partial class PageRenderContext : BunitContext { + public Mock Requests { get; } = new(MockBehavior.Strict); public Mock Client { get; } = new(MockBehavior.Strict); public Mock Sessions { get; } = new(MockBehavior.Strict); public Mock Authorization { get; } = new(MockBehavior.Strict); @@ -57,6 +58,7 @@ public PageRenderContext() Client.SetupGet(x => x.Users).Returns(Users.Object); Client.SetupGet(x => x.Identifiers).Returns(Identifiers.Object); Services.AddSingleton(Client.Object); + Services.AddSingleton(Requests.Object); Services.AddSingleton(Options.Create(new UAuthClientOptions())); Services.AddSingleton(Options.Create(new UAuthServerOptions())); var device = new Mock(); @@ -113,6 +115,7 @@ public void AddLabServices() Services.AddSingleton(Mock.Of()); Services.AddSingleton(Mock.Of()); Services.AddScoped(); + Services.AddScoped(); Services.AddScoped(); Services.AddScoped(); Services.AddScoped(); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/ValidatedRollbackContextTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/ValidatedRollbackContextTests.cs new file mode 100644 index 00000000..60ea2b38 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/ValidatedRollbackContextTests.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.Server.Auth; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Security; + +public sealed class ValidatedRollbackContextTests +{ + [Fact] + public async Task Retargeting_reuses_validated_flow_and_rejects_a_different_actor() + { + var owner = LabDatabase.OwnerA; + var target = UserKey.FromString("created-user"); + var validated = Contexts.AuthenticatedFlow(owner); + var flows = new Mock(MockBehavior.Strict); + flows.Setup(x => x.BeginAsync(AuthFlowType.ValidateSession, It.IsAny())).ReturnsAsync(validated); + var factory = new Mock(MockBehavior.Strict); + factory.Setup(x => x.CreateAsync(validated, It.IsAny(), "users", It.IsAny(), null, It.IsAny())) + .Returns((AuthFlowContext _, string action, string resource, string? id, IDictionary? attributes, CancellationToken ct) => + Task.FromResult(Contexts.Create(action, owner, id is null ? null : UserKey.FromString(id)))); + var provider = new LabAccessContextProvider(flows.Object, factory.Object); + var context = await provider.CreateAsync(UAuthActions.Users.CreateAdmin, "users", TestContext.Current.CancellationToken); + var rollback = await provider.CreateForValidatedUserAsync(context, UAuthActions.Users.DeleteAdmin, "users", target, TestContext.Current.CancellationToken); + Assert.Equal(owner, rollback.ActorUserKey); + Assert.Equal(target, rollback.TargetUserKey); + Assert.True(rollback.IsAuthenticated); + Assert.False(rollback.IsSystemActor); + await Assert.ThrowsAsync(() => provider.CreateForValidatedUserAsync( + Contexts.Create(UAuthActions.Users.CreateAdmin, LabDatabase.OwnerB), UAuthActions.Users.DeleteAdmin, + "users", target, TestContext.Current.CancellationToken)); + flows.Verify(x => x.BeginAsync(AuthFlowType.ValidateSession, It.IsAny()), Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs index bb5ec8fa..0a0750aa 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs @@ -25,8 +25,8 @@ public async Task Demo_operations_reject_system_and_forged_names_without_accessi var before = await db.SnapshotAsync(); var roles = new Mock(MockBehavior.Strict); var assignments = new Mock(MockBehavior.Strict); - var service = new LabDemoRoleService(ActorProvider.For(LabDatabase.OwnerA), db, roles.Object, assignments.Object); - await Assert.ThrowsAsync(() => service.SetRoleAsync(LabDatabase.MemberA, role, true, TestContext.Current.CancellationToken)); + var service = new LabDemoRoleService(db, roles.Object, assignments.Object); + await Assert.ThrowsAsync(() => service.SetRoleAsync(Contexts.Create("roles.assign.admin", LabDatabase.OwnerA), LabDatabase.MemberA, role, true, TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); roles.VerifyNoOtherCalls(); assignments.VerifyNoOtherCalls(); @@ -47,8 +47,8 @@ public async Task Assignment_requires_active_persisted_owner_and_same_sandbox_re var before = await db.SnapshotAsync(); var roles = new Mock(MockBehavior.Strict); var assignments = new Mock(MockBehavior.Strict); - var service = new LabDemoRoleService(ActorProvider.For(UserKey.FromString(actor)), db, roles.Object, assignments.Object); - await Assert.ThrowsAsync(() => service.SetRoleAsync(UserKey.FromString(target), LabRoles.Manager, true, TestContext.Current.CancellationToken)); + var service = new LabDemoRoleService(db, roles.Object, assignments.Object); + await Assert.ThrowsAsync(() => service.SetRoleAsync(Contexts.Create("roles.assign.admin", UserKey.FromString(actor)), UserKey.FromString(target), LabRoles.Manager, true, TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); roles.VerifyNoOtherCalls(); assignments.VerifyNoOtherCalls(); @@ -77,15 +77,15 @@ public async Task Demo_role_id_must_match_fixed_identity_before_any_assignment(b } var assignments = new Mock(); assignments.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); - var service = new LabDemoRoleService(ActorProvider.For(LabDatabase.OwnerA), db, roles.Object, assignments.Object); + var service = new LabDemoRoleService(db, roles.Object, assignments.Object); if (forged) { - await Assert.ThrowsAsync(() => service.SetRoleAsync(LabDatabase.MemberA, "Manager", true, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => service.SetRoleAsync(Contexts.Create("roles.assign.admin", LabDatabase.OwnerA), LabDatabase.MemberA, "Manager", true, TestContext.Current.CancellationToken)); userStore.VerifyNoOtherCalls(); } else { - await service.SetRoleAsync(LabDatabase.MemberA, "Manager", true, TestContext.Current.CancellationToken); + await service.SetRoleAsync(Contexts.Create("roles.assign.admin", LabDatabase.OwnerA), LabDatabase.MemberA, "Manager", true, TestContext.Current.CancellationToken); userStore.Verify(x => x.AssignAsync(LabDatabase.MemberA, LabRoleIds.Manager, It.IsAny(), It.IsAny()), Times.Once); } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs index 1800303b..99b315fe 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs @@ -36,12 +36,12 @@ public async Task Demo_store_only_accepts_exact_demo_permissions_from_fixed_role }); var roles = new Mock(); roles.Setup(x => x.Create(It.IsAny())).Returns(store.Object); - var service = new LabDemoStoreService(ActorProvider.For(LabDatabase.MemberA), db, roles.Object, users.Object); - var actual = await service.GetPermissionsAsync(TestContext.Current.CancellationToken); + var service = new LabDemoStoreService(db, roles.Object, users.Object); + var actual = await service.GetPermissionsAsync(Contexts.Create("demo.products.read", LabDatabase.MemberA), TestContext.Current.CancellationToken); Assert.True(actual.SetEquals(expected)); - await service.RequireAsync(LabDemoPermissions.ProductsRead, TestContext.Current.CancellationToken); - await Assert.ThrowsAsync(() => service.RequireAsync("users.delete.admin", TestContext.Current.CancellationToken)); + await service.RequireAsync(Contexts.Create("demo.products.read", LabDatabase.MemberA), LabDemoPermissions.ProductsRead, TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => service.RequireAsync(Contexts.Create("demo.products.read", LabDatabase.MemberA), "users.delete.admin", TestContext.Current.CancellationToken)); await db.SetStatusAsync(SandboxStatus.Suspended); - Assert.Empty(await service.GetPermissionsAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await service.GetPermissionsAsync(Contexts.Create("demo.products.read", LabDatabase.MemberA), TestContext.Current.CancellationToken)); } } From 3391db2fc072fe52b3a81f92456b53a4b1cd0ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 9 Nov 2026 16:08:22 +0000 Subject: [PATCH 2/2] fix(identitylab): pass validated request flow explicitly to rollback --- .../Authorization/LabAccessContextProvider.cs | 18 +++--- .../LabManagementAuthorizationFilter.cs | 6 +- .../LabManagementEndpointExtensions.cs | 2 +- .../Services/LabUserProvisioningService.cs | 8 ++- .../Services/SandboxUserService.cs | 5 +- apps/identity-lab/README.md | 29 +++++++++ .../Infrastructure/ActorProvider.cs | 2 - .../Infrastructure/Contexts.cs | 15 ++--- .../RegistrationIntegrationTests.cs | 4 +- .../Integration/UserWorkflowTests.cs | 10 +-- .../Provisioning/MemberProvisioningTests.cs | 27 ++++++-- .../Security/ValidatedRollbackContextTests.cs | 61 ++++++++++++++++--- 12 files changed, 141 insertions(+), 46 deletions(-) diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs index 3f24258e..5b8ea50c 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs @@ -6,23 +6,27 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Authorization; public sealed class LabAccessContextProvider(IAuthFlow authFlow, IAccessContextFactory contextFactory) { - private AuthFlowContext? validatedFlow; - // Authenticated management contexts must originate in an HTTP endpoint. public async Task CreateAsync(string action, string resource, CancellationToken cancellationToken = default) + { + var validated = await CreateValidatedAsync(action, resource, cancellationToken); + return validated.Context; + } + + public async Task<(AuthFlowContext Flow, AccessContext Context)> CreateValidatedAsync(string action, string resource, CancellationToken cancellationToken = default) { var flow = await authFlow.BeginAsync(AuthFlowType.ValidateSession, cancellationToken); - validatedFlow = flow; - return await contextFactory.CreateAsync(flow, action, resource, ct: cancellationToken); + var context = await contextFactory.CreateAsync(flow, action, resource, ct: cancellationToken); + return (flow, context); } - public Task CreateForValidatedUserAsync(AccessContext actor, string action, string resource, UserKey targetUserKey, CancellationToken ct = default) + public Task CreateForValidatedUserAsync(AuthFlowContext flow, AccessContext actor, string action, string resource, UserKey targetUserKey, CancellationToken ct = default) { // Reuse only the flow validated in this request, retaining actor, tenant and device validation. - if (validatedFlow is not { IsAuthenticated: true } flow || + if (!flow.IsAuthenticated || flow.UserKey is null || !actor.IsAuthenticated || actor.IsSystemActor || - flow.UserKey != actor.ActorUserKey || flow.Tenant != actor.ActorTenant) + flow.UserKey != actor.ActorUserKey || flow.Tenant != actor.ActorTenant || actor.IsCrossTenant) throw new UnauthorizedAccessException("Rollback actor mismatch."); return contextFactory.CreateAsync(flow, action, resource, resourceId: targetUserKey.Value, ct: ct); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabManagementAuthorizationFilter.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabManagementAuthorizationFilter.cs index d1ea91eb..bce998e3 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabManagementAuthorizationFilter.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabManagementAuthorizationFilter.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; using Microsoft.AspNetCore.Antiforgery; @@ -13,6 +14,8 @@ public sealed class LabManagementAuthorizationFilter( { public static AccessContext Context(HttpContext http) => (AccessContext)http.Items[typeof(AccessContext)]!; + public static AuthFlowContext Flow(HttpContext http) => (AuthFlowContext)http.Items[typeof(AuthFlowContext)]!; + public async ValueTask InvokeAsync(EndpointFilterInvocationContext invocation, EndpointFilterDelegate next) { var http = invocation.HttpContext; @@ -25,13 +28,14 @@ public sealed class LabManagementAuthorizationFilter( } var operation = http.GetEndpoint()!.Metadata.GetMetadata()!; - var context = await contextProvider.CreateAsync(operation.Action, operation.Resource, http.RequestAborted); + var (flow, context) = await contextProvider.CreateValidatedAsync(operation.Action, operation.Resource, http.RequestAborted); if (!context.IsAuthenticated || context.ActorUserKey is null) return Results.Unauthorized(); if (context.IsSystemActor || context.ActorTenant != TenantKeys.Single || context.IsCrossTenant) return Results.StatusCode(StatusCodes.Status403Forbidden); http.Items[typeof(AccessContext)] = context; + http.Items[typeof(AuthFlowContext)] = flow; return await next(invocation); } catch (UnauthorizedAccessException) { return Results.StatusCode(StatusCodes.Status403Forbidden); } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabManagementEndpointExtensions.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabManagementEndpointExtensions.cs index 05942d16..21245fb0 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabManagementEndpointExtensions.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabManagementEndpointExtensions.cs @@ -16,7 +16,7 @@ public static IEndpointRouteBuilder MapLabManagementEndpoints(this IEndpointRout { var group = endpoints.MapGroup("/api/lab/manage").AddEndpointFilter(); group.MapPost("/users", async (CreateUserRequest request, HttpContext http, SandboxUserService service, CancellationToken ct) => - Results.Ok(await service.CreateAsync(LabManagementAuthorizationFilter.Context(http), request, ct))) + Results.Ok(await service.CreateAsync(LabManagementAuthorizationFilter.Context(http), LabManagementAuthorizationFilter.Flow(http), request, ct))) .WithMetadata(new LabOperation(UAuthActions.Users.CreateAdmin, "users")); group.MapPost("/owner-repair/status", async (HttpContext http, LabOwnerRepairService service, CancellationToken ct) => diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs index b8ce384d..37016a34 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs @@ -6,6 +6,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.AspNetCore.Identity; @@ -21,11 +22,12 @@ public sealed class LabUserProvisioningService( LabProvisioningRollbackGate rollbackGate, ILogger logger) { - public async Task CreateMemberAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + public async Task CreateMemberAsync(AccessContext context, AuthFlowContext flow, CreateUserRequest request, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); - if (!context.IsAuthenticated || + if (!flow.IsAuthenticated || flow.UserKey != context.ActorUserKey || flow.Tenant != context.ActorTenant || + !context.IsAuthenticated || context.IsSystemActor || context.ActorUserKey is not UserKey authenticatedUserKey || context.ActorTenant != TenantKeys.Single || @@ -116,7 +118,7 @@ await sandboxService.AddMemberAsync( new CancellationTokenSource(TimeSpan.FromSeconds(15)); var deleteContext = await contextProvider.CreateForValidatedUserAsync( - context, + flow, context, UAuthActions.Users.DeleteAdmin, "users", newUserKey, diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs index 8de025ab..5b6e3648 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs @@ -10,6 +10,7 @@ using CodeBeam.UltimateAuth.IdentityLab.Persistence; using CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.IdentityLab.Services; @@ -68,7 +69,7 @@ public sealed class SandboxUserService(SandboxService sandboxService, IUAuthClie return new SandboxUsersSnapshot(sandbox.Id, sandbox.Settings.MaxUsers, users); } - public async Task CreateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + public async Task CreateAsync(AccessContext context, AuthFlowContext flow, CreateUserRequest request, CancellationToken ct = default) { if (!context.IsAuthenticated || context.IsSystemActor || context.ActorUserKey is not UserKey actorUserKey || context.ActorTenant != TenantKeys.Single || context.IsCrossTenant) @@ -82,7 +83,7 @@ public async Task CreateAsync(AccessContext context, Cre try { var result = await provisioningService.CreateMemberAsync( - context, + context, flow, request, ct); diff --git a/apps/identity-lab/README.md b/apps/identity-lab/README.md index c3b0d265..37efbf33 100644 --- a/apps/identity-lab/README.md +++ b/apps/identity-lab/README.md @@ -39,3 +39,32 @@ policy. It does not start another session validation or use a system actor. - **Existing browser SDK paths:** sandbox member deletion already uses the UltimateAuth browser client for the authenticated framework operation; it does not construct an authenticated flow in a component event. + +## Endpoint responsibility audit (PR #154) + +All Lab management routes below are application operations. None is a proxy for +an equivalent complete UltimateAuth client operation. + +| Route (relative to `/api/lab`) | Responsibility retained in Identity Lab | +| --- | --- | +| `POST manage/users` | Owner membership, active sandbox capacity, account/role/membership provisioning and gated rollback. Standard user creation alone cannot complete this workflow. | +| `POST manage/owner-repair/status` | Determine recovery eligibility from persisted Owner identity and sandbox state. | +| `POST manage/owner-repair` | Reconcile an Owner's sandbox and membership under the recovery rules. | +| `POST manage/demo-roles/query` | Require active ownership and a regular member target in the same sandbox; return only the fixed demo role identities. Standard role queries do not enforce these application rules. | +| `POST manage/demo-roles` | Enforce the same sandbox/target rules and the fixed demo-role allowlist. Public framework role mutation is deliberately denied by `LabApplicationPolicy`. | +| `POST manage/demo-permissions` | Require active sandbox membership and derive only exact demo permissions from the fixed Manager/Customer roles. `GetMyPermissionsAsync` returns general framework permissions with different semantics. | +| `POST manage/demo-permissions/check` | Re-evaluate those application-specific permissions before a demo operation. | +| `POST admin/repair/scan` | Ladmin-only cross-sandbox reconciliation snapshot. | +| `POST admin/repair/owner/{userKey}` | Ladmin-only Owner/sandbox/membership reconciliation. | +| `POST admin/repair/sandbox/{sandboxId}/delete` | Ladmin-only deletion of an eligible empty sandbox. | +| `GET admin/sandboxes` (existing) | Ladmin-only application sandbox query. | +| `GET admin/statistics` (existing) | Ladmin-only application statistics. | + +Standard framework operations continue to use their existing UltimateAuth client +APIs and endpoints (including member account deletion). No public client API +extension or server-to-server HTTP call is needed. + +The HTTP filter keeps the validated `AuthFlowContext` alongside its `AccessContext` +in the current request. Member provisioning receives both explicitly and passes +that same flow to rollback. The context provider retains no last-flow field; +creating another context cannot change the flow used by an in-progress rollback. diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs index f0a446ab..058c1277 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs @@ -22,8 +22,6 @@ public static LabAccessContextProvider For(UserKey? actor) IDictionary? attributes, CancellationToken ct) => Task.FromResult(Contexts.Create(action, actor, id is null ? null : UserKey.FromString(id)))); var provider = new LabAccessContextProvider(flow.Object, contexts.Object); - if (actor.HasValue) - provider.CreateAsync("users.create.admin", "users").GetAwaiter().GetResult(); return provider; } } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs index dbc99af6..09832269 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs @@ -11,7 +11,8 @@ internal static class Contexts // AccessContext deliberately has no public constructor. Keep reflection confined // to this adapter rather than adding production friend assemblies or public APIs. public static AccessContext Create(string action, UserKey? actor = null, UserKey? target = null, bool system = false, - IReadOnlyDictionary? attributes = null, IReadOnlyCollection? targets = null) + IReadOnlyDictionary? attributes = null, IReadOnlyCollection? targets = null, string resource = "identitylab.tests", + TenantKey? actorTenant = null, TenantKey? resourceTenant = null) { var constructor = typeof(AccessContext).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic).Single(); @@ -19,14 +20,14 @@ public static AccessContext Create(string action, UserKey? actor = null, UserKey .Select(parameter => parameter.Name switch { "actorUserKey" => (object?)actor, - "actorTenant" => TenantKeys.Single, + "actorTenant" => actorTenant ?? TenantKeys.Single, "isAuthenticated" => actor.HasValue, "isSystemActor" => system, "actorChainId" => null, "actorRoles" => Array.Empty(), - "resource" => "identitylab.tests", + "resource" => resource, "targetUserKey" => target, - "resourceTenant" => TenantKeys.Single, + "resourceTenant" => resourceTenant ?? TenantKeys.Single, "action" => action, "attributes" => attributes ?? new Dictionary(), "targetUserKeys" => targets, @@ -39,15 +40,15 @@ public static AccessContext Create(string action, UserKey? actor = null, UserKey return (AccessContext)constructor.Invoke(args); } - public static CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext AuthenticatedFlow(UserKey actor) + public static CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext AuthenticatedFlow(UserKey actor, bool authenticated = true, bool includeActor = true) { var constructor = typeof(CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext) .GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic).Single(); var args = constructor.GetParameters().Select(parameter => parameter.Name switch { "tenantKey" => (object)TenantKeys.Single, - "isAuthenticated" => true, - "userKey" => actor, + "isAuthenticated" => authenticated, + "userKey" => includeActor ? actor : null, _ => parameter.ParameterType.IsValueType ? Activator.CreateInstance(parameter.ParameterType) : null }).ToArray(); return (CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext)constructor.Invoke(args); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs index dfe29d70..00d56ca3 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs @@ -114,7 +114,7 @@ public async Task Owner_can_provision_member_and_assign_demo_role_but_cannot_del var provider = ActorProvider.For(owner); var provisioning = new LabUserProvisioningService(provider, new(db), new(db), users, roles, assignments, sp.GetRequiredService(), NullLogger.Instance); - var result = await provisioning.CreateMemberAsync(Contexts.Create("users.create.admin", owner), new CreateUserRequest + var result = await provisioning.CreateMemberAsync(Contexts.Create("users.create.admin", owner), Contexts.AuthenticatedFlow(owner), new CreateUserRequest { UserName = "member", Email = "member@example.test", Password = "test-password-8!" }, TestContext.Current.CancellationToken); Assert.True(result.IsSuccess, result.FailureReason); var member = UserKey.FromString(result.UserKey!); @@ -191,7 +191,7 @@ public async Task Provisioning_assignment_fault_hard_deletes_real_partial_accoun var provisioning = new LabUserProvisioningService(ActorProvider.For(owner), new(db), new(db), users, roles, failedFactory.Object, sp.GetRequiredService(), NullLogger.Instance); - var failure = await Assert.ThrowsAsync(() => provisioning.CreateMemberAsync(Contexts.Create("users.create.admin", owner), + var failure = await Assert.ThrowsAsync(() => provisioning.CreateMemberAsync(Contexts.Create("users.create.admin", owner), Contexts.AuthenticatedFlow(owner), new CreateUserRequest { UserName = "failed-member", Email = "failed-member@example.test", Password = "Member-test-password-8!" }, TestContext.Current.CancellationToken)); var partialKey = UserKey.FromString(failure.CreatedUserKey); await using var fresh = runtime.Services.CreateAsyncScope(); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs index 67d1418b..57fc9654 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs @@ -53,7 +53,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou var sp = scope.ServiceProvider; var owner = await RegisterAsync(sp, db, "owner"); var foreignOwner = await RegisterAsync(sp, db, "foreign"); - var foreign = await Provisioning(sp, db, foreignOwner).CreateMemberAsync(Contexts.Create("users.create.admin", foreignOwner), Request("foreign-member"), TestContext.Current.CancellationToken); + var foreign = await Provisioning(sp, db, foreignOwner).CreateMemberAsync(Contexts.Create("users.create.admin", foreignOwner), Contexts.AuthenticatedFlow(foreignOwner), Request("foreign-member"), TestContext.Current.CancellationToken); Assert.True(foreign.IsSuccess, foreign.FailureReason); var foreignKey = UserKey.FromString(foreign.UserKey!); @@ -69,7 +69,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou client.SetupGet(x => x.Users).Returns(userClient.Object); var service = UserService(sp, db, owner, client.Object); - await service.CreateAsync(Contexts.Create("users.create.admin", owner), Request("member"), TestContext.Current.CancellationToken); + await service.CreateAsync(Contexts.Create("users.create.admin", owner), Contexts.AuthenticatedFlow(owner), Request("member"), TestContext.Current.CancellationToken); var visible = await service.GetUsersAsync(owner, TestContext.Current.CancellationToken); Assert.NotNull(visible); var member = Assert.Single(visible.Users, x => !x.IsOwner).UserKey; @@ -77,7 +77,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou Assert.DoesNotContain(visible.Users, x => x.UserKey == foreignKey); var beforeRejected = await db.SnapshotAsync(); // The facade reports failures; assert persistence rather than dialog wording. - await service.CreateAsync(Contexts.Create("users.create.admin", owner), Request("rejected-member"), TestContext.Current.CancellationToken); + await service.CreateAsync(Contexts.Create("users.create.admin", owner), Contexts.AuthenticatedFlow(owner), Request("rejected-member"), TestContext.Current.CancellationToken); Assert.Equal(beforeRejected, await db.SnapshotAsync()); await using (var fresh = runtime.Services.CreateAsyncScope()) { @@ -127,7 +127,7 @@ public async Task Created_member_is_queryable_capacity_rejection_leaves_no_accou Assert.NotNull(dashboard); Assert.Equal(1, dashboard.MemberCount); Assert.Equal(1, dashboard.RemainingCapacity); - await service.CreateAsync(Contexts.Create("users.create.admin", owner), Request("replacement"), TestContext.Current.CancellationToken); + await service.CreateAsync(Contexts.Create("users.create.admin", owner), Contexts.AuthenticatedFlow(owner), Request("replacement"), TestContext.Current.CancellationToken); Assert.Equal(2, (await service.GetUsersAsync(owner, TestContext.Current.CancellationToken))!.Users.Count); await using var final = db.CreateDbContext(); Assert.False(await final.SandboxMembers.AnyAsync(x => x.UserKey == member, cancellationToken: TestContext.Current.CancellationToken)); @@ -145,7 +145,7 @@ public async Task Owner_and_admin_recovery_restore_missing_membership_without_du var sp = scope.ServiceProvider; var owner = await RegisterAsync(sp, db, "repair-owner"); var other = await RegisterAsync(sp, db, "other-owner"); - var member = await Provisioning(sp, db, owner).CreateMemberAsync(Contexts.Create("users.create.admin", owner), Request("retained-member"), TestContext.Current.CancellationToken); + var member = await Provisioning(sp, db, owner).CreateMemberAsync(Contexts.Create("users.create.admin", owner), Contexts.AuthenticatedFlow(owner), Request("retained-member"), TestContext.Current.CancellationToken); Assert.True(member.IsSuccess, member.FailureReason); var roles = sp.GetRequiredService(); var assignments = sp.GetRequiredService(); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs index e0762e33..655061b0 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs @@ -19,7 +19,8 @@ public sealed class MemberProvisioningTests(PostgresFixture postgres) : IClassFi [Theory] [InlineData(null)] [InlineData("member-a")] - public async Task Anonymous_or_regular_member_cannot_provision(string? actor) + [InlineData("owner-a")] + public async Task Anonymous_member_or_actor_mismatched_with_flow_cannot_provision(string? actor) { await using var db = await postgres.CreateAsync(); await db.SeedAsync(); @@ -31,19 +32,22 @@ public async Task Anonymous_or_regular_member_cannot_provision(string? actor) new(db), new(db), users.Object, roles.Object, assignments.Object, new(), NullLogger.Instance); await Assert.ThrowsAsync(() => service.CreateMemberAsync( - Contexts.Create("users.create.admin", actor is null ? null : UserKey.FromString(actor)), new CreateUserRequest(), TestContext.Current.CancellationToken)); + Contexts.Create("users.create.admin", actor is null ? null : UserKey.FromString(actor)), Contexts.AuthenticatedFlow(LabDatabase.MemberA), new CreateUserRequest(), TestContext.Current.CancellationToken)); Assert.Equal(before, await db.SnapshotAsync()); users.VerifyNoOtherCalls(); roles.VerifyNoOtherCalls(); assignments.VerifyNoOtherCalls(); } - [Fact] - public async Task Failed_role_assignment_rolls_back_only_created_user_and_token_cannot_be_replayed() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failed_role_assignment_rolls_back_only_created_user_and_token_cannot_be_replayed(bool cancelProvisioning) { await using var db = await postgres.CreateAsync(); await db.SeedAsync(); var before = await db.SnapshotAsync(); + using var provisioningCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); var gate = new LabProvisioningRollbackGate(); var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), gate); var createdKey = UserKey.FromString("new-member"); @@ -60,6 +64,9 @@ public async Task Failed_role_assignment_rolls_back_only_created_user_and_token_ users.Setup(x => x.DeleteUserAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (AccessContext context, DeleteUserRequest request, CancellationToken ct) => { + Assert.Equal(cancelProvisioning, provisioningCts.IsCancellationRequested); + Assert.False(ct.IsCancellationRequested); + Assert.NotEqual(provisioningCts.Token, ct); rollbackContext = context; rollbackMode = request.Mode; // An unrelated target cannot consume the real token. @@ -77,13 +84,21 @@ public async Task Failed_role_assignment_rolls_back_only_created_user_and_token_ roles.Setup(x => x.Create(It.IsAny())).Returns(roleStore.Object); var userStore = new Mock(MockBehavior.Strict); userStore.Setup(x => x.GetAssignmentsAsync(createdKey, It.IsAny())) - .ThrowsAsync(new InvalidOperationException("Injected assignment failure")); + .Returns(() => + { + if (cancelProvisioning) + { + provisioningCts.Cancel(); + throw new OperationCanceledException(provisioningCts.Token); + } + throw new InvalidOperationException("Injected assignment failure"); + }); var assignments = new Mock(); assignments.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); var service = new LabUserProvisioningService(ActorProvider.For(LabDatabase.OwnerA), new(db), new(db), users.Object, roles.Object, assignments.Object, gate, NullLogger.Instance); await Assert.ThrowsAsync(() => service.CreateMemberAsync( - Contexts.Create("users.create.admin", LabDatabase.OwnerA), new CreateUserRequest(), TestContext.Current.CancellationToken)); + Contexts.Create("users.create.admin", LabDatabase.OwnerA), Contexts.AuthenticatedFlow(LabDatabase.OwnerA), new CreateUserRequest(), provisioningCts.Token)); Assert.NotNull(rollbackContext); Assert.Equal(createdKey, rollbackContext!.TargetUserKey); Assert.Equal(DeleteMode.Hard, rollbackMode); diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/ValidatedRollbackContextTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/ValidatedRollbackContextTests.cs index 60ea2b38..fdcd07c1 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/ValidatedRollbackContextTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/ValidatedRollbackContextTests.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.Server.Auth; using Moq; @@ -10,27 +11,67 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Security; public sealed class ValidatedRollbackContextTests { [Fact] - public async Task Retargeting_reuses_validated_flow_and_rejects_a_different_actor() + public async Task Rollback_uses_explicit_request_flow_even_after_another_context_is_created() { var owner = LabDatabase.OwnerA; var target = UserKey.FromString("created-user"); var validated = Contexts.AuthenticatedFlow(owner); + var otherFlow = Contexts.AuthenticatedFlow(LabDatabase.OwnerB); var flows = new Mock(MockBehavior.Strict); - flows.Setup(x => x.BeginAsync(AuthFlowType.ValidateSession, It.IsAny())).ReturnsAsync(validated); + flows.SetupSequence(x => x.BeginAsync(AuthFlowType.ValidateSession, It.IsAny())) + .ReturnsAsync(validated).ReturnsAsync(otherFlow); var factory = new Mock(MockBehavior.Strict); - factory.Setup(x => x.CreateAsync(validated, It.IsAny(), "users", It.IsAny(), null, It.IsAny())) - .Returns((AuthFlowContext _, string action, string resource, string? id, IDictionary? attributes, CancellationToken ct) => - Task.FromResult(Contexts.Create(action, owner, id is null ? null : UserKey.FromString(id)))); + factory.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny(), "users", It.IsAny(), null, It.IsAny())) + .Returns((AuthFlowContext flow, string action, string resource, string? id, IDictionary? attributes, CancellationToken ct) => + Task.FromResult(Contexts.Create(action, flow.UserKey, id is null ? null : UserKey.FromString(id), resource: resource))); var provider = new LabAccessContextProvider(flows.Object, factory.Object); - var context = await provider.CreateAsync(UAuthActions.Users.CreateAdmin, "users", TestContext.Current.CancellationToken); - var rollback = await provider.CreateForValidatedUserAsync(context, UAuthActions.Users.DeleteAdmin, "users", target, TestContext.Current.CancellationToken); + var request = await provider.CreateValidatedAsync(UAuthActions.Users.CreateAdmin, "users", TestContext.Current.CancellationToken); + await provider.CreateAsync(UAuthActions.Users.CreateAdmin, "users", TestContext.Current.CancellationToken); + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var rollback = await provider.CreateForValidatedUserAsync(request.Flow, request.Context, + UAuthActions.Users.DeleteAdmin, "users", target, cleanup.Token); + Assert.Same(validated, request.Flow); Assert.Equal(owner, rollback.ActorUserKey); Assert.Equal(target, rollback.TargetUserKey); + Assert.Equal(UAuthActions.Users.DeleteAdmin, rollback.Action); + Assert.Equal("users", rollback.Resource); + Assert.Equal(request.Context.ActorTenant, rollback.ActorTenant); + Assert.Equal(request.Context.ResourceTenant, rollback.ResourceTenant); Assert.True(rollback.IsAuthenticated); Assert.False(rollback.IsSystemActor); + factory.Verify(x => x.CreateAsync(validated, UAuthActions.Users.DeleteAdmin, "users", target.Value, null, cleanup.Token), Times.Once); + flows.Verify(x => x.BeginAsync(AuthFlowType.ValidateSession, It.IsAny()), Times.Exactly(2)); + flows.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData("different-actor")] + [InlineData("anonymous-actor")] + [InlineData("system-actor")] + [InlineData("different-tenant")] + [InlineData("cross-tenant")] + [InlineData("unauthenticated-flow")] + [InlineData("missing-flow-actor")] + public async Task Invalid_actor_or_flow_is_rejected_before_target_context_creation(string mismatch) + { + var flow = Contexts.AuthenticatedFlow(LabDatabase.OwnerA, + authenticated: mismatch != "unauthenticated-flow", includeActor: mismatch != "missing-flow-actor"); + var actor = Contexts.Create(UAuthActions.Users.CreateAdmin, + mismatch == "different-actor" ? LabDatabase.OwnerB : LabDatabase.OwnerA); + actor = mismatch switch + { + "anonymous-actor" => Contexts.Create(UAuthActions.Users.CreateAdmin), + "system-actor" => Contexts.Create(UAuthActions.Users.CreateAdmin, LabDatabase.OwnerA, system: true), + "different-tenant" => Contexts.Create(UAuthActions.Users.CreateAdmin, LabDatabase.OwnerA, actorTenant: TenantKey.FromExternal("other"), resourceTenant: TenantKey.FromExternal("other")), + "cross-tenant" => Contexts.Create(UAuthActions.Users.CreateAdmin, LabDatabase.OwnerA, resourceTenant: TenantKey.FromExternal("other")), + _ => actor + }; + var flows = new Mock(MockBehavior.Strict); + var factory = new Mock(MockBehavior.Strict); + var provider = new LabAccessContextProvider(flows.Object, factory.Object); await Assert.ThrowsAsync(() => provider.CreateForValidatedUserAsync( - Contexts.Create(UAuthActions.Users.CreateAdmin, LabDatabase.OwnerB), UAuthActions.Users.DeleteAdmin, - "users", target, TestContext.Current.CancellationToken)); - flows.Verify(x => x.BeginAsync(AuthFlowType.ValidateSession, It.IsAny()), Times.Once); + flow, actor, UAuthActions.Users.DeleteAdmin, "users", UserKey.FromString("created-user"), TestContext.Current.CancellationToken)); + factory.VerifyNoOtherCalls(); + flows.VerifyNoOtherCalls(); } }