diff --git a/.github/codecov.yml b/.github/codecov.yml deleted file mode 100644 index 1d257667..00000000 --- a/.github/codecov.yml +++ /dev/null @@ -1,17 +0,0 @@ -comment: - require_changes: yes - -coverage: - status: - project: - default: - target: 80% - threshold: 2% - patch: - default: - target: 80% - threshold: 5% - -ignore: - - "samples/**" - - "tests/**" diff --git a/.github/workflows/ultimateauth-ci.yml b/.github/workflows/ultimateauth-ci.yml index 372f254b..372dec56 100644 --- a/.github/workflows/ultimateauth-ci.yml +++ b/.github/workflows/ultimateauth-ci.yml @@ -43,6 +43,7 @@ jobs: dotnet test \ --configuration Release \ --no-build \ + --filter "FullyQualifiedName!~CodeBeam.UltimateAuth.IdentityLab.Tests" \ --collect:"XPlat Code Coverage" - name: Upload coverage reports to Codecov @@ -50,3 +51,42 @@ jobs: 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 diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 050c32e0..c1910ba6 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -31,6 +31,7 @@ + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRoleService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRoleService.cs index 9a37ea80..8a120c9c 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRoleService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRoleService.cs @@ -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); } } diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..fde64fe1 --- /dev/null +++ b/codecov.yml @@ -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/**" \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Authorization/SandboxPolicyTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Authorization/SandboxPolicyTests.cs new file mode 100644 index 00000000..818b4c05 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Authorization/SandboxPolicyTests.cs @@ -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 +{ + public static TheoryData 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(), 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); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj new file mode 100644 index 00000000..a33d9b29 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + false + true + + + + + + + + + + + + + + + + + diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs new file mode 100644 index 00000000..db515780 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/ActorProvider.cs @@ -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(); + flow.Setup(x => x.BeginAsync(It.IsAny(), It.IsAny())) + .Returns(new ValueTask((AuthFlowContext)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); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs new file mode 100644 index 00000000..a9175b3d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/Contexts.cs @@ -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? attributes = null, + IReadOnlyCollection? targets = null) + => (AccessContext)typeof(AccessContext).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic) + .Single().Invoke(new object?[] { actor, TenantKeys.Single, actor.HasValue, system, + null, Array.Empty(), "identitylab.tests", target, TenantKeys.Single, + action, attributes ?? new Dictionary(), 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); + } + +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabRuntime.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabRuntime.cs new file mode 100644 index 00000000..58c5132b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/LabRuntime.cs @@ -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 CreateAsync(LabDatabase database) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddRouting(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + services.AddUltimateAuthServer().AddUltimateAuthEntityFrameworkCore(options => + options.UseNpgsql(database.ConnectionString, pg => pg.MigrationsAssembly(typeof(LabDbContext).Assembly.FullName))); + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton>(database); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.Configure(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().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; + } + } + + public ValueTask DisposeAsync() => provider.DisposeAsync(); + + private sealed class FixedClock : IClock + { + public DateTimeOffset UtcNow => new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/PostgresFixture.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/PostgresFixture.cs new file mode 100644 index 00000000..bf45c506 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Infrastructure/PostgresFixture.cs @@ -0,0 +1,89 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Infrastructure; + +public sealed class PostgresFixture : IAsyncLifetime +{ + private PostgreSqlContainer? container; + private string connectionString = ""; + public async Task InitializeAsync() + { + connectionString = Environment.GetEnvironmentVariable("IDENTITYLAB_TEST_POSTGRES") ?? ""; + if (string.IsNullOrWhiteSpace(connectionString)) + { + container = new PostgreSqlBuilder("postgres:17-alpine").Build(); + await container.StartAsync(); + connectionString = container.GetConnectionString(); + } + } + public async Task CreateAsync() + { + // A separate database per test prevents state leaking across tests or classes. + var name = "identitylab_test_" + Guid.NewGuid().ToString("N"); + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand($"CREATE DATABASE {name}", connection); + await command.ExecuteNonQueryAsync(); + var builder = new NpgsqlConnectionStringBuilder(connectionString) { Database = name, Pooling = false }; + var database = new LabDatabase(builder.ConnectionString, connectionString, name); + await using var db = database.CreateDbContext(); + await db.Database.MigrateAsync(); + return database; + } + public async Task DisposeAsync() + { + if (container is not null) await container.DisposeAsync(); + } +} + +public sealed class LabDatabase(string connectionString, string adminConnection, string name) + : IDbContextFactory, IAsyncDisposable +{ + public string ConnectionString => connectionString; + public static readonly UserKey OwnerA = UserKey.FromString("owner-a"); + public static readonly UserKey OwnerB = UserKey.FromString("owner-b"); + public static readonly UserKey MemberA = UserKey.FromString("member-a"); + public static readonly UserKey MemberB = UserKey.FromString("member-b"); + public LabDbContext CreateDbContext() => new(new DbContextOptionsBuilder() + .UseNpgsql(connectionString, options => options.MigrationsHistoryTable("__IdentityLabMigrationsHistory")).Options); + public Task CreateDbContextAsync(CancellationToken ct = default) + => Task.FromResult(CreateDbContext()); + public async Task SeedAsync() + { + var service = new SandboxService(this); + await service.CreateAsync(OwnerA, "Sandbox A"); + await service.CreateAsync(OwnerB, "Sandbox B"); + await service.AddMemberAsync(OwnerA, MemberA); + await service.AddMemberAsync(OwnerB, MemberB); + } + public async Task SetStatusAsync(SandboxStatus status) + { + await using var db = CreateDbContext(); + var sandbox = await db.Sandboxes.SingleAsync(x => x.OwnerUserKey == OwnerA); + sandbox.Status = status; + await db.SaveChangesAsync(); + } + public async Task SnapshotAsync() + { + await using var db = CreateDbContext(); + var sandboxes = await db.Sandboxes.AsNoTracking().OrderBy(x => x.Name).ToListAsync(); + var members = await db.SandboxMembers.AsNoTracking().OrderBy(x => x.UserKey).ToListAsync(); + var settings = await db.SandboxSettings.AsNoTracking().OrderBy(x => x.SandboxId).ToListAsync(); + return sandboxes.Select(x => $"S:{x.Id}:{x.OwnerUserKey}:{x.Name}:{x.Status}") + .Concat(members.Select(x => $"M:{x.Id}:{x.SandboxId}:{x.UserKey}:{x.Role}")) + .Concat(settings.Select(x => $"C:{x.SandboxId}:{x.MaxUsers}")).ToArray(); + } + public async ValueTask DisposeAsync() + { + await using var connection = new NpgsqlConnection(adminConnection); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand($"DROP DATABASE {name} WITH (FORCE)", connection); + await command.ExecuteNonQueryAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs new file mode 100644 index 00000000..51d0566f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/RegistrationIntegrationTests.cs @@ -0,0 +1,231 @@ +using Moq; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Seed; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Integration; + +public sealed class RegistrationIntegrationTests(PostgresFixture postgres) : IClassFixture +{ + [Fact] + public async Task Registration_persists_account_owner_role_and_sandbox_without_ladmin_assignment() + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await using var scope = runtime.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 result = await registration.RegisterAsync(new("alice", "alice@example.test", "Alice-test-password-8!")); + Assert.True(result.IsSuccess, result.ErrorMessage); + UserKey owner; + await using (var verify = db.CreateDbContext()) + { + var sandbox = await verify.Sandboxes.Include(x => x.Members).SingleAsync(); + owner = sandbox.OwnerUserKey; + Assert.Equal(owner, Assert.Single(sandbox.Members).UserKey); + } + // Query actual framework stores, using a new scope to avoid tracked state. + await using var verification = runtime.Services.CreateAsyncScope(); + var assignments = await verification.ServiceProvider.GetRequiredService() + .Create(TenantKeys.Single).GetAssignmentsAsync(owner); + Assert.Contains(assignments, x => x.RoleId == LabRoleIds.Owner); + Assert.DoesNotContain(assignments, x => x.RoleId == LabRoleIds.Admin); + var lifecycle = await verification.ServiceProvider.GetRequiredService() + .Create(TenantKeys.Single).GetAsync(new UserLifecycleKey(TenantKeys.Single, owner)); + Assert.NotNull(lifecycle); + var duplicate = await Record.ExceptionAsync(async () => + { + var duplicateResult = await registration.RegisterAsync(new("alice", "alice@example.test", "Alice-test-password-8!")); + Assert.False(duplicateResult.IsSuccess); + }); + // Duplicate validation may throw or return failure depending on the framework contract. + if (duplicate is not null) Assert.IsType(duplicate); + await using var final = db.CreateDbContext(); + Assert.Equal(1, await final.Sandboxes.CountAsync()); + } + + [Fact] + public async Task Trusted_seed_is_repeatable_and_assigns_ladmin_only_to_fixed_admin_identity() + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await using var scope = runtime.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + await ActivatorUtilities.CreateInstance(sp).SeedAsync(TenantKeys.Single); + await ActivatorUtilities.CreateInstance(sp).SeedAsync(TenantKeys.Single); + var store = sp.GetRequiredService().Create(TenantKeys.Single); + var assignments = await store.GetAssignmentsAsync(LabAdminIdentity.UserKey); + Assert.Single(assignments, x => x.RoleId == LabRoleIds.Admin); + Assert.Equal(1, await store.CountAssignmentsAsync(LabRoleIds.Admin)); + var roleStore = sp.GetRequiredService().Create(TenantKeys.Single); + var manager = await roleStore.GetByNameAsync("MANAGER"); + Assert.NotNull(manager); + Assert.Equal(LabRoleIds.Manager, manager.Id); + Assert.True(manager.Permissions.Select(x => x.Value).ToHashSet().SetEquals(LabDemoPermissions.Manager)); + } + [Theory] + [InlineData("Ladmin")] + [InlineData("Owner")] + public async Task Actual_role_service_denies_user_escalation_without_changing_persisted_assignments(string role) + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await db.SeedAsync(); + await using var scope = runtime.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var store = scope.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); + var before = await store.GetAssignmentsAsync(LabDatabase.MemberA); + var context = Contexts.Create(CodeBeam.UltimateAuth.Core.Defaults.UAuthActions.Authorization.Roles.AssignAdmin, + LabDatabase.MemberA, LabDatabase.MemberA); + await Assert.ThrowsAsync(() => + service.AssignAsync(context, LabDatabase.MemberA, role)); + await using var verification = runtime.Services.CreateAsyncScope(); + var after = await verification.ServiceProvider.GetRequiredService() + .Create(TenantKeys.Single).GetAssignmentsAsync(LabDatabase.MemberA); + Assert.Equal(before.Select(x => x.RoleId).OrderBy(x => x.Value), after.Select(x => x.RoleId).OrderBy(x => x.Value)); + } + + [Fact] + public async Task Owner_can_provision_member_and_assign_demo_role_but_cannot_delegate_ladmin() + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await using var scope = runtime.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var users = sp.GetRequiredService(); + var roles = sp.GetRequiredService(); + var assignments = sp.GetRequiredService(); + var registration = new LabRegistrationService(ActorProvider.For(null), users, roles, assignments, + new(db), new(db, NullLogger.Instance)); + Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "Owner-test-password-8!"))).IsSuccess); + UserKey owner; + await using (var verify = db.CreateDbContext()) owner = (await verify.Sandboxes.SingleAsync()).OwnerUserKey; + 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 + { UserName = "member", Email = "member@example.test", Password = "Member-test-password-8!" }); + 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); + await Assert.ThrowsAsync(() => demos.SetRoleAsync(member, "Ladmin", true)); + var roleService = sp.GetRequiredService(); + await Assert.ThrowsAsync(() => + roleService.AssignAsync(Contexts.Create(CodeBeam.UltimateAuth.Core.Defaults.UAuthActions.Authorization.Roles.AssignAdmin, + owner, member), member, "Ladmin")); + await using var fresh = runtime.Services.CreateAsyncScope(); + var persisted = await fresh.ServiceProvider.GetRequiredService() + .Create(TenantKeys.Single).GetAssignmentsAsync(member); + Assert.Contains(persisted, x => x.RoleId == LabRoleIds.User); + Assert.Contains(persisted, x => x.RoleId == LabRoleIds.Manager); + Assert.DoesNotContain(persisted, x => x.RoleId == LabRoleIds.Admin || x.RoleId == LabRoleIds.Owner); + await using var final = db.CreateDbContext(); + Assert.Equal(member, (await final.SandboxMembers.SingleAsync(x => x.UserKey == member)).UserKey); + } + + [Fact] + public async Task Owner_cannot_rename_delete_or_change_permissions_of_system_roles() + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await db.SeedAsync(); + await using var scope = runtime.Services.CreateAsyncScope(); + var roles = scope.ServiceProvider.GetRequiredService(); + var store = scope.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); + var before = await store.GetByNameAsync("LADMIN"); + Assert.NotNull(before); + CodeBeam.UltimateAuth.Core.Contracts.AccessContext Context(string action) => + Contexts.Create(action, LabDatabase.OwnerA); + await Assert.ThrowsAsync(() => + roles.RenameAsync(Context("authorization.roles.rename.admin"), LabRoleIds.Admin, "Manager")); + await Assert.ThrowsAsync(() => + roles.DeleteAsync(Context("authorization.roles.delete.admin"), LabRoleIds.Admin, + CodeBeam.UltimateAuth.Core.Contracts.DeleteMode.Hard)); + await Assert.ThrowsAsync(() => + roles.SetPermissionsAsync(Context("authorization.roles.permissions.admin"), LabRoleIds.Admin, + Array.Empty())); + await using var fresh = runtime.Services.CreateAsyncScope(); + var after = await fresh.ServiceProvider.GetRequiredService() + .Create(TenantKeys.Single).GetByNameAsync("LADMIN"); + Assert.NotNull(after); + Assert.Equal(before.Id, after.Id); + Assert.Equal(before.Version, after.Version); + Assert.Equal(before.Name, after.Name); + Assert.Equal(before.IsDeleted, after.IsDeleted); + Assert.Equal(before.Permissions.Select(x => x.Value).Order(), after.Permissions.Select(x => x.Value).Order()); + } + + [Fact] + public async Task Provisioning_assignment_fault_hard_deletes_real_partial_account_without_changing_sandboxes() + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await using var scope = runtime.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var users = sp.GetRequiredService(); + var roles = sp.GetRequiredService(); + var assignments = sp.GetRequiredService(); + var registration = new LabRegistrationService(ActorProvider.For(null), users, roles, assignments, + new(db), new(db, NullLogger.Instance)); + Assert.True((await registration.RegisterAsync(new("owner", "owner@example.test", "Owner-test-password-8!"))).IsSuccess); + UserKey owner; + await using (var verify = db.CreateDbContext()) owner = (await verify.Sandboxes.SingleAsync()).OwnerUserKey; + var before = await db.SnapshotAsync(); + var failedStore = new Moq.Mock(); + failedStore.Setup(x => x.GetAssignmentsAsync(Moq.It.IsAny(), Moq.It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Injected role-store failure")); + var failedFactory = new Moq.Mock(); + failedFactory.Setup(x => x.Create(Moq.It.IsAny())).Returns(failedStore.Object); + 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, + new CreateUserRequest { UserName = "failed-member", Email = "failed-member@example.test", Password = "Member-test-password-8!" })); + var partialKey = UserKey.FromString(failure.CreatedUserKey); + await using var fresh = runtime.Services.CreateAsyncScope(); + var lifecycle = await fresh.ServiceProvider.GetRequiredService() + .Create(TenantKeys.Single).GetAsync(new UserLifecycleKey(TenantKeys.Single, partialKey)); + Assert.Null(lifecycle); + var remainingRoles = await fresh.ServiceProvider.GetRequiredService() + .Create(TenantKeys.Single).GetAssignmentsAsync(partialKey); + Assert.Empty(remainingRoles); + Assert.Equal(before, await db.SnapshotAsync()); + } + + [Fact] + public async Task Actual_context_factory_replaces_forged_ladmin_attributes_with_stored_owner_identity() + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await db.SeedAsync(); + await using var scope = runtime.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + await new LabRoleService(sp.GetRequiredService(), sp.GetRequiredService()) + .AssignAsync(TenantKeys.Single, LabDatabase.OwnerA, "Owner"); + var context = await sp.GetRequiredService() + .CreateAsync(Contexts.AuthenticatedFlow(LabDatabase.OwnerA), "users.delete.admin", "users", + LabDatabase.MemberB.Value, new Dictionary { ["roles"] = new[] { LabRoleIds.Admin } }); + Assert.False(new LabRootResolver().IsLadmin(context)); + Assert.Equal(LabDatabase.OwnerA, context.ActorUserKey); + var resolved = Assert.IsAssignableFrom>(context.Attributes["roles"]); + Assert.Contains(LabRoleIds.Owner, resolved); + Assert.DoesNotContain(LabRoleIds.Admin, resolved); + var decision = await new CodeBeam.UltimateAuth.IdentityLab.Policy.SandboxUAuthAccessPolicy(db, + new LabRootResolver(), sp.GetRequiredService()).DecideAsync(context); + Assert.True(decision.IsDenied); + Assert.Equal("sandbox_target_out_of_scope", decision.DenyReason); + } + +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs new file mode 100644 index 00000000..2cfe13d0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs @@ -0,0 +1,99 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Integration; + +public sealed class SandboxPersistenceTests(PostgresFixture postgres) : IClassFixture +{ + [Fact] + public async Task Creation_persists_owner_membership_and_settings_together() + { + await using var db = await postgres.CreateAsync(); + var sandbox = await new SandboxService(db).CreateAsync(LabDatabase.OwnerA, " Sandbox A "); + await using var verify = db.CreateDbContext(); + var actual = await verify.Sandboxes.Include(x => x.Members).Include(x => x.Settings).SingleAsync(); + Assert.Equal(sandbox.Id, actual.Id); + Assert.Equal("Sandbox A", actual.Name); + Assert.Equal(LabDatabase.OwnerA, actual.OwnerUserKey); + var owner = Assert.Single(actual.Members); + Assert.Equal(LabDatabase.OwnerA, owner.UserKey); + Assert.Equal(SandboxMemberRole.Owner, owner.Role); + Assert.Equal(5, actual.Settings.MaxUsers); + } + + [Fact] + public async Task Cross_sandbox_member_removal_and_owner_removal_leave_database_unchanged() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var before = await db.SnapshotAsync(); + var service = new SandboxService(db); + Assert.False(await service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberB)); + await Assert.ThrowsAsync(() => service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.OwnerA)); + await Assert.ThrowsAsync(() => service.RemoveMemberAsync(LabDatabase.MemberA, LabDatabase.MemberA)); + Assert.Equal(before, await db.SnapshotAsync()); + Assert.True(await service.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberA)); + await using var verify = db.CreateDbContext(); + Assert.False(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberA)); + Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberB)); + } + + [Fact] + public async Task Duplicate_owner_creation_rolls_back_all_new_sandbox_state() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var before = await db.SnapshotAsync(); + await Assert.ThrowsAsync(() => new SandboxService(db).CreateAsync(LabDatabase.OwnerA, "Duplicate")); + Assert.Equal(before, await db.SnapshotAsync()); + } + + [Fact] + public async Task Concurrent_member_additions_cannot_exceed_capacity() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + await using (var setup = db.CreateDbContext()) + { + var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA); + settings.MaxUsers = 3; + await setup.SaveChangesAsync(); + } + var service = new SandboxService(db); + async Task Add(string key) => await Record.ExceptionAsync(async () => + { await service.AddMemberAsync(LabDatabase.OwnerA, UserKey.FromString(key)); }); + // No sleeps: serializable transactions must preserve the invariant for any scheduling. + var outcomes = await Task.WhenAll(Add("candidate-1"), Add("candidate-2")); + Assert.Single(outcomes, x => x is null); + await using var verify = db.CreateDbContext(); + Assert.Equal(3, await verify.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA)); + Assert.Equal(2, await verify.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerB)); + } + + [Fact] + public async Task Member_cannot_be_added_to_second_sandbox() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var before = await db.SnapshotAsync(); + await Assert.ThrowsAsync(() => new SandboxService(db).AddMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberB)); + Assert.Equal(before, await db.SnapshotAsync()); + } + [Fact] + public async Task Concurrent_creation_cannot_create_two_sandboxes_for_one_owner() + { + await using var db = await postgres.CreateAsync(); + var service = new SandboxService(db); + async Task Create(string name) => await Record.ExceptionAsync(async () => + { await service.CreateAsync(LabDatabase.OwnerA, name); }); + var outcomes = await Task.WhenAll(Create("First"), Create("Second")); + Assert.Single(outcomes, x => x is null); + await using var verify = db.CreateDbContext(); + Assert.Equal(1, await verify.Sandboxes.CountAsync()); + Assert.Equal(1, await verify.SandboxMembers.CountAsync()); + Assert.Equal(1, await verify.SandboxSettings.CountAsync()); + } + +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs new file mode 100644 index 00000000..c1a93792 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/MemberProvisioningTests.cs @@ -0,0 +1,97 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +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.IdentityLab.Policy; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Provisioning; + +public sealed class MemberProvisioningTests(PostgresFixture postgres) : IClassFixture +{ + [Theory] + [InlineData("owner-b")] + [InlineData("member-a")] + public async Task Forged_actor_or_regular_member_cannot_provision(string requestedActor) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var before = await db.SnapshotAsync(); + var users = new Mock(MockBehavior.Strict); + var roles = new Mock(MockBehavior.Strict); + var assignments = new Mock(MockBehavior.Strict); + var service = new LabUserProvisioningService(ActorProvider.For(LabDatabase.MemberA), + new(db), new(db), users.Object, roles.Object, assignments.Object, new(), + NullLogger.Instance); + await Assert.ThrowsAsync(() => service.CreateMemberAsync( + UserKey.FromString(requestedActor), new CreateUserRequest())); + 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() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var before = await db.SnapshotAsync(); + var gate = new LabProvisioningRollbackGate(); + var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), gate); + var createdKey = UserKey.FromString("new-member"); + var users = new Mock(MockBehavior.Strict); + users.Setup(x => x.CreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (AccessContext context, CreateUserRequest request, CancellationToken ct) => + { + Assert.True((await policy.DecideAsync(context, ct)).IsAllowed); + return UserCreateResult.Success(createdKey); + }); + AccessContext? rollbackContext = null; + bool? unrelatedDenied = null, rollbackAllowed = null, replayDenied = null; + DeleteMode? rollbackMode = null; + users.Setup(x => x.DeleteUserAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (AccessContext context, DeleteUserRequest request, CancellationToken ct) => + { + rollbackContext = context; + rollbackMode = request.Mode; + // An unrelated target cannot consume the real token. + var unrelated = Contexts.Create(UAuthActions.Users.DeleteAdmin, LabDatabase.OwnerA, + UserKey.FromString("unrelated"), attributes: context.Attributes); + unrelatedDenied = (await policy.DecideAsync(unrelated, ct)).IsDenied; + rollbackAllowed = (await policy.DecideAsync(context, ct)).IsAllowed; + replayDenied = (await policy.DecideAsync(context, ct)).IsDenied; + return UserDeleteResult.Success(DeleteMode.Hard); + }); + var roleStore = new Mock(); + roleStore.Setup(x => x.GetByNameAsync("USER", It.IsAny())) + .ReturnsAsync(Role.Create(LabRoleIds.User, TenantKeys.Single, "User", new HashSet(), DateTimeOffset.UnixEpoch)); + var roles = new Mock(); + 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")); + 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( + LabDatabase.OwnerA, new CreateUserRequest())); + Assert.NotNull(rollbackContext); + Assert.Equal(createdKey, rollbackContext!.TargetUserKey); + Assert.Equal(DeleteMode.Hard, rollbackMode); + Assert.True(unrelatedDenied); + Assert.True(rollbackAllowed); + Assert.True(replayDenied); + Assert.True((await policy.DecideAsync(rollbackContext!)).IsDenied); + users.Verify(x => x.DeleteUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + Assert.Equal(before, await db.SnapshotAsync()); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/ProvisioningPolicyTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/ProvisioningPolicyTests.cs new file mode 100644 index 00000000..ff1c440e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/ProvisioningPolicyTests.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Policy; +using CodeBeam.UltimateAuth.IdentityLab.Services; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Provisioning; + +public sealed class ProvisioningPolicyTests(PostgresFixture postgres) : IClassFixture +{ + [Theory] + [InlineData(true)] + [InlineData("trusted")] + [InlineData("identitylab.internal.provisioning")] + public async Task Client_values_cannot_forge_provisioning_or_rollback_tokens(object token) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var before = await db.SnapshotAsync(); + var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new()); + var attributes = new Dictionary + { + ["identitylab.internal.provisioning"] = token, + ["identitylab.internal.provisioning.rollback"] = token + }; + foreach (var action in new[] { UAuthActions.Users.CreateAnonymous, UAuthActions.Users.CreateAdmin, + UAuthActions.Users.DeleteAdmin }) + { + var result = await policy.DecideAsync(Contexts.Create(action, LabDatabase.OwnerA, + UserKey.FromString("unrelated"), attributes: attributes)); + Assert.True(result.IsDenied); + } + Assert.Equal(before, await db.SnapshotAsync()); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/RegistrationTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/RegistrationTests.cs new file mode 100644 index 00000000..38f108bf --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Provisioning/RegistrationTests.cs @@ -0,0 +1,57 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Policy; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Provisioning; + +public sealed class RegistrationTests(PostgresFixture postgres) : IClassFixture +{ + [Fact] + public async Task Registration_propagates_trusted_context_and_persists_matching_owner_relationships() + { + await using var db = await postgres.CreateAsync(); + var policy = new SandboxUAuthAccessPolicy(db, new LabRootResolver(), new()); + var users = new Mock(MockBehavior.Strict); + users.Setup(x => x.CreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (AccessContext context, CreateUserRequest request, CancellationToken ct) => + { + Assert.True((await policy.DecideAsync(context, ct)).IsAllowed); + Assert.Equal("alice", request.UserName); + return UserCreateResult.Success(LabDatabase.OwnerA); + }); + var roleStore = new Mock(); + roleStore.Setup(x => x.GetByNameAsync("OWNER", It.IsAny())) + .ReturnsAsync(Role.Create(LabRoleIds.Owner, TenantKeys.Single, "Owner", new HashSet(), DateTimeOffset.UnixEpoch)); + var roles = new Mock(); + roles.Setup(x => x.Create(It.IsAny())).Returns(roleStore.Object); + var userStore = new Mock(MockBehavior.Strict); + userStore.Setup(x => x.GetAssignmentsAsync(LabDatabase.OwnerA, It.IsAny())) + .ReturnsAsync(Array.Empty()); + userStore.Setup(x => x.AssignAsync(LabDatabase.OwnerA, LabRoleIds.Owner, + It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + var assignments = new Mock(); + assignments.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); + var service = new LabRegistrationService(ActorProvider.For(null), users.Object, roles.Object, + assignments.Object, new(db), new(db, NullLogger.Instance)); + var result = await service.RegisterAsync(new LabRegistrationRequest(" alice ", "alice@example.test", "test-password")); + Assert.True(result.IsSuccess); + await using var verify = db.CreateDbContext(); + var sandbox = await verify.Sandboxes.Include(x => x.Members).SingleAsync(); + Assert.Equal(LabDatabase.OwnerA, sandbox.OwnerUserKey); + Assert.Equal(LabDatabase.OwnerA, Assert.Single(sandbox.Members).UserKey); + userStore.Verify(x => x.AssignAsync(LabDatabase.OwnerA, LabRoleIds.Owner, + It.IsAny(), It.IsAny()), Times.Once); + userStore.Verify(x => x.GetAssignmentsAsync(LabDatabase.OwnerA, It.IsAny()), Times.Once); + userStore.VerifyNoOtherCalls(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/README.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/README.md new file mode 100644 index 00000000..c5afa29d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/README.md @@ -0,0 +1,17 @@ +# Identity Lab security suite + +Requires .NET 10 SDK and Docker with permission to start containers. Run from the repository root: + +```sh +dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release --logger trx +``` + +By default, each database test class starts PostgreSQL 17 through Testcontainers. Every test gets a new database, applies the actual LabDbContext migrations, and drops its database on disposal. Tests never use EF InMemory. Nothing connects to application production databases. + +Alternatively set `IDENTITYLAB_TEST_POSTGRES` to a connection string for a **dedicated test PostgreSQL instance**. Its login needs CREATE DATABASE and permission to drop the databases it creates. Only databases named `identitylab_test_` are created/dropped. This setting bypasses Docker; it does not bypass relational tests. Missing infrastructure fails fixture initialization rather than silently skipping tests. + +Tests replace authenticated-context sources and selected framework store/client contracts with Moq. The actual Lab services, policies, relational model, migrations and transaction boundaries execute normally. The focused mock tests do not exercise framework persistence. Integration/RegistrationIntegrationTests additionally composes the real framework services, both PostgreSQL DbContexts, migrations, seed contributors, registration, member provisioning, demo assignment and rollback deletion. Contexts.cs uses a small reflection adapter because AccessContext construction is internal; production visibility and authorization remain unchanged. + +`Regression/SystemRoleIdentityTests.cs` records an expected rejection that the inspected implementation does not enforce. It is intentionally neither skipped nor inverted to accept unsafe behavior. The `KnownDefect=SystemRoleIdentity` trait identifies it; normal local and CI runs include it. Expect that regression to fail until separately approved production work addresses the issue. Do not enable this suite as a required green check before defect triage. + +See [coverage matrix](docs/security-coverage.md) and [execution report](docs/execution-report.md). The project is included in the solution. Repository CI excludes it from the general matrix test step and runs it in a dedicated .NET 10 PostgreSQL job; the dedicated job uploads TRX results even on failure. diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Regression/SystemRoleIdentityTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Regression/SystemRoleIdentityTests.cs new file mode 100644 index 00000000..a6d147a6 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Regression/SystemRoleIdentityTests.cs @@ -0,0 +1,37 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Regression; + +public sealed class SystemRoleIdentityTests +{ + [Fact] + [Trait("KnownDefect", "SystemRoleIdentity")] + public async Task Owner_name_with_admin_id_must_be_rejected_before_assignment() + { + // Security expectation: fail closed on a corrupt system-role definition. + // Current LabRoleService checks the name but does not pin its ID. + var store = new Mock(); + store.Setup(x => x.GetByNameAsync("OWNER", It.IsAny())) + .ReturnsAsync(Role.Create(LabRoleIds.Admin, TenantKeys.Single, "Owner", + new HashSet(), DateTimeOffset.UnixEpoch)); + var roles = new Mock(); + roles.Setup(x => x.Create(It.IsAny())).Returns(store.Object); + var userStore = new Mock(); + userStore.Setup(x => x.GetAssignmentsAsync(LabDatabase.MemberA, It.IsAny())) + .ReturnsAsync(Array.Empty()); + userStore.Setup(x => x.AssignAsync(LabDatabase.MemberA, LabRoleIds.Admin, + It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + var assignments = new Mock(); + assignments.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); + var service = new LabRoleService(roles.Object, assignments.Object); + await Assert.ThrowsAsync(() => service.AssignAsync( + TenantKeys.Single, LabDatabase.MemberA, "Owner")); + userStore.Verify(x => x.AssignAsync(LabDatabase.MemberA, LabRoleIds.Admin, + It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/AdminGuardTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/AdminGuardTests.cs new file mode 100644 index 00000000..fd2fe01d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/AdminGuardTests.cs @@ -0,0 +1,46 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Security; + +public sealed class AdminGuardTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Admin_guard_uses_store_assignment_even_when_attributes_claim_ladmin(bool assigned) + { + var store = new Mock(); + store.Setup(x => x.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new[] { Role.Create(LabRoleIds.Admin, TenantKeys.Single, "Ladmin", + new HashSet(), DateTimeOffset.UnixEpoch) }); + var roles = new Mock(); + roles.Setup(x => x.Create(It.IsAny())).Returns(store.Object); + var userStore = new Mock(); + userStore.Setup(x => x.GetAssignmentsAsync(LabDatabase.OwnerA, It.IsAny())) + .ReturnsAsync(assigned ? new[] { new UserRole { RoleId = LabRoleIds.Admin } } : Array.Empty()); + var users = new Mock(); + users.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); + var context = Contexts.Create("identitylab.admin", LabDatabase.OwnerA, + attributes: new Dictionary { ["roles"] = new[] { LabRoleIds.Admin } }); + Assert.Equal(assigned, await new LabAdminGuard(roles.Object, users.Object).IsAdminAsync(context)); + } + + [Theory] + [InlineData("Ladmin")] + [InlineData("ladmin")] + [InlineData("Manager")] + [InlineData("Customer")] + public async Task Provisioning_role_helper_rejects_roles_outside_owner_and_user(string name) + { + var roles = new Mock(MockBehavior.Strict); + var users = new Mock(MockBehavior.Strict); + await Assert.ThrowsAsync(() => new CodeBeam.UltimateAuth.IdentityLab.Services.LabRoleService( + roles.Object, users.Object).AssignAsync(TenantKeys.Single, LabDatabase.MemberA, name)); + roles.VerifyNoOtherCalls(); + users.VerifyNoOtherCalls(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/RolePolicyTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/RolePolicyTests.cs new file mode 100644 index 00000000..4bef7f72 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Security/RolePolicyTests.cs @@ -0,0 +1,58 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Services; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Security; + +public sealed class RolePolicyTests +{ + [Theory] + [InlineData("owner-a")] + [InlineData("member-a")] + [InlineData("ladmin")] + public async Task Public_role_assignment_is_denied_even_when_context_claims_admin(string actor) + { + var context = Contexts.Create(UAuthActions.Authorization.Roles.AssignAdmin, + CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString(actor), + LabDatabase.MemberA, attributes: new Dictionary { ["roles"] = new[] { LabRoleIds.Admin } }); + var result = await new LabApplicationPolicy().DecideAsync(context); + Assert.True(result.IsDenied); + Assert.Equal("lab_role_management_restricted", result.DenyReason); + } + + [Fact] + public async Task Trusted_system_role_assignment_and_authenticated_read_remain_allowed() + { + var policy = new LabApplicationPolicy(); + Assert.True((await policy.DecideAsync(Contexts.Create( + UAuthActions.Authorization.Roles.AssignAdmin, system: true))).IsAllowed); + Assert.True((await policy.DecideAsync(Contexts.Create( + UAuthActions.Authorization.Roles.GetSelf, LabDatabase.MemberA))).IsAllowed); + Assert.True((await policy.DecideAsync(Contexts.Create( + UAuthActions.Authorization.Roles.GetSelf))).IsDenied); + } + + [Theory] + [InlineData("Ladmin")] + [InlineData("Owner")] + [InlineData("Manager")] + [InlineData("Customer")] + public void Role_names_cannot_forge_root_identity(string name) + { + var context = Contexts.Create("users.delete.admin", LabDatabase.MemberA, + attributes: new Dictionary { ["roles"] = new[] { name } }); + Assert.False(new LabRootResolver().IsLadmin(context)); + } + + [Fact] + public void Root_resolution_requires_authenticated_actor_and_typed_admin_id() + { + var attributes = new Dictionary { ["roles"] = new[] { LabRoleIds.Admin } }; + var resolver = new LabRootResolver(); + Assert.True(resolver.IsLadmin(Contexts.Create("test", LabDatabase.OwnerA, attributes: attributes))); + Assert.False(resolver.IsLadmin(Contexts.Create("test", attributes: attributes))); + Assert.False(resolver.IsLadmin(Contexts.Create("test", LabDatabase.OwnerA, system: true, attributes: attributes))); + Assert.False(resolver.IsLadmin(Contexts.Create("test", LabDatabase.OwnerA, + attributes: new Dictionary { ["roles"] = new[] { LabRoleIds.Manager, LabRoleIds.Customer } }))); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/AccountDeletionTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/AccountDeletionTests.cs new file mode 100644 index 00000000..dd0e29a3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/AccountDeletionTests.cs @@ -0,0 +1,44 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services; + +public sealed class AccountDeletionTests(PostgresFixture postgres) : IClassFixture +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Membership_is_removed_only_after_framework_self_deletion_succeeds(bool success) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var users = new Mock(MockBehavior.Strict); + users.Setup(x => x.DeleteMeAsync()).ReturnsAsync(new UAuthResult { IsSuccess = success }); + var client = new Mock(MockBehavior.Strict); + client.SetupGet(x => x.Users).Returns(users.Object); + var service = new SandboxAccountDeletionService(db, client.Object, NullLogger.Instance); + Assert.Equal(success, await service.DeleteMemberAsync(LabDatabase.MemberA)); + await using var verify = db.CreateDbContext(); + Assert.Equal(!success, await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberA)); + Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.MemberB)); + Assert.True(await verify.SandboxMembers.AnyAsync(x => x.UserKey == LabDatabase.OwnerA)); + } + + [Fact] + public async Task Owner_self_deletion_never_calls_framework_client() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var before = await db.SnapshotAsync(); + var client = new Mock(MockBehavior.Strict); + var service = new SandboxAccountDeletionService(db, client.Object, NullLogger.Instance); + Assert.False(await service.DeleteMemberAsync(LabDatabase.OwnerA)); + client.VerifyNoOtherCalls(); + Assert.Equal(before, await db.SnapshotAsync()); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs new file mode 100644 index 00000000..5077f1a9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoRoleServiceTests.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services; + +public sealed class DemoRoleServiceTests(PostgresFixture postgres) : IClassFixture +{ + [Theory] + [InlineData("Ladmin")] + [InlineData("lAdMiN")] + [InlineData("Owner")] + [InlineData("User")] + [InlineData(" Manager")] + [InlineData("Manager\0")] + public async Task Demo_operations_reject_system_and_forged_names_without_accessing_stores(string role) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + 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)); + Assert.Equal(before, await db.SnapshotAsync()); + roles.VerifyNoOtherCalls(); + assignments.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData("owner-a", "member-b", SandboxStatus.Active)] + [InlineData("member-a", "member-a", SandboxStatus.Active)] + [InlineData("owner-a", "owner-a", SandboxStatus.Active)] + [InlineData("owner-a", "missing", SandboxStatus.Active)] + [InlineData("owner-a", "member-a", SandboxStatus.Passive)] + [InlineData("owner-a", "member-a", SandboxStatus.Suspended)] + public async Task Assignment_requires_active_persisted_owner_and_same_sandbox_regular_member(string actor, string target, SandboxStatus status) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + await db.SetStatusAsync(status); + 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)); + Assert.Equal(before, await db.SnapshotAsync()); + roles.VerifyNoOtherCalls(); + assignments.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Demo_role_id_must_match_fixed_identity_before_any_assignment(bool forged) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var roleStore = new Mock(); + roleStore.Setup(x => x.GetByNameAsync("MANAGER", It.IsAny())) + .ReturnsAsync(Role.Create(forged ? LabRoleIds.Admin : LabRoleIds.Manager, TenantKeys.Single, + "Manager", new HashSet(), DateTimeOffset.UnixEpoch)); + var roles = new Mock(); + roles.Setup(x => x.Create(It.IsAny())).Returns(roleStore.Object); + var userStore = new Mock(MockBehavior.Strict); + if (!forged) + { + userStore.Setup(x => x.GetAssignmentsAsync(LabDatabase.MemberA, It.IsAny())) + .ReturnsAsync(Array.Empty()); + userStore.Setup(x => x.AssignAsync(LabDatabase.MemberA, LabRoleIds.Manager, + It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + } + 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); + if (forged) + { + await Assert.ThrowsAsync(() => service.SetRoleAsync(LabDatabase.MemberA, "Manager", true)); + userStore.VerifyNoOtherCalls(); + } + else + { + await service.SetRoleAsync(LabDatabase.MemberA, "Manager", true); + 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 new file mode 100644 index 00000000..f3ada7e1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DemoStoreTests.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services; + +public sealed class DemoStoreTests(PostgresFixture postgres) : IClassFixture +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Demo_store_only_accepts_exact_demo_permissions_from_fixed_role_ids(bool customer) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var id = customer ? LabRoleIds.Customer : LabRoleIds.Manager; + var expected = customer ? LabDemoPermissions.Customer : LabDemoPermissions.Manager; + var userStore = new Mock(); + userStore.Setup(x => x.GetAssignmentsAsync(LabDatabase.MemberA, It.IsAny())) + .ReturnsAsync(new[] { new UserRole { RoleId = id }, new UserRole { RoleId = LabRoleIds.Admin } }); + var users = new Mock(); + users.Setup(x => x.Create(It.IsAny())).Returns(userStore.Object); + var store = new Mock(); + // Deliberately hostile store response: wildcard, admin permission and an + // unrelated role must all be filtered even if the backing data is corrupt. + var permissions = expected.Select(Permission.From).Append(Permission.Wildcard) + .Append(Permission.From("users.delete.admin")).ToHashSet(); + store.Setup(x => x.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new[] { + Role.Create(id, TenantKeys.Single, customer ? "Customer" : "Manager", permissions, DateTimeOffset.UnixEpoch), + Role.Create(LabRoleIds.Admin, TenantKeys.Single, "Ladmin", permissions, DateTimeOffset.UnixEpoch) + }); + 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(); + Assert.True(actual.SetEquals(expected)); + await service.RequireAsync(LabDemoPermissions.ProductsRead); + await Assert.ThrowsAsync(() => service.RequireAsync("users.delete.admin")); + await db.SetStatusAsync(SandboxStatus.Suspended); + Assert.Empty(await service.GetPermissionsAsync()); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs new file mode 100644 index 00000000..a55933f6 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services; + +public sealed class RepairTests(PostgresFixture postgres) : IClassFixture +{ + [Fact] + public async Task Repair_removes_only_own_stale_members_and_preserves_both_owners() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var store = new Mock(); + store.Setup(x => x.GetByKeysAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + var stores = new Mock(); + stores.Setup(x => x.Create(It.IsAny())).Returns(store.Object); + var service = new SandboxRepairService(db, stores.Object, NullLogger.Instance); + await Assert.ThrowsAsync(() => service.RepairAsync(LabDatabase.MemberA)); + var result = await service.RepairAsync(LabDatabase.OwnerA); + Assert.Equal(1, result.RemovedMemberships); + await using var verify = db.CreateDbContext(); + var keys = await verify.SandboxMembers.Select(x => x.UserKey).ToListAsync(); + Assert.DoesNotContain(LabDatabase.MemberA, keys); + Assert.Contains(LabDatabase.MemberB, keys); + Assert.Contains(LabDatabase.OwnerA, keys); + Assert.Contains(LabDatabase.OwnerB, keys); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md new file mode 100644 index 00000000..2faaffd1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/execution-report.md @@ -0,0 +1,48 @@ +# Execution report — 2026-10-30 + +Branch: `test/identity-lab-security`, based on `dev` at `64d1dc5` (Identity Lab: UI Polish, Authorization Playground & Diagnostics, #118). Remote `dev` was fetched and confirmed to match the supplied checkout. No production source was changed. No merge or push was performed. + +## Implemented and executed + +.NET SDK 10.0.403; Release configuration; PostgreSQL 17 (Testcontainers 4.16.0); actual Lab and framework migrations. Every relational test uses an isolated database. The real integration runtime composes framework services, both PostgreSQL contexts, seed contributors, and Identity Lab policies; focused tests mock selected stores/clients and authenticated context sources. + +| Identity Lab area | Passed | Failed | Skipped | +|---|---:|---:|---:| +| Security: admin guard, role policies, root resolution | 15 | 0 | 0 | +| Authorization: two-sandbox targets, status, owner restrictions | 10 | 0 | 0 | +| Services: demo roles/store, repair, account deletion | 20 | 0 | 0 | +| Provisioning: forged actors/tokens, rollback replay, registration | 7 | 0 | 0 | +| Integration: real registration/seeding/provisioning, context attribute forgery, role denials, fault cleanup, relational invariants | 14 | 0 | 0 | +| Regression: mismatched system role identity | 0 | 1 | 0 | +| **Total** | **66** | **1** | **0** | + +The enabled failing regression expresses required rejection. It was not skipped, inverted or excluded from CI. Final Identity Lab execution took 33 seconds after build. All 67 discovered cases executed. + +Existing EF integration tests: **3 passed, 0 failed, 0 skipped**. Existing unit tests: **2,754 passed, 0 failed, 0 skipped**. Existing broader integration tests: **375 passed, 0 failed, 0 skipped** (4 minutes 44 seconds). Across existing projects: **3,132 passed, 0 failed, 0 skipped**. + +## Confirmed integrity defect: system-role ID validation gap + +Runtime reproduction is encoded in `Regression/SystemRoleIdentityTests.cs`: + +1. Configure the role-store contract to resolve normalized `OWNER` to a Role whose ID is `LabRoleIds.Admin`. +2. Return no assignments for a regular target user. +3. Invoke `LabRoleService.AssignAsync(singleTenant, target, "Owner")`. +4. The implementation calls AssignAsync with the returned Admin ID. The regression requires InvalidOperationException before assignment and fails because no exception is thrown. + +This requires a malformed role definition or compromised store; no public operation that creates that definition was established. Admin seed validation normally rejects mismatched system IDs. This is a reproduced defense-in-depth integrity defect, **not a demonstrated ordinary-user escalation**. The helper is not registered as a public role-mutation endpoint. Production code remains unchanged. + +The real-persistence tests separately confirm that ordinary role-service attempts to acquire Owner/Ladmin or mutate the admin role are denied without altering stored role state. The real context factory replaces forged typed Ladmin attributes with stored Owner role IDs, and the sandbox policy still denies the foreign target. Legitimate registration creates Owner rather than Ladmin, trusted seeding assigns Ladmin only to the fixed administrator, and member provisioning/demo assignment preserves regular user boundaries. + +Other risks, not confirmed exploits: registration permits partial account creation and returns RequiresRepair after some failures; multiple services trust actor-key arguments supplied by their callers. Authenticated transport tests are required before classifying those as exploitable defects. + +## Infrastructure and verification + +Initial shell attempts could not find dotnet or reach the proxy/Docker socket. Enabling network access for the shell command made the configured infrastructure accessible; a local .NET 10 SDK was installed under work/. These initial limitations were resolved. No relational tests were substituted or disabled. The test project explicitly pins EF Core 10.0.12 to match the application and avoid transitive 10.0.5 assembly conflicts. Testcontainers was updated to 4.16.0 after restore reported advisories in an older transitive SSH.NET dependency. + +`git diff --check` passes. Application builds emit an existing MudBlazor analyzer warning about Disabled on MudSelectItemGroupExtended; production code was left unchanged. CI itself has not been run remotely, and the external-PostgreSQL connection-string alternative was not separately exercised. + +## CI readiness and remaining boundaries + +The project is in UltimateAuth.slnx. Existing CI excludes it from the general matrix test step and runs it in a dedicated .NET 10 job using Docker/Testcontainers, with TRX uploaded even on failure. No continue-on-error or defect exclusion was added. + +**Not ready to require as a green CI gate:** the preserved integrity regression fails and needs separate triage/approved production work. Full hosted HTTP/cookie/context authenticity, diagnostics transport, standalone identifier availability, complete deletion cleanup, owner/admin repair, registration fault recovery, and cancellation/restart boundaries remain partial or untested. See the coverage matrix for exact boundaries. Passing the other tests is not a claim of complete security coverage. diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md new file mode 100644 index 00000000..9f6f5f98 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/security-coverage.md @@ -0,0 +1,48 @@ +# Inspection and security coverage matrix + +## Architecture and trust boundaries + +Identity Lab is a .NET 10 interactive Blazor Server application. Framework entities use UAuthDbContext; sandbox, membership, settings and activity entities use LabDbContext, on PostgreSQL. These are separate persistence units. Registration is a multi-step orchestration, not one transaction across both contexts. + +`SandboxUAuthAccessPolicy` constrains framework users, credentials, sessions, roles and flows. It queries membership/status and validates single and batch targets. `LabApplicationPolicy` blocks public role mutation even for Ladmin. Policies compose with framework policies: an Allow from one policy is not evidence that the entire operation succeeds. + +`LabDemoRoleService` resolves authenticated context internally, requires persisted active ownership, restricts targets to regular members in the same sandbox, and pins Manager/Customer IDs. `LabUserProvisioningService` additionally compares its actor argument to the authenticated actor. Internal reference tokens protect account creation and narrowly scoped rollback deletion. `LabRoleService` is a trusted store helper; it is not registered as a public mutation endpoint. + +`LabAdminGuard` rechecks fixed-ID roles and assignments in stores. `/api/lab/admin` applies an authorization filter and its services also use the guard. `LabRootResolver`, unlike the guard, consumes typed role IDs from server-generated context attributes. Context production and transport tamper resistance therefore need separate integration coverage. + +`SandboxService`, repair services and some other application methods accept user keys as trusted caller inputs. Direct invocation tests establish their database behavior; they do not establish that every Blazor or HTTP caller supplies the authenticated identity. UI visibility was not counted as authorization. + +Admin seeding verifies fixed system role IDs, prevents identifier takeover and assigns Ladmin to the configured fixed identity. Demo seeding pins IDs and reconciles permission sets. The database has unique owner and membership indexes, a filtered unique owner-membership index, foreign keys and cascading membership/settings deletion. Membership capacity changes use serializable transactions. + +Existing tests use xUnit 2.9.3, Test SDK 17.14.1 and net10.0 for EF integration; those tests use SQLite. This project follows their xUnit/SDK conventions but uses PostgreSQL for provider-specific constraints, migrations, scope queries and transaction behavior. It adds no duplicate framework CRUD test suite. + +## Matrix + +The implemented tests have been compiled and exercised against PostgreSQL 17. The execution report records final counts and the deliberately preserved failing regression. + +| Priority | Boundary | Test area | Status and limits | +|---|---|---|---| +| P0 | Public Owner/User/Ladmin role mutation | Security/RolePolicyTests | Implemented policy and actual framework role-service denials, stored assignments unchanged, authenticated reads and system positive control; no HTTP dispatch | +| P0 | Demo operations cannot assign system roles | Services/DemoRoleServiceTests | Implemented hostile names, owner/member scope, inactive status, wrong fixed ID, legitimate assignment; focused role stores mocked; full-persistence integration also covers legitimate Manager assignment and denied Ladmin assignment | +| P0 | Forged admin role attributes | Security/AdminGuardTests, RolePolicyTests; Integration/RegistrationIntegrationTests | Implemented persisted-assignment guard and typed-ID root behavior, plus real context-factory replacement of forged typed Ladmin attributes with stored Owner identity; HTTP/cookie tampering untested | +| P0 | Foreign/missing targets and sessions | Authorization/SandboxPolicyTests | Implemented read/update/delete/session/role decisions and positive controls, both owners/members; no real session rows | +| P0 | Batch targets, owner deletion, inactive sandbox | Authorization/SandboxPolicyTests | Implemented policy checks against PostgreSQL | +| P0 | Spoofed actor during provisioning | Provisioning/MemberProvisioningTests | Implemented mismatched actor and regular-member denials before framework calls | +| P0 | Provisioning token forgery | Provisioning/ProvisioningPolicyTests | Implemented bool/string impostors rejected by actual policy | +| P0 | Fault rollback and token reuse | Provisioning/MemberProvisioningTests | Implemented injected assignment failure, exact target, unrelated target, one-shot consumption and post-operation revocation; focused framework deletion mocked; real-account hard deletion after injected role-store failure also covered | +| P0 | Registration relationships | Provisioning/RegistrationTests | Implemented trusted-context handoff, PostgreSQL account/Owner-role/sandbox/membership creation, and absence of Ladmin assignment; both real-persistence and focused mock cases | +| P0 | Relational atomicity/ownership | Integration/SandboxPersistenceTests | Implemented duplicate-owner rollback, concurrent owner uniqueness, no cross-sandbox membership | +| P0 | User capacity contention | Integration/SandboxPersistenceTests | Implemented two independent serializable operations competing for one slot; any scheduling allowed, no sleeps | +| P0 | Deletion membership lifecycle | Services/AccountDeletionTests, Integration/SandboxPersistenceTests | Implemented success/failure ordering, owner restrictions and cross-sandbox preservation; actual framework cleanup untested | +| P0 | Repair scope | Services/RepairTests | Implemented stale-member cleanup restricted to one sandbox, owners preserved; owner/admin repair paths untested | +| P0 | Wrong system role ID | Regression/SystemRoleIdentityTests | Executed and reproduced failing rejection; corrupted store scenario, not demonstrated external exploit | +| P1 | Demo store permissions | Services/DemoStoreTests | Implemented Manager/Customer controls, wildcard/admin filtering, suspended denial; role persistence mocked | +| P1 | Status transitions | Authorization/SandboxPolicyTests | Active/passive/suspended behavior covered; authorized transition APIs untested | +| P1 | Identifier availability/duplicate registration | Integration/RegistrationIntegrationTests | Duplicate registration covered with real framework PostgreSQL composition; standalone availability interface not yet covered | +| P1 | Diagnostics authorization | Authorization/SandboxPolicyTests | Session read policy only; diagnostics UI/endpoint transport untested | +| P1 | Account management | Services/AccountDeletionTests | Partial: deletion ordering only; credentials/status/profile endpoint composition untested | +| P2 | Seed contributor end-to-end repeatability | Integration/RegistrationIntegrationTests | Implemented real PostgreSQL seeding, repeat execution, fixed admin assignment count and exact Manager permissions | +| P2 | Full HTTP/cookie/CSRF/context trust | — | Not implemented | +| P2 | Faults across both real DbContexts and cancellation/restart | Integration/RegistrationIntegrationTests | Real member-account hard-delete rollback after role-store fault covered; registration faults, cancellation and process restart remain untested | + +Passing the implemented tests cannot prove that Ladmin is unreachable through every Identity Lab operation. Highest-priority remaining work is authenticated transport tests for caller-supplied identities, registration failure recovery, and full cleanup/cancellation/restart fault coverage. Both DbContexts and production migrations are exercised, but no test runs the complete hosted HTTP application.