Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master'
uses: docker/login-action@v3
Expand All @@ -82,7 +82,11 @@ jobs:
deploy:
name: Deploy
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master'
if: >-
!cancelled() &&
needs.build.result == 'success' &&
github.event_name != 'pull_request' &&
github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Deploy to Server
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/identitylab-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout for push comparison
if: github.event_name != 'pull_request'
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Detect changed files
id: filter
uses: dorny/paths-filter@v3
Expand Down Expand Up @@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
Expand Down Expand Up @@ -91,7 +91,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Upload Identity Lab Compose
uses: appleboy/scp-action@v1
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/javascript-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout for push comparison
if: github.event_name != 'pull_request'
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Detect changed files
id: filter
uses: dorny/paths-filter@v3
Expand All @@ -41,10 +41,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '22'
node-version: '24'
- name: Run JavaScript tests
run: node --test tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/*.test.cjs
4 changes: 2 additions & 2 deletions .github/workflows/nuget-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
fi

- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Resolve supported SDKs
id: frameworks
Expand All @@ -41,7 +41,7 @@ jobs:
} >> "$GITHUB_OUTPUT"

- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ steps.frameworks.outputs.sdks }}

Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/ultimateauth-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
sdks: ${{ steps.policy.outputs.sdks }}
solution-frameworks: ${{ steps.policy.outputs.solution-frameworks }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Audit projects and generate compatibility matrix
id: policy
env:
Expand Down Expand Up @@ -103,10 +103,10 @@ jobs:
fail-fast: false
matrix: ${{ fromJSON(needs.frameworks.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup matching SDK
id: dotnet
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ matrix.sdk }}
- name: Select matching SDK
Expand Down Expand Up @@ -139,10 +139,10 @@ jobs:

steps:
- name: 📥 Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: 🧰 Setup .NET SDKs
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ needs.frameworks.outputs.sdks }}

Expand All @@ -169,7 +169,7 @@ jobs:

- name: Upload framework test and coverage results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: framework-test-results
path: TestResults/Framework
Expand All @@ -192,10 +192,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Setup required SDKs
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ needs.frameworks.outputs.sdks }}

Expand Down Expand Up @@ -274,7 +274,7 @@ jobs:

- name: Upload Identity Lab test and coverage results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: identity-lab-test-results
path: TestResults/IdentityLab
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,29 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;

public sealed class LabAccessContextProvider(IAuthFlow authFlow, IAccessContextFactory contextFactory)
{
// Authenticated management contexts must originate in an HTTP endpoint.
public async Task<AccessContext> 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);
return await contextFactory.CreateAsync(flow, action, resource, ct: cancellationToken);
var context = await contextFactory.CreateAsync(flow, action, resource, ct: cancellationToken);
return (flow, context);
}


public async Task<AccessContext> CreateForUserAsync(string action, string resource, UserKey targetUserKey, CancellationToken ct = default)
public Task<AccessContext> CreateForValidatedUserAsync(AuthFlowContext flow, 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 (!flow.IsAuthenticated || flow.UserKey is null ||
!actor.IsAuthenticated || actor.IsSystemActor ||
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<object?> 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();
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Server.Auth;
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<LabManagementAuthorizationFilter> logger) : IEndpointFilter
{
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<object?> 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<LabOperation>()!;
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); }
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.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -156,23 +155,14 @@
private bool _busy;
private string? _error;

private async Task<CodeBeam.UltimateAuth.Core.Contracts.AccessContext>
GetContextAsync()
{
return await ContextProvider.CreateAsync(
"identitylab.repair.admin",
"identitylab.repair");
}

private async Task LoadAsync()
{
_busy = true;
_error = null;

try
{
var context = await GetContextAsync();
_snapshot = await RepairService.GetSnapshotAsync(context);
_snapshot = await Management.SendAsync<RepairSnapshot>("/api/lab/admin/repair/scan");
}
catch (Exception)
{
Expand All @@ -197,8 +187,7 @@

await ExecuteAsync(async () =>
{
var context = await GetContextAsync();
return await RepairService.RepairOwnerAsync(context, user.UserKey);
return await Management.SendAsync<RepairOperationResult>($"/api/lab/admin/repair/owner/{Uri.EscapeDataString(user.UserKey.Value)}");
});
}

Expand All @@ -215,8 +204,7 @@

await ExecuteAsync(async () =>
{
var context = await GetContextAsync();
return await RepairService.DeleteEmptySandboxAsync(context, sandbox.SandboxId);
return await Management.SendAsync<RepairOperationResult>($"/api/lab/admin/repair/sandbox/{sandbox.SandboxId}/delete");
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
@using CodeBeam.UltimateAuth.IdentityLab.Authorization
@using CodeBeam.UltimateAuth.IdentityLab.Services

@inject LabDemoStoreService DemoStoreService
@inject LabManagementClient Management
@inject ISnackbar Snackbar

<PageTitle>Demo Store · Identity Lab</PageTitle>
Expand Down Expand Up @@ -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()
Expand All @@ -234,7 +236,7 @@
try
{
_permissions =
await DemoStoreService.GetPermissionsAsync();
await Management.SendAsync<HashSet<string>>("/api/lab/manage/demo-permissions");
}
catch (Exception)
{
Expand All @@ -259,7 +261,7 @@

try
{
await DemoStoreService.RequireAsync(permission);
await Management.SendAsync<bool>("/api/lab/manage/demo-permissions/check", new { Permission = permission });

operation();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
@using CodeBeam.UltimateAuth.Users.Contracts
@layout DashboardLayout
@inject SandboxUserService SandboxUserService
@inject LabManagementClient Management
@inject IDialogService DialogService
@inject ISnackbar Snackbar

Expand Down Expand Up @@ -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<CreateUserDialogResult>("/api/lab/manage/users", request);
}
catch (UnauthorizedAccessException)
{
return CreateUserDialogResult.Failure("You are not allowed to manage this sandbox.");
}
}

private async Task DeleteUserAsync(SandboxUserItem user)
Expand Down
Loading
Loading