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
17 changes: 0 additions & 17 deletions .github/codecov.yml

This file was deleted.

40 changes: 40 additions & 0 deletions .github/workflows/ultimateauth-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,50 @@ jobs:
dotnet test \
--configuration Release \
--no-build \
--filter "FullyQualifiedName!~CodeBeam.UltimateAuth.IdentityLab.Tests" \
--collect:"XPlat Code Coverage"

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: CodeBeamOrg/UltimateAuth
flags: framework

identity-lab-security:
name: Identity Lab security (PostgreSQL)
runs-on: ubuntu-latest
timeout-minutes: 20

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

- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Run Identity Lab tests with coverage
run: >-
dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests
--configuration Release
--collect:"XPlat Code Coverage"
--logger "trx;LogFileName=identity-lab.trx"
--results-directory TestResults/IdentityLab

- name: Upload Identity Lab coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: CodeBeamOrg/UltimateAuth
directory: TestResults/IdentityLab
flags: identitylab
fail_ci_if_error: true

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: identity-lab-test-results
path: TestResults/IdentityLab
1 change: 1 addition & 0 deletions UltimateAuth.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<File Path="Roadmap.md" />
</Folder>
<Folder Name="/Tests/">
<Project Path="tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj" />
<Project Path="tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj" Id="51652abe-8f77-4150-94ea-976ca41c8b91" />
<Project Path="tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj" Id="fe34a0b1-8038-400d-b8ab-02dad7051f2d" />
<Project Path="tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj" Id="6f4b22da-849a-4a79-b5c5-aee7cb1429a6" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,28 @@ public async Task AssignAsync(TenantKey tenant, UserKey userKey, string roleName
throw new InvalidOperationException("Unsupported role assignment.");
}

var expectedRoleId = roleName switch
{
LabRoles.Owner => LabRoleIds.Owner,
LabRoles.User => LabRoleIds.User,
_ => throw new InvalidOperationException("Unsupported role assignment.")
};

var roleStore = roleStoreFactory.Create(tenant);
var userRoleStore = userRoleStoreFactory.Create(tenant);

var role = await roleStore.GetByNameAsync(roleName.ToUpperInvariant(), ct);

if (role is null)
throw new InvalidOperationException($"System role '{roleName}' was not found.");
if (role is null || role.Id != expectedRoleId)
{
throw new InvalidOperationException($"System role '{roleName}' is missing or has an invalid identity.");
}

var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct);

if (assignments.Any(x => x.RoleId == role.Id))
if (assignments.Any(x => x.RoleId == expectedRoleId))
return;

await userRoleStore.AssignAsync(userKey, role.Id, DateTimeOffset.UtcNow, ct);
await userRoleStore.AssignAsync(userKey, expectedRoleId, DateTimeOffset.UtcNow, ct);
}
}
36 changes: 36 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
comment:
require_changes: yes

coverage:
status:
project:
default: false

framework:
flags:
- framework
target: 80%
threshold: 2%

patch:
default: false

framework:
flags:
- framework
target: 80%
threshold: 10%

flags:
framework:
paths:
- src/

identitylab:
paths:
- apps/identity-lab/

ignore:
- "samples/**"
- "tests/**"
- "**/Migrations/**"
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.IdentityLab.Domain;
using CodeBeam.UltimateAuth.IdentityLab.Policy;
using CodeBeam.UltimateAuth.IdentityLab.Services;

namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Authorization;

public sealed class SandboxPolicyTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
public static TheoryData<string> TargetActions => new()
{
UAuthActions.UserProfiles.GetAdmin, UAuthActions.UserProfiles.UpdateAdmin,
UAuthActions.Users.DeleteAdmin, UAuthActions.Sessions.GetChainAdmin,
UAuthActions.Sessions.RevokeAllChainsAdmin, UAuthActions.Authorization.Roles.GetAdmin
};

[Theory]
[MemberData(nameof(TargetActions))]
public async Task Owner_cannot_access_foreign_or_missing_targets(string action)
{
await using var db = await postgres.CreateAsync();
await db.SeedAsync();
var before = await db.SnapshotAsync();
var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new());
foreach (var target in new UserKey?[] { LabDatabase.MemberB, LabDatabase.OwnerB,
UserKey.FromString("nonexistent"), null })
{
var denied = await policy.DecideAsync(Contexts.Create(action, LabDatabase.OwnerA, target));
Assert.True(denied.IsDenied);
}
Assert.True((await policy.DecideAsync(Contexts.Create(action,
LabDatabase.OwnerA, LabDatabase.MemberA))).IsAllowed);
Assert.Equal(before, await db.SnapshotAsync());
}

[Theory]
[InlineData(SandboxStatus.Passive, false)]
[InlineData(SandboxStatus.Suspended, true)]
public async Task Inactive_sandbox_blocks_writes_and_suspension_also_blocks_reads(SandboxStatus status, bool readDenied)
{
await using var db = await postgres.CreateAsync();
await db.SeedAsync();
await db.SetStatusAsync(status);
var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new());
Assert.True((await policy.DecideAsync(Contexts.Create(UAuthActions.UserProfiles.UpdateAdmin,
LabDatabase.OwnerA, LabDatabase.MemberA))).IsDenied);
Assert.Equal(readDenied, (await policy.DecideAsync(Contexts.Create(UAuthActions.UserProfiles.GetAdmin,
LabDatabase.OwnerA, LabDatabase.MemberA))).IsDenied);
}

[Fact]
public async Task Members_cannot_manage_users_and_owners_cannot_be_deleted()
{
await using var db = await postgres.CreateAsync();
await db.SeedAsync();
var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new());
Assert.Equal("sandbox_owner_required", (await policy.DecideAsync(Contexts.Create(
UAuthActions.Users.DeleteAdmin, LabDatabase.MemberA, LabDatabase.OwnerA))).DenyReason);
Assert.Equal("sandbox_owner_cannot_be_deleted", (await policy.DecideAsync(Contexts.Create(
UAuthActions.Users.DeleteAdmin, LabDatabase.OwnerA, LabDatabase.OwnerA))).DenyReason);
Assert.Equal("sandbox_owner_cannot_delete_self", (await policy.DecideAsync(Contexts.Create(
UAuthActions.Users.DeleteSelf, LabDatabase.OwnerA, LabDatabase.OwnerA))).DenyReason);
}

[Fact]
public async Task Batch_read_checks_every_target_and_requires_nonempty_targets()
{
await using var db = await postgres.CreateAsync();
await db.SeedAsync();
var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new());
foreach (var targets in new[] { Array.Empty<UserKey>(), new[] { LabDatabase.MemberA, LabDatabase.MemberB } })
Assert.True((await policy.DecideAsync(Contexts.Create(UAuthActions.Users.GetByKeysAdmin,
LabDatabase.OwnerA, targets: targets))).IsDenied);
Assert.True((await policy.DecideAsync(Contexts.Create(UAuthActions.Users.GetByKeysAdmin,
LabDatabase.OwnerA, targets: new[] { LabDatabase.MemberA, LabDatabase.OwnerA }))).IsAllowed);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.16.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj" />
<Using Include="Xunit" />
<Using Include="CodeBeam.UltimateAuth.IdentityLab.Tests.Infrastructure" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.IdentityLab.Authorization;
using CodeBeam.UltimateAuth.Server.Auth;
using Moq;

namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Infrastructure;

internal static class ActorProvider
{
public static LabAccessContextProvider For(UserKey? actor)
{
// Only the authenticated context source is replaced; application services,
// ownership queries and PostgreSQL authorization decisions remain real.
var flow = new Mock<IAuthFlow>();
flow.Setup(x => x.BeginAsync(It.IsAny<AuthFlowType>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<AuthFlowContext>((AuthFlowContext)null!));
var contexts = new Mock<IAccessContextFactory>();
contexts.Setup(x => x.CreateAsync(It.IsAny<AuthFlowContext>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<IDictionary<string, object>?>(), It.IsAny<CancellationToken>()))
.Returns((AuthFlowContext _, string action, string resource, string? id,
IDictionary<string, object>? attributes, CancellationToken ct) =>
Task.FromResult(Contexts.Create(action, actor, id is null ? null : UserKey.FromString(id))));
return new LabAccessContextProvider(flow.Object, contexts.Object);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Reflection;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.MultiTenancy;

namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Infrastructure;

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<string, object>? attributes = null,
IReadOnlyCollection<UserKey>? targets = null)
=> (AccessContext)typeof(AccessContext).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic)
.Single().Invoke(new object?[] { actor, TenantKeys.Single, actor.HasValue, system,
null, Array.Empty<AccessActorRole>(), "identitylab.tests", target, TenantKeys.Single,
action, attributes ?? new Dictionary<string, object>(), targets });
public static CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext AuthenticatedFlow(UserKey actor)
{
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,
_ => parameter.ParameterType.IsValueType ? Activator.CreateInstance(parameter.ParameterType) : null
}).ToArray();
return (CodeBeam.UltimateAuth.Server.Auth.AuthFlowContext)constructor.Invoke(args);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using CodeBeam.UltimateAuth.Authorization;
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.EntityFrameworkCore;
using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions;
using CodeBeam.UltimateAuth.IdentityLab.Authorization;
using CodeBeam.UltimateAuth.IdentityLab.Persistence;
using CodeBeam.UltimateAuth.IdentityLab.Policy;
using CodeBeam.UltimateAuth.IdentityLab.Seed;
using CodeBeam.UltimateAuth.IdentityLab.Services;
using CodeBeam.UltimateAuth.Server.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Infrastructure;

internal sealed class LabRuntime(ServiceProvider provider) : IAsyncDisposable
{
public ServiceProvider Services => provider;

public static async Task<LabRuntime> CreateAsync(LabDatabase database)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddRouting();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection().Build());
services.AddUltimateAuthServer().AddUltimateAuthEntityFrameworkCore(options =>
options.UseNpgsql(database.ConnectionString, pg => pg.MigrationsAssembly(typeof(LabDbContext).Assembly.FullName)));
services.RemoveAll<IClock>();
services.AddSingleton<IClock, FixedClock>();
services.AddSingleton<IDbContextFactory<LabDbContext>>(database);
services.AddScoped<ILabRootResolver, LabRootResolver>();
services.AddScoped<LabProvisioningRollbackGate>();
services.AddScoped<IAccessPolicy, SandboxUAuthAccessPolicy>();
services.AddScoped<IAccessPolicy, LabApplicationPolicy>();
services.Configure<LabAdminOptions>(options =>
{
options.Username = "lab-admin";
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<UAuthDbContext>().Database.MigrateAsync();
await ActivatorUtilities.CreateInstance<LabAdminSeedContributor>(scope.ServiceProvider).SeedAsync(TenantKeys.Single);
await ActivatorUtilities.CreateInstance<LabDemoRoleSeedContributor>(scope.ServiceProvider).SeedAsync(TenantKeys.Single);
return runtime;
}
catch
{
await runtime.DisposeAsync();
throw;
}
}

public ValueTask DisposeAsync() => provider.DisposeAsync();

private sealed class FixedClock : IClock
{
public DateTimeOffset UtcNow => new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
}
Loading
Loading