From 84bd8aa1073f840877443a0b71028fd27760a7a2 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 29 Jun 2026 09:24:19 +0200 Subject: [PATCH 01/21] feat(realms): prod-safe hard-delete that drops the tenant database at runtime Adds IRealmProvisioningService.HardDeleteRealmAsync alongside the existing reversible soft-delete. Sequence (verified by integration test): MasterTableTenancy .RemoveTenantAsync (evict tenancy cache + dispose data source + delete the realms.mt_tenant_databases registry row) -> DROP DATABASE ... WITH (FORCE) (terminates the per-tenant async-daemon backend) -> remove the global Realm record + invalidate the realm cache. Guarded against the control-plane realm. RealmHardDeleteTests proves: the victim tenant DB is physically dropped, the global record is gone, a sibling realm + its DB are fully intact, and the control-plane realm is refused with its DB surviving. Known caveat (documented in code + Atlas): re-creating a realm with the SAME slug in the SAME process reuses Weasel's connection-string-cached NpgsqlDataSource (no per-key eviction). Unique slugs avoid it; a custom evictable INpgsqlDataSourceFactory is the clean fix if in-process slug reuse is ever needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmHardDeleteTests.cs | 101 ++++++++++++++++++ .../Realms/RealmProvisioningService.cs | 89 +++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 src/dotnet/Modgud.Api.Tests/ColdStart/RealmHardDeleteTests.cs diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmHardDeleteTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmHardDeleteTests.cs new file mode 100644 index 00000000..15747a16 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmHardDeleteTests.cs @@ -0,0 +1,101 @@ +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Stage 1a risk-gate: a prod-safe HARD remove that actually drops the tenant +/// database at runtime, vs today's reversible soft-delete. Proves the §4 drop +/// sequence (deregister tenant → DROP DATABASE ... WITH (FORCE) → remove global +/// record) works against a live host whose async daemon holds a connection to the +/// tenant DB, leaves sibling realms completely intact, and frees the slug for a +/// clean re-create. +/// +public class RealmHardDeleteTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Hard_delete_drops_the_tenant_database_and_leaves_other_realms_intact() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + + var svc = factory.Services.GetRequiredService(); + var masterCs = factory.Services.GetRequiredService().Value; + var mainDb = new NpgsqlConnectionStringBuilder(masterCs).Database!; + + // Two realms so we can prove isolation: the victim and an innocent bystander. + await CreateRealmAsync(svc, "victim", ct); + await CreateRealmAsync(svc, "bystander", ct); + + var victimDb = $"{mainDb}_victim"; + var bystanderDb = $"{mainDb}_bystander"; + Assert.True(await DatabaseExistsAsync(masterCs, victimDb, ct), "victim DB should exist after create"); + Assert.True(await DatabaseExistsAsync(masterCs, bystanderDb, ct), "bystander DB should exist after create"); + + // Act — hard-delete the victim while the daemon is live. + var result = await svc.HardDeleteRealmAsync("victim", ct); + Assert.False(result.IsError, result.IsError ? result.FirstError.Description : string.Empty); + + // Victim is physically gone: DB dropped + global record removed. + Assert.False(await DatabaseExistsAsync(masterCs, victimDb, ct), "victim DB must be dropped"); + Assert.Null(await svc.GetRealmBySlugAsync("victim", ct)); + + // Bystander is entirely unaffected. + Assert.True(await DatabaseExistsAsync(masterCs, bystanderDb, ct), "bystander DB must survive"); + Assert.NotNull(await svc.GetRealmBySlugAsync("bystander", ct)); + + // NOTE: re-creating a realm with the SAME slug in the SAME process is a + // documented caveat (Weasel's DefaultNpgsqlDataSourceFactory caches data + // sources by connection string with no per-key eviction). Realm lifecycles use + // unique slugs, so it is out of scope for this risk-gate; see HardDeleteRealmAsync. + } + + [Fact] + public async Task Hard_delete_refuses_the_control_plane_realm() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + + var svc = factory.Services.GetRequiredService(); + var masterCs = factory.Services.GetRequiredService().Value; + var mainDb = new NpgsqlConnectionStringBuilder(masterCs).Database!; + + var result = await svc.HardDeleteRealmAsync(TenantConstants.SystemTenantId, ct); + + Assert.True(result.IsError); + Assert.Equal("Realm.CannotDeleteControlPlane", result.FirstError.Code); + + // The system tenant DB must still be there. + Assert.True( + await DatabaseExistsAsync(masterCs, $"{mainDb}_{TenantConstants.SystemTenantId}", ct), + "system tenant DB must survive a refused hard-delete"); + } + + private static async Task CreateRealmAsync(IRealmProvisioningService svc, string slug, CancellationToken ct) + { + var result = await svc.CreateRealmAsync(new CreateRealmDto + { + Slug = slug, + DisplayName = slug, + Domains = [$"{slug}.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "admin", Email = $"admin@{slug}.test" }, + }, ct); + Assert.False(result.IsError, result.IsError ? result.FirstError.Description : string.Empty); + } + + private static async Task DatabaseExistsAsync(string masterCs, string dbName, CancellationToken ct) + { + var builder = new NpgsqlConnectionStringBuilder(masterCs) { Database = "postgres" }; + await using var conn = new NpgsqlConnection(builder.ConnectionString); + await conn.OpenAsync(ct); + await using var cmd = new NpgsqlCommand("SELECT 1 FROM pg_database WHERE datname = @n", conn); + cmd.Parameters.AddWithValue("@n", dbName); + return await cmd.ExecuteScalarAsync(ct) is not null; + } +} diff --git a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs index 0a72d109..7a5f1932 100644 --- a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs +++ b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs @@ -36,6 +36,17 @@ Task> UpdateRealmAsync( CancellationToken ct = default); Task> DeleteRealmAsync(string slug, CancellationToken ct = default); + /// + /// HARD-removes a realm: drops the tenant database entirely (event streams, + /// signing keys, the OpenIddict token store — all gone) and deletes the global + /// record. Unlike (a + /// reversible soft-delete) this is irreversible. Blocked for the control-plane + /// realm. Sequence: deregister the tenant from Marten's registry table, then + /// DROP DATABASE ... WITH (FORCE) to terminate any remaining daemon/pool + /// backends, then remove the global record + invalidate the realm cache. + /// + Task> HardDeleteRealmAsync(string slug, CancellationToken ct = default); + /// /// Compensation for a realm whose succeeded /// but whose post-create bootstrap (issuing the initial-admin invite) then @@ -445,6 +456,84 @@ public async Task> DeleteRealmAsync(string slug, CancellationToken return true; } + public async Task> HardDeleteRealmAsync(string slug, CancellationToken ct = default) + { + await using var session = _globalStore.LightweightSession(); + + var realm = await session.Query() + .FirstOrDefaultAsync(r => r.Slug == slug, ct); + + if (realm is null) + return Error.NotFound("Realm.NotFound", $"Realm '{slug}' not found."); + + // Same guard as DeleteRealmAsync: the Control-Plane realm holds the + // global administration surface and must never be dropped. + if (realm.IsControlPlane) + { + return Error.Validation("Realm.CannotDeleteControlPlane", + "Cannot hard-delete the Control-Plane realm — the deployment would lose its global administration surface."); + } + + var csBuilder = new NpgsqlConnectionStringBuilder(_masterCs.Value); + var mainDbName = csBuilder.Database!; + var tenantDbName = $"{mainDbName}_{slug}"; + + // 1. Hand the tenant back to Marten. RemoveTenantAsync evicts it from the + // tenancy's in-memory cache, disposes its Npgsql data source (gracefully + // closing the pool before the drop) and deletes the registry row in + // realms.mt_tenant_databases, so the async daemon stops rediscovering the + // database and it drops out of tenant resolution. + // + // Caveat — re-creating a realm with the SAME slug in the SAME process: + // Weasel's DefaultNpgsqlDataSourceFactory caches data sources by connection + // string with no per-key eviction, so the disposed data source would be + // handed back on a later create with the identical connection string. Realm + // slugs are unique per lifecycle (tests use unique slugs too), so this does + // not arise on the normal path; a custom evictable INpgsqlDataSourceFactory + // is the clean fix if in-process slug reuse is ever required. + var tenancy = (Marten.Storage.MasterTableTenancy)_tenantedStore.Options.Tenancy; + await tenancy.RemoveTenantAsync(slug); + + // 2. DROP DATABASE ... WITH (FORCE) on the maintenance DB. Marten holds one + // Npgsql data source (its own pool plus the async daemon's connection) per + // tenant DB; FORCE (PG13+) terminates every remaining backend so the drop + // succeeds without a "database is being accessed by other users" error. + var bootstrapBuilder = new NpgsqlConnectionStringBuilder(_masterCs.Value) { Database = "postgres" }; + await using (var bootstrapConn = new NpgsqlConnection(bootstrapBuilder.ConnectionString)) + { + await bootstrapConn.OpenAsync(ct); + var quotedName = "\"" + tenantDbName.Replace("\"", "\"\"") + "\""; +#pragma warning disable CA2100 // tenantDbName derives from the operator connection string + a validated slug, never raw request input + await using var dropCmd = new NpgsqlCommand( + $"DROP DATABASE IF EXISTS {quotedName} WITH (FORCE)", bootstrapConn); +#pragma warning restore CA2100 + await dropCmd.ExecuteNonQueryAsync(ct); + } + + // 3. Remove the global Realm record and invalidate the cache so middleware + // stops resolving the now-dropped realm. + session.Delete(realm); + await session.SaveChangesAsync(ct); + _realmCache.Invalidate(); + + _logger.LogWarning( + "Hard-deleted realm {Slug}: dropped tenant database {DbName} and removed the global Realm record. " + + "Irreversible — event streams, signing keys and the OpenIddict token store are gone.", + slug, tenantDbName); + + _securityAudit.Record(new SecurityAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + Level = "Warning", + Realm = slug, + Status = "hard-deleted", + Reason = "operator hard-delete", + Message = $"Hard-deleted realm {slug} (tenant database {tenantDbName} dropped)", + }); + + return true; + } + public async Task RollbackProvisionedRealmAsync(string slug, CancellationToken ct = default) { await using var session = _globalStore.LightweightSession(); From 43d4341381f68e861395d690adc4fd62bc6667be Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 29 Jun 2026 09:45:21 +0200 Subject: [PATCH 02/21] feat(provisioning): RealmManifestApplier.ImportNewRealmAsync (realm + oauth + users) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-process engine behind declarative realm provisioning. Maps a RealmManifest onto the EXISTING canonical operations — never reimplementing a mutation: realm shell via IRealmProvisioningService, settings via IRealmSettingsService, OAuth apis/scopes/ clients via OAuthAdminService, users via the CreateUserCommand Wolverine handler. Correctness: - Tenant routing: the realm shell is created against the global store, then per-tenant config runs under TenantContext.Enter(slug) + a fresh DI scope. TenantedSessionFactory prefers the AsyncLocal TenantContext over the ambient (control-plane) HttpContext, so direct-service writes land in the NEW realm even though the call runs on the CP host. - Wolverine commands resolve their Marten session from the message-envelope tenant, not TenantContext, so user creation uses InvokeForTenantAsync(slug, ...) (a plain InvokeAsync falls back to a tenant-less session and throws "Default tenant"). - All-or-nothing: any failure during apply hard-deletes the partially-provisioned realm. RealmManifestApplierTests proves an import lands the oauth config + user in the new realm's DB (verified via the canonical read methods), that it is isolated from the system tenant, and that a duplicate slug is rejected. v1 covers realm/settings/oauth/users; apps, roles, groups, login providers and the UpdateRealm merge come next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmManifestApplierTests.cs | 124 +++++++++++++++ .../Admin/Provisioning/RealmManifest.cs | 61 ++++++++ .../Provisioning/RealmManifestApplier.cs | 143 ++++++++++++++++++ src/dotnet/Modgud.Api/Program.cs | 5 + 4 files changed, 333 insertions(+) create mode 100644 src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs new file mode 100644 index 00000000..f0acc6df --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs @@ -0,0 +1,124 @@ +using Modgud.Api.Features.Admin.Provisioning; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.OAuth; +using Modgud.Application.DTOs.Realms; +using Modgud.Application.Services; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; +using Microsoft.Extensions.DependencyInjection; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Stage 1b: the RealmManifestApplier imports a fully-configured realm in-process by +/// reusing the canonical admin operations. Proves the writes land in the NEW realm's +/// tenant database (not the control-plane/system tenant the call runs under), via the +/// AsyncLocal TenantContext taking precedence over the ambient HttpContext. +/// +public class RealmManifestApplierTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Import_provisions_a_fully_configured_realm_in_the_right_tenant() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + + const string slug = "acme"; + var manifest = new RealmManifest + { + Realm = new CreateRealmDto + { + Slug = slug, + DisplayName = "Acme", + Domains = ["acme.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "admin", Email = "admin@acme.test" }, + }, + Apis = [new CreateOAuthApiDto { Name = "acme-api", DisplayName = "Acme API" }], + Scopes = [new CreateOAuthScopeDto { Name = "acme.read", DisplayName = "Acme — Read", Resources = ["acme-api"] }], + Clients = + [ + new CreateOAuthClientDto + { + ClientId = "acme-web", + DisplayName = "Acme Web", + ClientType = "confidential", + RedirectUris = ["https://acme.test/callback"], + Scopes = ["openid", "acme.read"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], + }, + ], + Users = [new RealmManifestUser { Email = "alice@acme.test", UserName = "alice", Password = "Passw0rd!23" }], + }; + + var applier = factory.Services.GetRequiredService(); + + var result = await applier.ImportNewRealmAsync(manifest, ct); + + Assert.False(result.IsError, result.IsError ? result.FirstError.Description : string.Empty); + Assert.Equal(slug, result.Value.Slug); + Assert.Equal("acme.localhost", result.Value.PrimaryDomain); + // The confidential client's generated secret is surfaced for the caller. + Assert.True(result.Value.ClientSecrets.ContainsKey("acme-web")); + Assert.False(string.IsNullOrWhiteSpace(result.Value.ClientSecrets["acme-web"])); + + // The realm shell exists in the global store. + var realms = factory.Services.GetRequiredService(); + Assert.NotNull(await realms.GetRealmBySlugAsync(slug, ct)); + + // The OAuth config landed in the NEW realm's tenant DB (read via the same + // inline-consistent admin read methods). + await InTenantAsync(factory, slug, async oauth => + { + var clients = await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct); + Assert.Contains(clients.Items, c => c.ClientId == "acme-web"); + var apis = await oauth.GetApisAsync(new PaginationRequest { PageSize = 200 }, ct); + Assert.Contains(apis.Items, a => a.Name == "acme-api"); + var scopes = await oauth.GetScopesAsync(ct); + Assert.Contains(scopes.Items, s => s.Name == "acme.read"); + }); + + // Isolation: the realm's client must NOT exist in the system tenant. + await InTenantAsync(factory, TenantConstants.SystemTenantId, async oauth => + { + var clients = await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct); + Assert.DoesNotContain(clients.Items, c => c.ClientId == "acme-web"); + }); + } + + [Fact] + public async Task Import_rejects_a_slug_that_already_exists() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + + var manifest = new RealmManifest + { + Realm = new CreateRealmDto + { + Slug = "dup", + DisplayName = "Dup", + Domains = ["dup.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "admin", Email = "admin@dup.test" }, + }, + }; + + var applier = factory.Services.GetRequiredService(); + + var first = await applier.ImportNewRealmAsync(manifest, ct); + Assert.False(first.IsError, first.IsError ? first.FirstError.Description : string.Empty); + + var second = await applier.ImportNewRealmAsync(manifest, ct); + Assert.True(second.IsError); + Assert.Equal("Realm.AlreadyExists", second.FirstError.Code); + } + + private static async Task InTenantAsync( + ColdStartWebApplicationFactory factory, string slug, Func body) + { + using var _ = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await body(scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs new file mode 100644 index 00000000..94db8213 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs @@ -0,0 +1,61 @@ +using Modgud.Api.Features.Users.Commands; +using Modgud.Application.DTOs.OAuth; +using Modgud.Application.DTOs.Realms; +using Modgud.Application.DTOs.RealmSettings; + +namespace Modgud.Api.Features.Admin.Provisioning; + +/// +/// A declarative description of a realm's complete configuration. Applied in-process +/// by , which maps each section onto the SAME +/// canonical application operation the admin UI/API uses — it never reimplements a +/// mutation. v1 carries the realm shell + settings + OAuth (apis/scopes/clients) + +/// users; apps, roles, groups and login providers are added incrementally. +/// +/// The sections reuse the existing Create DTOs verbatim, so the manifest schema +/// stays in lockstep with the operations it drives. Cross-references that need +/// server-generated ids (app linkage, role/group membership) arrive with those later +/// sections. +/// +public sealed record RealmManifest +{ + /// Realm shell + initial admin (see ). + public required CreateRealmDto Realm { get; init; } + + /// Optional realm settings patch (self-registration, native grants, ...). + public UpdateRealmSettingsDto? Settings { get; init; } + + public List Apis { get; init; } = []; + public List Scopes { get; init; } = []; + public List Clients { get; init; } = []; + public List Users { get; init; } = []; +} + +/// A user to provision into the realm (maps to ). +public sealed record RealmManifestUser +{ + public string? Firstname { get; init; } + public string? Lastname { get; init; } + public string? Acronym { get; init; } + public required string Email { get; init; } + public string? UserName { get; init; } + public string? Password { get; init; } + public bool EmailConfirmed { get; init; } + + public CreateUserCommand ToCommand() => + new(Firstname, Lastname, Acronym, Email, UserName ?? string.Empty, Password, EmailConfirmed); +} + +/// The outcome of a successful import. +public sealed record RealmImportResult +{ + public required string Slug { get; init; } + public required string PrimaryDomain { get; init; } + + /// + /// Plaintext secrets of the confidential clients created during the import + /// (clientId → secret). Secrets are only returned at create time, so they are + /// surfaced here for a test-kit / caller to use without a separate fetch. + /// + public Dictionary ClientSecrets { get; init; } = []; +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs new file mode 100644 index 00000000..58b7cb44 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -0,0 +1,143 @@ +using ErrorOr; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Modgud.Application.DTOs.User; +using Modgud.Application.Services; +using Modgud.Authentication.RealmSettings; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; +using Wolverine; + +namespace Modgud.Api.Features.Admin.Provisioning; + +/// +/// Applies a in-process by calling the existing canonical +/// application operations — the engine behind declarative realm provisioning. +/// +/// Invariant: ZERO new write logic. Each section is dispatched to the SAME +/// operation the admin UI / admin API uses (, +/// , , the user +/// Wolverine commands), so the manifest path and the manual path can never drift. +/// +/// Tenant routing: the realm shell is created via the global store (no tenant +/// context), then the per-tenant config runs inside TenantContext.Enter(slug) +/// + a fresh DI scope. TenantedSessionFactory prefers the AsyncLocal +/// TenantContext over the ambient (control-plane) HttpContext, so the +/// writes land in the NEW realm's database even though the import is triggered from +/// the control-plane host. +/// +public sealed class RealmManifestApplier +{ + private readonly IRealmProvisioningService _realms; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public RealmManifestApplier( + IRealmProvisioningService realms, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _realms = realms; + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + /// Imports a brand-new realm: the slug must NOT already exist. Provisions the realm + /// shell (tenant DB + seed) then applies the manifest's config. If any step fails + /// the whole partially-provisioned realm is hard-deleted, so a failed import leaves + /// nothing behind (all-or-nothing). + /// + public async Task> ImportNewRealmAsync( + RealmManifest manifest, CancellationToken ct = default) + { + var slug = manifest.Realm.Slug; + + if (await _realms.GetRealmBySlugAsync(slug, ct) is not null) + return Error.Conflict("Realm.AlreadyExists", + $"Realm '{slug}' already exists. Use UpdateRealm to modify an existing realm."); + + var realmResult = await _realms.CreateRealmAsync(manifest.Realm, ct); + if (realmResult.IsError) return realmResult.Errors; + var realm = realmResult.Value; + + try + { + var secrets = await ApplyTenantConfigAsync(slug, manifest, ct); + _logger.LogInformation( + "Imported realm {Slug}: {Apis} apis, {Scopes} scopes, {Clients} clients, {Users} users.", + slug, manifest.Apis.Count, manifest.Scopes.Count, manifest.Clients.Count, manifest.Users.Count); + return new RealmImportResult + { + Slug = slug, + PrimaryDomain = realm.PrimaryDomain, + ClientSecrets = secrets, + }; + } + catch (ManifestApplyException ex) + { + // A failed import must leave nothing behind: roll the whole realm back via + // the prod-safe hard-delete (drops the tenant DB + the global record). + _logger.LogError(ex, + "Manifest apply failed for realm {Slug} ({What}); hard-deleting the partially-provisioned realm.", + slug, ex.What); + await _realms.HardDeleteRealmAsync(slug, ct); + return ex.Errors; + } + } + + private async Task> ApplyTenantConfigAsync( + string slug, RealmManifest manifest, CancellationToken ct) + { + var secrets = new Dictionary(StringComparer.Ordinal); + + // Enter the new realm's tenant context, then resolve the per-tenant services in + // a FRESH scope so their IDocumentSession binds to this tenant (a session reads + // TenantContext at the moment it is opened). + using var _ = TenantContext.Enter(slug); + using var scope = _scopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + + if (manifest.Settings is not null) + EnsureOk(await sp.GetRequiredService().PatchAsync(manifest.Settings, ct), "settings"); + + var oauth = sp.GetRequiredService(); + + foreach (var api in manifest.Apis) + EnsureOk(await oauth.CreateApiAsync(api, ct), $"api '{api.Name}'"); + + foreach (var scopeDto in manifest.Scopes) + EnsureOk(await oauth.CreateScopeAsync(scopeDto, ct), $"scope '{scopeDto.Name}'"); + + foreach (var client in manifest.Clients) + { + var created = await oauth.CreateClientAsync(client, ct); + EnsureOk(created, $"client '{client.ClientId}'"); + if (created.Value.ClientSecret is not null) + secrets[client.ClientId] = created.Value.ClientSecret; + } + + // Wolverine opens the handler's Marten session from the message envelope's + // tenant, NOT from the ambient TenantContext — so the user commands must be + // dispatched with InvokeForTenantAsync(slug, ...) or they fall back to a + // tenant-less session ("Default tenant does not supported"). + var bus = sp.GetRequiredService(); + foreach (var user in manifest.Users) + EnsureOk(await bus.InvokeForTenantAsync>(slug, user.ToCommand(), ct), $"user '{user.Email}'"); + + return secrets; + } + + private static void EnsureOk(ErrorOr result, string what) + { + if (result.IsError) + throw new ManifestApplyException(what, result.Errors); + } + + private sealed class ManifestApplyException(string what, List errors) + : Exception($"Failed to apply {what}: {(errors.Count > 0 ? errors[0].Description : "unknown error")}") + { + public string What { get; } = what; + public List Errors { get; } = errors; + } +} diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index a54dda88..6a00ebce 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -552,6 +552,11 @@ // + IAutoMembershipRecalculator are all registered by AddModgudAuthorization // inside AddInfrastructure. Only keep app-specific wiring here. builder.Services.AddScoped(); + + // Declarative realm provisioning — applies a RealmManifest in-process by reusing + // the canonical admin operations (the engine behind import/apply/export). + builder.Services.AddScoped(); + // C16: Demo-seed runs as an API client now — see scripts/seed-demo.mjs. // No backend service, no DI registration, no PROD-01 bracket needed: // the script logs in as a regular admin and POSTs through the same From 3ee8d6f76567dcd0e7526778cf40e3c6acf78a52 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 29 Jun 2026 09:57:56 +0200 Subject: [PATCH 03/21] refactor(apps): extract canonical App create into AppAdminService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the App-create logic (slug/displayName/duplicate validation + NormalizePermissions + StartStream) out of the AppsEndpoints MapPost lambda into a shared AppAdminService returning ErrorOr. The endpoint now delegates and maps ErrorOr→IResult; the realm-provisioning applier will call the same service, so App creation has ONE canonical write path (the no-divergence invariant). Update/delete stay inline for now (their reference-checking is consolidated when the applier gains update via UpdateRealm). Behaviour-preserving: AppCatalogDeleteBlockTests stays green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Features/Admin/Apps/AppAdminService.cs | 103 ++++++++++++++++++ .../Features/Admin/Apps/AppsEndpoints.cs | 44 ++------ src/dotnet/Modgud.Api/Program.cs | 3 + 3 files changed, 114 insertions(+), 36 deletions(-) create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs new file mode 100644 index 00000000..41ad94a4 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs @@ -0,0 +1,103 @@ +using ErrorOr; +using Marten; +using Modgud.Authorization.Apps; +using Modgud.Authorization.Events; + +namespace Modgud.Api.Features.Admin.Apps; + +/// +/// The single canonical write path for creating records, shared by +/// and the realm-provisioning applier so the manual path +/// and the manifest path can never diverge. Returns so the +/// endpoint maps it to HTTP while the applier consumes it directly. The injected +/// is tenant-scoped, so a call lands in whatever realm +/// the ambient TenantContext selects. +/// +public sealed class AppAdminService(IDocumentSession session) +{ + public async Task> CreateAppAsync(CreateAppDto dto, CancellationToken ct = default) + { + if (!AppSlugRules.IsValidFormat(dto.Slug)) + return Error.Validation("App.InvalidSlug", + "Slug must be 3-63 characters, start with a letter, end with a letter or digit, and contain only lowercase letters, digits, and hyphens."); + + if (AppSlugRules.IsReserved(dto.Slug)) + return Error.Validation("App.ReservedSlug", $"The slug '{dto.Slug}' is reserved."); + + if (string.IsNullOrWhiteSpace(dto.DisplayName)) + return Error.Validation("App.DisplayNameRequired", "DisplayName is required."); + + var duplicate = await session.Query() + .Where(a => a.Slug == dto.Slug && !a.IsDeleted) + .AnyAsync(ct); + if (duplicate) + return Error.Conflict("App.DuplicateSlug", $"An app with slug '{dto.Slug}' already exists."); + + var permissions = NormalizePermissions(dto.Permissions, existingByKey: null); + if (permissions.IsError) return permissions.Errors; + + var id = Guid.NewGuid(); + session.Events.StartStream(id, new AppCreatedEvent( + Id: id, + Slug: dto.Slug, + DisplayName: dto.DisplayName, + Description: dto.Description, + Permissions: permissions.Value, + IsSystem: false)); + await session.SaveChangesAsync(ct); + + return (await session.LoadAsync(id, ct))!; + } + + /// + /// Validates and normalises the permission catalog off a create / update payload: + /// parses incoming ids (ShortGuid → Guid, minting a fresh one when absent), dedupes + /// by (Resource, Action), enforces the segment grammar and rejects the reserved + /// realm:admin bypass. Shared by create (here) and the AppsEndpoints update + /// path so there is one normalisation rule. + /// + internal static ErrorOr> NormalizePermissions( + List? payload, + IReadOnlyDictionary? existingByKey) + { + var input = payload ?? []; + var normalised = new List(input.Count); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var entry in input) + { + var resource = entry.Resource?.Trim() ?? string.Empty; + var action = entry.Action?.Trim() ?? string.Empty; + + if (!AppPermissionRules.IsValidSegment(resource) || + !AppPermissionRules.IsValidSegment(action)) + { + return Error.Validation("App.InvalidPermissionSegment", + $"Permission '{resource}:{action}' is invalid — both segments must match ^[a-z0-9-]+$."); + } + + // realm:admin is the synthetic realm-wide bypass — it must never be a catalog + // entry (audit H1, vector 3). Conferring realm:admin is reserved to a role's + // IsRealmAdmin flag, which is itself gated on the caller holding realm:admin. + if (AppPermissionRules.IsReservedBypass(resource, action)) + { + return Error.Validation("App.ReservedPermission", + "The permission 'realm:admin' is reserved — it is the realm-wide bypass and cannot be a catalog entry. Use a role's IsRealmAdmin flag instead."); + } + + var key = $"{resource}:{action}"; + if (!seen.Add(key)) + continue; // silently drop exact duplicates + + // Explicit id wins (rename / detached-replay path); otherwise mint a new one. + var id = Guid.NewGuid(); + if (!string.IsNullOrEmpty(entry.Id) && BuildingBlocks.Helper.ShortGuid.TryParse(entry.Id, out Guid parsed)) + id = parsed; + + var description = string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description.Trim(); + normalised.Add(new AppPermission(id, resource, action, description)); + } + + return normalised; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs index 29272c54..a66142dd 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs @@ -1,6 +1,7 @@ using BuildingBlocks.Helper; using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; +using Modgud.Authentication.ExtensionMethods; using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Events; @@ -90,43 +91,14 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s .WithName("V2_App_GetById") .RequiresPermission("app:read"); - appGroup.MapPost("", async (CreateAppDto dto, IDocumentSession session) => + // Create delegates to the shared AppAdminService — the single canonical create + // path that the realm-provisioning applier also calls (no divergence). Update / + // delete stay inline below (their reference-checking is consolidated when the + // applier gains update via UpdateRealm). + appGroup.MapPost("", async (CreateAppDto dto, AppAdminService appAdmin, CancellationToken ct) => { - if (!AppSlugRules.IsValidFormat(dto.Slug)) - return Results.BadRequest(new { Error = "App.InvalidSlug", - Message = "Slug must be 3-63 characters, start with a letter, end with a letter or digit, and contain only lowercase letters, digits, and hyphens." }); - - if (AppSlugRules.IsReserved(dto.Slug)) - return Results.BadRequest(new { Error = "App.ReservedSlug", - Message = $"The slug '{dto.Slug}' is reserved." }); - - if (string.IsNullOrWhiteSpace(dto.DisplayName)) - return Results.BadRequest(new { Error = "App.DisplayNameRequired", - Message = "DisplayName is required." }); - - var existing = await session.Query() - .Where(a => a.Slug == dto.Slug && !a.IsDeleted) - .AnyAsync(); - if (existing) - return Results.Conflict(new { Error = "App.DuplicateSlug", - Message = $"An app with slug '{dto.Slug}' already exists." }); - - var permissionsResult = NormalizePermissions(dto.Permissions, existingByKey: null); - if (permissionsResult.Error is not null) return permissionsResult.Error; - - var id = Guid.NewGuid(); - var created = new AppCreatedEvent( - Id: id, - Slug: dto.Slug, - DisplayName: dto.DisplayName, - Description: dto.Description, - Permissions: permissionsResult.Permissions, - IsSystem: false); - session.Events.StartStream(id, created); - await session.SaveChangesAsync(); - - var loaded = await session.LoadAsync(id); - return Results.Ok(MapToResponse(loaded!)); + var result = await appAdmin.CreateAppAsync(dto, ct); + return result.ToResult(app => Results.Ok(MapToResponse(app))); }) .WithName("V2_App_Create") .RequiresPermission("app:write"); diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 6a00ebce..19abad3d 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -553,6 +553,9 @@ // inside AddInfrastructure. Only keep app-specific wiring here. builder.Services.AddScoped(); + // Shared canonical App create path (AppsEndpoints + the provisioning applier). + builder.Services.AddScoped(); + // Declarative realm provisioning — applies a RealmManifest in-process by reusing // the canonical admin operations (the engine behind import/apply/export). builder.Services.AddScoped(); From 6c12ab7c56fa28eb4b368ccdb46b2d2fc1d57847 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 29 Jun 2026 10:33:24 +0200 Subject: [PATCH 04/21] feat(provisioning): full-coverage import (apps, roles) with key-based cross-references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns RealmManifest to key-based cross-references (apps by slug, roles by key, permissions by resource:action) mirroring demo-seed.json; the applier resolves keys to ids in dependency order (apps → apis/scopes/clients → roles → users). Adds App and Role to the applier by reusing the now-shared AppAdminService / RoleAdminService (Role create extracted from RolesEndpoints; the realm:admin guard is a parameter, true for trusted control-plane provisioning). APIs/roles resolve their app's permission catalog by resource:action; clients resolve app links by slug. RealmManifestApplierTests imports a realm with an app+catalog, an app-linked API/scope, a confidential client, an app-scoped role (2 permissions), and a user — and verifies they land in the new realm's tenant DB (isolated from system). Groups deferred: CreateGroupHandler cascades a durable Wolverine message (membership recalculation) that InvokeForTenantAsync routes to the tenant DB, which has no Wolverine durability tables. Recorded in Atlas for a focused follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmManifestApplierTests.cs | 99 ++++++--- .../Admin/Provisioning/RealmManifest.cs | 111 ++++++++-- .../Provisioning/RealmManifestApplier.cs | 201 +++++++++++++----- .../Features/Roles/RoleAdminService.cs | 95 +++++++++ .../Features/Roles/RolesEndpoints.cs | 26 +-- src/dotnet/Modgud.Api/Program.cs | 3 +- 6 files changed, 426 insertions(+), 109 deletions(-) create mode 100644 src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs index f0acc6df..6210bbb8 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs @@ -3,22 +3,26 @@ using Modgud.Application.DTOs.OAuth; using Modgud.Application.DTOs.Realms; using Modgud.Application.Services; +using Modgud.Authentication.Domain; +using Modgud.Authorization.Apps; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; +using Marten; using Microsoft.Extensions.DependencyInjection; namespace Modgud.Api.Tests.ColdStart; /// /// Stage 1b: the RealmManifestApplier imports a fully-configured realm in-process by -/// reusing the canonical admin operations. Proves the writes land in the NEW realm's -/// tenant database (not the control-plane/system tenant the call runs under), via the -/// AsyncLocal TenantContext taking precedence over the ambient HttpContext. +/// reusing the canonical admin operations, resolving key-based cross-references +/// (apps↔apis/scopes/clients/roles, groups↔users/roles) in dependency order. Proves the +/// writes land in the NEW realm's tenant database (not the control-plane/system tenant +/// the call runs under). /// public class RealmManifestApplierTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) { [Fact] - public async Task Import_provisions_a_fully_configured_realm_in_the_right_tenant() + public async Task Import_provisions_a_fully_configured_realm_with_resolved_cross_references() { await using var host = await Fixture.CreateIsolatedHostAsync(); var factory = host.Factory; @@ -34,11 +38,36 @@ public async Task Import_provisions_a_fully_configured_realm_in_the_right_tenant Domains = ["acme.localhost"], InitialAdmin = new InitialAdminDto { UserName = "admin", Email = "admin@acme.test" }, }, - Apis = [new CreateOAuthApiDto { Name = "acme-api", DisplayName = "Acme API" }], - Scopes = [new CreateOAuthScopeDto { Name = "acme.read", DisplayName = "Acme — Read", Resources = ["acme-api"] }], + Apps = + [ + new RealmManifestApp + { + Slug = "acme-app", + DisplayName = "Acme App", + Permissions = + [ + new RealmManifestPermission("acme", "read"), + new RealmManifestPermission("acme", "write"), + ], + }, + ], + Apis = + [ + new RealmManifestApi + { + Name = "acme-api", + DisplayName = "Acme API", + App = "acme-app", + Permissions = [new RealmManifestPermission("acme", "read")], + }, + ], + Scopes = + [ + new RealmManifestScope { Name = "acme.read", DisplayName = "Acme — Read", App = "acme-app", Resources = ["acme-api"] }, + ], Clients = [ - new CreateOAuthClientDto + new RealmManifestClient { ClientId = "acme-web", DisplayName = "Acme Web", @@ -46,9 +75,26 @@ public async Task Import_provisions_a_fully_configured_realm_in_the_right_tenant RedirectUris = ["https://acme.test/callback"], Scopes = ["openid", "acme.read"], AllowedGrantTypes = ["authorization_code", "refresh_token"], + Apps = ["acme-app"], + }, + ], + Roles = + [ + new RealmManifestRole + { + Name = "acme-admin", + App = "acme-app", + Permissions = + [ + new RealmManifestPermission("acme", "read"), + new RealmManifestPermission("acme", "write"), + ], }, ], - Users = [new RealmManifestUser { Email = "alice@acme.test", UserName = "alice", Password = "Passw0rd!23" }], + Users = + [ + new RealmManifestUser { Key = "alice", Email = "alice@acme.test", UserName = "alice", Password = "Passw0rd!23" }, + ], }; var applier = factory.Services.GetRequiredService(); @@ -58,31 +104,36 @@ public async Task Import_provisions_a_fully_configured_realm_in_the_right_tenant Assert.False(result.IsError, result.IsError ? result.FirstError.Description : string.Empty); Assert.Equal(slug, result.Value.Slug); Assert.Equal("acme.localhost", result.Value.PrimaryDomain); - // The confidential client's generated secret is surfaced for the caller. Assert.True(result.Value.ClientSecrets.ContainsKey("acme-web")); Assert.False(string.IsNullOrWhiteSpace(result.Value.ClientSecrets["acme-web"])); - // The realm shell exists in the global store. var realms = factory.Services.GetRequiredService(); Assert.NotNull(await realms.GetRealmBySlugAsync(slug, ct)); - // The OAuth config landed in the NEW realm's tenant DB (read via the same - // inline-consistent admin read methods). - await InTenantAsync(factory, slug, async oauth => + // Everything landed in the NEW realm's tenant DB (inline-consistent reads). + await InTenantAsync(factory, slug, async sp => { - var clients = await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct); - Assert.Contains(clients.Items, c => c.ClientId == "acme-web"); - var apis = await oauth.GetApisAsync(new PaginationRequest { PageSize = 200 }, ct); - Assert.Contains(apis.Items, a => a.Name == "acme-api"); - var scopes = await oauth.GetScopesAsync(ct); - Assert.Contains(scopes.Items, s => s.Name == "acme.read"); + var oauth = sp.GetRequiredService(); + Assert.Contains((await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct)).Items, c => c.ClientId == "acme-web"); + Assert.Contains((await oauth.GetApisAsync(new PaginationRequest { PageSize = 200 }, ct)).Items, a => a.Name == "acme-api"); + Assert.Contains((await oauth.GetScopesAsync(ct)).Items, s => s.Name == "acme.read"); + + var session = sp.GetRequiredService(); + Assert.True(await session.Query().AnyAsync(a => !a.IsDeleted && a.Slug == "acme-app", ct), "app landed"); + + // The role resolved its app + permissions (else CreateRole would have failed + // and rolled the import back). Confirm it persisted with both permissions. + var role = await session.Query().Where(r => !r.IsDeleted && r.Name == "acme-admin").SingleOrDefaultAsync(ct); + Assert.NotNull(role); + Assert.NotNull(role!.AppId); + Assert.Equal(2, role.PermissionIds.Count); }); // Isolation: the realm's client must NOT exist in the system tenant. - await InTenantAsync(factory, TenantConstants.SystemTenantId, async oauth => + await InTenantAsync(factory, TenantConstants.SystemTenantId, async sp => { - var clients = await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct); - Assert.DoesNotContain(clients.Items, c => c.ClientId == "acme-web"); + var oauth = sp.GetRequiredService(); + Assert.DoesNotContain((await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct)).Items, c => c.ClientId == "acme-web"); }); } @@ -115,10 +166,10 @@ public async Task Import_rejects_a_slug_that_already_exists() } private static async Task InTenantAsync( - ColdStartWebApplicationFactory factory, string slug, Func body) + ColdStartWebApplicationFactory factory, string slug, Func body) { using var _ = TenantContext.Enter(slug); using var scope = factory.Services.CreateScope(); - await body(scope.ServiceProvider.GetRequiredService()); + await body(scope.ServiceProvider); } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs index 94db8213..75d287c1 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs @@ -1,39 +1,113 @@ -using Modgud.Api.Features.Users.Commands; -using Modgud.Application.DTOs.OAuth; using Modgud.Application.DTOs.Realms; using Modgud.Application.DTOs.RealmSettings; namespace Modgud.Api.Features.Admin.Provisioning; /// -/// A declarative description of a realm's complete configuration. Applied in-process -/// by , which maps each section onto the SAME -/// canonical application operation the admin UI/API uses — it never reimplements a -/// mutation. v1 carries the realm shell + settings + OAuth (apis/scopes/clients) + -/// users; apps, roles, groups and login providers are added incrementally. -/// -/// The sections reuse the existing Create DTOs verbatim, so the manifest schema -/// stays in lockstep with the operations it drives. Cross-references that need -/// server-generated ids (app linkage, role/group membership) arrive with those later -/// sections. +/// A declarative description of a realm's complete configuration, applied in-process by +/// . Cross-references use stable KEYS (apps by slug, +/// roles/users by key, permissions by resource:action) — never server-generated +/// ids — mirroring the existing demo-seed.json contract; the applier resolves +/// them to ids as it creates entities in dependency order. Each section maps onto the +/// SAME canonical operation the admin UI/API uses, so the manifest path and the manual +/// path can never diverge. /// public sealed record RealmManifest { - /// Realm shell + initial admin (see ). + /// Realm shell + initial admin (reuses ). public required CreateRealmDto Realm { get; init; } /// Optional realm settings patch (self-registration, native grants, ...). public UpdateRealmSettingsDto? Settings { get; init; } - public List Apis { get; init; } = []; - public List Scopes { get; init; } = []; - public List Clients { get; init; } = []; + public List Apps { get; init; } = []; + public List Apis { get; init; } = []; + public List Scopes { get; init; } = []; + public List Clients { get; init; } = []; + public List Roles { get; init; } = []; public List Users { get; init; } = []; + // Groups are a follow-up — group creation cascades a durable Wolverine message + // (membership recalculation) that InvokeForTenantAsync routes to the tenant DB, + // which has no Wolverine durability tables. See the engineering note. } -/// A user to provision into the realm (maps to ). +/// A permission catalog entry referenced by resource:action. +public sealed record RealmManifestPermission(string Resource, string Action, string? Description = null); + +/// An App + its permission catalog (the per-app permission namespace). +public sealed record RealmManifestApp +{ + public required string Slug { get; init; } + public required string DisplayName { get; init; } + public string? Description { get; init; } + public List Permissions { get; init; } = []; +} + +/// An OAuth resource server (API). is a slug; resolve into the linked app's catalog. +public sealed record RealmManifestApi +{ + public required string Name { get; init; } + public string? DisplayName { get; init; } + public string? Description { get; init; } + public string? App { get; init; } + public List Scopes { get; init; } = []; + public List Permissions { get; init; } = []; + public List UserClaims { get; init; } = []; + public bool Enabled { get; init; } = true; + public bool AllowDynamicRegistration { get; init; } +} + +/// An OAuth scope. is a slug; are API audience names. +public sealed record RealmManifestScope +{ + public required string Name { get; init; } + public string? DisplayName { get; init; } + public string? Description { get; init; } + public string? App { get; init; } + public List Resources { get; init; } = []; + public List UserClaims { get; init; } = []; + public bool Enabled { get; init; } = true; + public bool Required { get; init; } + public bool Emphasize { get; init; } + public bool ShowInDiscoveryDocument { get; init; } = true; +} + +/// An OAuth client. are slugs; are scope names. +public sealed record RealmManifestClient +{ + public required string ClientId { get; init; } + public string? DisplayName { get; init; } + public required string ClientType { get; init; } + public string? ClientSecret { get; init; } + public List RedirectUris { get; init; } = []; + public List PostLogoutRedirectUris { get; init; } = []; + public List Scopes { get; init; } = []; + public List AllowedGrantTypes { get; init; } = []; + public List Apps { get; init; } = []; + public List Roles { get; init; } = []; + public string? WebAuthnRpId { get; init; } + public bool Enabled { get; init; } = true; + public bool RequireConsent { get; init; } + public string? AccessTokenType { get; init; } +} + +/// A role. is a slug; resolve into the linked app's catalog. (default ) is how groups reference it. +public sealed record RealmManifestRole +{ + public string? Key { get; init; } + public required string Name { get; init; } + public string? Description { get; init; } + public string? App { get; init; } + public bool IsRealmAdmin { get; init; } + public List Permissions { get; init; } = []; + + public string ResolveKey() => Key ?? Name; +} + +/// A user. (default ?? ) is how groups reference it as a member. public sealed record RealmManifestUser { + public string? Key { get; init; } public string? Firstname { get; init; } public string? Lastname { get; init; } public string? Acronym { get; init; } @@ -42,8 +116,7 @@ public sealed record RealmManifestUser public string? Password { get; init; } public bool EmailConfirmed { get; init; } - public CreateUserCommand ToCommand() => - new(Firstname, Lastname, Acronym, Email, UserName ?? string.Empty, Password, EmailConfirmed); + public string ResolveKey() => Key ?? UserName ?? Email; } /// The outcome of a successful import. diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs index 58b7cb44..268e7af6 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -1,9 +1,15 @@ +using BuildingBlocks.Helper; using ErrorOr; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Modgud.Api.Features.Admin.Apps; +using Modgud.Api.Features.Roles; +using Modgud.Api.Features.Users.Commands; +using Modgud.Application.DTOs.OAuth; using Modgud.Application.DTOs.User; using Modgud.Application.Services; using Modgud.Authentication.RealmSettings; +using Modgud.Authorization.Apps; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; using Wolverine; @@ -16,57 +22,53 @@ namespace Modgud.Api.Features.Admin.Provisioning; /// /// Invariant: ZERO new write logic. Each section is dispatched to the SAME /// operation the admin UI / admin API uses (, -/// , , the user +/// , , +/// , , the user/group /// Wolverine commands), so the manifest path and the manual path can never drift. /// -/// Tenant routing: the realm shell is created via the global store (no tenant -/// context), then the per-tenant config runs inside TenantContext.Enter(slug) -/// + a fresh DI scope. TenantedSessionFactory prefers the AsyncLocal -/// TenantContext over the ambient (control-plane) HttpContext, so the -/// writes land in the NEW realm's database even though the import is triggered from -/// the control-plane host. +/// Tenant routing: the realm shell is created via the global store, then the +/// per-tenant config runs inside TenantContext.Enter(slug) + a fresh DI scope — +/// TenantedSessionFactory prefers the AsyncLocal TenantContext over the +/// ambient (control-plane) HttpContext. Wolverine handlers resolve their session +/// from the message-envelope tenant, so the user/group commands use +/// InvokeForTenantAsync(slug, ...). +/// +/// Cross-references resolve in dependency order: apps → apis/scopes/clients → +/// roles → users. App slugs and resource:action permission keys are mapped to +/// ids as each entity is created. Groups are a follow-up (they need Wolverine +/// tenant-durability — see the engineering note). /// -public sealed class RealmManifestApplier +public sealed class RealmManifestApplier( + IRealmProvisioningService realms, + IServiceScopeFactory scopeFactory, + ILogger logger) { - private readonly IRealmProvisioningService _realms; - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - public RealmManifestApplier( - IRealmProvisioningService realms, - IServiceScopeFactory scopeFactory, - ILogger logger) - { - _realms = realms; - _scopeFactory = scopeFactory; - _logger = logger; - } - /// /// Imports a brand-new realm: the slug must NOT already exist. Provisions the realm - /// shell (tenant DB + seed) then applies the manifest's config. If any step fails - /// the whole partially-provisioned realm is hard-deleted, so a failed import leaves - /// nothing behind (all-or-nothing). + /// shell (tenant DB + seed) then applies the manifest. If any step fails the whole + /// partially-provisioned realm is hard-deleted, so a failed import leaves nothing + /// behind (all-or-nothing). /// public async Task> ImportNewRealmAsync( RealmManifest manifest, CancellationToken ct = default) { var slug = manifest.Realm.Slug; - if (await _realms.GetRealmBySlugAsync(slug, ct) is not null) + if (await realms.GetRealmBySlugAsync(slug, ct) is not null) return Error.Conflict("Realm.AlreadyExists", $"Realm '{slug}' already exists. Use UpdateRealm to modify an existing realm."); - var realmResult = await _realms.CreateRealmAsync(manifest.Realm, ct); + var realmResult = await realms.CreateRealmAsync(manifest.Realm, ct); if (realmResult.IsError) return realmResult.Errors; var realm = realmResult.Value; try { var secrets = await ApplyTenantConfigAsync(slug, manifest, ct); - _logger.LogInformation( - "Imported realm {Slug}: {Apis} apis, {Scopes} scopes, {Clients} clients, {Users} users.", - slug, manifest.Apis.Count, manifest.Scopes.Count, manifest.Clients.Count, manifest.Users.Count); + logger.LogInformation( + "Imported realm {Slug}: {Apps} apps, {Apis} apis, {Scopes} scopes, {Clients} clients, {Roles} roles, {Users} users.", + slug, manifest.Apps.Count, manifest.Apis.Count, manifest.Scopes.Count, + manifest.Clients.Count, manifest.Roles.Count, manifest.Users.Count); return new RealmImportResult { Slug = slug, @@ -78,10 +80,10 @@ public async Task> ImportNewRealmAsync( { // A failed import must leave nothing behind: roll the whole realm back via // the prod-safe hard-delete (drops the tenant DB + the global record). - _logger.LogError(ex, + logger.LogError(ex, "Manifest apply failed for realm {Slug} ({What}); hard-deleting the partially-provisioned realm.", slug, ex.What); - await _realms.HardDeleteRealmAsync(slug, ct); + await realms.HardDeleteRealmAsync(slug, ct); return ex.Errors; } } @@ -90,44 +92,147 @@ private async Task> ApplyTenantConfigAsync( string slug, RealmManifest manifest, CancellationToken ct) { var secrets = new Dictionary(StringComparer.Ordinal); + var apps = new Dictionary(StringComparer.Ordinal); // slug → App (id + catalog) // Enter the new realm's tenant context, then resolve the per-tenant services in - // a FRESH scope so their IDocumentSession binds to this tenant (a session reads - // TenantContext at the moment it is opened). + // a FRESH scope so their IDocumentSession binds to this tenant. using var _ = TenantContext.Enter(slug); - using var scope = _scopeFactory.CreateScope(); + using var scope = scopeFactory.CreateScope(); var sp = scope.ServiceProvider; if (manifest.Settings is not null) EnsureOk(await sp.GetRequiredService().PatchAsync(manifest.Settings, ct), "settings"); + // ── Apps (+ permission catalog) — referenced by everything below ────────── + var appAdmin = sp.GetRequiredService(); + foreach (var app in manifest.Apps) + { + var dto = new CreateAppDto(app.Slug, app.DisplayName, app.Description, + app.Permissions.Select(p => new AppPermissionDto(null, p.Resource, p.Action, p.Description)).ToList()); + var created = await appAdmin.CreateAppAsync(dto, ct); + EnsureOk(created, $"app '{app.Slug}'"); + apps[app.Slug] = created.Value; + } + var oauth = sp.GetRequiredService(); + // ── OAuth APIs ──────────────────────────────────────────────────────────── foreach (var api in manifest.Apis) - EnsureOk(await oauth.CreateApiAsync(api, ct), $"api '{api.Name}'"); + { + EnsureOk(await oauth.CreateApiAsync(new CreateOAuthApiDto + { + Name = api.Name, + DisplayName = api.DisplayName, + Description = api.Description, + Enabled = api.Enabled, + Scopes = api.Scopes, + UserClaims = api.UserClaims, + AppId = ResolveAppId(apps, api.App, $"api '{api.Name}'"), + PermissionIds = ResolvePermissionIds(apps, api.App, api.Permissions, $"api '{api.Name}'"), + AllowDynamicRegistration = api.AllowDynamicRegistration, + }, ct), $"api '{api.Name}'"); + } - foreach (var scopeDto in manifest.Scopes) - EnsureOk(await oauth.CreateScopeAsync(scopeDto, ct), $"scope '{scopeDto.Name}'"); + // ── OAuth scopes ────────────────────────────────────────────────────────── + foreach (var s in manifest.Scopes) + { + EnsureOk(await oauth.CreateScopeAsync(new CreateOAuthScopeDto + { + Name = s.Name, + DisplayName = s.DisplayName, + Description = s.Description, + Resources = s.Resources, + UserClaims = s.UserClaims, + Enabled = s.Enabled, + Required = s.Required, + Emphasize = s.Emphasize, + ShowInDiscoveryDocument = s.ShowInDiscoveryDocument, + AppId = ResolveAppId(apps, s.App, $"scope '{s.Name}'"), + }, ct), $"scope '{s.Name}'"); + } - foreach (var client in manifest.Clients) + // ── OAuth clients ───────────────────────────────────────────────────────── + foreach (var c in manifest.Clients) { - var created = await oauth.CreateClientAsync(client, ct); - EnsureOk(created, $"client '{client.ClientId}'"); + var created = await oauth.CreateClientAsync(new CreateOAuthClientDto + { + ClientId = c.ClientId, + DisplayName = c.DisplayName, + ClientType = c.ClientType, + ClientSecret = c.ClientSecret, + RedirectUris = c.RedirectUris, + PostLogoutRedirectUris = c.PostLogoutRedirectUris, + Scopes = c.Scopes, + AllowedGrantTypes = c.AllowedGrantTypes, + Roles = c.Roles, + WebAuthnRpId = c.WebAuthnRpId, + Enabled = c.Enabled, + RequireConsent = c.RequireConsent, + AppIds = c.Apps.Count == 0 + ? null + : c.Apps.Select(appSlug => ResolveAppId(apps, appSlug, $"client '{c.ClientId}'")!).ToList(), + }, ct); + EnsureOk(created, $"client '{c.ClientId}'"); if (created.Value.ClientSecret is not null) - secrets[client.ClientId] = created.Value.ClientSecret; + secrets[c.ClientId] = created.Value.ClientSecret; } - // Wolverine opens the handler's Marten session from the message envelope's - // tenant, NOT from the ambient TenantContext — so the user commands must be - // dispatched with InvokeForTenantAsync(slug, ...) or they fall back to a - // tenant-less session ("Default tenant does not supported"). + // ── Roles (app-scoped or realm-admin) ───────────────────────────────────── + var roleAdmin = sp.GetRequiredService(); + foreach (var r in manifest.Roles) + { + var payload = new RolePayload( + r.Name, + r.Description, + ResolveAppId(apps, r.App, $"role '{r.Name}'"), + r.IsRealmAdmin, + ResolvePermissionIds(apps, r.App, r.Permissions, $"role '{r.Name}'")); + // Control-plane provisioning is trusted, so the realm-admin guard is satisfied. + EnsureOk(await roleAdmin.CreateRoleAsync(payload, callerIsRealmAdmin: true, ct), $"role '{r.Name}'"); + } + + // ── Users — Wolverine commands, dispatched for the realm tenant ─────────── var bus = sp.GetRequiredService(); - foreach (var user in manifest.Users) - EnsureOk(await bus.InvokeForTenantAsync>(slug, user.ToCommand(), ct), $"user '{user.Email}'"); + foreach (var u in manifest.Users) + { + var cmd = new CreateUserCommand(u.Firstname, u.Lastname, u.Acronym, u.Email, + u.UserName ?? string.Empty, u.Password, u.EmailConfirmed); + EnsureOk(await bus.InvokeForTenantAsync>(slug, cmd, ct), $"user '{u.Email}'"); + } return secrets; } + private static string? ResolveAppId(IReadOnlyDictionary apps, string? slug, string context) + { + if (string.IsNullOrEmpty(slug)) return null; + if (!apps.TryGetValue(slug, out var app)) + throw new ManifestApplyException(context, + [Error.Validation("Manifest.UnknownApp", $"{context} references unknown app '{slug}'.")]); + return new ShortGuid(app.Id).ToString(); + } + + private static List ResolvePermissionIds( + IReadOnlyDictionary apps, string? appSlug, List perms, string context) + { + if (perms.Count == 0) return []; + if (string.IsNullOrEmpty(appSlug) || !apps.TryGetValue(appSlug, out var app)) + throw new ManifestApplyException(context, + [Error.Validation("Manifest.PermissionsNeedApp", $"{context} lists permissions but has no resolvable app.")]); + + var catalog = app.Permissions.ToDictionary(p => $"{p.Resource}:{p.Action}", p => p.Id); + var ids = new List(perms.Count); + foreach (var p in perms) + { + if (!catalog.TryGetValue($"{p.Resource}:{p.Action}", out var pid)) + throw new ManifestApplyException(context, + [Error.Validation("Manifest.UnknownPermission", + $"{context} references permission '{p.Resource}:{p.Action}' not in app '{appSlug}' catalog.")]); + ids.Add(new ShortGuid(pid).ToString()); + } + return ids; + } + private static void EnsureOk(ErrorOr result, string what) { if (result.IsError) diff --git a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs new file mode 100644 index 00000000..0bc89ceb --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs @@ -0,0 +1,95 @@ +using BuildingBlocks.Helper; +using ErrorOr; +using Marten; +using Modgud.Authentication.Domain; +using Modgud.Authentication.Events; +using Modgud.Authorization.Apps; + +namespace Modgud.Api.Features.Roles; + +/// +/// The single canonical create path for , shared by +/// and the realm-provisioning applier so the manual path +/// and the manifest path can never diverge. The realm-admin privilege-escalation guard +/// (audit H1) is a parameter: the endpoint passes the caller's realm:admin status, the +/// applier passes true (control-plane provisioning is a trusted path). +/// +public sealed class RoleAdminService(IDocumentSession session) +{ + public async Task> CreateRoleAsync( + RolePayload dto, bool callerIsRealmAdmin, CancellationToken ct = default) + { + if (dto.IsRealmAdmin && !callerIsRealmAdmin) + return Error.Forbidden("Role.RealmAdminForbidden", + "Only a realm administrator may create or modify a realm-admin role."); + + var built = await BuildRoleAsync(dto, ct); + if (built.IsError) return built.Errors; + + var role = built.Value; + // PermissionRoleProjection (inline) writes the doc from the event; emit only. + session.Events.StartStream(role.Id, + new PermissionRoleCreatedEvent( + role.Id, role.Name, role.Description, + role.AppId, role.IsRealmAdmin, role.PermissionIds)); + await session.SaveChangesAsync(ct); + return role; + } + + /// + /// Validates a payload into a (Id minted here): AppId + /// resolves to an existing App, every PermissionId resolves to that App's catalog, + /// PermissionIds require an App link, and a role must grant something (App link or + /// IsRealmAdmin). + /// + public async Task> BuildRoleAsync(RolePayload dto, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + return Error.Validation("Role.NameRequired", "Name is required."); + + var permissionIdsInput = dto.PermissionIds ?? []; + + Guid? appId = null; + App? linkedApp = null; + if (!string.IsNullOrEmpty(dto.AppId)) + { + if (!ShortGuid.TryParse(dto.AppId, out Guid parsed)) + return Error.Validation("Role.InvalidAppId", $"AppId '{dto.AppId}' is not a valid Guid or ShortGuid."); + linkedApp = await session.LoadAsync(parsed, ct); + if (linkedApp is null || linkedApp.IsDeleted) + return Error.Validation("Role.AppNotFound", $"App {dto.AppId} not found."); + appId = parsed; + } + + if (appId is null && permissionIdsInput.Count > 0) + return Error.Validation("Role.PermissionIdsRequireAppLink", + "PermissionIds cannot be set on a role without an AppId."); + + var catalogIds = linkedApp?.Permissions.Select(p => p.Id).ToHashSet() ?? new HashSet(); + var permissionIds = new List(permissionIdsInput.Count); + var seen = new HashSet(); + foreach (var raw in permissionIdsInput) + { + if (!ShortGuid.TryParse(raw, out Guid permId)) + return Error.Validation("Role.InvalidPermissionId", $"PermissionId '{raw}' is not a valid Guid or ShortGuid."); + if (!catalogIds.Contains(permId)) + return Error.Validation("Role.PermissionIdNotInAppCatalog", + $"PermissionId '{raw}' does not exist in App '{linkedApp!.Slug}'s catalog."); + if (seen.Add(permId)) permissionIds.Add(permId); + } + + if (appId is null && !dto.IsRealmAdmin) + return Error.Validation("Role.GrantsNothing", + "Role must either link to an App (AppId + PermissionIds) or set IsRealmAdmin=true."); + + return new PermissionRole + { + Id = Guid.NewGuid(), + Name = dto.Name, + Description = dto.Description, + AppId = appId, + IsRealmAdmin = dto.IsRealmAdmin, + PermissionIds = permissionIds, + }; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs index 39697f31..073c62e0 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs @@ -1,6 +1,7 @@ using BuildingBlocks.Helper; using Marten; using Modgud.Api.Authorization; +using Modgud.Authentication.ExtensionMethods; using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Apps; using Modgud.Authorization.Services; @@ -75,24 +76,15 @@ public static WebApplication MapRolesEndpoints(this WebApplication application, // Marten 8.34+ optimistic-concurrency detection — emit the event // only. Build the in-memory `role` instance just to compute the // response payload; the persisted doc comes from the projection. - roleGroup.MapPost("", async (RolePayload dto, HttpContext http, IPermissionService perms, IDocumentSession session) => + // Create delegates to the shared RoleAdminService — the single canonical create + // path the realm-provisioning applier also calls. The realm:admin guard is + // passed as a parameter (here from the HTTP caller's permissions). Update stays + // inline below; it is consolidated when the applier gains update via UpdateRealm. + roleGroup.MapPost("", async (RolePayload dto, HttpContext http, IPermissionService perms, RoleAdminService roleAdmin, CancellationToken ct) => { - // Privilege-escalation guard (audit H1): only a realm:admin may - // mint a realm-admin role. permission-role:write alone is not - // enough — a realm-admin role is the realm-wide bypass. - if (dto.IsRealmAdmin && !await CallerPermissions.IsRealmAdminAsync(http, perms)) - return RealmAdminForbidden(); - - var built = await BuildRoleAsync(dto, session); - if (built.Error is not null) return built.Error; - - var role = built.Role; - session.Events.StartStream(role.Id, - new PermissionRoleCreatedEvent( - role.Id, role.Name, role.Description, - role.AppId, role.IsRealmAdmin, role.PermissionIds)); - await session.SaveChangesAsync(); - return Results.Ok(MapToResponse(role)); + var callerIsRealmAdmin = await CallerPermissions.IsRealmAdminAsync(http, perms); + var result = await roleAdmin.CreateRoleAsync(dto, callerIsRealmAdmin, ct); + return result.ToResult(role => Results.Ok(MapToResponse(role))); }) .WithName("V2_Role_Create") .RequiresPermission("permission-role:write"); diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 19abad3d..d87e2c8e 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -553,8 +553,9 @@ // inside AddInfrastructure. Only keep app-specific wiring here. builder.Services.AddScoped(); - // Shared canonical App create path (AppsEndpoints + the provisioning applier). + // Shared canonical App + Role create paths (admin endpoints + provisioning applier). builder.Services.AddScoped(); + builder.Services.AddScoped(); // Declarative realm provisioning — applies a RealmManifest in-process by reusing // the canonical admin operations (the engine behind import/apply/export). From 8b8bce1ad9614e69a8ae3ceff2afb77c3af4a9be Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 29 Jun 2026 13:24:48 +0200 Subject: [PATCH 05/21] feat(provisioning): groups in the applier via a plain tenant-scoped session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes entity coverage. Groups are created by calling the canonical CreateGroupHandler directly with the applier scope's plain (TenantedSessionFactory) IDocumentSession — NOT the Wolverine-outbox-enrolled session that InvokeForTenantAsync would supply. That avoids the durable-inbox auto-membership event forwarding (ReferenceSyncRegistration routes GroupCreatedEvent to a UseDurableInbox() local queue) which would otherwise try to write wolverine_incoming_envelopes in the fresh tenant DB, which has no Wolverine tables. This mirrors why user creation already worked (ASP.NET Identity's UserManager commits via a non-enrolled session). The skipped auto-membership sync is fine for provisioning — membership re-derives at login (LoginTimeMembershipDeriver). Member/role cross-references resolve by key (user key → id, role key → id). The test imports a group referencing a manifest user + role and asserts both resolved. Applier coverage is now complete: realm, settings, apps(+catalog), apis, scopes, clients, roles, users, groups — all via the canonical operations with all-or-nothing rollback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmManifestApplierTests.cs | 11 +++ .../Admin/Provisioning/RealmManifest.cs | 19 +++++- .../Provisioning/RealmManifestApplier.cs | 67 +++++++++++++++++-- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs index 6210bbb8..66ce3b12 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs @@ -5,6 +5,7 @@ using Modgud.Application.Services; using Modgud.Authentication.Domain; using Modgud.Authorization.Apps; +using Modgud.Authorization.Principals; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; using Marten; @@ -95,6 +96,10 @@ public async Task Import_provisions_a_fully_configured_realm_with_resolved_cross [ new RealmManifestUser { Key = "alice", Email = "alice@acme.test", UserName = "alice", Password = "Passw0rd!23" }, ], + Groups = + [ + new RealmManifestGroup { Name = "Admins", Members = ["alice"], Roles = ["acme-admin"] }, + ], }; var applier = factory.Services.GetRequiredService(); @@ -127,6 +132,12 @@ await InTenantAsync(factory, slug, async sp => Assert.NotNull(role); Assert.NotNull(role!.AppId); Assert.Equal(2, role.PermissionIds.Count); + + // The group resolved its member (alice → user id) and role (acme-admin → role id). + var group = await session.Query().Where(gr => !gr.IsDeleted && gr.Name == "Admins").SingleOrDefaultAsync(ct); + Assert.NotNull(group); + Assert.Single(group!.MemberIds); + Assert.Single(group.RoleIds); }); // Isolation: the realm's client must NOT exist in the system tenant. diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs index 75d287c1..33da6ae3 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs @@ -26,9 +26,7 @@ public sealed record RealmManifest public List Clients { get; init; } = []; public List Roles { get; init; } = []; public List Users { get; init; } = []; - // Groups are a follow-up — group creation cascades a durable Wolverine message - // (membership recalculation) that InvokeForTenantAsync routes to the tenant DB, - // which has no Wolverine durability tables. See the engineering note. + public List Groups { get; init; } = []; } /// A permission catalog entry referenced by resource:action. @@ -119,6 +117,21 @@ public sealed record RealmManifestUser public string ResolveKey() => Key ?? UserName ?? Email; } +/// A group. are user keys; are role keys. +public sealed record RealmManifestGroup +{ + public required string Name { get; init; } + public string? Description { get; init; } + public List Members { get; init; } = []; + public List Roles { get; init; } = []; + public string MembershipMode { get; init; } = "Manual"; + public string? MembershipScript { get; init; } + public string? Email { get; init; } + public string EmailMode { get; init; } = "Shared"; + public List? BoundTo { get; init; } + public bool ExternallyDrivable { get; init; } +} + /// The outcome of a successful import. public sealed record RealmImportResult { diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs index 268e7af6..8fdbd9da 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -1,5 +1,6 @@ using BuildingBlocks.Helper; using ErrorOr; +using Marten; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Modgud.Api.Features.Admin.Apps; @@ -10,6 +11,9 @@ using Modgud.Application.Services; using Modgud.Authentication.RealmSettings; using Modgud.Authorization.Apps; +using Modgud.Authorization.Commands; +using Modgud.Authorization.Membership; +using Modgud.Authorization.Principals; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; using Wolverine; @@ -34,9 +38,8 @@ namespace Modgud.Api.Features.Admin.Provisioning; /// InvokeForTenantAsync(slug, ...). /// /// Cross-references resolve in dependency order: apps → apis/scopes/clients → -/// roles → users. App slugs and resource:action permission keys are mapped to -/// ids as each entity is created. Groups are a follow-up (they need Wolverine -/// tenant-durability — see the engineering note). +/// roles → users → groups. Keys (app slug, role/user key, resource:action) are +/// mapped to ids as each entity is created. /// public sealed class RealmManifestApplier( IRealmProvisioningService realms, @@ -66,9 +69,9 @@ public async Task> ImportNewRealmAsync( { var secrets = await ApplyTenantConfigAsync(slug, manifest, ct); logger.LogInformation( - "Imported realm {Slug}: {Apps} apps, {Apis} apis, {Scopes} scopes, {Clients} clients, {Roles} roles, {Users} users.", + "Imported realm {Slug}: {Apps} apps, {Apis} apis, {Scopes} scopes, {Clients} clients, {Roles} roles, {Users} users, {Groups} groups.", slug, manifest.Apps.Count, manifest.Apis.Count, manifest.Scopes.Count, - manifest.Clients.Count, manifest.Roles.Count, manifest.Users.Count); + manifest.Clients.Count, manifest.Roles.Count, manifest.Users.Count, manifest.Groups.Count); return new RealmImportResult { Slug = slug, @@ -93,6 +96,8 @@ private async Task> ApplyTenantConfigAsync( { var secrets = new Dictionary(StringComparer.Ordinal); var apps = new Dictionary(StringComparer.Ordinal); // slug → App (id + catalog) + var roleIds = new Dictionary(StringComparer.Ordinal); // role key → id (for groups) + var userIds = new Dictionary(StringComparer.Ordinal); // user key → id (for groups) // Enter the new realm's tenant context, then resolve the per-tenant services in // a FRESH scope so their IDocumentSession binds to this tenant. @@ -188,7 +193,9 @@ private async Task> ApplyTenantConfigAsync( r.IsRealmAdmin, ResolvePermissionIds(apps, r.App, r.Permissions, $"role '{r.Name}'")); // Control-plane provisioning is trusted, so the realm-admin guard is satisfied. - EnsureOk(await roleAdmin.CreateRoleAsync(payload, callerIsRealmAdmin: true, ct), $"role '{r.Name}'"); + var created = await roleAdmin.CreateRoleAsync(payload, callerIsRealmAdmin: true, ct); + EnsureOk(created, $"role '{r.Name}'"); + roleIds[r.ResolveKey()] = created.Value.Id; } // ── Users — Wolverine commands, dispatched for the realm tenant ─────────── @@ -197,7 +204,37 @@ private async Task> ApplyTenantConfigAsync( { var cmd = new CreateUserCommand(u.Firstname, u.Lastname, u.Acronym, u.Email, u.UserName ?? string.Empty, u.Password, u.EmailConfirmed); - EnsureOk(await bus.InvokeForTenantAsync>(slug, cmd, ct), $"user '{u.Email}'"); + var created = await bus.InvokeForTenantAsync>(slug, cmd, ct); + EnsureOk(created, $"user '{u.Email}'"); + if (ShortGuid.TryParse(created.Value.Id, out Guid uid)) + userIds[u.ResolveKey()] = uid; + } + + // ── Groups — committed via a PLAIN tenant-scoped session (NOT the Wolverine + // outbox session). InvokeForTenantAsync would enroll the Wolverine outbox, and + // the durable-inbox auto-membership event forwarding (ReferenceSync) would try + // to write wolverine_incoming_envelopes in the tenant DB, which a fresh realm + // lacks. A plain session skips that forwarding (auto-membership re-derives at + // login). We call the canonical CreateGroupHandler directly with this session. + if (manifest.Groups.Count > 0) + { + var groupHandler = new CreateGroupHandler( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()); + + foreach (var g in manifest.Groups) + { + var memberIds = g.Members.Select(m => ResolveRef(userIds, m, $"group '{g.Name}' member '{m}'")).ToList(); + var groupRoleIds = g.Roles.Select(rk => ResolveRef(roleIds, rk, $"group '{g.Name}' role '{rk}'")).ToList(); + var cmd = new CreateGroupCommand( + g.Name, g.Description, memberIds, groupRoleIds, + ParseEnum(g.MembershipMode, $"group '{g.Name}' membershipMode"), + g.MembershipScript, g.Email, + ParseEnum(g.EmailMode, $"group '{g.Name}' emailMode"), + g.BoundTo, g.ExternallyDrivable, CallerIsRealmAdmin: true); + EnsureOk(await groupHandler.Handle(cmd, ct), $"group '{g.Name}'"); + } } return secrets; @@ -233,6 +270,22 @@ private static List ResolvePermissionIds( return ids; } + private static Guid ResolveRef(IReadOnlyDictionary map, string key, string context) + { + if (!map.TryGetValue(key, out var id)) + throw new ManifestApplyException(context, + [Error.Validation("Manifest.UnknownReference", $"{context} references an unknown key.")]); + return id; + } + + private static TEnum ParseEnum(string value, string context) where TEnum : struct, Enum + { + if (!Enum.TryParse(value, ignoreCase: true, out var result)) + throw new ManifestApplyException(context, + [Error.Validation("Manifest.InvalidEnum", $"'{value}' is not a valid {typeof(TEnum).Name}.")]); + return result; + } + private static void EnsureOk(ErrorOr result, string what) { if (result.IsError) From 2201083bfc1988fdca3489771b26c37a4bf0a9c0 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 07:00:00 +0200 Subject: [PATCH 06/21] refactor(apps,roles): extract canonical Update ops + restore error-code bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the App/Role update operations into the shared admin services so the realm-provisioning applier and the admin API share one canonical write path: - AppAdminService.UpdateAppAsync (ErrorOr) — display name + catalog with the catalog-delete reference block; the rich 409 blocker list rides through Error.Metadata so AppsEndpoints renders the exact body AppDetails.vue consumes. - RoleAdminService.UpdateRoleAsync (ErrorOr) — the realm:admin guard is a parameter (endpoint passes the caller's status, applier passes true). - FindReferencesAsync + the permission-reference shape move into AppAdminService; the App-delete block reuses them. The duplicate endpoint-side NormalizePermissions and BuildRoleAsync are removed. Also fixes two latent regressions the earlier create-extraction introduced: the App/Role endpoints routed errors through the shared ErrorOrExtensions.ToResult, which drops the error code from the body and maps Forbidden to Results.Forbid() — under this app's cookie auth that is an empty-body 403 for /api/* (OnRedirectToAccessDenied). Both endpoints now render {Error,Message} with the code in the body, restoring RealmAdminEscalationGuardTests (role create-forbidden + app reserved-permission), which were only green because the prior work ran the provisioning filter, not the security suite. 1269 unit + 459 integration tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Features/Admin/Apps/AppAdminService.cs | 109 ++++++++- .../Features/Admin/Apps/AppsEndpoints.cs | 209 +++--------------- .../Features/Roles/RoleAdminService.cs | 38 ++++ .../Features/Roles/RolesEndpoints.cs | 171 +++----------- 4 files changed, 197 insertions(+), 330 deletions(-) diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs index 41ad94a4..bb2fb78d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs @@ -2,13 +2,15 @@ using Marten; using Modgud.Authorization.Apps; using Modgud.Authorization.Events; +using Modgud.Domain.OAuth.Apis; +using Modgud.Authorization.Roles; namespace Modgud.Api.Features.Admin.Apps; /// -/// The single canonical write path for creating records, shared by -/// and the realm-provisioning applier so the manual path -/// and the manifest path can never diverge. Returns so the +/// The single canonical write path for creating and updating records, +/// shared by and the realm-provisioning applier so the manual +/// path and the manifest path can never diverge. Returns so the /// endpoint maps it to HTTP while the applier consumes it directly. The injected /// is tenant-scoped, so a call lands in whatever realm /// the ambient TenantContext selects. @@ -49,6 +51,57 @@ public async Task> CreateAppAsync(CreateAppDto dto, CancellationTok return (await session.LoadAsync(id, ct))!; } + /// + /// The single canonical update path for an existing — display name, + /// description, and the permission catalog. Mirrors the create path's validation and + /// adds the catalog-edit safety net: removing a catalog entry that is still referenced + /// by a role or resource server is refused with a + /// whose Metadata["blockers"] carries the structured reference list (so the + /// admin endpoint can render its rich 409 body and the applier can surface the cause). + /// + public async Task> UpdateAppAsync(Guid id, UpdateAppDto dto, CancellationToken ct = default) + { + var app = await session.LoadAsync(id, ct); + if (app is null || app.IsDeleted) + return Error.NotFound("App.NotFound", "App not found."); + + if (string.IsNullOrWhiteSpace(dto.DisplayName)) + return Error.Validation("App.DisplayNameRequired", "DisplayName is required."); + + // Existing-permission lookup by id keeps stable identities across updates: an entry + // already present by id retains it, an entry without an id gets a fresh one. + var existingByKey = app.Permissions.ToDictionary(p => p.Id, p => p); + var permissions = NormalizePermissions(dto.Permissions, existingByKey); + if (permissions.IsError) return permissions.Errors; + + // Detect catalog deletions that would orphan FKs in PermissionRole.PermissionIds or + // OAuthApiState.PermissionIds. Removing a still-referenced entry is a silent + // permission revocation in disguise — refuse with 409 + what's blocking. + var newIds = permissions.Value.Select(p => p.Id).ToHashSet(); + var removedIds = app.Permissions.Where(p => !newIds.Contains(p.Id)).ToList(); + if (removedIds.Count > 0) + { + var blockers = await FindReferencesAsync(removedIds.Select(p => p.Id).ToList(), session, ct); + if (blockers.Count > 0) + { + var payload = blockers.Select(b => new AppCatalogBlocker( + new BuildingBlocks.Helper.ShortGuid(b.PermissionId).ToString(), + removedIds.First(p => p.Id == b.PermissionId).ToPermissionString(), + b.RoleNames, + b.OAuthApiNames)).ToList(); + return Error.Conflict("App.CatalogEntriesReferenced", + "Cannot remove catalog entries that are still referenced by roles or resource servers. Detach them first.", + new Dictionary { ["blockers"] = payload }); + } + } + + session.Events.Append(id, new AppUpdatedEvent( + id, dto.DisplayName, dto.Description, permissions.Value)); + await session.SaveChangesAsync(ct); + + return (await session.LoadAsync(id, ct))!; + } + /// /// Validates and normalises the permission catalog off a create / update payload: /// parses incoming ids (ShortGuid → Guid, minting a fresh one when absent), dedupes @@ -100,4 +153,54 @@ internal static ErrorOr> NormalizePermissions( return normalised; } + + /// + /// Per-permission-id reference summary used by the catalog editor's delete-block + /// panel. Only entries with at least one referencing role or RS are returned. + /// + internal sealed record PermissionReference(Guid PermissionId, List RoleNames, List OAuthApiNames); + + /// + /// Finds every and that + /// references any of the supplied permission ids in their respective + /// PermissionIds FK list. Returns one entry per permission-id that has at least + /// one referencing row — empty list = safe to remove. Shared by the catalog update + /// (here) and the App-delete block in . + /// + internal static async Task> FindReferencesAsync( + List permissionIds, IDocumentSession session, CancellationToken ct = default) + { + if (permissionIds.Count == 0) return []; + + // For our small catalogs it's acceptable to load every role/api with any non-empty + // PermissionIds and filter in memory. Tenant DBs aren't huge here. + var roles = await session.Query() + .Where(r => !r.IsDeleted && r.PermissionIds.Any()) + .ToListAsync(ct); + var apis = await session.Query() + .Where(a => !a.IsDeleted && a.PermissionIds.Any()) + .ToListAsync(ct); + + var result = new List(); + foreach (var pid in permissionIds) + { + var roleNames = roles.Where(r => r.PermissionIds.Contains(pid)).Select(r => r.Name).ToList(); + var apiNames = apis.Where(a => a.PermissionIds.Contains(pid)).Select(a => a.Name).ToList(); + if (roleNames.Count > 0 || apiNames.Count > 0) + result.Add(new PermissionReference(pid, roleNames, apiNames)); + } + return result; + } } + +/// +/// The rich blocker shape surfaced in the App.CatalogEntriesReferenced 409 body — +/// one entry per still-referenced catalog id the update tried to remove. Carried through +/// so can render it verbatim and +/// the admin SPA's AppDetails.vue delete-block panel keeps working. +/// +public sealed record AppCatalogBlocker( + string PermissionId, + string Permission, + List ReferencedByRoles, + List ReferencedByResourceServers); diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs index a66142dd..031f1efc 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs @@ -1,7 +1,7 @@ using BuildingBlocks.Helper; +using ErrorOr; using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; -using Modgud.Authentication.ExtensionMethods; using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Events; @@ -91,72 +91,31 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s .WithName("V2_App_GetById") .RequiresPermission("app:read"); - // Create delegates to the shared AppAdminService — the single canonical create - // path that the realm-provisioning applier also calls (no divergence). Update / - // delete stay inline below (their reference-checking is consolidated when the - // applier gains update via UpdateRealm). + // Create / Update both delegate to the shared AppAdminService — the single + // canonical write path the realm-provisioning applier also calls (no divergence). appGroup.MapPost("", async (CreateAppDto dto, AppAdminService appAdmin, CancellationToken ct) => { var result = await appAdmin.CreateAppAsync(dto, ct); - return result.ToResult(app => Results.Ok(MapToResponse(app))); + return result.IsError ? ToErrorResult(result.FirstError) : Results.Ok(MapToResponse(result.Value)); }) .WithName("V2_App_Create") .RequiresPermission("app:write"); - appGroup.MapPut("{id}", async (ShortGuid id, UpdateAppDto dto, IDocumentSession session) => + appGroup.MapPut("{id}", async (ShortGuid id, UpdateAppDto dto, AppAdminService appAdmin, CancellationToken ct) => { - var app = await session.LoadAsync(id.Guid); - if (app is null || app.IsDeleted) return Results.NotFound(); - - if (string.IsNullOrWhiteSpace(dto.DisplayName)) - return Results.BadRequest(new { Error = "App.DisplayNameRequired", - Message = "DisplayName is required." }); - - // Existing-permission lookup by id keeps stable identities - // across updates: an entry already present in the payload by - // id retains it, an entry without an id gets a fresh one. - var existingByKey = app.Permissions.ToDictionary(p => p.Id, p => p); - var permissionsResult = NormalizePermissions(dto.Permissions, existingByKey); - if (permissionsResult.Error is not null) return permissionsResult.Error; - - // Detect catalog deletions that would orphan FKs in - // PermissionRole.PermissionIds or OAuthApiState.PermissionIds. - // Removing an entry that's still referenced by a role or RS is - // a silent permission revocation in disguise — refuse with 409 - // and surface what's blocking so the admin can clean up. - var newIds = permissionsResult.Permissions.Select(p => p.Id).ToHashSet(); - var removedIds = app.Permissions - .Where(p => !newIds.Contains(p.Id)) - .ToList(); - if (removedIds.Count > 0) + var result = await appAdmin.UpdateAppAsync(id.Guid, dto, ct); + if (!result.IsError) return Results.Ok(MapToResponse(result.Value)); + + var error = result.FirstError; + // The catalog-delete block carries its rich blocker list through the error + // metadata; render the exact 409 body AppDetails.vue consumes. + if (error.Code == "App.CatalogEntriesReferenced" + && error.Metadata?.TryGetValue("blockers", out var blockers) == true) { - var blockers = await FindReferencesAsync(removedIds.Select(p => p.Id).ToList(), session); - if (blockers.Count > 0) - { - return Results.Conflict(new - { - Error = "App.CatalogEntriesReferenced", - Message = "Cannot remove catalog entries that are still referenced by roles or resource servers. Detach them first.", - Blockers = blockers.Select(b => new - { - PermissionId = new ShortGuid(b.PermissionId).ToString(), - Permission = removedIds.First(p => p.Id == b.PermissionId).ToPermissionString(), - ReferencedByRoles = b.RoleNames, - ReferencedByResourceServers = b.OAuthApiNames, - }), - }); - } + return Results.Conflict(new { Error = error.Code, Message = error.Description, Blockers = blockers }); } - session.Events.Append(id.Guid, new AppUpdatedEvent( - id.Guid, - dto.DisplayName, - dto.Description, - permissionsResult.Permissions)); - await session.SaveChangesAsync(); - - var loaded = await session.LoadAsync(id.Guid); - return Results.Ok(MapToResponse(loaded!)); + return ToErrorResult(error); }) .WithName("V2_App_Update") .RequiresPermission("app:write"); @@ -177,7 +136,7 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s // grants is a silent revoke. var allCatalogIds = app.Permissions.Select(p => p.Id).ToList(); var blockingByPermissionId = allCatalogIds.Count > 0 - ? await FindReferencesAsync(allCatalogIds, session) + ? await AppAdminService.FindReferencesAsync(allCatalogIds, session) : []; var rolesByApp = await session.Query() .Where(r => !r.IsDeleted && r.AppId == app.Id) @@ -234,131 +193,19 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s a.IsSystem, }; - /// - /// Per-permission-id reference summary used by the catalog editor's - /// delete-block panel. Only entries with at least one referencing role - /// or RS are returned. - /// - private record PermissionReference(Guid PermissionId, List RoleNames, List OAuthApiNames); - - /// - /// Finds every and - /// that references any of the supplied permission ids in their respective - /// PermissionIds FK list. Returns one entry per permission-id that - /// has at least one referencing row — empty list = safe to delete. - /// - private static async Task> FindReferencesAsync( - List permissionIds, IDocumentSession session) + // Renders an AppAdminService ErrorOr error with the error code in the body. The shared + // ErrorOrExtensions.ToResult collapses to { error: description } (no code) — the app + // admin SPA and the catalog security tests assert on the code, so keep {Error,Message}. + private static IResult ToErrorResult(Error error) { - if (permissionIds.Count == 0) return []; - - // Marten's LINQ provider supports IsOneOf for membership; for a - // small list of ids in our case (handful of catalog entries) it's - // acceptable to load every role/api with any non-empty PermissionIds - // and filter in memory. Tenant DBs aren't huge here. - var roles = await session.Query() - .Where(r => !r.IsDeleted && r.PermissionIds.Any()) - .ToListAsync(); - var apis = await session.Query() - .Where(a => !a.IsDeleted && a.PermissionIds.Any()) - .ToListAsync(); - - var result = new List(); - foreach (var pid in permissionIds) + var status = error.Type switch { - var roleNames = roles - .Where(r => r.PermissionIds.Contains(pid)) - .Select(r => r.Name) - .ToList(); - var apiNames = apis - .Where(a => a.PermissionIds.Contains(pid)) - .Select(a => a.Name) - .ToList(); - if (roleNames.Count > 0 || apiNames.Count > 0) - result.Add(new PermissionReference(pid, roleNames, apiNames)); - } - return result; - } - - /// - /// Validates and normalises the permission list off a create / update - /// payload: parses incoming ids (ShortGuid → Guid, generating a fresh - /// one when absent or unknown), dedupes by (Resource, Action), enforces - /// the segment grammar, and returns either a clean list ready to embed - /// in an event or an HTTP 400 with the first offending entry. - /// - private static (List Permissions, IResult? Error) NormalizePermissions( - List? payload, - IReadOnlyDictionary? existingByKey) - { - var input = payload ?? []; - var normalised = new List(input.Count); - var seen = new HashSet(StringComparer.Ordinal); - - foreach (var entry in input) - { - var resource = entry.Resource?.Trim() ?? string.Empty; - var action = entry.Action?.Trim() ?? string.Empty; - - if (!AppPermissionRules.IsValidSegment(resource) || - !AppPermissionRules.IsValidSegment(action)) - { - return (normalised, Results.BadRequest(new - { - Error = "App.InvalidPermissionSegment", - Message = $"Permission '{resource}:{action}' is invalid — both segments must match ^[a-z0-9-]+$.", - })); - } - - // realm:admin is the synthetic realm-wide bypass — it must never be - // a catalog entry (audit H1, vector 3). Conferring realm:admin is - // reserved to a role's IsRealmAdmin flag, which is itself gated on - // the caller already holding realm:admin. - if (AppPermissionRules.IsReservedBypass(resource, action)) - { - return (normalised, Results.BadRequest(new - { - Error = "App.ReservedPermission", - Message = "The permission 'realm:admin' is reserved — it is the realm-wide bypass and cannot be a catalog entry. Use a role's IsRealmAdmin flag instead.", - })); - } - - var key = $"{resource}:{action}"; - if (!seen.Add(key)) - { - // Silently drop exact duplicates — admin UIs may submit a - // fresh row alongside the existing one when toggling. - continue; - } - - // Resolve identity: explicit id wins (when it parses + matches an - // entry in existingByKey, that's the rename path); otherwise mint - // a new one. - Guid id = Guid.NewGuid(); - if (!string.IsNullOrEmpty(entry.Id) && ShortGuid.TryParse(entry.Id, out Guid parsed)) - { - if (existingByKey is not null && existingByKey.ContainsKey(parsed)) - { - id = parsed; - } - else - { - // Caller submitted an id we don't recognise. Keep their - // value rather than minting a new one — this lets a - // detached client hold on to a generated id and replay - // the payload without the server treating it as a fresh - // entity. - id = parsed; - } - } - - var description = string.IsNullOrWhiteSpace(entry.Description) - ? null - : entry.Description.Trim(); - - normalised.Add(new AppPermission(id, resource, action, description)); - } - - return (normalised, null); + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Conflict => StatusCodes.Status409Conflict, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status500InternalServerError, + }; + return Results.Json(new { Error = error.Code, Message = error.Description }, statusCode: status); } } diff --git a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs index 0bc89ceb..81b82961 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs @@ -36,6 +36,44 @@ public async Task> CreateRoleAsync( return role; } + /// + /// The single canonical update path for an existing , + /// shared by and the realm-provisioning applier. The + /// realm-admin privilege-escalation guard (audit H1) is a parameter: the endpoint + /// passes the caller's realm:admin status (so a non-admin may de-escalate but not + /// confer the flag), the applier passes true (trusted control-plane path). + /// + public async Task> UpdateRoleAsync( + Guid id, RolePayload dto, bool callerIsRealmAdmin, CancellationToken ct = default) + { + var existing = await session.LoadAsync(id, ct); + if (existing is null || existing.IsDeleted) + return Error.NotFound("Role.NotFound", "Role not found."); + + // Only a realm:admin may set/keep the realm-admin flag on a role. A non-admin may + // still de-escalate (clear the flag) or edit a non-admin role. + if (dto.IsRealmAdmin && !callerIsRealmAdmin) + return Error.Forbidden("Role.RealmAdminForbidden", + "Only a realm administrator may create or modify a realm-admin role."); + + var built = await BuildRoleAsync(dto, ct); + if (built.IsError) return built.Errors; + + var role = built.Value; + existing.Name = role.Name; + existing.Description = role.Description; + existing.AppId = role.AppId; + existing.IsRealmAdmin = role.IsRealmAdmin; + existing.PermissionIds = role.PermissionIds; + // PermissionRoleProjection (inline) writes the doc from the event; emit only. + session.Events.Append(id, + new PermissionRoleUpdatedEvent( + id, existing.Name, existing.Description, + existing.AppId, existing.IsRealmAdmin, existing.PermissionIds)); + await session.SaveChangesAsync(ct); + return existing; + } + /// /// Validates a payload into a (Id minted here): AppId /// resolves to an existing App, every PermissionId resolves to that App's catalog, diff --git a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs index 073c62e0..9ca1ed20 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs @@ -1,11 +1,9 @@ using BuildingBlocks.Helper; +using ErrorOr; using Marten; using Modgud.Api.Authorization; -using Modgud.Authentication.ExtensionMethods; using Modgud.Authorization.AspNetCore; -using Modgud.Authorization.Apps; using Modgud.Authorization.Services; -using Modgud.Authentication.Domain; using Modgud.Authentication.Events; namespace Modgud.Api.Features.Roles; @@ -76,44 +74,23 @@ public static WebApplication MapRolesEndpoints(this WebApplication application, // Marten 8.34+ optimistic-concurrency detection — emit the event // only. Build the in-memory `role` instance just to compute the // response payload; the persisted doc comes from the projection. - // Create delegates to the shared RoleAdminService — the single canonical create - // path the realm-provisioning applier also calls. The realm:admin guard is - // passed as a parameter (here from the HTTP caller's permissions). Update stays - // inline below; it is consolidated when the applier gains update via UpdateRealm. + // Create / Update both delegate to the shared RoleAdminService — the single + // canonical write path the realm-provisioning applier also calls. The realm:admin + // guard is passed as a parameter (here from the HTTP caller's permissions). roleGroup.MapPost("", async (RolePayload dto, HttpContext http, IPermissionService perms, RoleAdminService roleAdmin, CancellationToken ct) => { var callerIsRealmAdmin = await CallerPermissions.IsRealmAdminAsync(http, perms); var result = await roleAdmin.CreateRoleAsync(dto, callerIsRealmAdmin, ct); - return result.ToResult(role => Results.Ok(MapToResponse(role))); + return result.IsError ? ToErrorResult(result.Errors) : Results.Ok(MapToResponse(result.Value)); }) .WithName("V2_Role_Create") .RequiresPermission("permission-role:write"); - roleGroup.MapPut("{id}", async (ShortGuid id, RolePayload dto, HttpContext http, IPermissionService perms, IDocumentSession session) => + roleGroup.MapPut("{id}", async (ShortGuid id, RolePayload dto, HttpContext http, IPermissionService perms, RoleAdminService roleAdmin, CancellationToken ct) => { - var existing = await session.LoadAsync(id.Guid); - if (existing is null || existing.IsDeleted) return Results.NotFound(); - - // Privilege-escalation guard (audit H1): only a realm:admin may - // set/keep the realm-admin flag on a role. A non-admin may still - // de-escalate (clear the flag) or edit a non-admin role. - if (dto.IsRealmAdmin && !await CallerPermissions.IsRealmAdminAsync(http, perms)) - return RealmAdminForbidden(); - - var built = await BuildRoleAsync(dto, session); - if (built.Error is not null) return built.Error; - - existing.Name = built.Role.Name; - existing.Description = built.Role.Description; - existing.AppId = built.Role.AppId; - existing.IsRealmAdmin = built.Role.IsRealmAdmin; - existing.PermissionIds = built.Role.PermissionIds; - session.Events.Append(id.Guid, - new PermissionRoleUpdatedEvent( - id.Guid, existing.Name, existing.Description, - existing.AppId, existing.IsRealmAdmin, existing.PermissionIds)); - await session.SaveChangesAsync(); - return Results.Ok(MapToResponse(existing)); + var callerIsRealmAdmin = await CallerPermissions.IsRealmAdminAsync(http, perms); + var result = await roleAdmin.UpdateRoleAsync(id.Guid, dto, callerIsRealmAdmin, ct); + return result.IsError ? ToErrorResult(result.Errors) : Results.Ok(MapToResponse(result.Value)); }) .WithName("V2_Role_Update") .RequiresPermission("permission-role:write"); @@ -132,15 +109,23 @@ public static WebApplication MapRolesEndpoints(this WebApplication application, return application; } - // 403 for the realm:admin-conferral guard (audit H1) — named so the caller - // knows exactly which grant they lack, consistent with PermissionEndpointFilter. - private static IResult RealmAdminForbidden() => Results.Json( - new + // Renders a RoleAdminService ErrorOr error as HTTP with the error code in the body. + // The shared ErrorOrExtensions.ToResult maps Forbidden → Results.Forbid(), which under + // this app's cookie auth turns /api/* into an empty-body 403 (OnRedirectToAccessDenied) + // — losing the code the SPA + RealmAdminEscalationGuardTests rely on. This local + // renderer keeps the {Error,Message} body the role endpoints have always returned. + private static IResult ToErrorResult(List errors) + { + var error = errors[0]; + var status = error.Type switch { - Error = "Role.RealmAdminForbidden", - Message = "Only a realm administrator may create or modify a realm-admin role.", - }, - statusCode: StatusCodes.Status403Forbidden); + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.Conflict => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest, + }; + return Results.Json(new { Error = error.Code, Message = error.Description }, statusCode: status); + } private static object MapToResponse(PermissionRole r) => new { @@ -151,110 +136,4 @@ private static IResult RealmAdminForbidden() => Results.Json( r.IsRealmAdmin, PermissionIds = r.PermissionIds.Select(id => new ShortGuid(id).ToString()).ToList(), }; - - /// - /// Validates a payload and produces a ready - /// to persist (without the Id, which is filled in by the caller). On - /// failure returns a 400 result describing the first conflict found. - /// - private static async Task<(PermissionRole Role, IResult? Error)> BuildRoleAsync( - RolePayload dto, IDocumentSession session) - { - if (string.IsNullOrWhiteSpace(dto.Name)) - { - return (new PermissionRole(), Results.BadRequest(new - { - Error = "Role.NameRequired", - Message = "Name is required.", - })); - } - - // A client may omit PermissionIds entirely (or send a stale/renamed - // field); System.Text.Json then binds the record param to null. Coalesce - // to empty so a malformed/partial payload yields a clean 400 below - // (Role.GrantsNothing / Role.PermissionIdsRequireAppLink) instead of a - // 500 NullReferenceException. - var permissionIdsInput = dto.PermissionIds ?? new List(); - - // Resolve AppId (ShortGuid → Guid). Null payload = pure realm-admin role. - Guid? appId = null; - App? linkedApp = null; - if (!string.IsNullOrEmpty(dto.AppId)) - { - if (!ShortGuid.TryParse(dto.AppId, out Guid parsed)) - { - return (new PermissionRole(), Results.BadRequest(new - { - Error = "Role.InvalidAppId", - Message = $"AppId '{dto.AppId}' is not a valid Guid or ShortGuid.", - })); - } - linkedApp = await session.LoadAsync(parsed); - if (linkedApp is null || linkedApp.IsDeleted) - { - return (new PermissionRole(), Results.BadRequest(new - { - Error = "Role.AppNotFound", - Message = $"App {dto.AppId} not found.", - })); - } - appId = parsed; - } - - // PermissionIds without an App = invalid. - if (appId is null && permissionIdsInput.Count > 0) - { - return (new PermissionRole(), Results.BadRequest(new - { - Error = "Role.PermissionIdsRequireAppLink", - Message = "PermissionIds cannot be set on a role without an AppId.", - })); - } - - // Validate each permission id resolves to an entry in the linked App's catalog. - var catalogIds = linkedApp?.Permissions.Select(p => p.Id).ToHashSet() ?? new HashSet(); - var permissionIds = new List(permissionIdsInput.Count); - var seen = new HashSet(); - foreach (var raw in permissionIdsInput) - { - if (!ShortGuid.TryParse(raw, out Guid permId)) - { - return (new PermissionRole(), Results.BadRequest(new - { - Error = "Role.InvalidPermissionId", - Message = $"PermissionId '{raw}' is not a valid Guid or ShortGuid.", - })); - } - if (!catalogIds.Contains(permId)) - { - return (new PermissionRole(), Results.BadRequest(new - { - Error = "Role.PermissionIdNotInAppCatalog", - Message = $"PermissionId '{raw}' does not exist in App '{linkedApp!.Slug}'s catalog.", - })); - } - if (seen.Add(permId)) permissionIds.Add(permId); - } - - // A role with no AppId and no IsRealmAdmin grants nothing. Reject — admins - // who type that almost certainly meant something else. - if (appId is null && !dto.IsRealmAdmin) - { - return (new PermissionRole(), Results.BadRequest(new - { - Error = "Role.GrantsNothing", - Message = "Role must either link to an App (AppId + PermissionIds) or set IsRealmAdmin=true.", - })); - } - - return (new PermissionRole - { - Id = Guid.NewGuid(), - Name = dto.Name, - Description = dto.Description, - AppId = appId, - IsRealmAdmin = dto.IsRealmAdmin, - PermissionIds = permissionIds, - }, null); - } } From 55ca0452eb8761b4e3e8fdf2984688accbc270b4 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 07:00:13 +0200 Subject: [PATCH 07/21] feat(provisioning): RealmManifestApplier.UpdateRealmAsync (in-place merge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full-update half of declarative realm provisioning. UpdateRealmAsync requires the slug to exist and upserts every manifest entity by natural key (app slug, api/scope/role/group name, client id, user email/username) through the SAME canonical Update op the admin API uses, creating it when absent. The realm DB is never dropped (that would discard signing keys, the OpenIddict token store and user subs), so this is a strict in-place merge — unlike import there is no all-or-nothing rollback; a mid-apply failure leaves earlier writes committed and is safe to re-apply. Field semantics: booleans are always applied; scalar strings and non-empty lists replace the stored value; an omitted/empty list and a null app-link leave the stored value unchanged (sets and changes, never clears or detaches — that stays an admin-API/Stage-2 op). App-catalog entry ids are preserved by resource:action so an unchanged permission keeps its id and doesn't trip the catalog-delete block. Client secrets are minted only at create. Two tenant-durability gotchas mirrored from import: user UPDATE goes through UpdateUserHandler on a PLAIN session (not the bus) because UserUpdatedEvent's durable ReferenceSync forwarding would write wolverine_*_envelopes tables the tenant DB lacks; groups likewise use plain Create/UpdateGroupHandler. Group member/role refs fall back to a DB lookup for entities not created this run. Entity-level prune (removing entities absent from the manifest) is deliberately out of scope (Stage 2). RealmManifestApplierTests: import then update a realm that changes every entity and adds a new role; asserts the updates land, ids stay stable (in-place), and a missing slug is rejected (Realm.NotFound). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmManifestApplierTests.cs | 206 +++++++++ .../Provisioning/RealmManifestApplier.cs | 406 ++++++++++++++++++ 2 files changed, 612 insertions(+) diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs index 66ce3b12..2f3fd350 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs @@ -6,6 +6,10 @@ using Modgud.Authentication.Domain; using Modgud.Authorization.Apps; using Modgud.Authorization.Principals; +using Modgud.Authorization.Roles; +using Modgud.Domain.OAuth.Apis; +using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.OAuth.Scopes; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; using Marten; @@ -176,6 +180,208 @@ public async Task Import_rejects_a_slug_that_already_exists() Assert.Equal("Realm.AlreadyExists", second.FirstError.Code); } + [Fact] + public async Task Update_merges_in_place_keeping_ids_and_upserts_new_entities() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + + const string slug = "globex"; + var applier = factory.Services.GetRequiredService(); + + // ── Import the baseline realm ────────────────────────────────────────── + var imported = await applier.ImportNewRealmAsync(BuildGlobexManifest(slug, version: 1), ct); + Assert.False(imported.IsError, imported.IsError ? imported.FirstError.Description : string.Empty); + + // Capture the stable ids so we can prove the update was IN PLACE (not drop+recreate). + Guid appId = default, roleId = default, userId = default, groupId = default; + Guid clientId = default, scopeId = default, apiId = default; + await InTenantAsync(factory, slug, async sp => + { + var session = sp.GetRequiredService(); + appId = (await session.Query().SingleAsync(a => !a.IsDeleted && a.Slug == "globex-app", ct)).Id; + roleId = (await session.Query().SingleAsync(r => !r.IsDeleted && r.Name == "globex-admin", ct)).Id; + userId = (await session.Query().SingleAsync(p => !p.IsDeleted && p.AccountName == "alice", ct)).Id; + groupId = (await session.Query().SingleAsync(g => !g.IsDeleted && g.Name == "Admins", ct)).Id; + clientId = (await session.Query().SingleAsync(x => !x.IsDeleted && x.ClientId == "globex-web", ct)).Id; + scopeId = (await session.Query().SingleAsync(x => !x.IsDeleted && x.Name == "globex.read", ct)).Id; + apiId = (await session.Query().SingleAsync(x => !x.IsDeleted && x.Name == "globex-api", ct)).Id; + }); + + // ── Apply the v2 manifest: changes every existing entity + adds a new role ── + var updated = await applier.UpdateRealmAsync(BuildGlobexManifest(slug, version: 2), ct); + Assert.False(updated.IsError, updated.IsError ? updated.FirstError.Description : string.Empty); + + // The realm DB was never dropped. + var realms = factory.Services.GetRequiredService(); + Assert.NotNull(await realms.GetRealmBySlugAsync(slug, ct)); + + await InTenantAsync(factory, slug, async sp => + { + var session = sp.GetRequiredService(); + + // App: same id (in place), display name changed, catalog grew to 3. + var app = await session.Query().SingleAsync(a => !a.IsDeleted && a.Slug == "globex-app", ct); + Assert.Equal(appId, app.Id); + Assert.Equal("Globex App v2", app.DisplayName); + Assert.Equal(3, app.Permissions.Count); + + // Role: same id, now references all 3 permissions. + var role = await session.Query().SingleAsync(r => !r.IsDeleted && r.Name == "globex-admin", ct); + Assert.Equal(roleId, role.Id); + Assert.Equal(3, role.PermissionIds.Count); + + // The brand-new role was upsert-created. + Assert.True(await session.Query().AnyAsync(r => !r.IsDeleted && r.Name == "globex-viewer", ct)); + + // User: same id, firstname now set (was null on import). + var person = await session.Query().SingleAsync(p => !p.IsDeleted && p.AccountName == "alice", ct); + Assert.Equal(userId, person.Id); + Assert.Equal("Alice", person.Firstname); + + // Group: same id, description + role set replaced (now both roles). + var group = await session.Query().SingleAsync(g => !g.IsDeleted && g.Name == "Admins", ct); + Assert.Equal(groupId, group.Id); + Assert.Equal("Updated admins", group.Description); + Assert.Equal(2, group.RoleIds.Count); + + // OAuth entities kept their ids (in-place update, not recreated). + Assert.Equal(clientId, (await session.Query().SingleAsync(x => !x.IsDeleted && x.ClientId == "globex-web", ct)).Id); + Assert.Equal(scopeId, (await session.Query().SingleAsync(x => !x.IsDeleted && x.Name == "globex.read", ct)).Id); + Assert.Equal(apiId, (await session.Query().SingleAsync(x => !x.IsDeleted && x.Name == "globex-api", ct)).Id); + + // The client's redirect URI was replaced with the v2 value. + var oauth = sp.GetRequiredService(); + var client = (await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct)).Items.Single(c => c.ClientId == "globex-web"); + Assert.Contains("https://globex.test/cb2", client.RedirectUris); + Assert.DoesNotContain("https://globex.test/cb1", client.RedirectUris); + }); + } + + [Fact] + public async Task Update_rejects_a_slug_that_does_not_exist() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + + var applier = factory.Services.GetRequiredService(); + var result = await applier.UpdateRealmAsync(BuildGlobexManifest("ghost", version: 1), ct); + + Assert.True(result.IsError); + Assert.Equal("Realm.NotFound", result.FirstError.Code); + } + + /// + /// Builds the Globex manifest. 1 is the import baseline; + /// version 2 changes every existing entity (display names, catalog, redirect, role + /// permissions, user firstname, group membership) and adds a new "globex-viewer" role — + /// exercising both the update and the upsert-create branch. + /// + private static RealmManifest BuildGlobexManifest(string slug, int version) + { + var v2 = version == 2; + var catalog = new List + { + new("globex", "read"), + new("globex", "write"), + }; + if (v2) catalog.Add(new RealmManifestPermission("globex", "delete")); + + var roles = new List + { + new() + { + Name = "globex-admin", + App = "globex-app", + Permissions = catalog.ToList(), + }, + }; + if (v2) + roles.Add(new RealmManifestRole + { + Name = "globex-viewer", + App = "globex-app", + Permissions = [new RealmManifestPermission("globex", "read")], + }); + + return new RealmManifest + { + Realm = new CreateRealmDto + { + Slug = slug, + DisplayName = "Globex", + Domains = [$"{slug}.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "admin", Email = $"admin@{slug}.test" }, + }, + Apps = + [ + new RealmManifestApp + { + Slug = "globex-app", + DisplayName = v2 ? "Globex App v2" : "Globex App", + Permissions = catalog, + }, + ], + Apis = + [ + new RealmManifestApi + { + Name = "globex-api", + DisplayName = v2 ? "Globex API v2" : "Globex API", + App = "globex-app", + Permissions = [new RealmManifestPermission("globex", "read")], + }, + ], + Scopes = + [ + new RealmManifestScope + { + Name = "globex.read", + DisplayName = v2 ? "Globex Read v2" : "Globex Read", + App = "globex-app", + Resources = ["globex-api"], + }, + ], + Clients = + [ + new RealmManifestClient + { + ClientId = "globex-web", + DisplayName = v2 ? "Globex Web v2" : "Globex Web", + ClientType = "confidential", + RedirectUris = [v2 ? "https://globex.test/cb2" : "https://globex.test/cb1"], + Scopes = ["openid", "globex.read"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], + Apps = ["globex-app"], + }, + ], + Roles = roles, + Users = + [ + new RealmManifestUser + { + Key = "alice", + Email = "alice@globex.test", + UserName = "alice", + Firstname = v2 ? "Alice" : null, + Password = v2 ? null : "Passw0rd!23", + }, + ], + Groups = + [ + new RealmManifestGroup + { + Name = "Admins", + Description = v2 ? "Updated admins" : "Admins", + Members = ["alice"], + Roles = v2 ? ["globex-admin", "globex-viewer"] : ["globex-admin"], + }, + ], + }; + } + private static async Task InTenantAsync( ColdStartWebApplicationFactory factory, string slug, Func body) { diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs index 8fdbd9da..361aa1f8 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -9,11 +9,20 @@ using Modgud.Application.DTOs.OAuth; using Modgud.Application.DTOs.User; using Modgud.Application.Services; +using Modgud.Authentication.Api.Users; +using Modgud.Authentication.Applications; using Modgud.Authentication.RealmSettings; +using Modgud.Authentication.Sessions; using Modgud.Authorization.Apps; using Modgud.Authorization.Commands; using Modgud.Authorization.Membership; using Modgud.Authorization.Principals; +using Modgud.Authorization.Roles; +using Modgud.Authorization.Services; +using Modgud.Domain.Common; +using Modgud.Domain.OAuth.Apis; +using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.OAuth.Scopes; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; using Wolverine; @@ -91,6 +100,62 @@ public async Task> ImportNewRealmAsync( } } + /// + /// Updates an existing realm in place: the slug MUST already exist. Each entity in the + /// manifest is upserted by its natural key (app slug, api/scope/role/group name, client + /// id, user email/username) — created if absent, otherwise updated through the SAME + /// canonical Update operation the admin API uses. The realm database is NEVER dropped + /// (that would discard signing keys, the OpenIddict token store and user subs), + /// so this is a strict in-place merge. + /// + /// Semantics (v1, merge/upsert — entity-level prune is a separate later stage): + /// the manifest is the desired state for the fields it carries. Boolean flags are always + /// applied; scalar strings and non-empty lists replace the stored value; an omitted / + /// empty list and a null app-link leave the stored value unchanged (UpdateRealm sets and + /// changes, but never clears a list to empty or detaches an app-link — use the admin API + /// for that). Client secrets are only minted at create; an existing client keeps its + /// secret (rotate via the dedicated endpoint). + /// + /// Unlike import there is no all-or-nothing rollback: each canonical op commits its + /// own unit of work, so a mid-apply failure leaves the earlier successful writes in place. + /// The upserts are safe to re-apply after fixing the manifest. + /// + public async Task> UpdateRealmAsync( + RealmManifest manifest, CancellationToken ct = default) + { + var slug = manifest.Realm.Slug; + + var realm = await realms.GetRealmBySlugAsync(slug, ct); + if (realm is null) + return Error.NotFound("Realm.NotFound", + $"Realm '{slug}' does not exist. Use ImportNewRealm to create it."); + + try + { + var secrets = await ApplyTenantUpdateAsync(slug, manifest, ct); + logger.LogInformation( + "Updated realm {Slug}: {Apps} apps, {Apis} apis, {Scopes} scopes, {Clients} clients, {Roles} roles, {Users} users, {Groups} groups (in-place merge).", + slug, manifest.Apps.Count, manifest.Apis.Count, manifest.Scopes.Count, + manifest.Clients.Count, manifest.Roles.Count, manifest.Users.Count, manifest.Groups.Count); + return new RealmImportResult + { + Slug = slug, + PrimaryDomain = realm.PrimaryDomain, + ClientSecrets = secrets, + }; + } + catch (ManifestApplyException ex) + { + // In-place update never drops the realm DB. A partial failure leaves the writes + // that committed before it in place; surface the error so the caller can fix the + // manifest and re-apply (every step is an idempotent upsert). + logger.LogError(ex, + "Manifest update failed for realm {Slug} ({What}); the realm is left partially updated.", + slug, ex.What); + return ex.Errors; + } + } + private async Task> ApplyTenantConfigAsync( string slug, RealmManifest manifest, CancellationToken ct) { @@ -240,6 +305,347 @@ private async Task> ApplyTenantConfigAsync( return secrets; } + /// + /// In-place upsert of every entity in the manifest against an already-provisioned realm. + /// Mirrors but reads current state by natural key + /// and dispatches to the canonical Update op when the entity exists, the Create op when + /// it doesn't. See for the field-level merge semantics. + /// + private async Task> ApplyTenantUpdateAsync( + string slug, RealmManifest manifest, CancellationToken ct) + { + var secrets = new Dictionary(StringComparer.Ordinal); + var apps = new Dictionary(StringComparer.Ordinal); // slug → App (id + catalog) + var roleIds = new Dictionary(StringComparer.Ordinal); // role key → id (for groups) + var userIds = new Dictionary(StringComparer.Ordinal); // user key → id (for groups) + + using var _ = TenantContext.Enter(slug); + using var scope = scopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + var session = sp.GetRequiredService(); + + if (manifest.Settings is not null) + EnsureOk(await sp.GetRequiredService().PatchAsync(manifest.Settings, ct), "settings"); + + // ── Apps (+ permission catalog) ─────────────────────────────────────────── + // Seed the resolver with every existing app so downstream entities can reference + // apps the manifest doesn't re-list, then upsert the manifest's apps over them. + foreach (var existing in await session.Query().Where(a => !a.IsDeleted).ToListAsync(ct)) + apps[existing.Slug] = existing; + + var appAdmin = sp.GetRequiredService(); + foreach (var app in manifest.Apps) + { + App result; + if (apps.TryGetValue(app.Slug, out var current)) + { + // Preserve existing catalog-entry ids by resource:action so an unchanged + // permission keeps its id — otherwise it would look "removed + re-added" and + // trip the catalog-delete block (which guards FK references from roles/RSes). + // Genuinely new entries carry a null id (minted fresh); genuinely removed ones + // are then correctly subject to the reference check. + var byKey = current.Permissions.ToDictionary(p => $"{p.Resource}:{p.Action}", p => p.Id); + var permissions = app.Permissions.Select(p => new AppPermissionDto( + byKey.TryGetValue($"{p.Resource}:{p.Action}", out var existingId) + ? new ShortGuid(existingId).ToString() + : null, + p.Resource, p.Action, p.Description)).ToList(); + var updated = await appAdmin.UpdateAppAsync(current.Id, + new UpdateAppDto(app.DisplayName, app.Description, permissions), ct); + EnsureOk(updated, $"app '{app.Slug}'"); + result = updated.Value; + } + else + { + var permissions = app.Permissions + .Select(p => new AppPermissionDto(null, p.Resource, p.Action, p.Description)).ToList(); + var created = await appAdmin.CreateAppAsync( + new CreateAppDto(app.Slug, app.DisplayName, app.Description, permissions), ct); + EnsureOk(created, $"app '{app.Slug}'"); + result = created.Value; + } + apps[app.Slug] = result; + } + + var oauth = sp.GetRequiredService(); + + // ── OAuth APIs (natural key = Name / aud) ────────────────────────────────── + foreach (var api in manifest.Apis) + { + var ctx = $"api '{api.Name}'"; + var existing = await session.Query() + .FirstOrDefaultAsync(x => x.Name == api.Name && !x.IsDeleted, ct); + if (existing is null) + { + EnsureOk(await oauth.CreateApiAsync(new CreateOAuthApiDto + { + Name = api.Name, + DisplayName = api.DisplayName, + Description = api.Description, + Enabled = api.Enabled, + Scopes = api.Scopes, + UserClaims = api.UserClaims, + AppId = ResolveAppId(apps, api.App, ctx), + PermissionIds = ResolvePermissionIds(apps, api.App, api.Permissions, ctx), + AllowDynamicRegistration = api.AllowDynamicRegistration, + }, ct), ctx); + } + else + { + EnsureOk(await oauth.UpdateApiAsync(existing.Id.ToString(), new UpdateOAuthApiDto + { + DisplayName = api.DisplayName, + Description = api.Description, + Enabled = api.Enabled, + Scopes = NullIfEmpty(api.Scopes), + UserClaims = NullIfEmpty(api.UserClaims), + AppId = api.App is null ? null : ResolveAppId(apps, api.App, ctx), + PermissionIds = NullIfEmpty(ResolvePermissionIds(apps, api.App, api.Permissions, ctx)), + AllowDynamicRegistration = api.AllowDynamicRegistration, + }, ct), ctx); + } + } + + // ── OAuth scopes (natural key = Name) ────────────────────────────────────── + foreach (var s in manifest.Scopes) + { + var ctx = $"scope '{s.Name}'"; + var existing = await session.Query() + .FirstOrDefaultAsync(x => x.Name == s.Name && !x.IsDeleted, ct); + if (existing is null) + { + EnsureOk(await oauth.CreateScopeAsync(new CreateOAuthScopeDto + { + Name = s.Name, + DisplayName = s.DisplayName, + Description = s.Description, + Resources = s.Resources, + UserClaims = s.UserClaims, + Enabled = s.Enabled, + Required = s.Required, + Emphasize = s.Emphasize, + ShowInDiscoveryDocument = s.ShowInDiscoveryDocument, + AppId = ResolveAppId(apps, s.App, ctx), + }, ct), ctx); + } + else + { + EnsureOk(await oauth.UpdateScopeAsync(existing.Id.ToString(), new UpdateOAuthScopeDto + { + DisplayName = s.DisplayName, + Description = s.Description, + Resources = NullIfEmpty(s.Resources), + UserClaims = NullIfEmpty(s.UserClaims), + Enabled = s.Enabled, + Required = s.Required, + Emphasize = s.Emphasize, + ShowInDiscoveryDocument = s.ShowInDiscoveryDocument, + AppId = s.App is null ? null : ResolveAppId(apps, s.App, ctx), + }, ct), ctx); + } + } + + // ── OAuth clients (natural key = ClientId) ───────────────────────────────── + foreach (var c in manifest.Clients) + { + var ctx = $"client '{c.ClientId}'"; + var existing = await session.Query() + .FirstOrDefaultAsync(x => x.ClientId == c.ClientId && !x.IsDeleted, ct); + if (existing is null) + { + var created = await oauth.CreateClientAsync(new CreateOAuthClientDto + { + ClientId = c.ClientId, + DisplayName = c.DisplayName, + ClientType = c.ClientType, + ClientSecret = c.ClientSecret, + RedirectUris = c.RedirectUris, + PostLogoutRedirectUris = c.PostLogoutRedirectUris, + Scopes = c.Scopes, + AllowedGrantTypes = c.AllowedGrantTypes, + Roles = c.Roles, + WebAuthnRpId = c.WebAuthnRpId, + Enabled = c.Enabled, + RequireConsent = c.RequireConsent, + AppIds = c.Apps.Count == 0 ? null + : c.Apps.Select(appSlug => ResolveAppId(apps, appSlug, ctx)!).ToList(), + }, ct); + EnsureOk(created, ctx); + if (created.Value.ClientSecret is not null) + secrets[c.ClientId] = created.Value.ClientSecret; + } + else + { + // ClientType + secret are immutable through the canonical update path; an + // existing client keeps its secret (rotate via the dedicated endpoint). + EnsureOk(await oauth.UpdateClientAsync(existing.Id.ToString(), new UpdateOAuthClientDto + { + DisplayName = c.DisplayName, + RedirectUris = NullIfEmpty(c.RedirectUris), + PostLogoutRedirectUris = NullIfEmpty(c.PostLogoutRedirectUris), + Scopes = NullIfEmpty(c.Scopes), + AllowedGrantTypes = NullIfEmpty(c.AllowedGrantTypes), + Roles = NullIfEmpty(c.Roles), + WebAuthnRpId = c.WebAuthnRpId, + Enabled = c.Enabled, + RequireConsent = c.RequireConsent, + AppIds = c.Apps.Count == 0 ? null + : c.Apps.Select(appSlug => ResolveAppId(apps, appSlug, ctx)!).ToList(), + }, ct), ctx); + } + } + + // ── Roles (natural key = Name) ───────────────────────────────────────────── + var roleAdmin = sp.GetRequiredService(); + foreach (var r in manifest.Roles) + { + var ctx = $"role '{r.Name}'"; + var payload = new RolePayload( + r.Name, + r.Description, + ResolveAppId(apps, r.App, ctx), + r.IsRealmAdmin, + ResolvePermissionIds(apps, r.App, r.Permissions, ctx)); + var existing = await session.Query() + .FirstOrDefaultAsync(x => x.Name == r.Name && !x.IsDeleted, ct); + // Control-plane provisioning is trusted, so the realm-admin guard is satisfied. + ErrorOr result = existing is null + ? await roleAdmin.CreateRoleAsync(payload, callerIsRealmAdmin: true, ct) + : await roleAdmin.UpdateRoleAsync(existing.Id, payload, callerIsRealmAdmin: true, ct); + EnsureOk(result, ctx); + roleIds[r.ResolveKey()] = result.Value.Id; + } + + // ── Users (natural key = email or username) ──────────────────────────────── + var bus = sp.GetRequiredService(); + foreach (var u in manifest.Users) + { + var ctx = $"user '{u.Email}'"; + var normalizedEmail = u.Email.ToUpperInvariant(); + var normalizedUserName = u.UserName?.ToLowerInvariant(); + var existing = await session.Query() + .FirstOrDefaultAsync(p => !p.IsDeleted && + (p.NormalizedEmail == normalizedEmail || + (normalizedUserName != null && p.AccountName == normalizedUserName)), ct); + + Guid? uid; + if (existing is null) + { + var createCmd = new CreateUserCommand(u.Firstname, u.Lastname, u.Acronym, u.Email, + u.UserName ?? string.Empty, u.Password, u.EmailConfirmed); + var created = await bus.InvokeForTenantAsync>(slug, createCmd, ct); + EnsureOk(created, ctx); + uid = ShortGuid.TryParse(created.Value.Id, out Guid cid) ? cid : null; + } + else + { + // UpdateUserCommand mutates only the profile fields. Password / EmailConfirmed + // / active-state are divergent inline ops (Stage 2) — left untouched here. + var updateCmd = new UpdateUserCommand(existing.Id, + OptionalOf(u.Firstname), OptionalOf(u.Lastname), OptionalOf(u.Acronym), + new Optional(u.Email), OptionalOf(u.UserName)); + // Plain-session direct call (NOT the bus): UpdateUserHandler appends + // UserUpdatedEvent straight to its session, and under InvokeForTenantAsync + // that's the Wolverine outbox session — the durable ReferenceSync forwarding + // would then write wolverine_*_envelopes tables the tenant DB doesn't have + // (the same reason groups use a plain session). CreateUser is unaffected: it + // persists via UserManager on a separate, non-outbox session. + var updateHandler = new UpdateUserHandler(session); + var updated = await updateHandler.Handle(updateCmd, + sp.GetRequiredService(), + sp.GetRequiredService(), ct); + EnsureOk(updated, ctx); + uid = existing.Id; + } + if (uid.HasValue) userIds[u.ResolveKey()] = uid.Value; + } + + // ── Groups (natural key = Name) — PLAIN tenant-scoped session, NOT the Wolverine + // outbox: the durable-inbox auto-membership forwarding would write to wolverine + // tables the tenant DB doesn't have (see ApplyTenantConfigAsync). ────────────── + if (manifest.Groups.Count > 0) + { + var groupSession = sp.GetRequiredService(); + var evaluator = sp.GetRequiredService(); + var recalculator = sp.GetRequiredService(); + var createHandler = new CreateGroupHandler(groupSession, evaluator, recalculator); + var updateHandler = new UpdateGroupHandler(groupSession, evaluator, + sp.GetRequiredService(), recalculator); + + foreach (var g in manifest.Groups) + { + var ctx = $"group '{g.Name}'"; + // Members/roles may reference entities created this run OR pre-existing ones, + // so fall back to a DB lookup by key when the in-run map misses. + var memberIds = new List(g.Members.Count); + foreach (var m in g.Members) + memberIds.Add(await ResolveUserRefAsync(session, userIds, m, $"{ctx} member '{m}'", ct)); + var groupRoleIds = new List(g.Roles.Count); + foreach (var rk in g.Roles) + groupRoleIds.Add(await ResolveRoleRefAsync(session, roleIds, rk, $"{ctx} role '{rk}'", ct)); + + var mode = ParseEnum(g.MembershipMode, $"{ctx} membershipMode"); + var emailMode = ParseEnum(g.EmailMode, $"{ctx} emailMode"); + + var existing = await session.Query() + .FirstOrDefaultAsync(x => x.Name == g.Name && !x.IsDeleted, ct); + if (existing is null) + { + EnsureOk(await createHandler.Handle(new CreateGroupCommand( + g.Name, g.Description, memberIds, groupRoleIds, mode, + g.MembershipScript, g.Email, emailMode, + g.BoundTo, g.ExternallyDrivable, CallerIsRealmAdmin: true), ct), ctx); + } + else + { + EnsureOk(await updateHandler.Handle(new UpdateGroupCommand( + existing.Id, g.Name, g.Description, memberIds, groupRoleIds, mode, + g.MembershipScript, g.Email, emailMode, + g.BoundTo, g.ExternallyDrivable, CallerIsRealmAdmin: true), ct), ctx); + } + } + } + + return secrets; + } + + /// Wraps a manifest string in a "some" optional, or "none" when null — the + /// UpdateUserCommand semantics: a null manifest field leaves the stored value unchanged + /// rather than clearing it. + private static Optional OptionalOf(string? value) + => value is null ? Optional.None : new Optional(value); + + /// Returns null for an empty list so a canonical PATCH op treats it as + /// "no change" rather than "clear" — UpdateRealm sets and changes lists but never + /// clears them to empty (that stays an admin-API operation). + private static List? NullIfEmpty(List list) => list.Count == 0 ? null : list; + + private static async Task ResolveUserRefAsync( + IDocumentSession session, IReadOnlyDictionary map, string key, string context, CancellationToken ct) + { + if (map.TryGetValue(key, out var id)) return id; + var lowered = key.ToLowerInvariant(); + var upper = key.ToUpperInvariant(); + var person = await session.Query() + .FirstOrDefaultAsync(p => !p.IsDeleted && (p.AccountName == lowered || p.NormalizedEmail == upper), ct); + if (person is null) + throw new ManifestApplyException(context, + [Error.Validation("Manifest.UnknownReference", $"{context} resolves to no user.")]); + return person.Id; + } + + private static async Task ResolveRoleRefAsync( + IDocumentSession session, IReadOnlyDictionary map, string key, string context, CancellationToken ct) + { + if (map.TryGetValue(key, out var id)) return id; + var role = await session.Query() + .FirstOrDefaultAsync(r => !r.IsDeleted && r.Name == key, ct); + if (role is null) + throw new ManifestApplyException(context, + [Error.Validation("Manifest.UnknownReference", $"{context} resolves to no role.")]); + return role.Id; + } + private static string? ResolveAppId(IReadOnlyDictionary apps, string? slug, string context) { if (string.IsNullOrEmpty(slug)) return null; From f8abaf61aee381a639f30089d3787589c029f09d Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 07:32:04 +0200 Subject: [PATCH 08/21] feat(provisioning): control-plane endpoints for import / apply / hard-delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the RealmManifestApplier over the existing control-plane-gated /api/admin/realms group: - POST /import — provision a brand-new realm from a full manifest (201 + slug, primary domain, and the plaintext client secrets available only at create). - POST /{slug}/apply — in-place merge/upsert against an existing realm; the route slug must match the manifest realm slug (400 Manifest.SlugMismatch otherwise). - DELETE /{slug}?hard=true — escalates the existing soft-delete to the prod-safe hard delete that DROPs the tenant DB; default false keeps soft-delete behaviour. Manifest errors render {Error,Message} with the code in the body (Realm.AlreadyExists / Realm.NotFound / Manifest.*) so a test-kit can distinguish outcomes — not collapsed through the shared ToResult. RealmProvisioningEndpointsTests drives the import->apply->hard-delete round trip plus the duplicate-409 / missing-404 / slug-mismatch-400 paths against an isolated cold host. Export (GET /{slug}/export) is deferred — it needs a secrets round-trip design (hashes can't re-import through the canonical create ops) and isn't on the test-kit's critical path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../RealmProvisioningEndpointsTests.cs | 153 ++++++++++++++++++ .../Features/Admin/RealmsEndpoints.cs | 64 +++++++- 2 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs new file mode 100644 index 00000000..a46538f1 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Net.Http.Json; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Features.Admin.Provisioning; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; +using Modgud.Authorization.Apps; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Stage 1c: the control-plane provisioning endpoints exposing the RealmManifestApplier +/// over HTTP — POST /import (new realm), POST /{slug}/apply (in-place update), and +/// DELETE /{slug}?hard=true (drop the tenant DB). Drives them as the control-plane admin +/// against an isolated cold-boot host so the real tenant-DB create/drop pollutes nothing. +/// +public class RealmProvisioningEndpointsTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Import_then_apply_then_hard_delete_round_trip() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + var svc = factory.Services.GetRequiredService(); + + const string slug = "initech"; + + // ── Import ──────────────────────────────────────────────────────────── + var importResp = await client.PostAsJsonAsync( + "/api/admin/realms/import", BuildManifest(slug, "Initech App"), factory.JsonOptions, ct); + Assert.Equal(HttpStatusCode.Created, importResp.StatusCode); + + var imported = await importResp.Content.ReadFromJsonAsync(factory.JsonOptions, ct); + Assert.NotNull(imported); + Assert.Equal(slug, imported!.Slug); + Assert.True(imported.ClientSecrets.ContainsKey("initech-web")); + Assert.NotNull(await svc.GetRealmBySlugAsync(slug, ct)); + + // ── Apply (in-place update: change the app display name) ─────────────── + var applyResp = await client.PostAsJsonAsync( + $"/api/admin/realms/{slug}/apply", BuildManifest(slug, "Initech App v2"), factory.JsonOptions, ct); + Assert.Equal(HttpStatusCode.OK, applyResp.StatusCode); + + await InTenantAsync(factory, slug, async sp => + { + var session = sp.GetRequiredService(); + var app = await session.Query().SingleAsync(a => !a.IsDeleted && a.Slug == "initech-app", ct); + Assert.Equal("Initech App v2", app.DisplayName); + }); + + // ── Hard delete (drops the tenant DB) ───────────────────────────────── + var deleteResp = await client.DeleteAsync($"/api/admin/realms/{slug}?hard=true", ct); + Assert.Equal(HttpStatusCode.NoContent, deleteResp.StatusCode); + Assert.Null(await svc.GetRealmBySlugAsync(slug, ct)); + } + + [Fact] + public async Task Import_rejects_duplicate_slug_with_409() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + + const string slug = "dup-ep"; + var first = await client.PostAsJsonAsync( + "/api/admin/realms/import", BuildManifest(slug, "Dup"), factory.JsonOptions, ct); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var second = await client.PostAsJsonAsync( + "/api/admin/realms/import", BuildManifest(slug, "Dup"), factory.JsonOptions, ct); + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + Assert.Contains("Realm.AlreadyExists", await second.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task Apply_to_missing_realm_returns_404() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + + var resp = await client.PostAsJsonAsync( + "/api/admin/realms/ghost-ep/apply", BuildManifest("ghost-ep", "Ghost"), factory.JsonOptions, ct); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + Assert.Contains("Realm.NotFound", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task Apply_with_route_slug_not_matching_manifest_returns_400() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + + var resp = await client.PostAsJsonAsync( + "/api/admin/realms/other-slug/apply", BuildManifest("manifest-slug", "X"), factory.JsonOptions, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("Manifest.SlugMismatch", await resp.Content.ReadAsStringAsync(ct)); + } + + private static RealmManifest BuildManifest(string slug, string appDisplayName) => new() + { + Realm = new CreateRealmDto + { + Slug = slug, + DisplayName = slug, + Domains = [$"{slug}.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "admin", Email = $"admin@{slug}.test" }, + }, + Apps = + [ + new RealmManifestApp + { + Slug = "initech-app", + DisplayName = appDisplayName, + Permissions = [new RealmManifestPermission("initech", "read")], + }, + ], + Clients = + [ + new RealmManifestClient + { + ClientId = "initech-web", + DisplayName = "Initech Web", + ClientType = "confidential", + RedirectUris = [$"https://{slug}.test/cb"], + Scopes = ["openid"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], + Apps = ["initech-app"], + }, + ], + Users = + [ + new RealmManifestUser { Key = "admin", Email = $"admin@{slug}.test", UserName = "admin", Password = "Passw0rd!23" }, + ], + }; + + private static async Task InTenantAsync( + ColdStartWebApplicationFactory factory, string slug, Func body) + { + using var _ = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await body(scope.ServiceProvider); + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs index abf6f21e..ede85631 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using ErrorOr; +using Modgud.Api.Features.Admin.Provisioning; using Modgud.Application.DTOs.Realms; using Modgud.Authentication.ExtensionMethods; using Modgud.Authentication.Domain; @@ -215,14 +217,55 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, .WithName("Realms_Update") .RequiresPermission("realm:write", AppSlugs.ControlPlane); - group.MapDelete("{slug}", async (string slug, IRealmProvisioningService svc, CancellationToken ct) => + // ?hard=true escalates from the reversible soft-delete to the prod-safe hard + // delete that DROPs the tenant database (HardDeleteRealmAsync). Default false keeps + // the existing soft-delete behaviour. Hard-delete is refused for the control plane. + group.MapDelete("{slug}", async (string slug, IRealmProvisioningService svc, CancellationToken ct, bool hard = false) => { - var result = await svc.DeleteRealmAsync(slug, ct); + var result = hard + ? await svc.HardDeleteRealmAsync(slug, ct) + : await svc.DeleteRealmAsync(slug, ct); return result.IsError ? result.ToResult() : Results.NoContent(); }) .WithName("Realms_Delete") .RequiresPermission("realm:write", AppSlugs.ControlPlane); + // ── Declarative provisioning (RealmManifestApplier) ───────────────────────── + // Import a brand-new realm from a complete manifest (realm + settings + apps + + // apis + scopes + clients + roles + users + groups), all via the canonical admin + // operations. The slug must NOT already exist; a failed import rolls the whole + // realm back (hard-delete). Returns the created slug + primary domain + the + // plaintext secrets of any confidential clients (only available at create time). + group.MapPost("import", async ( + RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct) => + { + var result = await applier.ImportNewRealmAsync(manifest, ct); + if (result.IsError) return ManifestError(result.Errors); + ModgudMeters.RecordRealmProvisioned(); + return Results.Created($"{path}/admin/realms/{result.Value.Slug}", result.Value); + }) + .WithName("Realms_Import") + .RequiresPermission("realm:write", AppSlugs.ControlPlane); + + // Apply a manifest to an EXISTING realm: in-place merge/upsert per entity (never + // drops the DB). The route slug must match the manifest's realm slug. No prune — + // entities absent from the manifest are left untouched. + group.MapPost("{slug}/apply", async ( + string slug, RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct) => + { + if (!string.Equals(slug, manifest.Realm.Slug, StringComparison.Ordinal)) + return Results.BadRequest(new + { + Error = "Manifest.SlugMismatch", + Message = $"Route slug '{slug}' does not match the manifest realm slug '{manifest.Realm.Slug}'.", + }); + + var result = await applier.UpdateRealmAsync(manifest, ct); + return result.IsError ? ManifestError(result.Errors) : Results.Ok(result.Value); + }) + .WithName("Realms_Apply") + .RequiresPermission("realm:write", AppSlugs.ControlPlane); + // Transfer the control-plane role to {slug}. POST to the realm that // should BECOME the control plane, from the current control-plane host // (the group's RequireControlPlaneFilter enforces the latter). After @@ -295,6 +338,23 @@ private static async Task TargetHasUsableAdminAsync( } } + // Renders a RealmManifestApplier ErrorOr error with the code in the body — the manifest + // codes (Realm.AlreadyExists / Realm.NotFound / Manifest.*) are how a test-kit / caller + // distinguishes outcomes, so don't collapse them through the shared ToResult. + private static IResult ManifestError(List errors) + { + var error = errors[0]; + var status = error.Type switch + { + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status500InternalServerError, + }; + return Results.Json(new { Error = error.Code, Message = error.Description }, statusCode: status); + } + internal static RealmDto MapToDto(Realm realm) => new() { Id = realm.Id, From d7b053439ec32c4b97f1736fbaf96919e7156890 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 08:24:15 +0200 Subject: [PATCH 09/21] feat(provisioning): Modgud.Provisioning.TestKit + drift-guard parity test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone, dependency-light NuGet-able test-kit so consumer-app integration tests can spin up a real, isolated Modgud realm per test from a declarative manifest: - ModgudProvisioningClient(HttpClient) wraps the control-plane provisioning API. ImportRealmAsync(manifest) returns a ProvisionedRealm handle exposing Authority, PrimaryDomain, and the freshly minted client secrets (SecretFor). ApplyAsync does an in-place merge; DisposeAsync hard-deletes the realm (drops the tenant DB), so `await using` gives automatic teardown. Server error codes (Realm.AlreadyExists, Realm.NotFound, Manifest.SlugMismatch) surface as ModgudProvisioningException.Code. - The kit ships its own manifest POCOs (the client-side mirror of the server contract) so it carries zero server dependencies. ProvisioningTestKitTests serialises those POCOs against the live import/apply/delete endpoints, so any drift between the kit's shape and the server's manifest contract fails there. RealmManifestParityTests is the drift-guard: it builds the same OAuth client two ways — via OAuthAdminService.CreateClientAsync with an explicit DTO (the admin-API path) and via a manifest import — and asserts the projected client shape (type, consent, redirects, grants, permissions, enabled, app-links-by-slug) is identical. Both go through the same canonical service, so a mismatch can only mean the applier's manifest->DTO mapping drifted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/ProvisioningTestKitTests.cs | 114 +++++++++++++ .../ColdStart/RealmManifestParityTests.cs | 159 ++++++++++++++++++ .../Modgud.Api.Tests/Modgud.Api.Tests.csproj | 4 +- .../Modgud.Provisioning.TestKit.csproj | 47 ++++++ .../ModgudProvisioningClient.cs | 115 +++++++++++++ .../ProvisionedRealm.cs | 74 ++++++++ .../Modgud.Provisioning.TestKit/README.md | 54 ++++++ .../RealmManifest.cs | 143 ++++++++++++++++ src/dotnet/Modgud.slnx | 1 + 9 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/ColdStart/ProvisioningTestKitTests.cs create mode 100644 src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestParityTests.cs create mode 100644 src/dotnet/Modgud.Provisioning.TestKit/Modgud.Provisioning.TestKit.csproj create mode 100644 src/dotnet/Modgud.Provisioning.TestKit/ModgudProvisioningClient.cs create mode 100644 src/dotnet/Modgud.Provisioning.TestKit/ProvisionedRealm.cs create mode 100644 src/dotnet/Modgud.Provisioning.TestKit/README.md create mode 100644 src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/ProvisioningTestKitTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/ProvisioningTestKitTests.cs new file mode 100644 index 00000000..a08edc08 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/ProvisioningTestKitTests.cs @@ -0,0 +1,114 @@ +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authorization.Apps; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; +using Modgud.Provisioning.TestKit; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Stage 1d: drives the standalone Modgud.Provisioning.TestKit against the live +/// control-plane endpoints. Doubles as the kit's contract guard — the kit's own manifest +/// POCOs are serialised and posted to the real import/apply/delete endpoints, so any drift +/// between the kit's shape and the server's manifest contract fails here. +/// +public class ProvisioningTestKitTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task TestKit_imports_applies_and_hard_deletes_a_realm() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var httpClient = await factory.CreateRealmAdminAndLoginAsync(); + var svc = factory.Services.GetRequiredService(); + + var kit = new ModgudProvisioningClient(httpClient); + const string slug = "kittest"; + + var realm = await kit.ImportRealmAsync(BuildManifest(slug, "Kit App"), ct); + + // The handle surfaces everything an app-under-test needs. + Assert.Equal(slug, realm.Slug); + Assert.Equal("kittest.localhost", realm.PrimaryDomain); + Assert.Equal("https://kittest.localhost", realm.Authority); + Assert.False(string.IsNullOrWhiteSpace(realm.SecretFor("kit-web"))); + Assert.NotNull(await svc.GetRealmBySlugAsync(slug, ct)); + + // In-place apply through the kit. + await realm.ApplyAsync(BuildManifest(slug, "Kit App v2"), ct); + await InTenantAsync(factory, slug, async sp => + { + var session = sp.GetRequiredService(); + var app = await session.Query().SingleAsync(a => !a.IsDeleted && a.Slug == "kit-app", ct); + Assert.Equal("Kit App v2", app.DisplayName); + }); + + // Explicit teardown asserts the hard-delete really dropped the realm. + await realm.DeleteAsync(ct); + Assert.Null(await svc.GetRealmBySlugAsync(slug, ct)); + } + + [Fact] + public async Task TestKit_surfaces_the_server_error_code_on_duplicate_import() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var kit = new ModgudProvisioningClient(await factory.CreateRealmAdminAndLoginAsync()); + + const string slug = "kitdup"; + await using var first = await kit.ImportRealmAsync(BuildManifest(slug, "Dup"), ct); + + var ex = await Assert.ThrowsAsync( + () => kit.ImportRealmAsync(BuildManifest(slug, "Dup"), ct)); + Assert.Equal("Realm.AlreadyExists", ex.Code); + } + + private static RealmManifest BuildManifest(string slug, string appDisplayName) => new() + { + Realm = new RealmSpec + { + Slug = slug, + DisplayName = slug, + Domains = [$"{slug}.localhost"], + InitialAdmin = new InitialAdmin { UserName = "admin", Email = $"admin@{slug}.test" }, + }, + Apps = + [ + new RealmManifestApp + { + Slug = "kit-app", + DisplayName = appDisplayName, + Permissions = [new RealmManifestPermission("kit", "read")], + }, + ], + Clients = + [ + new RealmManifestClient + { + ClientId = "kit-web", + DisplayName = "Kit Web", + ClientType = "confidential", + RedirectUris = [$"https://{slug}.localhost/callback"], + Scopes = ["openid"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], + Apps = ["kit-app"], + }, + ], + Users = + [ + new RealmManifestUser { Key = "admin", Email = $"admin@{slug}.test", UserName = "admin", Password = "Passw0rd!23" }, + ], + }; + + private static async Task InTenantAsync( + ColdStartWebApplicationFactory factory, string slug, Func body) + { + using var _ = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await body(scope.ServiceProvider); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestParityTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestParityTests.cs new file mode 100644 index 00000000..bc13b37f --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestParityTests.cs @@ -0,0 +1,159 @@ +using BuildingBlocks.Helper; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Features.Admin.Provisioning; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.OAuth; +using Modgud.Application.DTOs.Realms; +using Modgud.Application.Services; +using Modgud.Authorization.Apps; +using Modgud.Infrastructure.Persistence.Tenancy; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Stage 1d drift-guard: the manifest path must produce the SAME state as the canonical +/// admin operation for the same logical input. An OAuth client is built two ways — via +/// with an explicit DTO (what the admin +/// UI/API submits) in realm A, and via a manifest import in realm B — and the projected +/// client shape is asserted identical. Both go through the same canonical service, so a +/// mismatch can only mean the applier's manifest→DTO mapping has drifted. +/// +public class RealmManifestParityTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Client_built_via_admin_service_and_via_manifest_have_identical_state() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var applier = factory.Services.GetRequiredService(); + + const string slugA = "parity-admin"; + const string slugB = "parity-manifest"; + + // Realm A: import just the realm + app, then create the client via the canonical + // OAuthAdminService with an explicit DTO (the admin-API path). + var importA = await applier.ImportNewRealmAsync(BaseManifest(slugA), ct); + Assert.False(importA.IsError, importA.IsError ? importA.FirstError.Description : string.Empty); + await InTenantAsync(factory, slugA, async sp => + { + var session = sp.GetRequiredService(); + var appId = (await session.Query().SingleAsync(a => !a.IsDeleted && a.Slug == "parity-app", ct)).Id; + var oauth = sp.GetRequiredService(); + var created = await oauth.CreateClientAsync(new CreateOAuthClientDto + { + ClientId = "parity-web", + DisplayName = "Parity Web", + ClientType = "confidential", + RedirectUris = ["https://parity.test/cb"], + Scopes = ["openid"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], + Enabled = true, + RequireConsent = false, + AppIds = [new ShortGuid(appId).ToString()], + }, ct); + Assert.False(created.IsError, created.IsError ? created.FirstError.Description : string.Empty); + }); + + // Realm B: the SAME client described in the manifest (the applier path). + var manifestB = BaseManifest(slugB) with + { + Clients = + [ + new RealmManifestClient + { + ClientId = "parity-web", + DisplayName = "Parity Web", + ClientType = "confidential", + RedirectUris = ["https://parity.test/cb"], + Scopes = ["openid"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], + Apps = ["parity-app"], + }, + ], + }; + var importB = await applier.ImportNewRealmAsync(manifestB, ct); + Assert.False(importB.IsError, importB.IsError ? importB.FirstError.Description : string.Empty); + + var shapeA = await GetClientShapeAsync(factory, slugA, "parity-web", ct); + var shapeB = await GetClientShapeAsync(factory, slugB, "parity-web", ct); + Assert.Equal(shapeA, shapeB); + } + + /// A realm-independent, order-stable projection of a client's externally + /// meaningful state. App links are normalised to slugs (the ids differ per realm). + private sealed record ClientShape( + string ClientType, + string ConsentType, + string RedirectUris, + string PostLogoutRedirectUris, + string AllowedGrantTypes, + string Permissions, + bool Enabled, + bool RequireConsent, + string AppSlugs); + + private static async Task GetClientShapeAsync( + ColdStartWebApplicationFactory factory, string slug, string clientId, CancellationToken ct) + { + ClientShape shape = null!; + await InTenantAsync(factory, slug, async sp => + { + var oauth = sp.GetRequiredService(); + var client = (await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct)) + .Items.Single(c => c.ClientId == clientId); + + // Resolve the app links to slugs so the comparison is realm-independent. + var session = sp.GetRequiredService(); + var slugs = new List(); + foreach (var appId in client.AppIds) + { + var app = await session.LoadAsync(new ShortGuid(appId).Guid, ct); + if (app is not null) slugs.Add(app.Slug); + } + + shape = new ClientShape( + client.ClientType, + client.ConsentType, + Join(client.RedirectUris), + Join(client.PostLogoutRedirectUris), + Join(client.AllowedGrantTypes), + Join(client.Permissions), + client.Enabled, + client.RequireConsent, + Join(slugs)); + }); + return shape; + } + + private static string Join(IEnumerable values) => string.Join(",", values.OrderBy(v => v, StringComparer.Ordinal)); + + private static RealmManifest BaseManifest(string slug) => new() + { + Realm = new CreateRealmDto + { + Slug = slug, + DisplayName = slug, + Domains = [$"{slug}.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "admin", Email = $"admin@{slug}.test" }, + }, + Apps = + [ + new RealmManifestApp + { + Slug = "parity-app", + DisplayName = "Parity App", + Permissions = [new RealmManifestPermission("parity", "read")], + }, + ], + }; + + private static async Task InTenantAsync( + ColdStartWebApplicationFactory factory, string slug, Func body) + { + using var _ = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await body(scope.ServiceProvider); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj b/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj index 12798958..ca68ce4e 100644 --- a/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj +++ b/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj @@ -36,6 +36,7 @@ + @@ -51,8 +52,7 @@ PreserveNewest - + PreserveNewest diff --git a/src/dotnet/Modgud.Provisioning.TestKit/Modgud.Provisioning.TestKit.csproj b/src/dotnet/Modgud.Provisioning.TestKit/Modgud.Provisioning.TestKit.csproj new file mode 100644 index 00000000..5cb88541 --- /dev/null +++ b/src/dotnet/Modgud.Provisioning.TestKit/Modgud.Provisioning.TestKit.csproj @@ -0,0 +1,47 @@ + + + + Modgud.Provisioning.TestKit + + Test-kit for spinning up isolated Modgud realms from a declarative manifest. + A thin client over the control-plane provisioning API (import / apply / + hard-delete): ImportRealmAsync(manifest) returns a disposable realm handle + exposing the authority, client ids and freshly minted client secrets; + disposing it hard-deletes the realm (drops the tenant database). Built for + consumer-app integration tests that need a real, throwaway realm per test. + + + + + + Modgud.Provisioning.TestKit + Cocoar + Cocoar + Copyright © Cocoar + Apache-2.0 + README.md + https://github.com/cocoar-dev/modgud + https://github.com/cocoar-dev/modgud + git + modgud;provisioning;testing;integration-tests;realm;oauth;oidc + true + snupkg + true + true + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/dotnet/Modgud.Provisioning.TestKit/ModgudProvisioningClient.cs b/src/dotnet/Modgud.Provisioning.TestKit/ModgudProvisioningClient.cs new file mode 100644 index 00000000..43ea0a32 --- /dev/null +++ b/src/dotnet/Modgud.Provisioning.TestKit/ModgudProvisioningClient.cs @@ -0,0 +1,115 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Modgud.Provisioning.TestKit; + +/// +/// Thin client over the Modgud control-plane provisioning API. Wraps an +/// the caller has already pointed at a running Modgud instance and +/// authenticated as a control-plane admin (cookie or bearer). The only entry point is +/// , which provisions a fresh realm and hands back a +/// disposable handle. +/// +public sealed class ModgudProvisioningClient +{ + // Server (re)serialises PascalCase and omits null members; case-insensitive read keeps + // us robust to either convention. + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient _http; + + /// An whose + /// is the Modgud instance and which already carries control-plane admin auth. + public ModgudProvisioningClient(HttpClient http) + => _http = http ?? throw new ArgumentNullException(nameof(http)); + + /// + /// Provisions a brand-new realm from (the slug must not + /// already exist) and returns a handle that hard-deletes the realm on dispose. Throws + /// if the server rejects the import. + /// + public async Task ImportRealmAsync( + RealmManifest manifest, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + using var response = await _http.PostAsJsonAsync( + "api/admin/realms/import", manifest, JsonOptions, ct); + var result = await ReadResultOrThrowAsync(response, "import", manifest.Realm.Slug, ct); + return new ProvisionedRealm(this, result); + } + + internal async Task ApplyAsync(string slug, RealmManifest manifest, CancellationToken ct) + { + using var response = await _http.PostAsJsonAsync( + $"api/admin/realms/{slug}/apply", manifest, JsonOptions, ct); + await ReadResultOrThrowAsync(response, "apply", slug, ct); + } + + internal async Task HardDeleteAsync(string slug, CancellationToken ct) + { + using var response = await _http.DeleteAsync($"api/admin/realms/{slug}?hard=true", ct); + if (!response.IsSuccessStatusCode) + await ThrowFromResponseAsync(response, "hard-delete", slug, ct); + } + + private static async Task ReadResultOrThrowAsync( + HttpResponseMessage response, string op, string slug, CancellationToken ct) + { + if (!response.IsSuccessStatusCode) + await ThrowFromResponseAsync(response, op, slug, ct); + + var result = await response.Content.ReadFromJsonAsync(JsonOptions, ct); + return result ?? throw new ModgudProvisioningException( + response.StatusCode, op, slug, code: null, + $"Realm {op} for '{slug}' returned {(int)response.StatusCode} with an empty body."); + } + + private static async Task ThrowFromResponseAsync( + HttpResponseMessage response, string op, string slug, CancellationToken ct) + { + string? code = null; + string? message = null; + var body = await response.Content.ReadAsStringAsync(ct); + try + { + var error = JsonSerializer.Deserialize(body, JsonOptions); + code = error?.Error; + message = error?.Message; + } + catch (JsonException) { /* non-JSON body — fall back to the raw text below */ } + + throw new ModgudProvisioningException(response.StatusCode, op, slug, code, + message ?? $"Realm {op} for '{slug}' failed with {(int)response.StatusCode}: {body}"); + } + + private sealed record ManifestErrorBody(string? Error, string? Message); +} + +/// The successful-provisioning response: the realm's slug + canonical host and the +/// plaintext secrets of any confidential clients (only available at create time). +public sealed record RealmImportResult +{ + public required string Slug { get; init; } + public required string PrimaryDomain { get; init; } + public Dictionary ClientSecrets { get; init; } = []; +} + +/// Thrown when the provisioning API rejects an import / apply / hard-delete. Carries +/// the HTTP status and the server's error (e.g. Realm.AlreadyExists, +/// Realm.NotFound, Manifest.SlugMismatch) when present. +public sealed class ModgudProvisioningException( + HttpStatusCode statusCode, string operation, string slug, string? code, string message) + : Exception(message) +{ + public HttpStatusCode StatusCode { get; } = statusCode; + public string Operation { get; } = operation; + public string Slug { get; } = slug; + public string? Code { get; } = code; +} diff --git a/src/dotnet/Modgud.Provisioning.TestKit/ProvisionedRealm.cs b/src/dotnet/Modgud.Provisioning.TestKit/ProvisionedRealm.cs new file mode 100644 index 00000000..58814a44 --- /dev/null +++ b/src/dotnet/Modgud.Provisioning.TestKit/ProvisionedRealm.cs @@ -0,0 +1,74 @@ +namespace Modgud.Provisioning.TestKit; + +/// +/// A live, isolated realm provisioned by . +/// Exposes everything a consumer-app test needs to point its OAuth/OIDC client at the realm — +/// the , the configured client ids, and their freshly minted secrets. +/// Disposing the handle HARD-deletes the realm (drops the tenant database), so a +/// await using gives each test a throwaway realm with automatic teardown. +/// +public sealed class ProvisionedRealm : IAsyncDisposable +{ + private readonly ModgudProvisioningClient _client; + private bool _deleted; + + internal ProvisionedRealm(ModgudProvisioningClient client, RealmImportResult result) + { + _client = client; + Slug = result.Slug; + PrimaryDomain = result.PrimaryDomain; + ClientSecrets = result.ClientSecrets; + } + + /// The realm's slug — its identity in the control-plane API. + public string Slug { get; } + + /// The realm's canonical public host (one of its domains). Anchors the issuer + /// and the WebAuthn RP id. + public string PrimaryDomain { get; } + + /// The OIDC authority / issuer base URL for this realm + /// (https://{PrimaryDomain}) — feed this to the app-under-test's OIDC handler. + public string Authority => $"https://{PrimaryDomain}"; + + /// Plaintext secrets of the confidential clients created with the realm, + /// keyed by client id. Only available here (the server never returns them again). + public IReadOnlyDictionary ClientSecrets { get; } + + /// The plaintext secret for , or throws if the client + /// was not a confidential client created with this realm. + public string SecretFor(string clientId) + => ClientSecrets.TryGetValue(clientId, out var secret) + ? secret + : throw new KeyNotFoundException( + $"No client secret for '{clientId}' in realm '{Slug}'. Known clients: {string.Join(", ", ClientSecrets.Keys)}."); + + /// Applies to this realm in place (merge/upsert). + /// The manifest's realm slug must match this realm. New confidential-client secrets are + /// NOT surfaced — existing clients keep their secret. + public Task ApplyAsync(RealmManifest manifest, CancellationToken ct = default) + { + if (!string.Equals(manifest.Realm.Slug, Slug, StringComparison.Ordinal)) + throw new ArgumentException( + $"Manifest realm slug '{manifest.Realm.Slug}' does not match this realm '{Slug}'.", nameof(manifest)); + return _client.ApplyAsync(Slug, manifest, ct); + } + + /// Hard-deletes the realm (drops the tenant database). Idempotent — a second call + /// is a no-op. Called automatically by . + public async Task DeleteAsync(CancellationToken ct = default) + { + if (_deleted) return; + await _client.HardDeleteAsync(Slug, ct); + _deleted = true; + } + + /// Tears the realm down via . Deliberately swallows + /// teardown failures so a cleanup error can't mask the actual test result — call + /// explicitly when you want to assert the teardown. + public async ValueTask DisposeAsync() + { + try { await DeleteAsync(); } + catch (ModgudProvisioningException) { /* best-effort teardown */ } + } +} diff --git a/src/dotnet/Modgud.Provisioning.TestKit/README.md b/src/dotnet/Modgud.Provisioning.TestKit/README.md new file mode 100644 index 00000000..b4290313 --- /dev/null +++ b/src/dotnet/Modgud.Provisioning.TestKit/README.md @@ -0,0 +1,54 @@ +# Modgud.Provisioning.TestKit + +Spin up isolated [Modgud](https://github.com/cocoar-dev/modgud) realms from a declarative +manifest in your integration tests. A thin client over Modgud's control-plane provisioning +API (`import` / `apply` / hard-delete) that gives each test a real, throwaway realm with +automatic teardown. + +## Usage + +Point an `HttpClient` at a running Modgud instance, authenticated as a control-plane admin +(cookie or bearer), then: + +```csharp +var client = new ModgudProvisioningClient(httpClient); + +var manifest = new RealmManifest +{ + Realm = new RealmSpec { Slug = "acme-test", Domains = ["acme-test.localhost"] }, + Apps = [ new RealmManifestApp { Slug = "acme", DisplayName = "Acme", + Permissions = [ new("acme", "read") ] } ], + Clients = [ new RealmManifestClient { + ClientId = "acme-web", ClientType = "confidential", + RedirectUris = ["https://acme-test.localhost/callback"], + Scopes = ["openid"], AllowedGrantTypes = ["authorization_code", "refresh_token"], + Apps = ["acme"] } ], + Users = [ new RealmManifestUser { Email = "alice@acme.test", UserName = "alice", + Password = "Passw0rd!23" } ], +}; + +await using var realm = await client.ImportRealmAsync(manifest); + +// Point the app-under-test at the realm: +var authority = realm.Authority; // https://acme-test.localhost +var clientSecret = realm.SecretFor("acme-web"); + +// In-place updates (merge/upsert): +await realm.ApplyAsync(updatedManifest); + +// Disposing hard-deletes the realm (drops the tenant database). +``` + +Run tests in parallel by giving each a unique slug — every realm is a physically isolated +database. + +## Notes + +- The realm is provisioned through the same canonical operations the Modgud admin UI uses, + so the manifest path and the manual path can't drift. +- Client secrets are returned only at import (`ClientSecrets` / `SecretFor`). Existing + clients keep their secret across `ApplyAsync`. +- Entity-level prune is not performed — entities absent from a manifest applied with + `ApplyAsync` are left untouched. + +Apache-2.0. diff --git a/src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs b/src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs new file mode 100644 index 00000000..dd30ae31 --- /dev/null +++ b/src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs @@ -0,0 +1,143 @@ +using System.Text.Json.Nodes; + +namespace Modgud.Provisioning.TestKit; + +/// +/// Declarative description of a realm's complete configuration, posted to the Modgud +/// control-plane provisioning API. This is the client-side mirror of the server's manifest +/// contract — cross-references use stable KEYS (apps by slug, roles/users by key, +/// permissions by resource:action), never server-generated ids. The JSON shape is +/// what POST /api/admin/realms/import and POST /{slug}/apply bind; the +/// round-trip is exercised end-to-end by the IdP repo's own provisioning tests so the two +/// sides can't silently drift. +/// +public sealed record RealmManifest +{ + /// Realm shell + (for import) the initial-admin placeholder. On apply only + /// is read. + public required RealmSpec Realm { get; init; } + + /// Optional raw realm-settings patch (self-registration, native grants, …). + /// Left as a free-form JSON object so the kit doesn't have to mirror the full settings + /// surface; null = no settings change. + public JsonObject? Settings { get; init; } + + public List Apps { get; init; } = []; + public List Apis { get; init; } = []; + public List Scopes { get; init; } = []; + public List Clients { get; init; } = []; + public List Roles { get; init; } = []; + public List Users { get; init; } = []; + public List Groups { get; init; } = []; +} + +public sealed record RealmSpec +{ + public required string Slug { get; init; } + public string DisplayName { get; init; } = string.Empty; + public string? Description { get; init; } + public string[]? Domains { get; init; } + public string? PrimaryDomain { get; init; } + public InitialAdmin InitialAdmin { get; init; } = new(); +} + +/// Initial-admin placeholder. Required JSON-shape-wise on import (the realm shell +/// reuses the create-realm DTO) but ignored by the manifest flow, which provisions admins +/// directly via + . +public sealed record InitialAdmin +{ + public string UserName { get; init; } = "admin"; + public string Email { get; init; } = "admin@example.test"; + public string? Firstname { get; init; } + public string? Lastname { get; init; } +} + +public sealed record RealmManifestPermission(string Resource, string Action, string? Description = null); + +public sealed record RealmManifestApp +{ + public required string Slug { get; init; } + public required string DisplayName { get; init; } + public string? Description { get; init; } + public List Permissions { get; init; } = []; +} + +public sealed record RealmManifestApi +{ + public required string Name { get; init; } + public string? DisplayName { get; init; } + public string? Description { get; init; } + public string? App { get; init; } + public List Scopes { get; init; } = []; + public List Permissions { get; init; } = []; + public List UserClaims { get; init; } = []; + public bool Enabled { get; init; } = true; + public bool AllowDynamicRegistration { get; init; } +} + +public sealed record RealmManifestScope +{ + public required string Name { get; init; } + public string? DisplayName { get; init; } + public string? Description { get; init; } + public string? App { get; init; } + public List Resources { get; init; } = []; + public List UserClaims { get; init; } = []; + public bool Enabled { get; init; } = true; + public bool Required { get; init; } + public bool Emphasize { get; init; } + public bool ShowInDiscoveryDocument { get; init; } = true; +} + +public sealed record RealmManifestClient +{ + public required string ClientId { get; init; } + public string? DisplayName { get; init; } + public required string ClientType { get; init; } + public string? ClientSecret { get; init; } + public List RedirectUris { get; init; } = []; + public List PostLogoutRedirectUris { get; init; } = []; + public List Scopes { get; init; } = []; + public List AllowedGrantTypes { get; init; } = []; + public List Apps { get; init; } = []; + public List Roles { get; init; } = []; + public string? WebAuthnRpId { get; init; } + public bool Enabled { get; init; } = true; + public bool RequireConsent { get; init; } +} + +public sealed record RealmManifestRole +{ + public string? Key { get; init; } + public required string Name { get; init; } + public string? Description { get; init; } + public string? App { get; init; } + public bool IsRealmAdmin { get; init; } + public List Permissions { get; init; } = []; +} + +public sealed record RealmManifestUser +{ + public string? Key { get; init; } + public string? Firstname { get; init; } + public string? Lastname { get; init; } + public string? Acronym { get; init; } + public required string Email { get; init; } + public string? UserName { get; init; } + public string? Password { get; init; } + public bool EmailConfirmed { get; init; } +} + +public sealed record RealmManifestGroup +{ + public required string Name { get; init; } + public string? Description { get; init; } + public List Members { get; init; } = []; + public List Roles { get; init; } = []; + public string MembershipMode { get; init; } = "Manual"; + public string? MembershipScript { get; init; } + public string? Email { get; init; } + public string EmailMode { get; init; } = "Shared"; + public List? BoundTo { get; init; } + public bool ExternallyDrivable { get; init; } +} diff --git a/src/dotnet/Modgud.slnx b/src/dotnet/Modgud.slnx index db715e02..e976c2d4 100644 --- a/src/dotnet/Modgud.slnx +++ b/src/dotnet/Modgud.slnx @@ -24,4 +24,5 @@ + From 37df3a0685c2bc3b16fe538287c7f36a14c40b8e Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 09:14:59 +0200 Subject: [PATCH 10/21] feat(provisioning): structure-only realm export + set-password on apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the get → edit → "set a password" → apply round-trip the operator workflow needs, without ever exposing stored credentials. Export (GET /api/admin/realms/{slug}/export, control-plane realm:read): - RealmManifestExporter is the inverse of the applier — reads the realm's current state and emits a RealmManifest, reversing cross-references back to keys (app slug, role/user key, resource:action). STRUCTURE-ONLY: never emits client secrets or password hashes (they're one-way; a re-import generates fresh secrets / leaves passwords untouched). Omits entities that can't cleanly re-apply: auto-seeded standard OIDC scopes and system apps, plus service-account-linked clients (the manifest doesn't model service accounts). Settings are not exported yet — re-applying with no Settings leaves them untouched. Set-password on apply: - Extracts the admin set/reset-password logic into the canonical SetUserPasswordHandler (the PUT /api/user/{id}/password endpoint now delegates to it, preserving 200 + the RevokeAllAccessAsync kill-switch). The applier's update path calls it when a manifest carries a Password on an EXISTING user — so you can export (passwordless), drop a password on a user, and apply to make them able to log in. New users already get theirs at create. RealmManifestExportTests: import (passwordless user + confidential client) → export → assert no secret/password/seeded-entity leaks → re-apply unedited (idempotent) → set the user's password in the export → apply → the user now has a password. Plus an export-endpoint HTTP test asserting the client secret is omitted from the response. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmManifestExportTests.cs | 104 +++++++++ .../RealmProvisioningEndpointsTests.cs | 26 +++ .../Provisioning/RealmManifestApplier.cs | 8 + .../Provisioning/RealmManifestExporter.cs | 202 ++++++++++++++++++ .../Features/Admin/RealmsEndpoints.cs | 12 ++ .../Users/Commands/SetUserPasswordHandler.cs | 66 ++++++ .../Features/Users/UsersEndpoints.cs | 57 +---- src/dotnet/Modgud.Api/Program.cs | 5 +- 8 files changed, 433 insertions(+), 47 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs create mode 100644 src/dotnet/Modgud.Api/Features/Users/Commands/SetUserPasswordHandler.cs diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs new file mode 100644 index 00000000..78f9c82d --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Features.Admin.Provisioning; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; +using Modgud.Authentication.Domain; +using Modgud.Infrastructure.Persistence.Tenancy; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Stage 1c (Export): the structure-only export round-trips with apply. Imports a realm +/// with a passwordless user + a confidential client, exports it, asserts no secrets / +/// passwords / seeded entities leak, re-applies the unedited export idempotently, then edits +/// the export to set the user's password and re-applies — proving the +/// export → edit → "set a password" → apply flow. +/// +public class RealmManifestExportTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Export_is_structure_only_and_round_trips_with_apply_and_password_set() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var applier = factory.Services.GetRequiredService(); + var exporter = factory.Services.GetRequiredService(); + + const string slug = "exporttest"; + var manifest = new RealmManifest + { + Realm = new CreateRealmDto + { + Slug = slug, + DisplayName = slug, + Domains = [$"{slug}.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "admin", Email = $"admin@{slug}.test" }, + }, + Apps = + [ + new RealmManifestApp { Slug = "ex-app", DisplayName = "Ex App", + Permissions = [new RealmManifestPermission("ex", "read")] }, + ], + Clients = + [ + new RealmManifestClient + { + ClientId = "ex-web", + ClientType = "confidential", + RedirectUris = ["https://ex.test/cb"], + Scopes = ["openid"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], + Apps = ["ex-app"], + }, + ], + Users = [new RealmManifestUser { Key = "bob", Email = "bob@ex.test", UserName = "bob" }], // passwordless + }; + Assert.False((await applier.ImportNewRealmAsync(manifest, ct)).IsError); + + // ── Export ──────────────────────────────────────────────────────────── + var exported = await exporter.ExportRealmAsync(slug, ct); + Assert.False(exported.IsError, exported.IsError ? exported.FirstError.Description : string.Empty); + var m = exported.Value; + + // Structure-only: no client secret, no user password. + var exClient = Assert.Single(m.Clients, c => c.ClientId == "ex-web"); + Assert.Null(exClient.ClientSecret); + Assert.Contains("openid", exClient.Scopes); + Assert.Contains("ex-app", exClient.Apps); + var exUser = Assert.Single(m.Users, u => u.UserName == "bob"); + Assert.Null(exUser.Password); + + // Seeded entities that can't cleanly re-apply are excluded; the authored app survives. + Assert.Contains(m.Apps, a => a.Slug == "ex-app"); + Assert.DoesNotContain(m.Apps, a => a.Slug == "modgud"); // system app + Assert.DoesNotContain(m.Scopes, s => s.Name == "openid"); // standard scope + + // ── Re-apply the UNEDITED export = idempotent ────────────────────────── + Assert.False((await applier.UpdateRealmAsync(m, ct)).IsError); + + // ── Edit: set bob's password, re-apply ───────────────────────────────── + var withPassword = m with + { + Users = m.Users.Select(u => u.UserName == "bob" ? u with { Password = "Bobsecret1!" } : u).ToList(), + }; + Assert.False((await applier.UpdateRealmAsync(withPassword, ct)).IsError); + + await InTenantAsync(factory, slug, async sp => + { + var userManager = sp.GetRequiredService>(); + var bob = await userManager.FindByNameAsync("bob"); + Assert.NotNull(bob); + Assert.True(await userManager.HasPasswordAsync(bob!), "bob should have a password after apply"); + }); + } + + private static async Task InTenantAsync( + ColdStartWebApplicationFactory factory, string slug, Func body) + { + using var _ = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await body(scope.ServiceProvider); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs index a46538f1..026f273b 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using Marten; using Microsoft.Extensions.DependencyInjection; using Modgud.Api.Features.Admin.Provisioning; @@ -59,6 +60,31 @@ await InTenantAsync(factory, slug, async sp => Assert.Null(await svc.GetRealmBySlugAsync(slug, ct)); } + [Fact] + public async Task Export_endpoint_returns_a_structure_only_manifest() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + + const string slug = "exportep"; + Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync( + "/api/admin/realms/import", BuildManifest(slug, "Ex EP App"), factory.JsonOptions, ct)).StatusCode); + + var resp = await client.GetAsync($"/api/admin/realms/{slug}/export", ct); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + var root = json.RootElement; + Assert.Equal(slug, root.GetProperty("Realm").GetProperty("Slug").GetString()); + + // The confidential client is present but its secret is omitted (structure-only). + var web = root.GetProperty("Clients").EnumerateArray() + .Single(c => c.GetProperty("ClientId").GetString() == "initech-web"); + Assert.False(web.TryGetProperty("ClientSecret", out var secret) && secret.ValueKind != JsonValueKind.Null); + } + [Fact] public async Task Import_rejects_duplicate_slug_with_409() { diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs index 361aa1f8..431ecbb3 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -518,6 +518,7 @@ private async Task> ApplyTenantUpdateAsync( // ── Users (natural key = email or username) ──────────────────────────────── var bus = sp.GetRequiredService(); + var setPassword = sp.GetRequiredService(); foreach (var u in manifest.Users) { var ctx = $"user '{u.Email}'"; @@ -556,6 +557,13 @@ private async Task> ApplyTenantUpdateAsync( sp.GetRequiredService(), ct); EnsureOk(updated, ctx); uid = existing.Id; + + // A manifest password on an EXISTING user IS applied (the profile update + // alone never touches the password) — this is what makes the + // export → edit → "set a password" → apply flow work. New users already + // get their password at create via CreateUserCommand above. + if (!string.IsNullOrWhiteSpace(u.Password)) + EnsureOk(await setPassword.Handle(existing.Id, u.Password, ct), $"{ctx} password"); } if (uid.HasValue) userIds[u.ResolveKey()] = uid.Value; } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs new file mode 100644 index 00000000..59cee08c --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs @@ -0,0 +1,202 @@ +using BuildingBlocks.Helper; +using ErrorOr; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Application.DTOs.OAuth; +using Modgud.Application.DTOs.Realms; +using Modgud.Application.Services; +using Modgud.Authentication.Domain; +using Modgud.Authorization.Apps; +using Modgud.Authorization.Principals; +using Modgud.Authorization.Roles; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; + +namespace Modgud.Api.Features.Admin.Provisioning; + +/// +/// Produces a from a realm's CURRENT state — the inverse of +/// . The export is STRUCTURE-ONLY: it never emits client +/// secrets or user passwords (those are stored as one-way hashes and can't be recovered). +/// Re-applying the export with POST /{slug}/apply is therefore a no-op on credentials +/// (confidential clients keep their secret; users keep their password) — set a fresh password +/// by adding it to a user before re-applying. +/// +/// Cross-references are reversed back to KEYS (app slug, role/user key, +/// resource:action). Entities that can't be cleanly re-applied are omitted: the +/// auto-seeded standard OIDC scopes and system apps, plus service-account-linked clients (the +/// manifest doesn't model service accounts). Realm settings are not exported yet — re-applying +/// with no Settings leaves them untouched. +/// +public sealed class RealmManifestExporter( + IRealmProvisioningService realms, + IServiceScopeFactory scopeFactory) +{ + // OpenIddict's scope-permission prefix; a client's requested scopes are stored as + // "scp:" entries in its permission list. + private const string ScopePrefix = "scp:"; + + public async Task> ExportRealmAsync(string slug, CancellationToken ct = default) + { + var realm = await realms.GetRealmBySlugAsync(slug, ct); + if (realm is null) + return Error.NotFound("Realm.NotFound", $"Realm '{slug}' does not exist."); + + using var _ = TenantContext.Enter(slug); + using var scope = scopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + var session = sp.GetRequiredService(); + var oauth = sp.GetRequiredService(); + + // ── Apps + reverse-resolution maps (these cover ALL apps incl. system, so + // downstream references to a system app still resolve to a slug). ────────── + var apps = await session.Query().Where(a => !a.IsDeleted).ToListAsync(ct); + var appSlugById = apps.ToDictionary(a => a.Id, a => a.Slug); + var permKeyById = new Dictionary(); + foreach (var a in apps) + foreach (var p in a.Permissions) + permKeyById[p.Id] = new RealmManifestPermission(p.Resource, p.Action, p.Description); + + // System apps are auto-seeded — not part of a realm's authored config. + var manifestApps = apps.Where(a => !a.IsSystem).Select(a => new RealmManifestApp + { + Slug = a.Slug, + DisplayName = a.DisplayName, + Description = a.Description, + Permissions = a.Permissions + .Select(p => new RealmManifestPermission(p.Resource, p.Action, p.Description)).ToList(), + }).ToList(); + + // ── APIs / scopes / clients via the admin DTOs (flags already resolved) ────── + var apis = (await oauth.GetApisAsync(new PaginationRequest { PageSize = 1000 }, ct)).Items; + var manifestApis = apis.Select(api => new RealmManifestApi + { + Name = api.Name, + DisplayName = api.DisplayName, + Description = api.Description, + App = SlugOfShort(appSlugById, api.AppId), + Scopes = api.Scopes, + UserClaims = api.UserClaims, + Permissions = PermsOfShort(permKeyById, api.PermissionIds), + Enabled = api.Enabled, + AllowDynamicRegistration = api.AllowDynamicRegistration, + }).ToList(); + + // Standard OIDC scopes are auto-seeded and rejected by the update path — omit them. + var scopes = (await oauth.GetScopesAsync(ct)).Items.Where(s => !s.IsStandard); + var manifestScopes = scopes.Select(s => new RealmManifestScope + { + Name = s.Name, + DisplayName = s.DisplayName, + Description = s.Description, + App = SlugOfShort(appSlugById, s.AppId), + Resources = s.Resources, + UserClaims = s.UserClaims, + Enabled = s.Enabled, + Required = s.Required, + Emphasize = s.Emphasize, + ShowInDiscoveryDocument = s.ShowInDiscoveryDocument, + }).ToList(); + + // Service-account-linked clients are M2M credentials the manifest can't model — skip. + var clients = (await oauth.GetClientsAsync(new PaginationRequest { PageSize = 1000 }, ct)) + .Items.Where(c => c.LinkedServiceAccountId is null); + var manifestClients = clients.Select(c => new RealmManifestClient + { + ClientId = c.ClientId, + DisplayName = c.DisplayName, + ClientType = c.ClientType, + // No ClientSecret — it's a hash; a re-import generates a fresh one. + RedirectUris = c.RedirectUris, + PostLogoutRedirectUris = c.PostLogoutRedirectUris, + Scopes = c.Permissions.Where(p => p.StartsWith(ScopePrefix, StringComparison.Ordinal)) + .Select(p => p[ScopePrefix.Length..]).ToList(), + AllowedGrantTypes = c.AllowedGrantTypes, + Apps = c.AppIds.Select(id => SlugOfShort(appSlugById, id)).Where(s => s is not null).Select(s => s!).ToList(), + Roles = c.Roles, + WebAuthnRpId = c.WebAuthnRpId, + Enabled = c.Enabled, + RequireConsent = c.RequireConsent, + }).ToList(); + + // ── Roles (raw — ids are Guids) ────────────────────────────────────────────── + var roles = await session.Query().Where(r => !r.IsDeleted).ToListAsync(ct); + var roleKeyById = roles.ToDictionary(r => r.Id, r => r.Name); + var manifestRoles = roles.Select(r => new RealmManifestRole + { + Name = r.Name, + Description = r.Description, + App = r.AppId is { } aid && appSlugById.TryGetValue(aid, out var slugOf) ? slugOf : null, + IsRealmAdmin = r.IsRealmAdmin, + Permissions = r.PermissionIds + .Where(permKeyById.ContainsKey).Select(id => permKeyById[id]).ToList(), + }).ToList(); + + // ── Users (raw Person for the human list + ApplicationUser for EmailConfirmed) ─ + var persons = await session.Query().Where(p => !p.IsDeleted).ToListAsync(ct); + var appUsers = (await session.Query().ToListAsync(ct)) + .ToDictionary(u => u.Id, u => u); + var userKeyById = persons.ToDictionary(p => p.Id, p => p.AccountName ?? p.Email ?? p.Id.ToString()); + var manifestUsers = persons.Select(p => new RealmManifestUser + { + Key = p.AccountName ?? p.Email, + Firstname = p.Firstname, + Lastname = p.Lastname, + Acronym = p.Acronym, + Email = p.Email ?? string.Empty, + UserName = p.AccountName, + // No Password — stored as a hash. Add one before re-applying to set it. + EmailConfirmed = appUsers.TryGetValue(p.Id, out var au) && au.EmailConfirmed, + }).ToList(); + + // ── Groups (raw — ids are Guids; resolve members→user keys, roles→role names) ─ + var groups = await session.Query().Where(g => !g.IsDeleted).ToListAsync(ct); + var manifestGroups = groups.Select(g => new RealmManifestGroup + { + Name = g.Name, + Description = g.Description, + Members = g.MemberIds.Where(userKeyById.ContainsKey).Select(id => userKeyById[id]).ToList(), + Roles = g.RoleIds.Where(roleKeyById.ContainsKey).Select(id => roleKeyById[id]).ToList(), + MembershipMode = g.MembershipMode.ToString(), + MembershipScript = g.MembershipScript, + Email = g.Email, + EmailMode = g.EmailMode.ToString(), + BoundTo = g.BoundTo.Count == 0 ? null : g.BoundTo, + ExternallyDrivable = g.ExternallyDrivable, + }).ToList(); + + return new RealmManifest + { + Realm = new CreateRealmDto + { + Slug = realm.Slug, + DisplayName = realm.DisplayName, + Description = realm.Description, + Domains = realm.Domains, + PrimaryDomain = realm.PrimaryDomain, + // InitialAdmin is meaningless for an existing realm; left default (ignored on apply). + }, + Apps = manifestApps, + Apis = manifestApis, + Scopes = manifestScopes, + Clients = manifestClients, + Roles = manifestRoles, + Users = manifestUsers, + Groups = manifestGroups, + }; + } + + private static string? SlugOfShort(IReadOnlyDictionary appSlugById, string? shortGuid) + => !string.IsNullOrEmpty(shortGuid) && ShortGuid.TryParse(shortGuid, out Guid id) + && appSlugById.TryGetValue(id, out var slug) ? slug : null; + + private static List PermsOfShort( + IReadOnlyDictionary permKeyById, IEnumerable shortGuids) + { + var result = new List(); + foreach (var s in shortGuids) + if (ShortGuid.TryParse(s, out Guid id) && permKeyById.TryGetValue(id, out var perm)) + result.Add(perm); + return result; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs index ede85631..ba3114c9 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs @@ -266,6 +266,18 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, .WithName("Realms_Apply") .RequiresPermission("realm:write", AppSlugs.ControlPlane); + // Export a realm's current config as a manifest (structure-only — never secrets or + // password hashes). Round-trips with /apply: GET, edit (e.g. add a user password), + // POST back to /{slug}/apply. + group.MapGet("{slug}/export", async ( + string slug, RealmManifestExporter exporter, CancellationToken ct) => + { + var result = await exporter.ExportRealmAsync(slug, ct); + return result.IsError ? ManifestError(result.Errors) : Results.Ok(result.Value); + }) + .WithName("Realms_Export") + .RequiresPermission("realm:read", AppSlugs.ControlPlane); + // Transfer the control-plane role to {slug}. POST to the realm that // should BECOME the control plane, from the current control-plane host // (the group's RequireControlPlaneFilter enforces the latter). After diff --git a/src/dotnet/Modgud.Api/Features/Users/Commands/SetUserPasswordHandler.cs b/src/dotnet/Modgud.Api/Features/Users/Commands/SetUserPasswordHandler.cs new file mode 100644 index 00000000..e2501572 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Users/Commands/SetUserPasswordHandler.cs @@ -0,0 +1,66 @@ +using ErrorOr; +using Marten; +using Microsoft.AspNetCore.Identity; +using Modgud.Authentication.Domain; +using Modgud.Authentication.Events; +using Modgud.Authentication.Sessions; +using Modgud.Authorization.Principals; + +namespace Modgud.Api.Features.Users.Commands; + +/// +/// The single canonical path for an admin setting/resetting a user's password, shared by +/// UsersEndpoints (PUT /api/user/{id}/password) and the realm-provisioning applier +/// so the manual path and the manifest path can't diverge. Mirrors the legacy inline +/// endpoint exactly: (re)set the Identity password, emit , +/// then revoke the target's live access (audit remediation #2 — a reset must cut OAuth +/// tokens + device sessions, not just rotate the stamp). The injected +/// is tenant-scoped, so this lands in the active realm. +/// +public sealed class SetUserPasswordHandler( + IDocumentSession session, + UserManager userManager, + IUserAccessRevoker accessRevoker) +{ + public async Task> Handle(Guid userId, string password, CancellationToken ct = default) + { + var person = await session.LoadAsync(userId, ct); + if (person is null || person.IsDeleted) + return Error.NotFound("User.NotFound", "User not found"); + + var appUser = await userManager.FindByIdAsync(userId.ToString()); + if (appUser is null) + { + // No ApplicationUser yet (e.g. a passwordless user) — create it with the password. + appUser = new ApplicationUser(person.AccountName ?? person.Acronym ?? person.Id.ToString(), person.Email) + { + Id = person.Id, + Firstname = person.Firstname, + Lastname = person.Lastname, + Acronym = person.Acronym, + IsActive = person.IsActive, + }; + var createResult = await userManager.CreateAsync(appUser, password); + if (!createResult.Succeeded) + return Error.Validation("User.PasswordError", + string.Join("; ", createResult.Errors.Select(e => e.Description))); + } + else + { + await userManager.RemovePasswordAsync(appUser); + var addResult = await userManager.AddPasswordAsync(appUser, password); + if (!addResult.Succeeded) + return Error.Validation("User.PasswordError", + string.Join("; ", addResult.Errors.Select(e => e.Description))); + } + + session.Events.Append(userId, new UserPasswordChangedEvent(userId, null)); + await session.SaveChangesAsync(ct); + + // A password reset is an incident-response lever — kill OAuth tokens + device + // sessions, not just rotate the stamp. No ct: a kill switch must run to completion. + await accessRevoker.RevokeAllAccessAsync(userId, AccessRevocationReason.ForceSignOut); + + return Result.Success; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs b/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs index 1080c625..ada60f67 100644 --- a/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs @@ -211,53 +211,18 @@ public static WebApplication MapUsersEndpoints(this WebApplication application, .WithName("V2_User_Restore") .RequiresPermission("user:write"); - // Set/Reset password for a user - userGroup.MapPut("{id}/password", async (ShortGuid id, SetPasswordDto dto, IDocumentSession session, UserManager userManager, IUserAccessRevoker accessRevoker) => + // Set/Reset password for a user — delegates to the shared canonical + // SetUserPasswordHandler (the path the realm-provisioning applier also uses). + userGroup.MapPut("{id}/password", async (ShortGuid id, SetPasswordDto dto, SetUserPasswordHandler setPassword, CancellationToken ct) => { - var person = await session.LoadAsync(id.Guid); - if (person is null || person.IsDeleted) - return Results.NotFound(new { Message = "User not found" }); - - var appUser = await userManager.FindByIdAsync(id.Guid.ToString()); - if (appUser is null) - { - // Create ApplicationUser if it doesn't exist yet - appUser = new ApplicationUser(person.AccountName ?? person.Acronym ?? person.Id.ToString(), person.Email) - { - Id = person.Id, - Firstname = person.Firstname, - Lastname = person.Lastname, - Acronym = person.Acronym, - IsActive = person.IsActive - }; - var createResult = await userManager.CreateAsync(appUser, dto.Password); - if (!createResult.Succeeded) - return Results.Problem( - statusCode: StatusCodes.Status400BadRequest, - title: "Password error", - detail: string.Join("; ", createResult.Errors.Select(e => e.Description))); - } - else - { - await userManager.RemovePasswordAsync(appUser); - var addResult = await userManager.AddPasswordAsync(appUser, dto.Password); - if (!addResult.Succeeded) - return Results.Problem( - statusCode: StatusCodes.Status400BadRequest, - title: "Password error", - detail: string.Join("; ", addResult.Errors.Select(e => e.Description))); - } - - session.Events.Append(id.Guid, new UserPasswordChangedEvent(id.Guid, null)); - await session.SaveChangesAsync(); - - // Audit remediation #2: the incident-response lever ("user compromised, - // reset their password"). The reset rotates the Identity stamp but never - // revoked OAuth tokens / device-session rows — kill them too. Mirrors the - // sibling /active endpoint, which already injects the revoker. - await accessRevoker.RevokeAllAccessAsync(id.Guid, AccessRevocationReason.ForceSignOut); - - return Results.Ok(new { Message = "Password set successfully" }); + var result = await setPassword.Handle(id.Guid, dto.Password, ct); + if (!result.IsError) return Results.Ok(new { Message = "Password set successfully" }); + + var error = result.FirstError; + return error.Type == ErrorOr.ErrorType.NotFound + ? Results.NotFound(new { Message = "User not found" }) + : Results.Problem(statusCode: StatusCodes.Status400BadRequest, + title: "Password error", detail: error.Description); }) .WithName("V2_User_SetPassword") .RequiresPermission("user:write"); diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index d87e2c8e..bab8cfa9 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -553,13 +553,16 @@ // inside AddInfrastructure. Only keep app-specific wiring here. builder.Services.AddScoped(); - // Shared canonical App + Role create paths (admin endpoints + provisioning applier). + // Shared canonical App + Role create paths + admin set-password (admin endpoints + + // provisioning applier). builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // Declarative realm provisioning — applies a RealmManifest in-process by reusing // the canonical admin operations (the engine behind import/apply/export). builder.Services.AddScoped(); + builder.Services.AddScoped(); // C16: Demo-seed runs as an API client now — see scripts/seed-demo.mjs. // No backend service, no DI registration, no PROD-01 bracket needed: From 9df7a0133f75f2b149e138397ec82b609ec20515 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 09:32:01 +0200 Subject: [PATCH 11/21] feat(provisioning): export realm settings (round-trip the full config) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends RealmManifestExporter to emit manifest.Settings — all nine realm-settings sections (self-registration, registration-fields, native grants, branding, auth rate limits, deletion, audit, DCR, CIMD) reverse-mapped from the read shape to the patch shape at their current values, so an export shows the full config and you can decide what to change. The write-only captcha secret is intentionally omitted (only a CaptchaSecretSet flag is readable, never the plaintext); re-applying leaves the stored secret untouched. Import/apply already consume manifest.Settings, so settings now round-trip end-to-end. RealmManifestExportTests: asserts Settings is exported (default RegistrationFields value, captcha secret null), then edits RegistrationFields.Username to Required, applies, and re-exports to confirm it round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmManifestExportTests.cs | 18 ++++ .../Provisioning/RealmManifestExporter.cs | 91 ++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs index 78f9c82d..e4c3701d 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs @@ -3,6 +3,7 @@ using Modgud.Api.Features.Admin.Provisioning; using Modgud.Api.Tests.Infrastructure; using Modgud.Application.DTOs.Realms; +using Modgud.Application.DTOs.RealmSettings; using Modgud.Authentication.Domain; using Modgud.Infrastructure.Persistence.Tenancy; @@ -75,9 +76,26 @@ public async Task Export_is_structure_only_and_round_trips_with_apply_and_passwo Assert.DoesNotContain(m.Apps, a => a.Slug == "modgud"); // system app Assert.DoesNotContain(m.Scopes, s => s.Name == "openid"); // standard scope + // Settings ARE exported (all sections, current values) so you can see what to change. + Assert.NotNull(m.Settings); + Assert.Equal("Optional", m.Settings!.RegistrationFields!.Username); // shipped default + Assert.Null(m.Settings.SelfRegistration!.CaptchaSecret); // write-only — never exported + // ── Re-apply the UNEDITED export = idempotent ────────────────────────── Assert.False((await applier.UpdateRealmAsync(m, ct)).IsError); + // ── Edit a setting and re-apply → it round-trips ─────────────────────── + var withSetting = m with + { + Settings = new UpdateRealmSettingsDto + { + RegistrationFields = new UpdateRegistrationFieldsSettingsDto { Username = "Required" }, + }, + }; + Assert.False((await applier.UpdateRealmAsync(withSetting, ct)).IsError); + var reexport = await exporter.ExportRealmAsync(slug, ct); + Assert.Equal("Required", reexport.Value.Settings!.RegistrationFields!.Username); + // ── Edit: set bob's password, re-apply ───────────────────────────────── var withPassword = m with { diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs index 59cee08c..ab73724c 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs @@ -4,8 +4,10 @@ using Microsoft.Extensions.DependencyInjection; using Modgud.Application.DTOs.OAuth; using Modgud.Application.DTOs.Realms; +using Modgud.Application.DTOs.RealmSettings; using Modgud.Application.Services; using Modgud.Authentication.Domain; +using Modgud.Authentication.RealmSettings; using Modgud.Authorization.Apps; using Modgud.Authorization.Principals; using Modgud.Authorization.Roles; @@ -25,8 +27,9 @@ namespace Modgud.Api.Features.Admin.Provisioning; /// Cross-references are reversed back to KEYS (app slug, role/user key, /// resource:action). Entities that can't be cleanly re-applied are omitted: the /// auto-seeded standard OIDC scopes and system apps, plus service-account-linked clients (the -/// manifest doesn't model service accounts). Realm settings are not exported yet — re-applying -/// with no Settings leaves them untouched. +/// manifest doesn't model service accounts). Realm settings ARE exported (all sections, current +/// values) EXCEPT the write-only captcha secret (a CaptchaSecretSet flag, never the +/// plaintext) — re-applying leaves that untouched. /// public sealed class RealmManifestExporter( IRealmProvisioningService realms, @@ -48,6 +51,9 @@ public async Task> ExportRealmAsync(string slug, Cancella var session = sp.GetRequiredService(); var oauth = sp.GetRequiredService(); + // Realm settings (all sections, current values) reverse-mapped read→patch shape. + var settings = MapSettings(await sp.GetRequiredService().GetDtoAsync(ct)); + // ── Apps + reverse-resolution maps (these cover ALL apps incl. system, so // downstream references to a system app still resolve to a slug). ────────── var apps = await session.Query().Where(a => !a.IsDeleted).ToListAsync(ct); @@ -176,6 +182,7 @@ public async Task> ExportRealmAsync(string slug, Cancella PrimaryDomain = realm.PrimaryDomain, // InitialAdmin is meaningless for an existing realm; left default (ignored on apply). }, + Settings = settings, Apps = manifestApps, Apis = manifestApis, Scopes = manifestScopes, @@ -186,6 +193,86 @@ public async Task> ExportRealmAsync(string slug, Cancella }; } + /// + /// Reverse-maps the realm-settings read shape to the patch shape the manifest carries — + /// every section emitted with its current effective values so the export shows the full + /// config. The write-only captcha secret is intentionally left null (no plaintext to read); + /// re-applying leaves the stored secret untouched. + /// + private static UpdateRealmSettingsDto MapSettings(RealmSettingsDto s) => new() + { + SelfRegistration = new UpdateSelfRegistrationDto + { + Enabled = s.SelfRegistration.Enabled, + RequireEmailVerification = s.SelfRegistration.RequireEmailVerification, + AllowedEmailDomains = s.SelfRegistration.AllowedEmailDomains, + RequireAdminApproval = s.SelfRegistration.RequireAdminApproval, + DefaultGroupIds = s.SelfRegistration.DefaultGroupIds, + TermsOfServiceUrl = s.SelfRegistration.TermsOfServiceUrl, + PrivacyPolicyUrl = s.SelfRegistration.PrivacyPolicyUrl, + CaptchaEnabled = s.SelfRegistration.CaptchaEnabled, + CaptchaSiteKey = s.SelfRegistration.CaptchaSiteKey, + // CaptchaSecret is write-only (only a CaptchaSecretSet flag is readable) — leave null. + }, + Dcr = new UpdateDcrSettingsDto + { + Enabled = s.Dcr.Enabled, + AccessTokenLifetimeMinutes = s.Dcr.AccessTokenLifetimeMinutes, + RefreshTokenLifetimeDays = s.Dcr.RefreshTokenLifetimeDays, + GcTtlDays = s.Dcr.GcTtlDays, + PerIpRateLimitPerHour = s.Dcr.PerIpRateLimitPerHour, + PerRealmRateLimitPerDay = s.Dcr.PerRealmRateLimitPerDay, + ReservedNames = s.Dcr.ReservedNames, + }, + Cimd = new UpdateCimdSettingsDto + { + Enabled = s.Cimd.Enabled, + AccessTokenLifetimeMinutes = s.Cimd.AccessTokenLifetimeMinutes, + RefreshTokenLifetimeDays = s.Cimd.RefreshTokenLifetimeDays, + }, + NativeGrants = new UpdateNativeGrantSettingsDto + { + Enabled = s.NativeGrants.Enabled, + AccessTokenLifetimeMinutes = s.NativeGrants.AccessTokenLifetimeMinutes, + RefreshTokenLifetimeDays = s.NativeGrants.RefreshTokenLifetimeDays, + }, + AuthRateLimits = new UpdateAuthRateLimitsDto + { + // Read + patch share RateLimitRuleDto, so the rules copy across directly. + NativeOtp = s.AuthRateLimits.NativeOtp, + MagicLink = s.AuthRateLimits.MagicLink, + PasswordReset = s.AuthRateLimits.PasswordReset, + EmailOtp = s.AuthRateLimits.EmailOtp, + EmailVerification = s.AuthRateLimits.EmailVerification, + PasskeyBegin = s.AuthRateLimits.PasskeyBegin, + Bootstrap = s.AuthRateLimits.Bootstrap, + }, + Branding = new UpdateBrandingSettingsDto + { + ProductName = s.Branding.ProductName, + LogoAssetId = s.Branding.LogoAssetId, + FaviconAssetId = s.Branding.FaviconAssetId, + PrimaryColor = s.Branding.PrimaryColor, + }, + RegistrationFields = new UpdateRegistrationFieldsSettingsDto + { + Username = s.RegistrationFields.Username, + Firstname = s.RegistrationFields.Firstname, + Lastname = s.RegistrationFields.Lastname, + }, + Deletion = new UpdateDeletionSettingsDto + { + GraceDays = s.Deletion.GraceDays, + ReminderLeadDays = s.Deletion.ReminderLeadDays, + AdminRetentionDays = s.Deletion.AdminRetentionDays, + AutoPurgeEnabled = s.Deletion.AutoPurgeEnabled, + }, + Audit = new UpdateAuditSettingsDto + { + VisibilityWindowDays = s.Audit.VisibilityWindowDays, + }, + }; + private static string? SlugOfShort(IReadOnlyDictionary appSlugById, string? shortGuid) => !string.IsNullOrEmpty(shortGuid) && ShortGuid.TryParse(shortGuid, out Guid id) && appSlugById.TryGetValue(id, out var slug) ? slug : null; From 0246ea07a61ef3ab7fa0bb2cefd8d4b4f02439ce Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 10:33:38 +0200 Subject: [PATCH 12/21] feat(provisioning): nullable OAuth bools so apply patches surgically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the OAuth-entity (api/scope/client) bool flags nullable in the manifest so a partial apply only changes what's present: an omitted bool is "no change" on update and the shipped default on create (Enabled/ShowInDiscoveryDocument default true, the rest false). Previously a manifest bool always carried its default, so sending a partial entity to change one field could silently flip e.g. a disabled client back on. This is the JSON-wire form of Optional: for value types bool? is identical, and the manifest is bound directly as the HTTP body — matching how the codebase's own partial-update endpoint (ProfileEndpoints) keeps its wire DTO nullable and uses Optional only for internal representations (binding Optional from the body would force AddOptionalAware onto the global resolver). Strings already patch via null=no-change and lists via empty=no-change; only the always-applied bools needed fixing. The TestKit's manifest POCO mirrors the nullable bools (wire-compatible). Applier import and the update-create branch coalesce to defaults; the update path passes the nullable straight into the already-nullable Update DTOs. RealmManifestApplierTests: import a disabled client, apply a partial update that changes the redirect and omits Enabled — the client stays disabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmManifestApplierTests.cs | 63 +++++++++++++++++++ .../Admin/Provisioning/RealmManifest.cs | 25 +++++--- .../Provisioning/RealmManifestApplier.cs | 32 +++++----- .../RealmManifest.cs | 19 +++--- 4 files changed, 107 insertions(+), 32 deletions(-) diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs index 2f3fd350..a3e2ef10 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs @@ -259,6 +259,69 @@ await InTenantAsync(factory, slug, async sp => }); } + [Fact] + public async Task Update_omitting_a_bool_leaves_it_unchanged() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var applier = factory.Services.GetRequiredService(); + + const string slug = "boolpatch"; + // Import a DISABLED confidential client (Enabled explicitly false). + var manifest = new RealmManifest + { + Realm = new CreateRealmDto + { + Slug = slug, + DisplayName = slug, + Domains = [$"{slug}.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "admin", Email = $"admin@{slug}.test" }, + }, + Apps = [new RealmManifestApp { Slug = "bp-app", DisplayName = "BP", Permissions = [new RealmManifestPermission("bp", "read")] }], + Clients = + [ + new RealmManifestClient + { + ClientId = "bp-web", + ClientType = "confidential", + RedirectUris = ["https://bp.test/cb1"], + Scopes = ["openid"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], + Apps = ["bp-app"], + Enabled = false, + }, + ], + }; + Assert.False((await applier.ImportNewRealmAsync(manifest, ct)).IsError); + + // Apply a partial update: change the redirect URI, OMIT Enabled (null = no change). + var patch = new RealmManifest + { + Realm = manifest.Realm, + Clients = + [ + new RealmManifestClient + { + ClientId = "bp-web", + ClientType = "confidential", + RedirectUris = ["https://bp.test/cb2"], + Apps = ["bp-app"], + // Enabled deliberately omitted. + }, + ], + }; + Assert.False((await applier.UpdateRealmAsync(patch, ct)).IsError); + + await InTenantAsync(factory, slug, async sp => + { + var client = (await sp.GetRequiredService() + .GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct)).Items.Single(c => c.ClientId == "bp-web"); + Assert.False(client.Enabled, "the omitted Enabled bool must not flip the disabled client back on"); + Assert.Contains("https://bp.test/cb2", client.RedirectUris); + }); + } + [Fact] public async Task Update_rejects_a_slug_that_does_not_exist() { diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs index 33da6ae3..fe88d8f8 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs @@ -51,8 +51,11 @@ public sealed record RealmManifestApi public List Scopes { get; init; } = []; public List Permissions { get; init; } = []; public List UserClaims { get; init; } = []; - public bool Enabled { get; init; } = true; - public bool AllowDynamicRegistration { get; init; } + + // Bool flags are nullable so an apply can patch surgically: omitted = no change on + // update (and the shipped default on create). Enabled defaults to true on create. + public bool? Enabled { get; init; } + public bool? AllowDynamicRegistration { get; init; } } /// An OAuth scope. is a slug; are API audience names. @@ -64,10 +67,13 @@ public sealed record RealmManifestScope public string? App { get; init; } public List Resources { get; init; } = []; public List UserClaims { get; init; } = []; - public bool Enabled { get; init; } = true; - public bool Required { get; init; } - public bool Emphasize { get; init; } - public bool ShowInDiscoveryDocument { get; init; } = true; + + // Nullable for surgical patching: omitted = no change on update / shipped default on + // create (Enabled + ShowInDiscoveryDocument default true, the rest false). + public bool? Enabled { get; init; } + public bool? Required { get; init; } + public bool? Emphasize { get; init; } + public bool? ShowInDiscoveryDocument { get; init; } } /// An OAuth client. are slugs; are scope names. @@ -84,8 +90,11 @@ public sealed record RealmManifestClient public List Apps { get; init; } = []; public List Roles { get; init; } = []; public string? WebAuthnRpId { get; init; } - public bool Enabled { get; init; } = true; - public bool RequireConsent { get; init; } + + // Nullable for surgical patching: omitted = no change on update / shipped default on + // create (Enabled defaults true, RequireConsent false). + public bool? Enabled { get; init; } + public bool? RequireConsent { get; init; } public string? AccessTokenType { get; init; } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs index 431ecbb3..78b7f7b5 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -194,12 +194,12 @@ private async Task> ApplyTenantConfigAsync( Name = api.Name, DisplayName = api.DisplayName, Description = api.Description, - Enabled = api.Enabled, + Enabled = api.Enabled ?? true, Scopes = api.Scopes, UserClaims = api.UserClaims, AppId = ResolveAppId(apps, api.App, $"api '{api.Name}'"), PermissionIds = ResolvePermissionIds(apps, api.App, api.Permissions, $"api '{api.Name}'"), - AllowDynamicRegistration = api.AllowDynamicRegistration, + AllowDynamicRegistration = api.AllowDynamicRegistration ?? false, }, ct), $"api '{api.Name}'"); } @@ -213,10 +213,10 @@ private async Task> ApplyTenantConfigAsync( Description = s.Description, Resources = s.Resources, UserClaims = s.UserClaims, - Enabled = s.Enabled, - Required = s.Required, - Emphasize = s.Emphasize, - ShowInDiscoveryDocument = s.ShowInDiscoveryDocument, + Enabled = s.Enabled ?? true, + Required = s.Required ?? false, + Emphasize = s.Emphasize ?? false, + ShowInDiscoveryDocument = s.ShowInDiscoveryDocument ?? true, AppId = ResolveAppId(apps, s.App, $"scope '{s.Name}'"), }, ct), $"scope '{s.Name}'"); } @@ -236,8 +236,8 @@ private async Task> ApplyTenantConfigAsync( AllowedGrantTypes = c.AllowedGrantTypes, Roles = c.Roles, WebAuthnRpId = c.WebAuthnRpId, - Enabled = c.Enabled, - RequireConsent = c.RequireConsent, + Enabled = c.Enabled ?? true, + RequireConsent = c.RequireConsent ?? false, AppIds = c.Apps.Count == 0 ? null : c.Apps.Select(appSlug => ResolveAppId(apps, appSlug, $"client '{c.ClientId}'")!).ToList(), @@ -382,12 +382,12 @@ private async Task> ApplyTenantUpdateAsync( Name = api.Name, DisplayName = api.DisplayName, Description = api.Description, - Enabled = api.Enabled, + Enabled = api.Enabled ?? true, Scopes = api.Scopes, UserClaims = api.UserClaims, AppId = ResolveAppId(apps, api.App, ctx), PermissionIds = ResolvePermissionIds(apps, api.App, api.Permissions, ctx), - AllowDynamicRegistration = api.AllowDynamicRegistration, + AllowDynamicRegistration = api.AllowDynamicRegistration ?? false, }, ct), ctx); } else @@ -421,10 +421,10 @@ private async Task> ApplyTenantUpdateAsync( Description = s.Description, Resources = s.Resources, UserClaims = s.UserClaims, - Enabled = s.Enabled, - Required = s.Required, - Emphasize = s.Emphasize, - ShowInDiscoveryDocument = s.ShowInDiscoveryDocument, + Enabled = s.Enabled ?? true, + Required = s.Required ?? false, + Emphasize = s.Emphasize ?? false, + ShowInDiscoveryDocument = s.ShowInDiscoveryDocument ?? true, AppId = ResolveAppId(apps, s.App, ctx), }, ct), ctx); } @@ -465,8 +465,8 @@ private async Task> ApplyTenantUpdateAsync( AllowedGrantTypes = c.AllowedGrantTypes, Roles = c.Roles, WebAuthnRpId = c.WebAuthnRpId, - Enabled = c.Enabled, - RequireConsent = c.RequireConsent, + Enabled = c.Enabled ?? true, + RequireConsent = c.RequireConsent ?? false, AppIds = c.Apps.Count == 0 ? null : c.Apps.Select(appSlug => ResolveAppId(apps, appSlug, ctx)!).ToList(), }, ct); diff --git a/src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs b/src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs index dd30ae31..4b2ee5ab 100644 --- a/src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs +++ b/src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs @@ -71,8 +71,9 @@ public sealed record RealmManifestApi public List Scopes { get; init; } = []; public List Permissions { get; init; } = []; public List UserClaims { get; init; } = []; - public bool Enabled { get; init; } = true; - public bool AllowDynamicRegistration { get; init; } + // Nullable = surgical patch: omitted = no change on apply / default on create. + public bool? Enabled { get; init; } + public bool? AllowDynamicRegistration { get; init; } } public sealed record RealmManifestScope @@ -83,10 +84,11 @@ public sealed record RealmManifestScope public string? App { get; init; } public List Resources { get; init; } = []; public List UserClaims { get; init; } = []; - public bool Enabled { get; init; } = true; - public bool Required { get; init; } - public bool Emphasize { get; init; } - public bool ShowInDiscoveryDocument { get; init; } = true; + // Nullable = surgical patch: omitted = no change on apply / default on create. + public bool? Enabled { get; init; } + public bool? Required { get; init; } + public bool? Emphasize { get; init; } + public bool? ShowInDiscoveryDocument { get; init; } } public sealed record RealmManifestClient @@ -102,8 +104,9 @@ public sealed record RealmManifestClient public List Apps { get; init; } = []; public List Roles { get; init; } = []; public string? WebAuthnRpId { get; init; } - public bool Enabled { get; init; } = true; - public bool RequireConsent { get; init; } + // Nullable = surgical patch: omitted = no change on apply / default on create. + public bool? Enabled { get; init; } + public bool? RequireConsent { get; init; } } public sealed record RealmManifestRole From 7f01bd672106de23c7f5f10d3bbf3025ba0adc01 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 11:08:36 +0200 Subject: [PATCH 13/21] refactor(apps,roles,groups): extract canonical delete ops for prune reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 (prune) prep: the applier must reuse the SAME delete op the admin API uses, per the single-canonical-write-path invariant. Three delete paths that lived inline in endpoints are now consolidated: - AppAdminService.DeleteAppAsync — system-app guard + the App-level reference block (roles/RSes linked directly or via the catalog). The rich blocker payload rides through Error.Metadata so AppsEndpoints renders the exact App.HasReferences 409 body AppDetails.vue consumes (now test-pinned — it had zero coverage before). - RoleAdminService.DeleteRoleAsync — load + PermissionRoleDeletedEvent. - DeleteGroupCommand/DeleteGroupHandler (Modgud.Authorization.Commands) — mirrors create/update; the endpoint invokes it on the bus. The applier will construct the handler directly on a PLAIN tenant session (GroupDeleted has a durable ReferenceSync forwarder — same tenant-DB-outbox trap as create/update groups). Endpoints delegate and render coded {Error,Message} bodies via their local ToErrorResult (not the shared ToResult, which drops the code / collapses Forbidden to an empty 403 — the §7 lesson). Behaviour-preserving. Tests: App delete-block (unreferenced→204, system→400, role-referenced→409 with the full blocker shape) + role/group delete endpoints (group delete proves Wolverine discovers DeleteGroupHandler at runtime). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AppCatalogDeleteBlockTests.cs | 72 +++++++++++++++++ .../EntityDeleteEndpointTests.cs | 81 +++++++++++++++++++ .../Features/Admin/Apps/AppAdminService.cs | 68 ++++++++++++++++ .../Features/Admin/Apps/AppsEndpoints.cs | 62 +++++--------- .../Features/Groups/GroupEndpoints.cs | 13 ++- .../Features/Roles/RoleAdminService.cs | 17 ++++ .../Features/Roles/RolesEndpoints.cs | 12 ++- .../Commands/DeleteGroupCommand.cs | 32 ++++++++ 8 files changed, 301 insertions(+), 56 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/Authorization/EntityDeleteEndpointTests.cs create mode 100644 src/dotnet/Modgud.Authorization/Commands/DeleteGroupCommand.cs diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/AppCatalogDeleteBlockTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/AppCatalogDeleteBlockTests.cs index d56307a2..715c73b3 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/AppCatalogDeleteBlockTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/AppCatalogDeleteBlockTests.cs @@ -151,8 +151,80 @@ public async Task PUT_renaming_entry_succeeds_even_when_referenced() Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + // ── App DELETE block (the App.HasReferences body AppDetails.vue consumes) ── + + [Fact] + public async Task DELETE_unreferenced_app_succeeds() + { + var (appId, _) = await SeedAppWithCatalogAsync("epsilon", [("policy", "read")]); + + var response = await Client.DeleteAsync( + $"/api/app/{new ShortGuid(appId)}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task DELETE_system_app_returns_400_CannotDeleteSystemApp() + { + var appId = await SeedSystemAppAsync("zeta-system"); + + var response = await Client.DeleteAsync( + $"/api/app/{new ShortGuid(appId)}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + using var json = JsonDocument.Parse(body); + Assert.Equal("App.CannotDeleteSystemApp", json.RootElement.GetProperty("Error").GetString()); + } + + [Fact] + public async Task DELETE_app_referenced_by_role_returns_409_with_role_in_blockers() + { + // A role links directly to the app (role.AppId == app.Id) → deleting the app + // would silently revoke that role's grant, so it's refused with the rich body. + var (appId, perms) = await SeedAppWithCatalogAsync("eta", [("policy", "read"), ("policy", "write")]); + var policyWriteId = perms.First(p => p.Resource == "policy" && p.Action == "write").Id; + await SeedRoleAsync("Eta Editor", appId, [policyWriteId]); + + var response = await Client.DeleteAsync( + $"/api/app/{new ShortGuid(appId)}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + using var json = JsonDocument.Parse(body); + var root = json.RootElement; + Assert.Equal("App.HasReferences", root.GetProperty("Error").GetString()); + + // The role appears both as a direct App reference and as a catalog-entry reference. + var directRoles = root.GetProperty("ReferencedByRoles").EnumerateArray() + .Select(e => e.GetString()).ToList(); + Assert.Contains("Eta Editor", directRoles); + + var catalogRefs = root.GetProperty("CatalogEntryReferences"); + Assert.True(catalogRefs.GetArrayLength() >= 1); + var blocker = catalogRefs[0]; + Assert.Equal("policy:write", blocker.GetProperty("Permission").GetString()); + var blockerRoles = blocker.GetProperty("ReferencedByRoles").EnumerateArray() + .Select(e => e.GetString()).ToList(); + Assert.Contains("Eta Editor", blockerRoles); + } + // ── helpers ────────────────────────────────────────────────────────── + private async Task SeedSystemAppAsync(string slug) + { + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + var id = Guid.NewGuid(); + session.Events.StartStream(id, new AppCreatedEvent( + Id: id, Slug: slug, DisplayName: slug, Description: null, + Permissions: [], IsSystem: true)); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + return id; + } + private async Task<(Guid AppId, List Permissions)> SeedAppWithCatalogAsync( string slug, IReadOnlyList<(string Resource, string Action)> catalog) { diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/EntityDeleteEndpointTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/EntityDeleteEndpointTests.cs new file mode 100644 index 00000000..91816471 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/EntityDeleteEndpointTests.cs @@ -0,0 +1,81 @@ +using System.Net; +using BuildingBlocks.Helper; +using Modgud.Api.Tests.Infrastructure; +using Marten; +using Microsoft.Extensions.DependencyInjection; + +namespace Modgud.Api.Tests.Authorization; + +/// +/// Pins the canonical delete endpoints after they were consolidated onto shared +/// operations (the realm-provisioning prune reuses the same ops). The group delete +/// in particular now routes through DeleteGroupCommand on the Wolverine bus — +/// this proves the handler is actually discovered at runtime, not just compiles. +/// +[Collection(IntegrationTestCollection.Name)] +public class EntityDeleteEndpointTests : IntegrationTestBase +{ + public EntityDeleteEndpointTests(SharedPostgresFixture fixture) : base(fixture) { } + + [Fact] + public async Task DELETE_role_soft_deletes_and_is_gone() + { + var roleId = await SeedRoleAsync($"DeletableRole_{Guid.NewGuid():N}"); + + var del = await Client.DeleteAsync( + $"/api/role/{new ShortGuid(roleId)}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + + var get = await Client.GetAsync( + $"/api/role/{new ShortGuid(roleId)}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, get.StatusCode); + } + + [Fact] + public async Task DELETE_missing_role_returns_404() + { + var del = await Client.DeleteAsync( + $"/api/role/{new ShortGuid(Guid.NewGuid())}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, del.StatusCode); + } + + [Fact] + public async Task DELETE_group_routes_through_the_bus_soft_deletes_and_is_gone() + { + // Confirms Wolverine discovers DeleteGroupHandler (the endpoint InvokeAsync's + // DeleteGroupCommand) — a missing handler would throw 500 here. + var group = await Factory.CreateTestGroupAsync( + name: $"DeletableGroup_{Guid.NewGuid():N}", memberIds: [], roleIds: []); + + var del = await Client.DeleteAsync( + $"/api/group/{new ShortGuid(group.Id)}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + + var get = await Client.GetAsync( + $"/api/group/{new ShortGuid(group.Id)}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, get.StatusCode); + } + + [Fact] + public async Task DELETE_missing_group_returns_404() + { + var del = await Client.DeleteAsync( + $"/api/group/{new ShortGuid(Guid.NewGuid())}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, del.StatusCode); + } + + private async Task SeedRoleAsync(string name) + { + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + // PermissionRoleProjection (inline) writes the doc from the event — direct Store + // conflicts under Marten 8.34+ optimistic concurrency. A realm-admin role grants + // something without needing an App link. + var id = Guid.NewGuid(); + session.Events.StartStream(id, new PermissionRoleCreatedEvent( + id, name, Description: null, AppId: null, IsRealmAdmin: true, PermissionIds: [])); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + return id; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs index bb2fb78d..9885df16 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs @@ -102,6 +102,63 @@ public async Task> UpdateAppAsync(Guid id, UpdateAppDto dto, Cancel return (await session.LoadAsync(id, ct))!; } + /// + /// The single canonical delete path for an , shared by + /// and the realm-provisioning applier's prune. Refuses the + /// system app, and refuses an App that is still referenced — by a role / resource server + /// linked directly to it (PermissionRole.AppId / OAuthApiState.AppId) or by + /// an FK into any of its catalog entries — because deleting it would silently revoke those + /// grants. The structured reference list rides through Metadata["appReferences"] + /// so the admin endpoint can render its rich 409 body (the same one + /// AppDetails.vue consumes). + /// + public async Task> DeleteAppAsync(Guid id, CancellationToken ct = default) + { + var app = await session.LoadAsync(id, ct); + if (app is null || app.IsDeleted) + return Error.NotFound("App.NotFound", "App not found."); + + if (app.IsSystem) + return Error.Validation("App.CannotDeleteSystemApp", + $"The system app '{app.Slug}' cannot be deleted."); + + // App-level delete-block: if any role or resource-server FKs into this App's catalog + // (or directly into the App via PermissionRole.AppId / OAuthApiState.AppId), refuse. + // Same rationale as the per-entry catalog block: deleting an App with live grants is a + // silent revoke. + var allCatalogIds = app.Permissions.Select(p => p.Id).ToList(); + var blockingByPermissionId = allCatalogIds.Count > 0 + ? await FindReferencesAsync(allCatalogIds, session, ct) + : []; + var rolesByApp = await session.Query() + .Where(r => !r.IsDeleted && r.AppId == app.Id) + .Select(r => r.Name) + .ToListAsync(ct); + var apisByApp = await session.Query() + .Where(a => !a.IsDeleted && a.AppId == app.Id) + .Select(a => a.Name) + .ToListAsync(ct); + + if (blockingByPermissionId.Count > 0 || rolesByApp.Count > 0 || apisByApp.Count > 0) + { + var catalogBlockers = blockingByPermissionId.Select(b => new AppCatalogBlocker( + new BuildingBlocks.Helper.ShortGuid(b.PermissionId).ToString(), + app.Permissions.First(p => p.Id == b.PermissionId).ToPermissionString(), + b.RoleNames, + b.OAuthApiNames)).ToList(); + return Error.Conflict("App.HasReferences", + "Cannot delete an App that's still referenced. Detach roles and resource servers first.", + new Dictionary + { + ["appReferences"] = new AppReferenceBlockers(rolesByApp.ToList(), apisByApp.ToList(), catalogBlockers), + }); + } + + session.Events.Append(id, new AppDeletedEvent(id)); + await session.SaveChangesAsync(ct); + return Result.Success; + } + /// /// Validates and normalises the permission catalog off a create / update payload: /// parses incoming ids (ShortGuid → Guid, minting a fresh one when absent), dedupes @@ -204,3 +261,14 @@ public sealed record AppCatalogBlocker( string Permission, List ReferencedByRoles, List ReferencedByResourceServers); + +/// +/// The rich blocker shape surfaced in the App.HasReferences 409 body when a delete is +/// refused — the roles / resource servers linked directly to the App plus the per-catalog-entry +/// references. Carried through so can +/// render it verbatim for AppDetails.vue's delete-block panel. +/// +public sealed record AppReferenceBlockers( + List ReferencedByRoles, + List ReferencedByResourceServers, + List CatalogEntryReferences); diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs index 031f1efc..c6fb6c63 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs @@ -4,9 +4,6 @@ using Modgud.Application.Services; using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; -using Modgud.Authorization.Events; -using Modgud.Authorization.Roles; -using Modgud.Domain.OAuth.Apis; using Marten; namespace Modgud.Api.Features.Admin.Apps; @@ -120,54 +117,37 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s .WithName("V2_App_Update") .RequiresPermission("app:write"); - appGroup.MapDelete("{id}", async (ShortGuid id, IDocumentSession session) => + // Delete delegates to the shared AppAdminService — the same canonical path the + // realm-provisioning prune calls. The App-level reference block carries its rich + // blocker list through the error metadata; render the exact 409 body AppDetails.vue + // consumes. + appGroup.MapDelete("{id}", async (ShortGuid id, AppAdminService appAdmin, CancellationToken ct) => { - var app = await session.LoadAsync(id.Guid); - if (app is null || app.IsDeleted) return Results.NotFound(); + var result = await appAdmin.DeleteAppAsync(id.Guid, ct); + if (!result.IsError) return Results.NoContent(); - if (app.IsSystem) - return Results.BadRequest(new { Error = "App.CannotDeleteSystemApp", - Message = $"The system app '{app.Slug}' cannot be deleted." }); - - // App-level delete-block: if any role or resource-server FKs - // into this App's catalog (or directly into the App via - // PermissionRole.AppId / OAuthApiState.AppId), refuse. Same - // rationale as the per-entry block: deleting an App with live - // grants is a silent revoke. - var allCatalogIds = app.Permissions.Select(p => p.Id).ToList(); - var blockingByPermissionId = allCatalogIds.Count > 0 - ? await AppAdminService.FindReferencesAsync(allCatalogIds, session) - : []; - var rolesByApp = await session.Query() - .Where(r => !r.IsDeleted && r.AppId == app.Id) - .Select(r => r.Name) - .ToListAsync(); - var apisByApp = await session.Query() - .Where(a => !a.IsDeleted && a.AppId == app.Id) - .Select(a => a.Name) - .ToListAsync(); - - if (blockingByPermissionId.Count > 0 || rolesByApp.Count > 0 || apisByApp.Count > 0) + var error = result.FirstError; + if (error.Code == "App.HasReferences" + && error.Metadata?.TryGetValue("appReferences", out var raw) == true + && raw is AppReferenceBlockers refs) { return Results.Conflict(new { - Error = "App.HasReferences", - Message = "Cannot delete an App that's still referenced. Detach roles and resource servers first.", - ReferencedByRoles = rolesByApp, - ReferencedByResourceServers = apisByApp, - CatalogEntryReferences = blockingByPermissionId.Select(b => new + Error = error.Code, + Message = error.Description, + ReferencedByRoles = refs.ReferencedByRoles, + ReferencedByResourceServers = refs.ReferencedByResourceServers, + CatalogEntryReferences = refs.CatalogEntryReferences.Select(b => new { - PermissionId = new ShortGuid(b.PermissionId).ToString(), - Permission = app.Permissions.First(p => p.Id == b.PermissionId).ToPermissionString(), - ReferencedByRoles = b.RoleNames, - ReferencedByResourceServers = b.OAuthApiNames, + b.PermissionId, + b.Permission, + b.ReferencedByRoles, + b.ReferencedByResourceServers, }), }); } - session.Events.Append(id.Guid, new AppDeletedEvent(id.Guid)); - await session.SaveChangesAsync(); - return Results.NoContent(); + return ToErrorResult(error); }) .WithName("V2_App_Delete") .RequiresPermission("app:write"); diff --git a/src/dotnet/Modgud.Api/Features/Groups/GroupEndpoints.cs b/src/dotnet/Modgud.Api/Features/Groups/GroupEndpoints.cs index b043869f..be09408d 100644 --- a/src/dotnet/Modgud.Api/Features/Groups/GroupEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Groups/GroupEndpoints.cs @@ -3,7 +3,6 @@ using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Commands; -using Modgud.Authorization.Events; using Modgud.Authorization.Principals; using Modgud.Authorization.Services; using ErrorOr; @@ -184,14 +183,12 @@ object MapPrincipal(Principal p, string? viaId = null, string? viaName = null) .WithName("V2_Group_Update") .RequiresPermission("authorization-group:write"); - groupGroup.MapDelete("{id}", async (ShortGuid id, IDocumentSession session) => + // Delete delegates to the shared DeleteGroupCommand — the same canonical path the + // realm-provisioning prune calls (mirrors create/update; no longer endpoint-inline). + groupGroup.MapDelete("{id}", async (ShortGuid id, IMessageBus bus) => { - var group = await session.LoadAsync(id.Guid); - if (group is null || group.IsDeleted) return Results.NotFound(); - group.IsDeleted = true; - session.Events.Append(id.Guid, new GroupDeletedEvent(id.Guid)); - await session.SaveChangesAsync(); - return Results.NoContent(); + var result = await bus.InvokeAsync>(new DeleteGroupCommand(id.Guid)); + return result.Match(_ => Results.NoContent(), ToErrorResult); }) .WithName("V2_Group_Delete") .RequiresPermission("authorization-group:write"); diff --git a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs index 81b82961..e78d9f4e 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs @@ -74,6 +74,23 @@ public async Task> UpdateRoleAsync( return existing; } + /// + /// The single canonical delete path for an existing , shared + /// by and the realm-provisioning applier's prune. Soft-deletes + /// by emitting PermissionRoleDeletedEvent (the inline projection flips + /// IsDeleted). Idempotent: a missing / already-deleted role returns NotFound. + /// + public async Task> DeleteRoleAsync(Guid id, CancellationToken ct = default) + { + var role = await session.LoadAsync(id, ct); + if (role is null || role.IsDeleted) + return Error.NotFound("Role.NotFound", "Role not found."); + + session.Events.Append(id, new PermissionRoleDeletedEvent(id)); + await session.SaveChangesAsync(ct); + return ErrorOr.Result.Success; + } + /// /// Validates a payload into a (Id minted here): AppId /// resolves to an existing App, every PermissionId resolves to that App's catalog, diff --git a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs index 9ca1ed20..e332b191 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs @@ -4,7 +4,6 @@ using Modgud.Api.Authorization; using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Services; -using Modgud.Authentication.Events; namespace Modgud.Api.Features.Roles; @@ -95,13 +94,12 @@ public static WebApplication MapRolesEndpoints(this WebApplication application, .WithName("V2_Role_Update") .RequiresPermission("permission-role:write"); - roleGroup.MapDelete("{id}", async (ShortGuid id, IDocumentSession session) => + // Delete delegates to the shared RoleAdminService — the same canonical path the + // realm-provisioning prune calls. + roleGroup.MapDelete("{id}", async (ShortGuid id, RoleAdminService roleAdmin, CancellationToken ct) => { - var role = await session.LoadAsync(id.Guid); - if (role is null || role.IsDeleted) return Results.NotFound(); - session.Events.Append(id.Guid, new PermissionRoleDeletedEvent(id.Guid)); - await session.SaveChangesAsync(); - return Results.NoContent(); + var result = await roleAdmin.DeleteRoleAsync(id.Guid, ct); + return result.IsError ? ToErrorResult(result.Errors) : Results.NoContent(); }) .WithName("V2_Role_Delete") .RequiresPermission("permission-role:write"); diff --git a/src/dotnet/Modgud.Authorization/Commands/DeleteGroupCommand.cs b/src/dotnet/Modgud.Authorization/Commands/DeleteGroupCommand.cs new file mode 100644 index 00000000..cacf493e --- /dev/null +++ b/src/dotnet/Modgud.Authorization/Commands/DeleteGroupCommand.cs @@ -0,0 +1,32 @@ +using Modgud.Authorization.Events; +using Modgud.Authorization.Principals; +using ErrorOr; +using Marten; + +namespace Modgud.Authorization.Commands; + +/// +/// Soft-deletes a — the single canonical delete path shared by +/// GroupEndpoints (via the bus) and the realm-provisioning applier's prune (which +/// constructs directly on a PLAIN tenant session, NOT the +/// Wolverine outbox: GroupDeletedEvent has a durable ReferenceSync forwarder +/// that would otherwise write wolverine_*_envelopes a fresh tenant DB lacks — the same +/// trap as create/update groups). Mirrors create/update so delete is no longer endpoint-inline. +/// Idempotent: a missing / already-deleted group returns NotFound. +/// +public record DeleteGroupCommand(Guid Id); + +public class DeleteGroupHandler(IDocumentSession session) +{ + public async Task> Handle(DeleteGroupCommand command, CancellationToken ct) + { + var group = await session.LoadAsync(command.Id, ct); + if (group is null || group.IsDeleted) + return Error.NotFound("Group.NotFound", "Group not found"); + + group.IsDeleted = true; + session.Events.Append(command.Id, new GroupDeletedEvent(command.Id)); + await session.SaveChangesAsync(ct); + return Result.Success; + } +} From ad1ad55bda4c115439dd8a34160cd7a201124531 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 11:26:44 +0200 Subject: [PATCH 14/21] feat(provisioning): prune in the applier + ?prune=true (full-sync apply) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2: UpdateRealm gains an opt-in prune. Without it the additive merge is unchanged; with ?prune=true the apply becomes a full sync (k8s apply --prune) — after the upsert, every entity in the realm but absent from the manifest is deleted via its canonical delete op (the ones consolidated in the previous commit), in reverse-dependency order so a dependent is gone before the app/role it points at: clients → scopes → apis → groups → users → roles → apps. A still- referenced app correctly errors. Lockout + infrastructure protection (the robust superset of "System + last admin" — protect ALL admins so no manifest can lock the realm out): NEVER pruned — the system app, auto-seeded standard scopes, SA-linked clients, any realm-admin role, any user who currently holds realm:admin, and any group that confers realm:admin (else pruning an admin's group silently strips their admin path even though the role + user survive). The protection checks run AFTER the upsert so they see the realm's desired post-merge role graph. Tenant durability (same trap as create/update): user delete via DeleteUsersHandler and group delete via DeleteGroupHandler run on the PLAIN tenant session, not the bus — their events have durable ReferenceSync forwarders that would hit wolverine_*_envelopes a tenant DB lacks. Also fixes a real divergence the prune test surfaced: group CREATE now mirrors the create endpoint's `BoundTo ?? [modgud]` default (CreateGroupHandler itself defaults null → [] = dormant), so a manifest-provisioned admin group actually confers its roles instead of silently granting nothing. GroupMembershipGuards made public so the prune can reuse GroupConfersRealmAdminAsync. Tests (cold-start): prune removes absent client/scope/api/group/user/role/app together (reverse-order) while protecting the system app, standard scopes, and the full realm-admin path (role + admin-conferring group + admin user keeps realm:admin); ?prune=true endpoint wiring prunes an absent client over HTTP. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ColdStart/RealmManifestApplierTests.cs | 136 +++++++++++++++++- .../ColdStart/RealmManifestExportTests.cs | 6 +- .../RealmProvisioningEndpointsTests.cs | 30 ++++ .../Provisioning/RealmManifestApplier.cs | 129 ++++++++++++++++- .../Features/Admin/RealmsEndpoints.cs | 10 +- .../Commands/GroupMembershipGuards.cs | 7 +- 6 files changed, 301 insertions(+), 17 deletions(-) diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs index a3e2ef10..01817def 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs @@ -12,6 +12,7 @@ using Modgud.Domain.OAuth.Scopes; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; +using Modgud.Permissions; using Marten; using Microsoft.Extensions.DependencyInjection; @@ -210,7 +211,7 @@ await InTenantAsync(factory, slug, async sp => }); // ── Apply the v2 manifest: changes every existing entity + adds a new role ── - var updated = await applier.UpdateRealmAsync(BuildGlobexManifest(slug, version: 2), ct); + var updated = await applier.UpdateRealmAsync(BuildGlobexManifest(slug, version: 2), ct: ct); Assert.False(updated.IsError, updated.IsError ? updated.FirstError.Description : string.Empty); // The realm DB was never dropped. @@ -311,7 +312,7 @@ public async Task Update_omitting_a_bool_leaves_it_unchanged() }, ], }; - Assert.False((await applier.UpdateRealmAsync(patch, ct)).IsError); + Assert.False((await applier.UpdateRealmAsync(patch, ct: ct)).IsError); await InTenantAsync(factory, slug, async sp => { @@ -330,12 +331,141 @@ public async Task Update_rejects_a_slug_that_does_not_exist() var ct = TestContext.Current.CancellationToken; var applier = factory.Services.GetRequiredService(); - var result = await applier.UpdateRealmAsync(BuildGlobexManifest("ghost", version: 1), ct); + var result = await applier.UpdateRealmAsync(BuildGlobexManifest("ghost", version: 1), ct: ct); Assert.True(result.IsError); Assert.Equal("Realm.NotFound", result.FirstError.Code); } + [Fact] + public async Task Prune_removes_absent_entities_but_protects_infra_and_admins() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var applier = factory.Services.GetRequiredService(); + + const string slug = "prune"; + + // Import a realm with keep-* + drop-* entities AND a full admin path + // (realm-admin role + user + group). The prune manifest will OMIT every drop-* + // entity AND the whole admin path — drop-* must go, the admin path must survive + // (no lockout). drop-app is referenced by drop-role/drop-api/drop.read/drop-web, + // all dropped too → exercises reverse-dependency-order pruning. + var full = new RealmManifest + { + Realm = new CreateRealmDto + { + Slug = slug, + DisplayName = "Prune", + Domains = [$"{slug}.localhost"], + InitialAdmin = new InitialAdminDto { UserName = "boot", Email = "boot@prune.test" }, + }, + Apps = + [ + new RealmManifestApp { Slug = "keep-app", DisplayName = "Keep", Permissions = [new RealmManifestPermission("keep", "read")] }, + new RealmManifestApp { Slug = "drop-app", DisplayName = "Drop", Permissions = [new RealmManifestPermission("drop", "read")] }, + ], + Apis = + [ + new RealmManifestApi { Name = "keep-api", DisplayName = "Keep API", App = "keep-app" }, + new RealmManifestApi { Name = "drop-api", DisplayName = "Drop API", App = "drop-app" }, + ], + Scopes = + [ + new RealmManifestScope { Name = "keep.read", DisplayName = "Keep", App = "keep-app", Resources = ["keep-api"] }, + new RealmManifestScope { Name = "drop.read", DisplayName = "Drop", App = "drop-app", Resources = ["drop-api"] }, + ], + Clients = + [ + new RealmManifestClient { ClientId = "keep-web", ClientType = "confidential", RedirectUris = ["https://k.test/cb"], Scopes = ["openid"], AllowedGrantTypes = ["authorization_code"], Apps = ["keep-app"] }, + new RealmManifestClient { ClientId = "drop-web", ClientType = "confidential", RedirectUris = ["https://d.test/cb"], Scopes = ["openid"], AllowedGrantTypes = ["authorization_code"], Apps = ["drop-app"] }, + ], + Roles = + [ + new RealmManifestRole { Name = "keep-role", App = "keep-app", Permissions = [new RealmManifestPermission("keep", "read")] }, + new RealmManifestRole { Name = "drop-role", App = "drop-app", Permissions = [new RealmManifestPermission("drop", "read")] }, + new RealmManifestRole { Name = "super-admin", IsRealmAdmin = true }, + ], + Users = + [ + new RealmManifestUser { Key = "keepuser", Email = "keep@prune.test", UserName = "keepuser", Password = "Passw0rd!23" }, + new RealmManifestUser { Key = "dropuser", Email = "drop@prune.test", UserName = "dropuser", Password = "Passw0rd!23" }, + new RealmManifestUser { Key = "adminuser", Email = "admin2@prune.test", UserName = "adminuser", Password = "Passw0rd!23" }, + ], + Groups = + [ + new RealmManifestGroup { Name = "KeepGroup", Members = ["keepuser"], Roles = ["keep-role"] }, + new RealmManifestGroup { Name = "DropGroup", Members = ["dropuser"], Roles = ["drop-role"] }, + new RealmManifestGroup { Name = "AdminGroup", Members = ["adminuser"], Roles = ["super-admin"] }, + ], + }; + var import = await applier.ImportNewRealmAsync(full, ct); + Assert.False(import.IsError, import.IsError ? import.FirstError.Description : string.Empty); + + // The prune manifest keeps only the keep-* entities; everything else is absent. + var keepOnly = new RealmManifest + { + Realm = full.Realm, + Apps = [full.Apps[0]], + Apis = [full.Apis[0]], + Scopes = [full.Scopes[0]], + Clients = [full.Clients[0]], + Roles = [full.Roles[0]], + Users = [full.Users[0]], + Groups = [full.Groups[0]], + }; + + var pruned = await applier.UpdateRealmAsync(keepOnly, prune: true, ct); + Assert.False(pruned.IsError, pruned.IsError ? pruned.FirstError.Description : string.Empty); + + // The realm DB was never dropped. + Assert.NotNull(await factory.Services.GetRequiredService().GetRealmBySlugAsync(slug, ct)); + + await InTenantAsync(factory, slug, async sp => + { + var session = sp.GetRequiredService(); + var perms = sp.GetRequiredService(); + + // ── Absent, non-protected entities are pruned ────────────────────── + Assert.False(await session.Query().AnyAsync(a => !a.IsDeleted && a.Slug == "drop-app", ct), "drop-app pruned"); + Assert.False(await session.Query().AnyAsync(r => !r.IsDeleted && r.Name == "drop-role", ct), "drop-role pruned"); + Assert.False(await session.Query().AnyAsync(x => !x.IsDeleted && x.ClientId == "drop-web", ct), "drop-web pruned"); + Assert.False(await session.Query().AnyAsync(x => !x.IsDeleted && x.Name == "drop.read", ct), "drop.read pruned"); + Assert.False(await session.Query().AnyAsync(x => !x.IsDeleted && x.Name == "drop-api", ct), "drop-api pruned"); + Assert.False(await session.Query().AnyAsync(g => !g.IsDeleted && g.Name == "DropGroup", ct), "DropGroup pruned"); + // User delete is the canonical recycle-bin soft-delete (deactivate + pending), + // so the Person survives but the ApplicationUser is deactivated. + var dropPerson = await session.Query().SingleAsync(p => p.AccountName == "dropuser", ct); + var dropUser = await session.LoadAsync(dropPerson.Id, ct); + Assert.False(dropUser!.IsActive, "dropuser binned (deactivated)"); + + // ── Kept entities survive ────────────────────────────────────────── + Assert.True(await session.Query().AnyAsync(a => !a.IsDeleted && a.Slug == "keep-app", ct), "keep-app kept"); + Assert.True(await session.Query().AnyAsync(r => !r.IsDeleted && r.Name == "keep-role", ct), "keep-role kept"); + Assert.True(await session.Query().AnyAsync(x => !x.IsDeleted && x.ClientId == "keep-web", ct), "keep-web kept"); + Assert.True(await session.Query().AnyAsync(x => !x.IsDeleted && x.Name == "keep.read", ct), "keep.read kept"); + Assert.True(await session.Query().AnyAsync(x => !x.IsDeleted && x.Name == "keep-api", ct), "keep-api kept"); + Assert.True(await session.Query().AnyAsync(g => !g.IsDeleted && g.Name == "KeepGroup", ct), "KeepGroup kept"); + var keepPerson = await session.Query().SingleAsync(p => p.AccountName == "keepuser", ct); + Assert.True((await session.LoadAsync(keepPerson.Id, ct))!.IsActive, "keepuser still active"); + + // ── Lockout protection: the whole admin path survives despite being omitted ── + Assert.True(await session.Query().AnyAsync(r => !r.IsDeleted && r.Name == "super-admin", ct), "realm-admin role protected"); + Assert.True(await session.Query().AnyAsync(g => !g.IsDeleted && g.Name == "AdminGroup", ct), "admin-conferring group protected"); + var adminPerson = await session.Query().SingleAsync(p => !p.IsDeleted && p.AccountName == "adminuser", ct); + Assert.True((await session.LoadAsync(adminPerson.Id, ct))!.IsActive, "admin user not binned"); + Assert.True( + await perms.HasPermissionAsync(adminPerson.Id, AppSlugs.Modgud, PermissionEvaluator.RealmAdminPermission, ct), + "admin user retains realm:admin after prune"); + + // ── Infrastructure protection ────────────────────────────────────── + Assert.True(await session.Query().AnyAsync(a => !a.IsDeleted && a.IsSystem, ct), "system app protected"); + var scopes = (await sp.GetRequiredService().GetScopesAsync(ct)).Items; + Assert.Contains(scopes, s => s.Name == "openid"); // auto-seeded standard scope protected + }); + } + /// /// Builds the Globex manifest. 1 is the import baseline; /// version 2 changes every existing entity (display names, catalog, redirect, role diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs index e4c3701d..2d92ac3b 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs @@ -82,7 +82,7 @@ public async Task Export_is_structure_only_and_round_trips_with_apply_and_passwo Assert.Null(m.Settings.SelfRegistration!.CaptchaSecret); // write-only — never exported // ── Re-apply the UNEDITED export = idempotent ────────────────────────── - Assert.False((await applier.UpdateRealmAsync(m, ct)).IsError); + Assert.False((await applier.UpdateRealmAsync(m, ct: ct)).IsError); // ── Edit a setting and re-apply → it round-trips ─────────────────────── var withSetting = m with @@ -92,7 +92,7 @@ public async Task Export_is_structure_only_and_round_trips_with_apply_and_passwo RegistrationFields = new UpdateRegistrationFieldsSettingsDto { Username = "Required" }, }, }; - Assert.False((await applier.UpdateRealmAsync(withSetting, ct)).IsError); + Assert.False((await applier.UpdateRealmAsync(withSetting, ct: ct)).IsError); var reexport = await exporter.ExportRealmAsync(slug, ct); Assert.Equal("Required", reexport.Value.Settings!.RegistrationFields!.Username); @@ -101,7 +101,7 @@ public async Task Export_is_structure_only_and_round_trips_with_apply_and_passwo { Users = m.Users.Select(u => u.UserName == "bob" ? u with { Password = "Bobsecret1!" } : u).ToList(), }; - Assert.False((await applier.UpdateRealmAsync(withPassword, ct)).IsError); + Assert.False((await applier.UpdateRealmAsync(withPassword, ct: ct)).IsError); await InTenantAsync(factory, slug, async sp => { diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs index 026f273b..59b461bc 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs @@ -132,6 +132,36 @@ public async Task Apply_with_route_slug_not_matching_manifest_returns_400() Assert.Contains("Manifest.SlugMismatch", await resp.Content.ReadAsStringAsync(ct)); } + [Fact] + public async Task Apply_with_prune_true_removes_a_client_absent_from_the_manifest() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + + const string slug = "pruneep"; + Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync( + "/api/admin/realms/import", BuildManifest(slug, "Prune EP"), factory.JsonOptions, ct)).StatusCode); + + // Re-apply with ?prune=true a manifest that drops the client → it must be pruned. + var withoutClient = BuildManifest(slug, "Prune EP") with { Clients = [] }; + var applyResp = await client.PostAsJsonAsync( + $"/api/admin/realms/{slug}/apply?prune=true", withoutClient, factory.JsonOptions, ct); + Assert.Equal(HttpStatusCode.OK, applyResp.StatusCode); + + await InTenantAsync(factory, slug, async sp => + { + var session = sp.GetRequiredService(); + Assert.False( + await session.Query() + .AnyAsync(x => !x.IsDeleted && x.ClientId == "initech-web", ct), + "the client absent from the ?prune=true manifest was pruned"); + // The app is still in the manifest → untouched. + Assert.True(await session.Query().AnyAsync(a => !a.IsDeleted && a.Slug == "initech-app", ct)); + }); + } + private static RealmManifest BuildManifest(string slug, string appDisplayName) => new() { Realm = new CreateRealmDto diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs index 78b7f7b5..ea6b2fca 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -25,6 +25,7 @@ using Modgud.Domain.OAuth.Scopes; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; +using Modgud.Permissions; using Wolverine; namespace Modgud.Api.Features.Admin.Provisioning; @@ -119,9 +120,17 @@ public async Task> ImportNewRealmAsync( /// Unlike import there is no all-or-nothing rollback: each canonical op commits its /// own unit of work, so a mid-apply failure leaves the earlier successful writes in place. /// The upserts are safe to re-apply after fixing the manifest. + /// + /// When is set the merge becomes a full sync (k8s + /// apply --prune): after the upsert, every entity that exists in the realm but is + /// absent from the manifest is deleted via its canonical delete op, in reverse-dependency + /// order. Lockout- and infrastructure-protected entities are NEVER pruned — the system app, + /// auto-seeded standard scopes, service-account-linked clients, and anything conferring + /// realm:admin (a realm-admin role, any user who currently holds realm:admin, and any + /// admin-conferring group). Without the flag the additive merge above is unchanged. /// public async Task> UpdateRealmAsync( - RealmManifest manifest, CancellationToken ct = default) + RealmManifest manifest, bool prune = false, CancellationToken ct = default) { var slug = manifest.Realm.Slug; @@ -132,7 +141,7 @@ public async Task> UpdateRealmAsync( try { - var secrets = await ApplyTenantUpdateAsync(slug, manifest, ct); + var secrets = await ApplyTenantUpdateAsync(slug, manifest, prune, ct); logger.LogInformation( "Updated realm {Slug}: {Apps} apps, {Apis} apis, {Scopes} scopes, {Clients} clients, {Roles} roles, {Users} users, {Groups} groups (in-place merge).", slug, manifest.Apps.Count, manifest.Apis.Count, manifest.Scopes.Count, @@ -297,7 +306,11 @@ private async Task> ApplyTenantConfigAsync( ParseEnum(g.MembershipMode, $"group '{g.Name}' membershipMode"), g.MembershipScript, g.Email, ParseEnum(g.EmailMode, $"group '{g.Name}' emailMode"), - g.BoundTo, g.ExternallyDrivable, CallerIsRealmAdmin: true); + // Mirror the create endpoint's default (GroupEndpoints: dto.BoundTo ?? [Modgud]) + // so a manifest group is bound to the IdP and actually confers its roles — + // CreateGroupHandler itself defaults null to [] (dormant), which would make an + // imported admin group silently grant nothing. + g.BoundTo ?? [AppSlugs.Modgud], g.ExternallyDrivable, CallerIsRealmAdmin: true); EnsureOk(await groupHandler.Handle(cmd, ct), $"group '{g.Name}'"); } } @@ -312,7 +325,7 @@ private async Task> ApplyTenantConfigAsync( /// it doesn't. See for the field-level merge semantics. /// private async Task> ApplyTenantUpdateAsync( - string slug, RealmManifest manifest, CancellationToken ct) + string slug, RealmManifest manifest, bool prune, CancellationToken ct) { var secrets = new Dictionary(StringComparer.Ordinal); var apps = new Dictionary(StringComparer.Ordinal); // slug → App (id + catalog) @@ -599,10 +612,11 @@ private async Task> ApplyTenantUpdateAsync( .FirstOrDefaultAsync(x => x.Name == g.Name && !x.IsDeleted, ct); if (existing is null) { + // Create-branch mirrors the create endpoint's BoundTo default (see import). EnsureOk(await createHandler.Handle(new CreateGroupCommand( g.Name, g.Description, memberIds, groupRoleIds, mode, g.MembershipScript, g.Email, emailMode, - g.BoundTo, g.ExternallyDrivable, CallerIsRealmAdmin: true), ct), ctx); + g.BoundTo ?? [AppSlugs.Modgud], g.ExternallyDrivable, CallerIsRealmAdmin: true), ct), ctx); } else { @@ -614,9 +628,114 @@ private async Task> ApplyTenantUpdateAsync( } } + // ── Prune: full-sync removal of entities absent from the manifest. Runs AFTER the + // upsert so the protection checks see the realm's desired (post-merge) role graph. + if (prune) + await PruneAsync(sp, session, manifest, appAdmin, oauth, roleAdmin, ct); + return secrets; } + /// + /// Deletes every entity that exists in the realm but is absent from the manifest, each via + /// its canonical delete op (the same the admin API uses), in reverse-dependency order so a + /// dependent is gone before the app / role it points at — clients → scopes → apis → groups + /// → users → roles → apps. An app still referenced by a manifest-KEPT role / resource server + /// correctly errors (surfaced via ). + /// + /// NEVER pruned (infrastructure + lockout protection — the robust superset of "System + /// + last admin": protect ALL admins so no manifest can lock the realm out): the system app + /// (IsSystem), auto-seeded standard scopes (StandardScopes.IsStandard), + /// service-account-linked clients (LinkedServiceAccountId), any realm-admin role + /// (IsRealmAdmin), any user who currently holds realm:admin, and any group that + /// confers realm:admin (else pruning an admin's group silently strips their admin path + /// even though the role + user survive). + /// + /// Tenant durability (same trap as create/update): user delete runs through + /// and group delete through + /// on the PLAIN tenant session, NOT the bus — UserDeactivatedEvent / + /// GroupDeletedEvent have durable ReferenceSync forwarders that would write + /// wolverine_*_envelopes a tenant DB lacks. OAuth / app / role deletes go through their + /// services on the same scoped session. + /// + private async Task PruneAsync( + IServiceProvider sp, IDocumentSession session, RealmManifest manifest, + AppAdminService appAdmin, OAuthAdminService oauth, RoleAdminService roleAdmin, + CancellationToken ct) + { + var perms = sp.GetRequiredService(); + + // ── Clients (natural key = ClientId) — keep SA-linked (auto-managed, not modelled). ── + var keepClients = manifest.Clients.Select(c => c.ClientId).ToHashSet(StringComparer.Ordinal); + foreach (var c in await session.Query().Where(x => !x.IsDeleted).ToListAsync(ct)) + { + if (keepClients.Contains(c.ClientId) || c.LinkedServiceAccountId.HasValue) continue; + EnsureOk(await oauth.DeleteClientAsync(c.Id.ToString(), ct), $"prune client '{c.ClientId}'"); + } + + // ── Scopes (natural key = Name) — keep auto-seeded standard scopes. ────────────────── + var keepScopes = manifest.Scopes.Select(s => s.Name).ToHashSet(StringComparer.Ordinal); + foreach (var s in await session.Query().Where(x => !x.IsDeleted).ToListAsync(ct)) + { + if (keepScopes.Contains(s.Name) || StandardScopes.IsStandard(s.Name)) continue; + EnsureOk(await oauth.DeleteScopeAsync(s.Id.ToString(), ct), $"prune scope '{s.Name}'"); + } + + // ── APIs (natural key = Name / aud). ───────────────────────────────────────────────── + var keepApis = manifest.Apis.Select(a => a.Name).ToHashSet(StringComparer.Ordinal); + foreach (var a in await session.Query().Where(x => !x.IsDeleted).ToListAsync(ct)) + { + if (keepApis.Contains(a.Name)) continue; + EnsureOk(await oauth.DeleteApiAsync(a.Id.ToString(), ct), $"prune api '{a.Name}'"); + } + + // ── Groups (natural key = Name) — keep admin-conferring groups (lockout guard). ────── + var keepGroups = manifest.Groups.Select(g => g.Name).ToHashSet(StringComparer.Ordinal); + var groupHandler = new DeleteGroupHandler(session); + foreach (var g in await session.Query().Where(x => !x.IsDeleted).ToListAsync(ct)) + { + if (keepGroups.Contains(g.Name)) continue; + if (await GroupMembershipGuards.GroupConfersRealmAdminAsync(session, perms, g, ct)) continue; + EnsureOk(await groupHandler.Handle(new DeleteGroupCommand(g.Id), ct), $"prune group '{g.Name}'"); + } + + // ── Users (natural key = email / username) — keep anyone who holds realm:admin. ────── + var keepEmails = manifest.Users.Select(u => u.Email.ToUpperInvariant()).ToHashSet(StringComparer.Ordinal); + var keepUserNames = manifest.Users + .Where(u => !string.IsNullOrEmpty(u.UserName)) + .Select(u => u.UserName!.ToLowerInvariant()) + .ToHashSet(StringComparer.Ordinal); + var userHandler = new DeleteUsersHandler( + session, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()); + foreach (var p in await session.Query().Where(x => !x.IsDeleted).ToListAsync(ct)) + { + if (keepEmails.Contains(p.NormalizedEmail ?? string.Empty) || + (p.AccountName is not null && keepUserNames.Contains(p.AccountName))) continue; + if (await perms.HasPermissionAsync(p.Id, AppSlugs.Modgud, PermissionEvaluator.RealmAdminPermission, ct)) + continue; + EnsureOk(await userHandler.Handle(new DeleteUsersCommand([p.Id]), ct), $"prune user '{p.AccountName ?? p.Id.ToString()}'"); + } + + // ── Roles (natural key = Name) — keep realm-admin roles (lockout guard). ───────────── + var keepRoles = manifest.Roles.Select(r => r.Name).ToHashSet(StringComparer.Ordinal); + foreach (var r in await session.Query().Where(x => !x.IsDeleted).ToListAsync(ct)) + { + if (keepRoles.Contains(r.Name) || r.IsRealmAdmin) continue; + EnsureOk(await roleAdmin.DeleteRoleAsync(r.Id, ct), $"prune role '{r.Name}'"); + } + + // ── Apps (natural key = Slug) — keep the system app; a still-referenced app errors. ── + var keepApps = manifest.Apps.Select(a => a.Slug).ToHashSet(StringComparer.Ordinal); + foreach (var a in await session.Query().Where(x => !x.IsDeleted).ToListAsync(ct)) + { + if (keepApps.Contains(a.Slug) || a.IsSystem) continue; + EnsureOk(await appAdmin.DeleteAppAsync(a.Id, ct), $"prune app '{a.Slug}'"); + } + } + /// Wraps a manifest string in a "some" optional, or "none" when null — the /// UpdateUserCommand semantics: a null manifest field leaves the stored value unchanged /// rather than clearing it. diff --git a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs index ba3114c9..c79c4afe 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs @@ -248,10 +248,12 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, .RequiresPermission("realm:write", AppSlugs.ControlPlane); // Apply a manifest to an EXISTING realm: in-place merge/upsert per entity (never - // drops the DB). The route slug must match the manifest's realm slug. No prune — - // entities absent from the manifest are left untouched. + // drops the DB). The route slug must match the manifest's realm slug. Default is an + // additive merge (entities absent from the manifest are left untouched); + // ?prune=true makes it a full sync that also deletes the absent entities (k8s + // apply --prune — infrastructure + every realm:admin path are protected, never pruned). group.MapPost("{slug}/apply", async ( - string slug, RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct) => + string slug, RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct, bool prune = false) => { if (!string.Equals(slug, manifest.Realm.Slug, StringComparison.Ordinal)) return Results.BadRequest(new @@ -260,7 +262,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, Message = $"Route slug '{slug}' does not match the manifest realm slug '{manifest.Realm.Slug}'.", }); - var result = await applier.UpdateRealmAsync(manifest, ct); + var result = await applier.UpdateRealmAsync(manifest, prune, ct); return result.IsError ? ManifestError(result.Errors) : Results.Ok(result.Value); }) .WithName("Realms_Apply") diff --git a/src/dotnet/Modgud.Authorization/Commands/GroupMembershipGuards.cs b/src/dotnet/Modgud.Authorization/Commands/GroupMembershipGuards.cs index 280a254d..1340ab8d 100644 --- a/src/dotnet/Modgud.Authorization/Commands/GroupMembershipGuards.cs +++ b/src/dotnet/Modgud.Authorization/Commands/GroupMembershipGuards.cs @@ -10,9 +10,12 @@ namespace Modgud.Authorization.Commands; /// Shared write-time guards for group commands. Keeps the federation v1 /// realm:admin-local-only invariant AND the "only a realm:admin may /// confer realm:admin" privilege-escalation guard in one place so create and -/// update enforce them identically. +/// update enforce them identically. Public so the realm-provisioning prune can +/// reuse to decide which groups it must +/// never delete (deleting an admin-conferring group would strip an admin's path +/// to realm:admin even when the role + user survive). /// -internal static class GroupMembershipGuards +public static class GroupMembershipGuards { /// /// Federation v1 (decision G): a group whose roles confer realm:admin From 72c85d07b71063b54e8dabcc2829079afb920727 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 11:29:13 +0200 Subject: [PATCH 15/21] docs(provisioning): declarative realm provisioning design note dev-docs design-of-record for the feature: the endpoint surface, the single-canonical-write-path invariant, the manifest schema, apply=patch field semantics (nullable bools / null-string-no-change / empty-list-no-change / catalog-id preservation / set-password-on-apply), prune (full sync) with the lockout + infrastructure protection rules, the structure-only export + secrets stance, and the load-bearing tenant-durability gotchas (TenantContext routing, plain-vs-bus sessions, the group BoundTo default). Registered in the dev-docs sidebar + linked from the future-features index. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev-docs/.vitepress/config.ts | 1 + .../declarative-realm-provisioning.md | 255 ++++++++++++++++++ dev-docs/future-features/index.md | 11 + 3 files changed, 267 insertions(+) create mode 100644 dev-docs/future-features/declarative-realm-provisioning.md diff --git a/dev-docs/.vitepress/config.ts b/dev-docs/.vitepress/config.ts index ca6cf0a2..92bc65ba 100644 --- a/dev-docs/.vitepress/config.ts +++ b/dev-docs/.vitepress/config.ts @@ -84,6 +84,7 @@ export default withMermaid(defineConfig({ text: 'Future Features', items: [ { text: 'Overview', link: '/future-features/' }, + { text: '⭐ Declarative Realm Provisioning (shipped)', link: '/future-features/declarative-realm-provisioning' }, { text: '⭐ Human-path testing — the cold-start ladder', link: '/future-features/human-path-testing-ladder' }, { text: '⭐ Identity-Lifecycle Untangle + Federation group-sync', link: '/future-features/identity-lifecycle-untangle' }, { text: '⭐ Federation v1 — Implementation Spec', link: '/future-features/federation-v1-design' }, diff --git a/dev-docs/future-features/declarative-realm-provisioning.md b/dev-docs/future-features/declarative-realm-provisioning.md new file mode 100644 index 00000000..62de90aa --- /dev/null +++ b/dev-docs/future-features/declarative-realm-provisioning.md @@ -0,0 +1,255 @@ +# Declarative Realm Provisioning + +**Status:** Shipped (Stage 1 — import / in-place update / hard-delete / +structure-only export; Stage 2 — prune). This page is the design-of-record; +promote it to a public `/admin/` or `/integrate/` page when the feature gets +user-facing docs. + +**Why:** Bernhard builds .NET apps that use Modgud as their OAuth/OIDC server. +Standing up a realm to test an app — clients, scopes, APIs, users, roles, +groups, settings — by hand through the admin API is slow and unrepeatable. The +goal is **declarative realm provisioning at runtime**: hand Modgud one JSON +document and have it materialise (or update, or tear down) a complete realm +in-process, fast enough to do per-test, in parallel. The risk gate — and the +piece the owner valued most — was a **prod-safe hard-delete that actually drops +the tenant database**. + +## The shape + +Everything hangs off the existing control-plane realms group +(`/api/admin/realms`, gated by `RequireControlPlaneFilter` + +`RequiresPermission("realm:*", AppSlugs.ControlPlane)` — there is no anonymous +provisioning): + +| Verb | Route | Does | +|------|-------|------| +| `POST` | `/import` | Create a brand-new realm from a manifest. Slug must NOT exist. All-or-nothing: a failed import hard-deletes the partial realm. → `201` + `RealmImportResult` (incl. minted client secrets). | +| `POST` | `/{slug}/apply` | In-place merge/upsert into an EXISTING realm. Never drops the DB. Route slug must equal the manifest slug (`Manifest.SlugMismatch` → `400`). | +| `POST` | `/{slug}/apply?prune=true` | As above, then a **full sync**: delete entities present in the realm but absent from the manifest (see [Prune](#prune-full-sync)). | +| `GET` | `/{slug}/export` | Structure-only manifest of the realm (the inverse of the applier). Never emits secrets / password hashes. `realm:read`. | +| `DELETE` | `/{slug}?hard=true` | Hard-delete: drop the tenant database. Default (`hard=false`) is the existing soft-delete. | + +`Import` vs `Apply` is deliberate: import creates (rolls back on failure), apply +merges (each op commits its own unit; safe to re-apply after fixing the +manifest). **`UpdateRealm` is an in-place merge, NEVER `Remove + Import`** — +dropping the tenant DB would discard the realm's signing keys, wipe the +OpenIddict token store, and change every user's `sub`, invalidating all issued +tokens. + +## The governing invariant + +> **Exactly one canonical write path per mutation.** The applier reimplements +> nothing — for each entity change it calls the *same* application operation the +> admin UI/API uses, so the manifest path and the manual path can never drift. + +Modgud is a hybrid (events for state/projections, but **imperative +orchestration** for side-effects like token revocation and SignalR dispatch), so +"just fire the raw events" would skip those side-effects — never do it. Reuse +the operation: + +| Section | Canonical op | +|---------|--------------| +| Realm shell | `IRealmProvisioningService.CreateRealmAsync` (global store, no tenant ctx) | +| Settings | `IRealmSettingsService.PatchAsync` | +| Apps (+catalog) | `AppAdminService.Create/Update/DeleteAppAsync` | +| APIs / scopes / clients | `OAuthAdminService.Create/Update/Delete{Api,Scope,Client}Async` | +| Roles | `RoleAdminService.Create/Update/DeleteRoleAsync` | +| Users | `CreateUserCommand` / `UpdateUserHandler` / `SetUserPasswordHandler` / `DeleteUsersCommand` | +| Groups | `Create/Update/DeleteGroupHandler` | + +Stage 2 (prune) added the `Delete*` ops; several lived inline in their HTTP +endpoints and were consolidated onto the services/commands first so the applier +could reuse them (see the Atlas note +`engineering/realm-provisioning-write-path-divergences`). + +## The manifest schema + +Cross-references use stable **keys**, never server-generated ids — apps by +`slug`, roles/users by `key`, permissions by `resource:action` — mirroring the +`demo-seed.json` contract. The applier resolves keys → ids in dependency order: +**apps → apis/scopes/clients → roles → users → groups**. + +```jsonc +{ + "Realm": { /* CreateRealmDto: Slug, DisplayName, Domains[], InitialAdmin{} */ }, + "Settings": { /* UpdateRealmSettingsDto — optional; all 9 sections */ }, + + "Apps": [ + { "Slug": "acme-app", "DisplayName": "Acme", + "Permissions": [ { "Resource": "acme", "Action": "read" } ] } + ], + "Apis": [ + { "Name": "acme-api", "App": "acme-app", // App is a slug + "Permissions": [ { "Resource": "acme", "Action": "read" } ], // resolve into the app's catalog + "Scopes": [], "UserClaims": [], + "Enabled": null, "AllowDynamicRegistration": null } // bools nullable — see merge semantics + ], + "Scopes": [ + { "Name": "acme.read", "App": "acme-app", "Resources": ["acme-api"], + "Enabled": null, "Required": null, "Emphasize": null, "ShowInDiscoveryDocument": null } + ], + "Clients": [ + { "ClientId": "acme-web", "ClientType": "confidential", + "RedirectUris": ["https://acme.test/cb"], "Scopes": ["openid", "acme.read"], + "AllowedGrantTypes": ["authorization_code", "refresh_token"], + "Apps": ["acme-app"], "Roles": [], "WebAuthnRpId": null, + "Enabled": null, "RequireConsent": null } // ClientSecret minted at create only + ], + "Roles": [ + { "Key": "acme-admin", "Name": "acme-admin", "App": "acme-app", + "IsRealmAdmin": false, + "Permissions": [ { "Resource": "acme", "Action": "read" } ] } + ], + "Users": [ + { "Key": "alice", "Email": "alice@acme.test", "UserName": "alice", + "Password": null, "EmailConfirmed": false } // created passwordless if Password null + ], + "Groups": [ + { "Name": "Admins", "Members": ["alice"], "Roles": ["acme-admin"], + "MembershipMode": "Manual", "BoundTo": null, // BoundTo null → defaults to [modgud] + "ExternallyDrivable": false } + ] +} +``` + +The C# record is `RealmManifest` in +`Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs` — the authoritative +schema. The TestKit ships a client-side mirror (`Modgud.Provisioning.TestKit`). + +## Field-merge semantics (apply = patch) + +`apply` (without prune) is the desired state **for the fields it carries**: + +- **Boolean flags are nullable** (`bool?`): omitted = no change on update, the + shipped default on create (`Enabled` / `ShowInDiscoveryDocument` → `true`, the + rest → `false`). This is the surgical-patch wire form — identical to + `Optional` for value types but without forcing the global JSON resolver + onto `AddOptionalAware`. (`Optional` infra exists but is for internal + Optional-typed DTOs; the manifest IS the HTTP body, so `bool?` is the + consistent shape — same call the `ProfileEndpoints` partial-update makes.) +- **Scalar strings** replace when present; **null = no change** (never clears). +- **Non-empty lists** replace; an **omitted/empty list = no change** (apply + sets and changes lists, but never clears one to empty — that stays an admin-API + operation, or use prune). +- **App-link** (`null`) = no change (never detaches). +- **App-catalog ids are preserved by `resource:action`** across an update, so an + unchanged permission keeps its id and doesn't trip the catalog-delete block + (which guards FK references from roles / resource servers). +- **Client secret** is minted only at create; an existing client keeps its secret + (rotate via the dedicated endpoint). +- **Set-a-password on apply**: a `Password` on an EXISTING user IS applied (via + the canonical `SetUserPasswordHandler`, which carries the kill-switch revoke) — + this is what makes the *export (passwordless) → add a password → apply* flow + work. New users get theirs at create. + +## Prune (full sync) + +`apply?prune=true` is the k8s `apply --prune` model: after the upsert, delete +every entity that exists in the realm but is **absent from the manifest**, via +its canonical delete op, in **reverse-dependency order** so a dependent is gone +before the app/role it points at: + +``` +clients → scopes → apis → groups → users → roles → apps +``` + +An app still referenced by a manifest-KEPT role / resource server correctly +errors (the App-delete reference block). Protection checks run AFTER the upsert, +so they see the realm's desired post-merge role graph. + +### Never pruned — lockout + infrastructure protection + +The chosen rule is the robust superset of "System + last admin": protect **all** +admins, so no manifest can lock the realm out. + +- **System app** (`App.IsSystem`) — auto-seeded. +- **Standard scopes** (`StandardScopes.IsStandard`) — auto-seeded (`openid`, …). +- **Service-account-linked clients** (`OAuthApplicationState.LinkedServiceAccountId`) + — auto-managed, not manifest-modelled. +- **Any realm-admin role** (`PermissionRole.IsRealmAdmin`). +- **Any user who currently holds `realm:admin`** (checked via + `IPermissionService.HasPermissionAsync(..., "realm:admin")`, so an admin not + listed in the manifest survives). +- **Any group that confers `realm:admin`** (via + `GroupMembershipGuards.GroupConfersRealmAdminAsync`). This is the load-bearing + refinement: the user-admin check is `BoundTo`-gated, so without it pruning an + admin's group could silently strip the admin path even though the role + user + survive. + +User delete is the canonical **recycle-bin soft-delete** (deactivate + pending, +not a hard erase) — the same op the admin "delete user" uses. + +## Structure-only export + the secrets stance + +A real backup/restore needs the whole tenant DB (events + signing keys + token +store) and is explicitly **not** what this feature is. What's wanted is (1) +create-from-JSON and (2) get-config → edit → set-a-password → apply. So +**export is structure-only**: + +- It NEVER emits client secrets or password hashes (one-way), and the write-only + captcha secret is surfaced only as a `CaptchaSecretSet` flag, never the + plaintext. +- It omits auto-seeded standard scopes, system apps, and SA-linked clients (which + can't be cleanly re-applied). +- All 9 realm-settings sections ARE exported (reverse-mapped read → patch shape), + so settings round-trip. + +The key fact that makes structure-only clean: **no entity fails without a +credential.** Confidential clients auto-generate a secret (returned in +`RealmImportResult.ClientSecrets`), users are created passwordless. So a +structure-only import yields a fully working realm; the missing credentials are +exactly what you'd reissue on a clone. + +## Implementation gotchas (load-bearing) + +- **Tenant routing.** `TenantedSessionFactory` prefers the AsyncLocal + `TenantContext` over the ambient (control-plane) `HttpContext`. The applier + runs the per-tenant config inside `TenantContext.Enter(slug)` + a **fresh DI + scope**, so direct-service writes land in the NEW realm even though the call + runs on the control-plane host. +- **Wolverine commands resolve their session from the message-envelope tenant**, + not `TenantContext` → users use `bus.InvokeForTenantAsync(slug, ...)`. A plain + `InvokeAsync` inside `TenantContext.Enter` opens a tenant-less session ("Default + tenant does not supported"). +- **Groups + user-update + all prune deletes use a PLAIN (non-Wolverine) session, + NOT the bus.** `GroupCreated/Updated/Deleted`, `UserUpdated`, and + `UserDeactivated` have durable `ReferenceSync` forwarders (`UseFastEventForwarding` + + `UseDurableInbox`) that, under `InvokeForTenantAsync`, would write + `wolverine_*_envelopes` tables a fresh tenant DB lacks. A plain session skips + the forwarding (auto-membership re-derives at login). `CreateUser` is the + exception that works via the bus — `userManager.CreateAsync` persists on a + separate, non-outbox session. +- **Group `BoundTo` default.** The create *endpoint* applies + `dto.BoundTo ?? [modgud]` before calling the command (the handler itself + defaults null → `[]` = dormant). The applier mirrors that on create, so a + manifest-provisioned admin group actually confers its roles instead of silently + granting nothing. +- **Hard-delete.** `RemoveTenantAsync` (evicts the tenancy cache + disposes the + data source + deletes the `mt_tenant_databases` row) → `DROP DATABASE … WITH + (FORCE)` → remove the global Realm record + invalidate the realm cache. Refuses + the control-plane realm. Caveat: re-creating the **same slug in the same + process** fails (Weasel caches `NpgsqlDataSource` by connection string with no + per-key eviction) — use unique slugs, or a custom evictable factory if in-process + reuse is ever needed. See Atlas `engineering/realm-hard-delete-drop-database`. + +## Test kit + +`Modgud.Provisioning.TestKit` is a standalone, NuGet-able project with **zero +server deps** (its own manifest POCOs, the client-side mirror of the server +contract): `new ModgudProvisioningClient(httpClient).ImportRealmAsync(manifest)` +→ `ProvisionedRealm` (`Authority` / `PrimaryDomain` / `SecretFor(clientId)` / +`ApplyAsync` / `DisposeAsync` → hard-delete). Server error codes surface as +`ModgudProvisioningException.Code`. A token-minting helper is deliberately out of +v1 (the manifest can't model SA / `client_credentials` clients), so the consumer +drives auth flows with the exposed Authority/ClientId/Secret. + +## Not in v1 + +- Login providers (OIDC/SAML) — a bus command with `JsonDocument? FlavorData`; + follow the plain-session pattern if its event forwards durably. +- Per-user enabled-state (activate/deactivate) in the applier — its op is still + endpoint-inline (see the write-path-divergences note); add when needed. +- OAuth client-delete token revocation — a real bug recorded in the + write-path-divergences note, independent of the applier (both UI and prune call + the same `DeleteClientAsync`, so prune introduces no new divergence). Fix needs + either a DIP move of `IOAuthGrantRevoker` into Application or an event handler. diff --git a/dev-docs/future-features/index.md b/dev-docs/future-features/index.md index 58b5c81f..f586f054 100644 --- a/dev-docs/future-features/index.md +++ b/dev-docs/future-features/index.md @@ -29,6 +29,17 @@ Severity. Detail-Pages unten. [logging-audit-redesign](./logging-audit-redesign) — split today's `AuthLog` (a fragile Serilog "Auth:"-magic-prefix sink that also silently fails GDPR) into two tracks: (A) a typed, **durable** (Wolverine outbox), GDPR-erasable per-realm **audit** trail (event-sourced), and (B) a centralized **operational** logging track (OTel Logs → OTLP + a slim in-app platform live-tail). Grounded in existing conventions (outbox, GdprService masking, Inbox slice, RealmSettings). Has 7 open decisions + a 6-phase plan. Read before any audit/logging work. +⭐ **Declarative Realm Provisioning (shipped — Stage 1 + 2):** +[declarative-realm-provisioning](./declarative-realm-provisioning) +— provision a complete realm from one JSON manifest at runtime: `POST /import` +(create), `POST /{slug}/apply` (in-place merge), `?prune=true` (full sync that +deletes absent entities, with lockout + infra protection), `GET /export` +(structure-only, never secrets), `DELETE ?hard=true` (drops the tenant DB). The +design-of-record: the single-canonical-write-path invariant, the manifest schema, +patch-vs-prune field semantics, the prune protection rules, the structure-only +export + secrets stance, and the tenant-durability gotchas. Read before any +provisioning / TestKit / prune work. + ### Audit-Followups (in Severity-Reihenfolge) - Observability — OpenTelemetry / Metrics / Tracing — ✅ shipped (see From 67e711af23b6713dd776a1e7e36cd05e5b14cbf4 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 12:32:39 +0200 Subject: [PATCH 16/21] feat(provisioning): served JSON Schema for the realm manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/admin/realms/manifest-schema returns the JSON Schema for the import/apply body, so a consumer (or an agent) can fetch the contract and author a valid manifest without reading the source. - Generated from the live RealmManifest type via JsonSchemaExporter using the API's own JsonSerializerOptions, so property casing + nullability always match the wire contract (can't drift). required: ["Realm"]; the entity lists default to empty. - Per-field docs ride along: every manifest property/record carries a [Description] (keys-not-ids, resource:action, the BoundTo lockout note, nullable-bool patch semantics, …) which the exporter copies into each node's `description`. A worked example is attached at the root. - Gated with realm:write on the control-plane app — the SAME permission as import/apply, so only a caller who could apply a manifest may fetch its schema (not realm:read). Tests: admin gets the described schema + example; an unauthenticated caller is denied. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../declarative-realm-provisioning.md | 1 + .../RealmProvisioningEndpointsTests.cs | 51 +++++++ .../Admin/Provisioning/RealmManifest.cs | 140 +++++++++++++++++- .../Admin/Provisioning/RealmManifestSchema.cs | 106 +++++++++++++ .../Features/Admin/RealmsEndpoints.cs | 17 +++ 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestSchema.cs diff --git a/dev-docs/future-features/declarative-realm-provisioning.md b/dev-docs/future-features/declarative-realm-provisioning.md index 62de90aa..ed8cba6d 100644 --- a/dev-docs/future-features/declarative-realm-provisioning.md +++ b/dev-docs/future-features/declarative-realm-provisioning.md @@ -27,6 +27,7 @@ provisioning): | `POST` | `/{slug}/apply` | In-place merge/upsert into an EXISTING realm. Never drops the DB. Route slug must equal the manifest slug (`Manifest.SlugMismatch` → `400`). | | `POST` | `/{slug}/apply?prune=true` | As above, then a **full sync**: delete entities present in the realm but absent from the manifest (see [Prune](#prune-full-sync)). | | `GET` | `/{slug}/export` | Structure-only manifest of the realm (the inverse of the applier). Never emits secrets / password hashes. `realm:read`. | +| `GET` | `/manifest-schema` | The JSON Schema for the import/apply body, generated from the live `RealmManifest` type (can't drift) with per-field `description`s + a worked `example`. Lets a consumer / agent fetch the contract and author a valid manifest without the source. Gated with `realm:write` (same as import/apply — only a caller who can apply a manifest may fetch its schema). | | `DELETE` | `/{slug}?hard=true` | Hard-delete: drop the tenant database. Default (`hard=false`) is the existing soft-delete. | `Import` vs `Apply` is deliberate: import creates (rolls back on failure), apply diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs index 59b461bc..a4e561a9 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs @@ -162,6 +162,57 @@ await InTenantAsync(factory, slug, async sp => }); } + [Fact] + public async Task Manifest_schema_endpoint_returns_a_described_json_schema_with_an_example() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + + var resp = await client.GetAsync("/api/admin/realms/manifest-schema", ct); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var body = await resp.Content.ReadAsStringAsync(ct); + using var json = JsonDocument.Parse(body); + var root = json.RootElement; + + // A real JSON Schema for an object with all the manifest sections. + Assert.Equal("object", root.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("$schema", out _)); + var props = root.GetProperty("properties"); + foreach (var section in new[] { "Realm", "Settings", "Apps", "Apis", "Scopes", "Clients", "Roles", "Users", "Groups" }) + Assert.True(props.TryGetProperty(section, out _), $"schema missing '{section}'"); + + // Only the realm shell is required; the entity lists default to empty. + var required = root.GetProperty("required").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("Realm", required); + Assert.DoesNotContain("Apps", required); + + // Field-level [Description]s are injected (proves the docs ride along). + Assert.Contains("permission namespace", props.GetProperty("Apps").GetProperty("description").GetString()); + Assert.Contains("resource:action", body); // RealmManifestPermission description + + // A worked example is attached so a consumer can author a manifest from the schema alone. + var examples = root.GetProperty("examples"); + Assert.True(examples.GetArrayLength() >= 1); + Assert.Equal("acme-test", examples[0].GetProperty("Realm").GetProperty("Slug").GetString()); + } + + [Fact] + public async Task Manifest_schema_endpoint_is_gated_for_an_unauthenticated_caller() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var ct = TestContext.Current.CancellationToken; + + // No login → the schema (gated with realm:write, same as import/apply) must not leak. + var anon = host.Factory.CreateClient(); + var resp = await anon.GetAsync("/api/admin/realms/manifest-schema", ct); + + Assert.NotEqual(HttpStatusCode.OK, resp.StatusCode); + Assert.Contains(resp.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); + } + private static RealmManifest BuildManifest(string slug, string appDisplayName) => new() { Realm = new CreateRealmDto diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs index fe88d8f8..b458d524 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs @@ -1,8 +1,14 @@ +using System.ComponentModel; using Modgud.Application.DTOs.Realms; using Modgud.Application.DTOs.RealmSettings; namespace Modgud.Api.Features.Admin.Provisioning; +// The [Description] attributes below are the field-level documentation: they are +// emitted into the JSON Schema served at GET /api/admin/realms/manifest-schema, so a +// consumer (or an agent) can fetch the contract and build a valid manifest without +// reading the source. Keep them concise and accurate — they ARE the docs. + /// /// A declarative description of a realm's complete configuration, applied in-process by /// . Cross-references use stable KEYS (apps by slug, @@ -12,100 +18,198 @@ namespace Modgud.Api.Features.Admin.Provisioning; /// SAME canonical operation the admin UI/API uses, so the manifest path and the manual /// path can never diverge. /// +[Description("A complete, declarative realm configuration. POST to /api/admin/realms/import to create a new realm, or to /{slug}/apply to merge into an existing one (add ?prune=true for a full sync that also deletes entities absent from the manifest). Cross-references use stable keys (app slug, role/user key, permission 'resource:action'), never server ids.")] public sealed record RealmManifest { /// Realm shell + initial admin (reuses ). + [Description("REQUIRED. The realm shell (slug, display name, routing domains) and its first admin.")] public required CreateRealmDto Realm { get; init; } /// Optional realm settings patch (self-registration, native grants, ...). + [Description("Optional. Realm-settings patch (self-registration, registration fields, native grants, branding, auth rate limits, deletion, audit, DCR, CIMD). Omit to keep defaults; only the sections/fields you include are changed. Mirrors the realm-settings PATCH shape.")] public UpdateRealmSettingsDto? Settings { get; init; } + [Description("Apps. Each app is a permission namespace: a catalog of 'resource:action' permissions plus a display name. APIs, scopes, clients and roles reference an app by its Slug.")] public List Apps { get; init; } = []; + + [Description("OAuth resource servers (APIs). The 'aud' value clients request is the API's Name.")] public List Apis { get; init; } = []; + + [Description("OAuth scopes (consent/authorization scopes), optionally linked to an app + API audiences.")] public List Scopes { get; init; } = []; + + [Description("OAuth clients (applications that request tokens). Confidential clients get a generated secret returned at import.")] public List Clients { get; init; } = []; + + [Description("Roles (named permission sets). Either app-scoped (App + Permissions) or a pure realm-admin role (IsRealmAdmin=true).")] public List Roles { get; init; } = []; + + [Description("Users. Created passwordless unless a Password is given. Referenced by groups via Key.")] public List Users { get; init; } = []; + + [Description("Groups. The ONLY way users get roles: a user is a group member, the group carries roles. Members/Roles are keys, not ids.")] public List Groups { get; init; } = []; } /// A permission catalog entry referenced by resource:action. -public sealed record RealmManifestPermission(string Resource, string Action, string? Description = null); +[Description("A permission catalog entry, addressed elsewhere as 'resource:action' (e.g. 'invoice:read'). Both segments must match ^[a-z0-9-]+$. 'realm:admin' is reserved and cannot be a catalog entry (use a role's IsRealmAdmin flag).")] +public sealed record RealmManifestPermission( + [property: Description("Resource segment, e.g. 'invoice'. ^[a-z0-9-]+$.")] string Resource, + [property: Description("Action segment, e.g. 'read'. ^[a-z0-9-]+$.")] string Action, + [property: Description("Optional human-readable description of the permission.")] string? Description = null); /// An App + its permission catalog (the per-app permission namespace). public sealed record RealmManifestApp { + [Description("Stable key for this app: 3-63 chars, lowercase letters/digits/hyphens, starts with a letter. APIs/scopes/clients/roles reference the app by this Slug.")] public required string Slug { get; init; } + + [Description("Human-readable app name.")] public required string DisplayName { get; init; } + + [Description("Optional description.")] public string? Description { get; init; } + + [Description("The app's permission catalog — the set of 'resource:action' permissions roles/APIs can grant from this app.")] public List Permissions { get; init; } = []; } /// An OAuth resource server (API). is a slug; resolve into the linked app's catalog. public sealed record RealmManifestApi { + [Description("The API's audience ('aud') — the natural key. This is what clients request and resource servers validate.")] public required string Name { get; init; } + + [Description("Optional display name.")] public string? DisplayName { get; init; } + + [Description("Optional description.")] public string? Description { get; init; } + + [Description("Optional app slug this API belongs to. Required if Permissions are set (they resolve into this app's catalog).")] public string? App { get; init; } + + [Description("Scope names this API accepts.")] public List Scopes { get; init; } = []; + + [Description("Permissions from the linked app's catalog this API exposes (requires App).")] public List Permissions { get; init; } = []; + + [Description("OIDC user claims this API wants surfaced.")] public List UserClaims { get; init; } = []; // Bool flags are nullable so an apply can patch surgically: omitted = no change on // update (and the shipped default on create). Enabled defaults to true on create. + [Description("Optional. Omit = no change on apply / default true on create.")] public bool? Enabled { get; init; } + + [Description("Optional. Allow dynamic client registration (DCR) against this API. Omit = no change / default false on create.")] public bool? AllowDynamicRegistration { get; init; } } /// An OAuth scope. is a slug; are API audience names. public sealed record RealmManifestScope { + [Description("Scope name — the natural key (e.g. 'invoice.read', 'openid').")] public required string Name { get; init; } + + [Description("Optional display name shown on the consent screen.")] public string? DisplayName { get; init; } + + [Description("Optional description shown on the consent screen.")] public string? Description { get; init; } + + [Description("Optional app slug this scope belongs to.")] public string? App { get; init; } + + [Description("API audience names ('aud') this scope grants access to.")] public List Resources { get; init; } = []; + + [Description("OIDC user claims this scope releases.")] public List UserClaims { get; init; } = []; // Nullable for surgical patching: omitted = no change on update / shipped default on // create (Enabled + ShowInDiscoveryDocument default true, the rest false). + [Description("Optional. Omit = no change / default true on create.")] public bool? Enabled { get; init; } + + [Description("Optional. Scope is always granted (cannot be deselected on consent). Omit = no change / default false.")] public bool? Required { get; init; } + + [Description("Optional. Emphasize on the consent screen. Omit = no change / default false.")] public bool? Emphasize { get; init; } + + [Description("Optional. List the scope in the discovery document. Omit = no change / default true.")] public bool? ShowInDiscoveryDocument { get; init; } } /// An OAuth client. are slugs; are scope names. public sealed record RealmManifestClient { + [Description("The OAuth client_id — the natural key.")] public required string ClientId { get; init; } + + [Description("Optional display name.")] public string? DisplayName { get; init; } + + [Description("'confidential' (server-side; a secret is generated and returned at import) or 'public' (SPA/native; PKCE, no secret).")] public required string ClientType { get; init; } + + [Description("Optional explicit secret for a confidential client. Usually omit and let the server generate one (returned in the import result's ClientSecrets). Never set at apply — existing clients keep their secret.")] public string? ClientSecret { get; init; } + + [Description("Allowed redirect URIs (authorization_code flow).")] public List RedirectUris { get; init; } = []; + + [Description("Allowed post-logout redirect URIs.")] public List PostLogoutRedirectUris { get; init; } = []; + + [Description("Scope names this client may request (e.g. 'openid', 'invoice.read').")] public List Scopes { get; init; } = []; + + [Description("OAuth grant types, e.g. 'authorization_code', 'refresh_token', 'client_credentials'.")] public List AllowedGrantTypes { get; init; } = []; + + [Description("App slugs this client is bound to (which permission namespaces it operates in).")] public List Apps { get; init; } = []; + + [Description("Role names granted to this client itself (e.g. for client_credentials/service-to-service).")] public List Roles { get; init; } = []; + + [Description("Optional WebAuthn Relying Party id (passkeys) for this client.")] public string? WebAuthnRpId { get; init; } // Nullable for surgical patching: omitted = no change on update / shipped default on // create (Enabled defaults true, RequireConsent false). + [Description("Optional. Omit = no change / default true on create.")] public bool? Enabled { get; init; } + + [Description("Optional. Force the consent screen even for first-party clients. Omit = no change / default false.")] public bool? RequireConsent { get; init; } + + [Description("Optional access token format: 'Jwt' (self-contained) or reference (default). Omit for the server default.")] public string? AccessTokenType { get; init; } } /// A role. is a slug; resolve into the linked app's catalog. (default ) is how groups reference it. public sealed record RealmManifestRole { + [Description("Optional stable key groups use to reference this role. Defaults to Name.")] public string? Key { get; init; } + + [Description("Role name — the natural key for upsert.")] public required string Name { get; init; } + + [Description("Optional description.")] public string? Description { get; init; } + + [Description("App slug whose catalog Permissions resolve into. Omit for a pure realm-admin role.")] public string? App { get; init; } + + [Description("If true, this role confers realm:admin — the realm-wide bypass (full administration). A realm-admin role needs no App/Permissions. Provisioning is trusted, so this is allowed from the manifest.")] public bool IsRealmAdmin { get; init; } + + [Description("Permissions from the linked app's catalog this role grants (requires App).")] public List Permissions { get; init; } = []; public string ResolveKey() => Key ?? Name; @@ -114,13 +218,28 @@ public sealed record RealmManifestRole /// A user. (default ?? ) is how groups reference it as a member. public sealed record RealmManifestUser { + [Description("Optional stable key groups use to reference this user as a member. Defaults to UserName, else Email.")] public string? Key { get; init; } + + [Description("Optional first name.")] public string? Firstname { get; init; } + + [Description("Optional last name.")] public string? Lastname { get; init; } + + [Description("Optional short acronym/initials.")] public string? Acronym { get; init; } + + [Description("Email — the user's natural key (also the login identifier when no UserName is set).")] public required string Email { get; init; } + + [Description("Optional username. Falls back to the email local-part if omitted.")] public string? UserName { get; init; } + + [Description("Optional password. Omit to create the user passwordless (set one later, or use a passwordless flow). On apply, a password on an EXISTING user updates it.")] public string? Password { get; init; } + + [Description("Mark the email as already verified. Default false.")] public bool EmailConfirmed { get; init; } public string ResolveKey() => Key ?? UserName ?? Email; @@ -129,15 +248,34 @@ public sealed record RealmManifestUser /// A group. are user keys; are role keys. public sealed record RealmManifestGroup { + [Description("Group name — the natural key.")] public required string Name { get; init; } + + [Description("Optional description.")] public string? Description { get; init; } + + [Description("Member user keys (RealmManifestUser.Key — NOT ids). For MembershipMode=Manual.")] public List Members { get; init; } = []; + + [Description("Role keys (RealmManifestRole.Key/Name — NOT ids) this group grants to its members.")] public List Roles { get; init; } = []; + + [Description("'Manual' (explicit Members) or 'Auto' (members computed from MembershipScript). Default 'Manual'.")] public string MembershipMode { get; init; } = "Manual"; + + [Description("For MembershipMode=Auto: a TypeScript membership predicate. Ignored for Manual.")] public string? MembershipScript { get; init; } + + [Description("Optional shared group email.")] public string? Email { get; init; } + + [Description("'Shared' or 'Individual'. Default 'Shared'.")] public string EmailMode { get; init; } = "Shared"; + + [Description("App slugs this group's roles apply to. Omit -> defaults to ['modgud'] (the IdP itself). An empty list makes the group dormant (its roles confer nothing).")] public List? BoundTo { get; init; } + + [Description("Allow an external IdP (federation) to drive this group's membership. A realm:admin-conferring group can never be externally drivable. Default false.")] public bool ExternallyDrivable { get; init; } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestSchema.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestSchema.cs new file mode 100644 index 00000000..02693c51 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestSchema.cs @@ -0,0 +1,106 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; + +namespace Modgud.Api.Features.Admin.Provisioning; + +/// +/// Builds the JSON Schema for served at +/// GET /api/admin/realms/manifest-schema. Generated from the live type via +/// using the API's own , +/// so the schema's property names + nullability always match the actual wire contract (no +/// drift). Each node's description is pulled from the +/// on the corresponding property/type, and a worked example is attached at the root — enough +/// for a consumer (or an agent) to author a valid manifest from the fetched schema alone. +/// +public static class RealmManifestSchema +{ + public static JsonNode Build(JsonSerializerOptions serializerOptions) + { + var exporterOptions = new JsonSchemaExporterOptions + { + // A non-nullable reference-typed property is a genuine "required" field. + TreatNullObliviousAsNonNullable = true, + TransformSchemaNode = InjectDescriptions, + }; + + var schema = serializerOptions.GetJsonSchemaAsNode(typeof(RealmManifest), exporterOptions); + + if (schema is JsonObject root) + { + root["$schema"] = "https://json-schema.org/draft/2020-12/schema"; + root["title"] = "Modgud realm manifest"; + root["examples"] = new JsonArray(Example()); + } + + return schema; + } + + /// Copies the off the property (preferred) or + /// the type onto the generated schema node's description. + private static JsonNode InjectDescriptions(JsonSchemaExporterContext context, JsonNode schema) + { + if (schema is not JsonObject obj || obj["description"] is not null) + return schema; + + var description = + GetDescription(context.PropertyInfo?.AttributeProvider) + ?? GetDescription(context.TypeInfo.Type); + + if (description is not null) + obj["description"] = description; + + return schema; + } + + private static string? GetDescription(ICustomAttributeProvider? provider) => + provider? + .GetCustomAttributes(typeof(DescriptionAttribute), inherit: false) + .OfType() + .FirstOrDefault()? + .Description; + + private static JsonNode Example() => JsonNode.Parse( + """ + { + "Realm": { + "Slug": "acme-test", + "DisplayName": "Acme Test", + "Domains": ["acme-test.localhost"], + "InitialAdmin": { "UserName": "admin", "Email": "admin@acme-test.local" } + }, + "Apps": [ + { "Slug": "acme", "DisplayName": "Acme", + "Permissions": [ { "Resource": "invoice", "Action": "read" }, + { "Resource": "invoice", "Action": "write" } ] } + ], + "Apis": [ + { "Name": "acme-api", "App": "acme", + "Permissions": [ { "Resource": "invoice", "Action": "read" } ] } + ], + "Scopes": [ + { "Name": "invoice.read", "App": "acme", "Resources": ["acme-api"] } + ], + "Clients": [ + { "ClientId": "acme-web", "ClientType": "confidential", + "RedirectUris": ["https://acme-test.localhost/cb"], + "Scopes": ["openid", "invoice.read"], + "AllowedGrantTypes": ["authorization_code", "refresh_token"], + "Apps": ["acme"] } + ], + "Roles": [ + { "Key": "acme-admin", "Name": "acme-admin", "App": "acme", + "Permissions": [ { "Resource": "invoice", "Action": "read" }, + { "Resource": "invoice", "Action": "write" } ] } + ], + "Users": [ + { "Key": "alice", "Email": "alice@acme.test", "UserName": "alice", "Password": "Passw0rd!23" } + ], + "Groups": [ + { "Name": "Acme Admins", "Members": ["alice"], "Roles": ["acme-admin"], "BoundTo": ["acme"] } + ] + } + """)!; +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs index c79c4afe..856a91d3 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs @@ -280,6 +280,23 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, .WithName("Realms_Export") .RequiresPermission("realm:read", AppSlugs.ControlPlane); + // The JSON Schema for the import/apply body, generated from the live RealmManifest type + // (so it can't drift from the contract) with per-field descriptions + a worked example. + // Lets a consumer / agent fetch the contract and author a valid manifest without the + // source. Generated with the API's own JSON options so property casing matches the wire. + // Gated with the SAME permission as import/apply (realm:write) — only a caller who can + // actually apply a manifest may fetch its schema. + group.MapGet("manifest-schema", ( + Microsoft.Extensions.Options.IOptions jsonOptions) => + { + var schema = Provisioning.RealmManifestSchema.Build(jsonOptions.Value.SerializerOptions); + return Results.Text( + schema.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }), + "application/json"); + }) + .WithName("Realms_ManifestSchema") + .RequiresPermission("realm:write", AppSlugs.ControlPlane); + // Transfer the control-plane role to {slug}. POST to the realm that // should BECOME the control plane, from the current control-plane host // (the group's RequireControlPlaneFilter enforces the latter). After From 87ee6f5c9d73f946e43c324ec3648b413cba8862 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 12:32:49 +0200 Subject: [PATCH 17/21] dev(provisioning): local app-testing docker stack + recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev/app-testing/ — a self-contained stack (Postgres + the locally-built modgud:local image, which carries the provisioning feature that isn't on :beta) on isolated ports, plus a README recipe so another local app's integration tests can provision a throwaway realm per run via the control-plane API / TestKit and hard-delete it after. Documents the one-time bootstrap-admin step, the cookie-auth + TestKit flow, where to fetch the manifest schema, and the host-routing caveat for driving real OAuth flows against a provisioned realm. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/app-testing/README.md | 149 +++++++++++++++++++++++++++++ dev/app-testing/docker-compose.yml | 69 +++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 dev/app-testing/README.md create mode 100644 dev/app-testing/docker-compose.yml diff --git a/dev/app-testing/README.md b/dev/app-testing/README.md new file mode 100644 index 00000000..a56d86d2 --- /dev/null +++ b/dev/app-testing/README.md @@ -0,0 +1,149 @@ +# Local Modgud for app integration tests + +Run a real Modgud locally so another app's integration tests can spin up a +**throwaway realm per test run** (clients, scopes, APIs, users, roles, groups, +settings) in seconds and tear it down after — using the declarative +realm-provisioning API (`import` / `apply` / `?prune=true` / hard-delete) and the +`Modgud.Provisioning.TestKit` client. + +> This uses the **locally-built `modgud:local`** image, not `ghcr.io/cocoar-dev/modgud:beta` +> — the provisioning feature isn't on `:beta` yet (branch `feat/realm-declarative-provisioning`). + +## The manifest contract — fetch the schema + +The import/apply body is a **realm manifest**. Its full JSON Schema — every field, its +type, what's required, and a per-field description + a worked example — is served live: + +``` +GET /api/admin/realms/manifest-schema (control-plane auth, realm:write — same as import/apply) +``` + +So an agent doesn't need to guess property names: log in, `GET` the schema, and author a +valid manifest from it. The schema is generated from the server's own type, so it can never +drift from what the endpoint actually accepts. The shape in short: + +- `Realm` (**required**) — slug, display name, routing `Domains[]`, `InitialAdmin`. +- `Settings` (optional) — realm-settings patch (self-reg, native grants, branding, …). +- `Apps[]` — permission namespaces (each a catalog of `resource:action` permissions). +- `Apis[]`, `Scopes[]`, `Clients[]`, `Roles[]`, `Users[]`, `Groups[]`. + +Cross-references use **keys, not ids**: APIs/scopes/clients/roles point at an app by its +`Slug`; groups list `Members` (user keys) and `Roles` (role keys); permissions are addressed +as `resource:action`. Confidential clients get a generated secret back in the import result. + +## 1. One-time setup + +```powershell +# From the repo root — build the image (multi-stage: .NET + Vue admin + docs) +docker build -f docker/Dockerfile -t modgud:local . + +# Start Postgres + Modgud (host port 18080; Postgres internal-only) +docker compose -f dev/app-testing/docker-compose.yml up -d + +# Create the first control-plane admin. The system realm IS the control plane, +# so a realm:admin there can call the provisioning endpoints. Idempotent-ish: +# re-running errors if the user exists — that's fine. +docker compose -f dev/app-testing/docker-compose.yml exec modgud ` + dotnet Modgud.Api.dll recover bootstrap-admin ` + --email admin@local --username admin --password 'ABC12abc!' +``` + +Modgud is now at `http://localhost:18080` (admin UI + `/api`), control-plane admin +`admin` / `ABC12abc!`. + +## 2. Smoke-test the loop (curl) + +```bash +# Log in as control-plane admin, keep the cookie +curl -sS -c cookies.txt -X POST http://localhost:18080/api/account/login \ + -H 'Content-Type: application/json' \ + -d '{"UserName":"admin","Password":"ABC12abc!"}' + +# Import a realm from a manifest → 201 + the minted client secret(s) +curl -sS -b cookies.txt -X POST http://localhost:18080/api/admin/realms/import \ + -H 'Content-Type: application/json' \ + -d '{ + "Realm": { "Slug": "acme-test", "DisplayName": "Acme Test", + "Domains": ["acme-test.localhost"], + "InitialAdmin": { "UserName": "admin", "Email": "admin@acme-test.local" } }, + "Apps": [ { "Slug": "acme", "DisplayName": "Acme", + "Permissions": [ { "Resource": "acme", "Action": "read" } ] } ], + "Clients": [ { "ClientId": "acme-web", "ClientType": "confidential", + "RedirectUris": ["https://acme-test.localhost/cb"], + "Scopes": ["openid"], "AllowedGrantTypes": ["authorization_code","refresh_token"], + "Apps": ["acme"] } ], + "Users": [ { "Key": "alice", "Email": "alice@acme.test", "UserName": "alice", "Password": "Passw0rd!23" } ] + }' + +# Tear it down (drops the tenant database) +curl -sS -b cookies.txt -X DELETE "http://localhost:18080/api/admin/realms/acme-test?hard=true" +``` + +## 3. App-side recipe (the `Modgud.Provisioning.TestKit`) + +In the app-under-test's integration suite, point an authenticated `HttpClient` at +the container and let the kit manage the realm lifecycle. Give each test run a +**unique slug** — every realm is a physically isolated Postgres DB, so they run in +parallel. + +```csharp +using Modgud.Provisioning.TestKit; + +// 1) An HttpClient authenticated as control-plane admin (cookie auth). +var handler = new HttpClientHandler { CookieContainer = new(), UseCookies = true }; +var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:18080") }; +await http.PostAsJsonAsync("/api/account/login", + new { UserName = "admin", Password = "ABC12abc!" }); + +// 2) Provision a throwaway realm (dispose hard-deletes it). +var kit = new ModgudProvisioningClient(http); +await using var realm = await kit.ImportRealmAsync(new RealmManifest +{ + Realm = new RealmSpec { Slug = $"acme-{Guid.NewGuid():N}", Domains = ["acme.localhost"] }, + Apps = [ new RealmManifestApp { Slug = "acme", DisplayName = "Acme", + Permissions = [ new("acme", "read") ] } ], + Clients = [ new RealmManifestClient { ClientId = "acme-web", ClientType = "confidential", + RedirectUris = ["https://acme.localhost/cb"], Scopes = ["openid"], + AllowedGrantTypes = ["authorization_code", "refresh_token"], Apps = ["acme"] } ], + Users = [ new RealmManifestUser { Email = "alice@acme.test", UserName = "alice", + Password = "Passw0rd!23" } ], +}); + +var clientSecret = realm.SecretFor("acme-web"); // returned only at import +// realm.ApplyAsync(updated) → in-place merge/upsert +// (disposal at end of test → hard-delete, tenant DB dropped) +``` + +Reference the kit either as a NuGet package or a project reference to +`src/dotnet/Modgud.Provisioning.TestKit` — it has **zero** Modgud server deps +(ships its own manifest POCOs). + +## 4. Caveat — using the provisioned realm for real OAuth flows + +Creating / updating / deleting realms is fully turnkey: the control-plane +endpoints live on the system realm (Host `localhost`), so the cookie above is all +you need. + +**Driving OAuth flows _against_ a provisioned realm is host-routed.** Modgud +resolves the tenant from the `Host` header (`Realm.Domains`), and each realm's +issuer is `https://{PrimaryDomain}`. So a token request for the `acme-test` realm +must arrive with `Host: acme-test.localhost`, and the issuer won't match +`http://localhost:18080`. For headless integration tests that's usually fine: + +- **`client_credentials` / native grants / introspection** — point the request at + `http://localhost:18080` with `Host: acme-test.localhost` (`*.localhost` resolves + to 127.0.0.1 on Windows/macOS/most Linux). Works without a browser. +- **Authorization-code (browser) flows** — need the realm host reachable + an + issuer scheme/port that matches what you configure in the client; doable but + more setup. Out of scope for this convenience stack. + +If your app needs the realm reachable on a clean host, add its domain to the +manifest (`Realm.Domains`) and map that host to `localhost` in your test runner's +hosts resolution. + +## 5. Teardown + +```powershell +docker compose -f dev/app-testing/docker-compose.yml down # keep the volume +docker compose -f dev/app-testing/docker-compose.yml down -v # nuke the data too +``` diff --git a/dev/app-testing/docker-compose.yml b/dev/app-testing/docker-compose.yml new file mode 100644 index 00000000..4c937028 --- /dev/null +++ b/dev/app-testing/docker-compose.yml @@ -0,0 +1,69 @@ +# Local app-testing stack for declarative realm provisioning. +# +# Brings up a self-contained Modgud (the LOCALLY-BUILT `modgud:local` image — +# which carries the realm-provisioning feature that isn't on :beta yet) plus its +# own Postgres, on ports that DON'T collide with the contributor dev stack +# (cocoar-postgres :5432, the dev backend :9099). Another local app's integration +# tests point an HttpClient here, provision a throwaway realm per test run via the +# control-plane API (or Modgud.Provisioning.TestKit), and hard-delete it after. +# +# Build the image first (from the repo root): +# docker build -f docker/Dockerfile -t modgud:local . +# Then: +# docker compose -f dev/app-testing/docker-compose.yml up -d +# # one-time: create the control-plane admin (system realm == control plane) +# docker compose -f dev/app-testing/docker-compose.yml exec modgud \ +# dotnet Modgud.Api.dll recover bootstrap-admin \ +# --email admin@local --username admin --password 'ABC12abc!' +# +# See README.md for the app-side recipe + the host-routing caveat. +name: modgud-app-testing + +services: + postgres: + image: postgres:17-alpine + container_name: modgud-apptest-postgres + # No host port — Modgud reaches it over the internal docker network, and the + # app-under-test talks to Modgud on :8080, never to Postgres directly. (Add a + # mapping like "5433:5432" here only if you want to inspect the DB from the host.) + environment: + POSTGRES_PASSWORD: postgres + volumes: + - apptest-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 10 + + modgud: + image: modgud:local + container_name: modgud-apptest + # Container binds 8081 (non-root); exposed on the host as :18080. (18080 to + # dodge the busy 808x range on this box — cocoar-nexus 8081/8082, amzettel + # 8091, and a host process on 8080. Pick any free host port you like.) + ports: + - "18080:8081" + environment: + # Development on purpose: the Production boot guards reject a localhost / + # http issuer + DevelopmentMode (ephemeral keys). For app-testing that's + # exactly what we want — see the root docker-compose.yml for the rationale. + ASPNETCORE_ENVIRONMENT: Development + DbSettings__ConnectionString: "Host=postgres;Database=modgud;Username=postgres;Password=postgres;Keepalive=30" + AppUrl: "http://0.0.0.0:8081" + # Issuer must match how clients reach the container (host-mapped :18080). + # Per-realm issuer overrides apply at request time via RealmIssuerHandler. + OpenIddict__Issuer: "http://localhost:18080" + OpenIddict__DevelopmentMode: "true" + # SMTP routes nowhere in this stack (no MTA) — fine for tests. + Email__Smtp__Host: "localhost" + Email__Smtp__Port: "25" + Email__Smtp__UseSsl: "false" + Email__Smtp__FromAddress: "noreply@localhost" + Email__Smtp__FromName: "Modgud" + depends_on: + postgres: + condition: service_healthy + +volumes: + apptest-pgdata: From a491e6cf7c837b33b70aeea467c735a8a2d8380f Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 12:39:40 +0200 Subject: [PATCH 18/21] docs(provisioning): user-facing guide for declarative realm provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public/in-app docs (docs/) so humans AND agents discover the feature and learn the contract: - New admin/realm-provisioning.md — what it is + why (realm-as-code, per-test realms, agent automation), the endpoint table, where to fetch the JSON Schema (GET /manifest-schema, realm:write), the manifest at a glance (keys-not-ids, resource:action), a curl quickstart, merge-vs-prune, structure-only export, the TestKit, and the host-routing caveat for OAuth flows. - Registered in the docs sidebar (Realm group) + linked from the admin index and the Realms page. - reference/realm-api.md: added the import / apply / apply?prune / export / manifest-schema rows and the ?hard=true note. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/.vitepress/config-base.ts | 1 + docs/admin/index.md | 1 + docs/admin/realm-provisioning.md | 184 +++++++++++++++++++++++++++++++ docs/admin/realms.md | 8 ++ docs/reference/realm-api.md | 9 +- 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 docs/admin/realm-provisioning.md diff --git a/docs/.vitepress/config-base.ts b/docs/.vitepress/config-base.ts index 0faca68e..f896bc10 100644 --- a/docs/.vitepress/config-base.ts +++ b/docs/.vitepress/config-base.ts @@ -161,6 +161,7 @@ export const baseConfig = { items: [ { text: 'Applications', link: '/admin/applications' }, { text: 'Realms', link: '/admin/realms' }, + { text: 'Declarative Realm Provisioning', link: '/admin/realm-provisioning' }, { text: 'Realm Settings', link: '/admin/realm-settings' }, { text: 'Auth Log', link: '/admin/auth-log' }, { text: 'Scheduled Jobs', link: '/admin/scheduled-jobs' }, diff --git a/docs/admin/index.md b/docs/admin/index.md index 33b15678..57e304df 100644 --- a/docs/admin/index.md +++ b/docs/admin/index.md @@ -33,6 +33,7 @@ Modgud is not just a login frontend — it's a full **OAuth 2.0 / OpenID Connect - [Login Providers](./login-providers) — built-in Internal plus external OIDC (Google, Microsoft, Entra, any OIDC); step-by-step setup walkthroughs included - [Realms](./realms) — multi-tenant setup; each tenant gets its own database +- [Declarative Realm Provisioning](./realm-provisioning) — create/update/tear down a whole realm from one JSON manifest (realm-as-code, per-test realms, agent automation); serves a fetchable schema - [Realm Settings](./realm-settings) — realm-admin-owned config (self-registration, DCR policy, branding) ### Customization diff --git a/docs/admin/realm-provisioning.md b/docs/admin/realm-provisioning.md new file mode 100644 index 00000000..3be07500 --- /dev/null +++ b/docs/admin/realm-provisioning.md @@ -0,0 +1,184 @@ +# Declarative Realm Provisioning + +Provision a **complete realm from a single JSON document** — apps, OAuth +APIs/scopes/clients, roles, users, groups and realm settings — in one call, +at runtime. Think *realm-as-code*: instead of clicking (or scripting dozens of) +admin API calls, you `POST` a **manifest** and Modgud materialises the whole +realm by running the same operations the admin UI uses. + +It's built for three jobs: + +- **Bootstrap a realm reproducibly** — keep a realm's shape in version control and + re-apply it. +- **Per-test realms** — an app's integration suite spins up a fresh, isolated realm + per run (every realm is a physically separate database, so tests run in parallel), + then tears it down. +- **Agents / automation** — a machine can fetch the [contract schema](#discover-the-schema) + and author a valid manifest without reading any source. + +::: info Where it runs +All endpoints live on the **Control-Plane realm** (the realm flagged +`IsControlPlane = true`, i.e. the system realm). On any other host they return +**404**. See [Control Plane / Data Plane](../concepts/control-plane). +::: + +## The endpoints + +All under `/api/admin/realms`, all requiring **`realm:write`** on the +`control-plane` app (the `realm:admin` bypass also grants it): + +| Method | Path | What it does | +|---|---|---| +| `POST` | `/import` | Create a **new** realm from a manifest. The slug must not exist. All-or-nothing: a failed import rolls the whole realm back. | +| `POST` | `/{slug}/apply` | **Merge** a manifest into an existing realm (upsert per entity). Never drops the database. | +| `POST` | `/{slug}/apply?prune=true` | **Full sync** — like apply, then delete entities present in the realm but absent from the manifest. | +| `GET` | `/{slug}/export` | Export the realm as a manifest (structure-only — never secrets or password hashes). | +| `GET` | `/manifest-schema` | The JSON Schema for the manifest (see [below](#discover-the-schema)). | +| `DELETE` | `/{slug}?hard=true` | **Hard-delete** — drop the tenant database. Without `?hard=true` it's the reversible soft-delete. | + +Authenticate as a Control-Plane admin (cookie or bearer) before calling these — +e.g. `POST /api/account/login` for a cookie session. + +## Discover the schema + +You don't have to guess property names. The full, machine-readable **JSON Schema** +of the manifest — every field, its type, what's required, a description per field, +and a worked example — is served live: + +```http +GET /api/admin/realms/manifest-schema (realm:write) +``` + +The schema is **generated from the live manifest type** using the API's own JSON +settings, so it can never drift from what `import`/`apply` actually accept. It's +gated with the same permission as import/apply: only a caller who could apply a +manifest may fetch its schema. + +```bash +curl -b cookies.txt https:///api/admin/realms/manifest-schema +``` + +Point any JSON-Schema-aware tool (or an agent) at the result and it can validate +and author manifests directly. + +## The manifest at a glance + +A manifest is one object with a required `Realm` plus optional entity lists. +**Cross-references use stable keys, never server-generated ids:** + +- APIs / scopes / clients / roles reference an app by its **`Slug`**. +- Permissions are addressed as **`resource:action`** (e.g. `invoice:read`). +- Groups list **`Members`** (user keys) and **`Roles`** (role keys). Group + membership is the *only* way users get roles. + +```jsonc +{ + "Realm": { // REQUIRED — shell + first admin + "Slug": "acme", + "DisplayName": "Acme", + "Domains": ["acme.example.com"], + "InitialAdmin": { "UserName": "admin", "Email": "admin@acme.example.com" } + }, + "Settings": { /* optional realm-settings patch (self-reg, native grants, …) */ }, + "Apps": [ { "Slug": "acme", "DisplayName": "Acme", + "Permissions": [ { "Resource": "invoice", "Action": "read" } ] } ], + "Apis": [ { "Name": "acme-api", "App": "acme", + "Permissions": [ { "Resource": "invoice", "Action": "read" } ] } ], + "Scopes": [ { "Name": "invoice.read", "App": "acme", "Resources": ["acme-api"] } ], + "Clients": [ { "ClientId": "acme-web", "ClientType": "confidential", + "RedirectUris": ["https://acme.example.com/cb"], + "Scopes": ["openid", "invoice.read"], + "AllowedGrantTypes": ["authorization_code", "refresh_token"], + "Apps": ["acme"] } ], + "Roles": [ { "Key": "acme-admin", "Name": "acme-admin", "App": "acme", + "Permissions": [ { "Resource": "invoice", "Action": "read" } ] } ], + "Users": [ { "Key": "alice", "Email": "alice@acme.example.com", "UserName": "alice" } ], + "Groups": [ { "Name": "Admins", "Members": ["alice"], "Roles": ["acme-admin"] } ] +} +``` + +See the [schema](#discover-the-schema) for every field and its meaning. + +## Quickstart + +```bash +AUTH=https:// + +# 1) Log in as a Control-Plane admin (cookie) +curl -c cookies.txt -X POST "$AUTH/api/account/login" \ + -H 'Content-Type: application/json' \ + -d '{"UserName":"admin","Password":""}' + +# 2) Create the realm from a manifest → 201, with any generated client secrets +curl -b cookies.txt -X POST "$AUTH/api/admin/realms/import" \ + -H 'Content-Type: application/json' -d @manifest.json +# → {"Slug":"acme","PrimaryDomain":"acme.example.com","ClientSecrets":{"acme-web":"…"}} + +# 3) Later: re-apply changes in place (merge) +curl -b cookies.txt -X POST "$AUTH/api/admin/realms/acme/apply" \ + -H 'Content-Type: application/json' -d @manifest.json + +# 4) Tear it down +curl -b cookies.txt -X DELETE "$AUTH/api/admin/realms/acme?hard=true" +``` + +::: tip Client secrets +Confidential clients get a **generated secret returned only at import** (in +`ClientSecrets`). Store it then — there's no way to read it back later. Existing +clients keep their secret across `apply`. +::: + +## Apply: merge vs. prune + +`apply` is a desired-state merge **for the fields the manifest carries**: + +- Boolean flags are nullable — omit one to leave it unchanged (it takes the shipped + default only on create). +- Scalar strings and non-empty lists replace; an omitted/empty value is left + unchanged (apply never clears a list or detaches a link). +- App-catalog permission ids are preserved across updates, so unchanged permissions + keep their grants. + +Add **`?prune=true`** to make it a full sync: after the merge, entities in the realm +that are *absent* from the manifest are deleted (in dependency order). To prevent a +manifest from locking a realm out, prune **never deletes** the system app, auto-seeded +standard scopes, service-account-linked clients, or anything conferring `realm:admin` +(a realm-admin role, any current admin user, or an admin-conferring group). + +## Export + +`GET /{slug}/export` returns the realm as a manifest — the inverse of import. It is +**structure-only**: it never emits client secrets or password hashes (those are +one-way), and it omits auto-seeded standard scopes / system apps / SA-linked clients. +This is deliberate — it is *not* a backup (a real backup needs the whole tenant +database). Its purpose is **get-config → edit → re-apply**: export a realm, add a user +password or tweak a setting, and `POST` it back to `/{slug}/apply`. Because confidential +clients regenerate a secret on import and users can be created passwordless, a +structure-only manifest still re-applies into a fully working realm. + +## Provisioning from a .NET test suite + +For .NET apps, the **`Modgud.Provisioning.TestKit`** package wraps these endpoints +with automatic teardown — give each test a unique slug and dispose to hard-delete: + +```csharp +var http = new HttpClient(new HttpClientHandler { CookieContainer = new() }) + { BaseAddress = new Uri("https://") }; +await http.PostAsJsonAsync("/api/account/login", + new { UserName = "admin", Password = "" }); + +var kit = new ModgudProvisioningClient(http); +await using var realm = await kit.ImportRealmAsync(manifest); // dispose → hard-delete +var secret = realm.SecretFor("acme-web"); +``` + +## Caveat — using a provisioned realm for OAuth flows + +Creating, updating and deleting realms is host-agnostic (the control-plane endpoints +live on the system realm). But **driving OAuth flows _against_ a provisioned realm is +host-routed**: Modgud resolves the tenant from the request's `Host` header +(`Realm.Domains`), and each realm's issuer is `https://{PrimaryDomain}`. So a token +request for a realm must arrive with that realm's host. For machine flows +(`client_credentials`, native grants, introspection) that's just a `Host` header; for +browser authorization-code flows the realm host must be reachable and match the issuer +you configure in the client. diff --git a/docs/admin/realms.md b/docs/admin/realms.md index ece9db9c..35858f31 100644 --- a/docs/admin/realms.md +++ b/docs/admin/realms.md @@ -73,6 +73,14 @@ Control-Plane host. From a tenant host the realm-management surface is 404. ::: +::: tip Realm-as-code / per-test realms +To create (or update, or tear down) a **complete** realm — apps, OAuth +clients/scopes/APIs, roles, users, groups and settings — from a single JSON +manifest in one call, see [Declarative Realm Provisioning](./realm-provisioning). +Ideal for reproducible setups, per-test realms, and automation. It also serves a +JSON Schema of the manifest you (or an agent) can fetch to author it. +::: + Admin → **Realms** → **Create**. | Field | Example | diff --git a/docs/reference/realm-api.md b/docs/reference/realm-api.md index b936996f..b7b0ca0f 100644 --- a/docs/reference/realm-api.md +++ b/docs/reference/realm-api.md @@ -15,8 +15,15 @@ Endpoints in `Modgud.Api/Features/Admin/RealmsEndpoints.cs`. | `GET` | `/api/admin/realms/{slug}` | `realm:read` | | `POST` | `/api/admin/realms` | `realm:write` | | `PATCH` | `/api/admin/realms/{slug}` | `realm:write` | -| `DELETE` | `/api/admin/realms/{slug}` | `realm:write` (soft-delete = deactivate) | +| `DELETE` | `/api/admin/realms/{slug}` | `realm:write` (soft-delete = deactivate; `?hard=true` drops the tenant database) | | `POST` | `/api/admin/realms/{slug}/resend-bootstrap-invite` | `realm:write` | +| `POST` | `/api/admin/realms/import` | `realm:write` (create a realm from a manifest) | +| `POST` | `/api/admin/realms/{slug}/apply` | `realm:write` (merge a manifest; `?prune=true` = full sync) | +| `GET` | `/api/admin/realms/{slug}/export` | `realm:read` (structure-only manifest) | +| `GET` | `/api/admin/realms/manifest-schema` | `realm:write` (JSON Schema of the manifest + example) | + +See [Declarative Realm Provisioning](../admin/realm-provisioning) for the manifest +contract, merge-vs-prune semantics, and how to fetch the schema. ::: tip Permission context These permissions live in the **`control-plane`** App's catalog From 51aaa0cad581b61d972f1177bf0ee7fa850a4eb4 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 13:07:31 +0200 Subject: [PATCH 19/21] feat(provisioning): per-realm self-service config (realm:admin, data-plane) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second declarative-config surface for delegating ONE realm to its own admin — without control-plane powers. New RealmConfigEndpoints at /api/admin/realm-config/* (apply / export / manifest-schema), reusing the same RealmManifestApplier / RealmManifestExporter as the control plane; only the entry point + gate differ. - Runs on the realm's OWN host (not control-plane-filtered), gated by realm:admin in the current realm (works for a user OR a service-account token holding realm:admin). - Scope = TenantContext.Current: the endpoint pins the manifest to the current realm; a manifest targeting a different slug is refused (Manifest.SlugMismatch). No import, no realm-delete — realm lifecycle stays control-plane-only. apply supports ?prune=true bounded to the realm, with the same lockout/infra protections. - So an operator can: create a realm (control plane), grant a principal realm:admin in it, and hand that credential off — it can fully manage that one realm's config + entities and nothing else. Tests (cold-start): apply manages the current realm + export round-trips; a foreign slug is rejected (400); the surface is gated for an unauthenticated caller. Docs: the admin guide now contrasts the two surfaces and documents delegation; reference + the dev-docs note updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../declarative-realm-provisioning.md | 20 ++++ docs/admin/realm-provisioning.md | 74 +++++++++++-- docs/reference/realm-api.md | 12 +++ .../ColdStart/RealmConfigEndpointsTests.cs | 98 +++++++++++++++++ .../Provisioning/RealmConfigEndpoints.cs | 101 ++++++++++++++++++ src/dotnet/Modgud.Api/Program.cs | 2 + 6 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/ColdStart/RealmConfigEndpointsTests.cs create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmConfigEndpoints.cs diff --git a/dev-docs/future-features/declarative-realm-provisioning.md b/dev-docs/future-features/declarative-realm-provisioning.md index ed8cba6d..210ae6eb 100644 --- a/dev-docs/future-features/declarative-realm-provisioning.md +++ b/dev-docs/future-features/declarative-realm-provisioning.md @@ -37,6 +37,26 @@ dropping the tenant DB would discard the realm's signing keys, wipe the OpenIddict token store, and change every user's `sub`, invalidating all issued tokens. +### Per-realm (data-plane) surface — delegated self-service + +The above is the **control-plane** surface (operators; full realm lifecycle). A second +surface lets a realm's **own** admin manage just that realm declaratively, without +control-plane powers — `RealmConfigEndpoints` at `/api/admin/realm-config/*` +(`apply` / `export` / `manifest-schema`): + +- Runs on **any** realm's own host (NOT control-plane-filtered), gated by + **`realm:admin` in the current realm** (`.RequiresPermission(PermissionEvaluator.RealmAdminPermission)` + on the `modgud` app), reachable by a user OR a service-account token holding realm:admin. +- **Scope = `TenantContext.Current`** (the host-routed realm). The endpoint pins the + manifest to the current slug; a manifest whose `Realm.Slug` names a different realm is + rejected (`Manifest.SlugMismatch`). No `import`, no realm-delete — lifecycle stays + control-plane-only. +- **Reuses the same `RealmManifestApplier.UpdateRealmAsync` / `RealmManifestExporter`** — it + only changes the entry point + the gate. Works from the data plane because the applier + reads the global realm record (any host) then `TenantContext.Enter`s the slug; prune's + lockout/infra protections apply identically. The realm shell (domains/slug) is not mutated + by apply — only in-realm config + entities. Tests: `RealmConfigEndpointsTests`. + ## The governing invariant > **Exactly one canonical write path per mutation.** The applier reimplements diff --git a/docs/admin/realm-provisioning.md b/docs/admin/realm-provisioning.md index 3be07500..abdc842a 100644 --- a/docs/admin/realm-provisioning.md +++ b/docs/admin/realm-provisioning.md @@ -16,16 +16,30 @@ It's built for three jobs: - **Agents / automation** — a machine can fetch the [contract schema](#discover-the-schema) and author a valid manifest without reading any source. -::: info Where it runs -All endpoints live on the **Control-Plane realm** (the realm flagged -`IsControlPlane = true`, i.e. the system realm). On any other host they return -**404**. See [Control Plane / Data Plane](../concepts/control-plane). -::: +## Two surfaces — pick by who's calling + +The same manifest format is applied through **two** surfaces, differing in scope and +who's allowed: + +| | Control-plane provisioning | Per-realm self-service | +|---|---|---| +| **For** | Operators managing the deployment | A realm's own admin (delegate this) | +| **Path** | `/api/admin/realms/*` | `/api/admin/realm-config/*` | +| **Runs on** | Control-Plane realm only (404 elsewhere) | The realm's own host (any realm) | +| **Permission** | `realm:write` on the `control-plane` app | `realm:admin` **in that realm** | +| **Can** | Create / update / export / **delete any** realm | Update + export **its own** realm (incl. prune) | +| **Cannot** | — | Create or delete realms; touch another realm | + +If you run a **shared** Modgud and want to hand one realm to an app team (or an +agent) so they manage *only* that realm without operator powers, use +[per-realm self-service](#per-realm-self-service). For full lifecycle control +(creating/removing realms), use the control-plane surface below. -## The endpoints +## Control-plane endpoints All under `/api/admin/realms`, all requiring **`realm:write`** on the -`control-plane` app (the `realm:admin` bypass also grants it): +`control-plane` app (the `realm:admin` bypass also grants it), and only on the +Control-Plane host ([404 elsewhere](../concepts/control-plane)): | Method | Path | What it does | |---|---|---| @@ -156,6 +170,52 @@ password or tweak a setting, and `POST` it back to `/{slug}/apply`. Because conf clients regenerate a secret on import and users can be created passwordless, a structure-only manifest still re-applies into a fully working realm. +## Per-realm self-service + +On a **shared** deployment you often want to delegate one realm to its owner — an app +team or an agent — so they can fully manage *that* realm's config and entities, but +**not** create or delete realms and **not** see any other realm. That is exactly what a +**`realm:admin` in that realm** can do, through `/api/admin/realm-config/*`: + +| Method | Path | What it does | +|---|---|---| +| `GET` | `/api/admin/realm-config/manifest-schema` | The manifest JSON Schema (identical to the control-plane one). | +| `GET` | `/api/admin/realm-config/export` | Export **this** realm as a manifest. | +| `POST` | `/api/admin/realm-config/apply` | Apply a manifest to **this** realm (merge; `?prune=true` = full sync within the realm). | + +- **Scope is the calling realm** — resolved from the request host, never from a slug in + the body. A manifest whose `Realm.Slug` names a *different* realm is rejected + (`Manifest.SlugMismatch`). There is no `import` and no realm-delete here — realm + lifecycle stays control-plane-only. +- **Permission**: `realm:admin` in the realm being called. Nothing control-plane. +- **Same engine, same protections** as the control-plane path: prune is bounded to the + realm and never removes the system app, standard scopes, service-account clients, or any + `realm:admin` path — so a manifest can't lock the realm out. + +### Delegating a realm + +To grant someone management of exactly one realm: + +1. **Create the realm** (control-plane: `import`, or the admin UI). +2. **In that realm, give the principal `realm:admin`** — either a **user** (interactive) + or a **service account** (machine / agent, `client_credentials`). Both work; the only + requirement is that the credential carries `realm:admin` in that realm. +3. They call `/api/admin/realm-config/*` against the realm's host with that credential. + +That credential can do everything to *its* realm's config and **nothing** to any other +realm — and cannot create or delete realms. + +```bash +REALM=https://acme.example.com # the realm's own host + +curl -c cookies.txt -X POST "$REALM/api/account/login" \ + -H 'Content-Type: application/json' -d '{"UserName":"realm-admin","Password":""}' + +curl -b cookies.txt "$REALM/api/admin/realm-config/export" # current config +curl -b cookies.txt -X POST "$REALM/api/admin/realm-config/apply" \ + -H 'Content-Type: application/json' -d @manifest.json # apply edits (+ ?prune=true) +``` + ## Provisioning from a .NET test suite For .NET apps, the **`Modgud.Provisioning.TestKit`** package wraps these endpoints diff --git a/docs/reference/realm-api.md b/docs/reference/realm-api.md index b7b0ca0f..b7be1e38 100644 --- a/docs/reference/realm-api.md +++ b/docs/reference/realm-api.md @@ -25,6 +25,18 @@ Endpoints in `Modgud.Api/Features/Admin/RealmsEndpoints.cs`. See [Declarative Realm Provisioning](../admin/realm-provisioning) for the manifest contract, merge-vs-prune semantics, and how to fetch the schema. +### Per-realm self-service (data plane — not control-plane) + +A realm's own admin can manage **just that realm** from a manifest, without control-plane +powers. These run on the realm's **own host** and require **`realm:admin` in that realm** +(not the `control-plane` app); they cannot create or delete realms or target another realm. + +| Method | Path | Permission | +|---|---|---| +| `GET` | `/api/admin/realm-config/manifest-schema` | `realm:admin` (in the realm) | +| `GET` | `/api/admin/realm-config/export` | `realm:admin` (in the realm) | +| `POST` | `/api/admin/realm-config/apply` | `realm:admin` (in the realm; `?prune=true` = full sync within the realm) | + ::: tip Permission context These permissions live in the **`control-plane`** App's catalog (seeded only on the Control-Plane realm). The same string `realm:read` diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmConfigEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmConfigEndpointsTests.cs new file mode 100644 index 00000000..fecf1a88 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmConfigEndpointsTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.Http.Json; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authorization.Apps; +using Modgud.Infrastructure.Persistence.Tenancy; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// The per-realm (data-plane) declarative-config surface: a realm:admin manages THEIR +/// OWN realm from a manifest via /api/admin/realm-config/* — reusing the applier/exporter +/// but scoped to the host-routed realm and gated by realm:admin (not the control plane). It can +/// fully edit the realm's config + entities (incl. prune within the realm), but cannot target +/// another realm, and create/delete-realm stay control-plane-only. +/// +public class RealmConfigEndpointsTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Apply_manages_the_current_realm_for_a_realm_admin() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + + // Slug omitted → the endpoint pins the manifest to the caller's current realm. + var manifest = new + { + Realm = new { }, + Apps = new[] + { + new { Slug = "rc-app", DisplayName = "Realm-Config App", + Permissions = new[] { new { Resource = "rc", Action = "read" } } }, + }, + }; + + var apply = await client.PostAsJsonAsync( + "/api/admin/realm-config/apply", manifest, factory.JsonOptions, ct); + Assert.Equal(HttpStatusCode.OK, apply.StatusCode); + + // It landed in the current (system) realm — the data-plane apply targets TenantContext.Current. + await InTenantAsync(factory, TenantConstants.SystemTenantId, async sp => + { + var session = sp.GetRequiredService(); + Assert.True(await session.Query().AnyAsync(a => !a.IsDeleted && a.Slug == "rc-app", ct), + "rc-app applied to the current realm via the data-plane endpoint"); + }); + + // Export of the current realm works on the same surface (round-trips the manifest). + var export = await client.GetAsync("/api/admin/realm-config/export", ct); + Assert.Equal(HttpStatusCode.OK, export.StatusCode); + Assert.Contains("rc-app", await export.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task Apply_refuses_a_manifest_targeting_a_different_realm() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + + // A realm admin may only manage their own realm — a foreign slug is the data-plane boundary. + var foreign = new + { + Realm = new { Slug = "some-other-realm" }, + Apps = new[] { new { Slug = "x", DisplayName = "X", Permissions = new object[0] } }, + }; + + var resp = await client.PostAsJsonAsync( + "/api/admin/realm-config/apply", foreign, factory.JsonOptions, ct); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.Contains("Manifest.SlugMismatch", await resp.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task Surface_is_gated_for_an_unauthenticated_caller() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var ct = TestContext.Current.CancellationToken; + + var anon = host.Factory.CreateClient(); + var resp = await anon.GetAsync("/api/admin/realm-config/manifest-schema", ct); + + Assert.Contains(resp.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); + } + + private static async Task InTenantAsync( + ColdStartWebApplicationFactory factory, string slug, Func body) + { + using var _ = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await body(scope.ServiceProvider); + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmConfigEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmConfigEndpoints.cs new file mode 100644 index 00000000..c732e76a --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmConfigEndpoints.cs @@ -0,0 +1,101 @@ +using ErrorOr; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Options; +using Modgud.Authorization.AspNetCore; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Permissions; + +namespace Modgud.Api.Features.Admin.Provisioning; + +/// +/// Per-realm (data-plane) declarative config — lets a realm:admin manage THEIR OWN +/// realm from a manifest (export → edit → apply, with optional prune), reusing the same +/// / as the +/// control-plane provisioning. +/// +/// The difference is the scope + the gate. These endpoints are NOT control-plane: they +/// run on whatever realm the request is host-routed to () +/// and require realm:admin in THAT realm. So a delegated per-realm credential +/// (a service account or user holding realm:admin in one realm) can fully manage that realm's +/// config + entities, but CANNOT create or delete realms (those stay control-plane-only) and +/// cannot touch any other realm (tenant isolation + the slug guard below). Prune is allowed, +/// but only within the realm and with the same lockout/infra protections as the control-plane +/// path (system app, standard scopes, SA clients, and every realm:admin path are never pruned). +/// +public static class RealmConfigEndpoints +{ + public static WebApplication MapRealmConfigEndpoints(this WebApplication application, string path) + { + var group = application.MapGroup($"{path}/admin/realm-config") + .WithTags("Realm Config") + .RequireAuthorization() + // realm:admin in the CURRENT realm (the modgud app's realm-wide bypass). Not the + // control-plane app — this surface is the realm's own, not cross-realm. + .RequiresPermission(PermissionEvaluator.RealmAdminPermission); + + // The manifest JSON Schema (identical to the control-plane one) so a realm admin / agent + // can fetch the contract without control-plane access. + group.MapGet("manifest-schema", (IOptions jsonOptions) => + { + var schema = RealmManifestSchema.Build(jsonOptions.Value.SerializerOptions); + return Results.Text( + schema.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }), + "application/json"); + }) + .WithName("RealmConfig_ManifestSchema"); + + // Export THIS realm's config as a structure-only manifest (never secrets / hashes). + group.MapGet("export", async (RealmManifestExporter exporter, CancellationToken ct) => + { + var result = await exporter.ExportRealmAsync(TenantContext.Current, ct); + return result.IsError ? ManifestError(result.Errors) : Results.Ok(result.Value); + }) + .WithName("RealmConfig_Export"); + + // Apply a manifest to THIS realm: in-place merge/upsert. ?prune=true makes it a full + // sync (deletes entities absent from the manifest) — bounded to this realm, protections + // as on the control-plane path. Never drops the realm database. + group.MapPost("apply", async ( + RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct, bool prune = false) => + { + var currentSlug = TenantContext.Current; + + // A realm admin may only manage their OWN realm. A manifest aimed at a different + // slug is refused — this is the data-plane safety boundary (cross-realm writes and + // realm lifecycle stay control-plane-only). + if (!string.IsNullOrEmpty(manifest.Realm.Slug) && + !string.Equals(manifest.Realm.Slug, currentSlug, StringComparison.Ordinal)) + { + return Results.BadRequest(new + { + Error = "Manifest.SlugMismatch", + Message = $"This realm is '{currentSlug}'. A realm admin can only manage their own realm; the manifest targets '{manifest.Realm.Slug}'.", + }); + } + + // Pin the manifest to the current realm (covers an empty slug in the body). The + // realm shell (domains/display name) is not mutated by apply — only in-realm config. + var scoped = manifest with { Realm = manifest.Realm with { Slug = currentSlug } }; + var result = await applier.UpdateRealmAsync(scoped, prune, ct); + return result.IsError ? ManifestError(result.Errors) : Results.Ok(result.Value); + }) + .WithName("RealmConfig_Apply"); + + return application; + } + + // Renders a manifest ErrorOr with the error code in the body (mirrors RealmsEndpoints). + private static IResult ManifestError(List errors) + { + var error = errors[0]; + var status = error.Type switch + { + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status500InternalServerError, + }; + return Results.Json(new { Error = error.Code, Message = error.Description }, statusCode: status); + } +} diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index bab8cfa9..c7d04bc8 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -25,6 +25,7 @@ using Modgud.Authentication.Api.Account; using Modgud.Authentication.Api.Account.Services; using Modgud.Api.Features.Admin; +using Modgud.Api.Features.Admin.Provisioning; using Modgud.Api.Features.Admin.OAuth; using Modgud.Authentication.Api.Admin; using Modgud.Authentication.Api.Admin.LoginProviders; @@ -1217,6 +1218,7 @@ app.MapAppSettingsEndpoints("api"); app.MapProjectionEndpoints("api"); app.MapRealmsEndpoints("api"); + app.MapRealmConfigEndpoints("api"); app.MapOAuthClientsEndpoints("api"); app.MapOAuthScopesEndpoints("api"); app.MapOAuthApisEndpoints("api"); From 323d4f3c6f71977f484ad0b2e64c792395572b5c Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 13:17:03 +0200 Subject: [PATCH 20/21] docs(app-testing): agent quickstart for a running instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a TL;DR at the top of the app-testing README for the common case where an agent is handed an already-running instance: base URL, control-plane admin creds, and the 5-step login → fetch-schema → import → test → delete flow, with pointers to the TestKit recipe and the OAuth host-routing caveat. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/app-testing/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/dev/app-testing/README.md b/dev/app-testing/README.md index a56d86d2..fbe90e1d 100644 --- a/dev/app-testing/README.md +++ b/dev/app-testing/README.md @@ -9,6 +9,25 @@ realm-provisioning API (`import` / `apply` / `?prune=true` / hard-delete) and th > This uses the **locally-built `modgud:local`** image, not `ghcr.io/cocoar-dev/modgud:beta` > — the provisioning feature isn't on `:beta` yet (branch `feat/realm-declarative-provisioning`). +## TL;DR — you were given a running instance + +If someone already started this for you, here's all you need: + +- **Base URL:** `http://localhost:18080` (admin UI + `/api`; in-app docs at `/docs/`) +- **Control-plane admin:** `admin` / `ABC12abc!` (full rights — create/delete any realm) +- **Flow for your tests:** + 1. Log in: `POST /api/account/login` `{"UserName":"admin","Password":"ABC12abc!"}` (keep the cookie). + 2. Learn the manifest contract: `GET /api/admin/realms/manifest-schema` (JSON Schema + example). + 3. Create a realm: `POST /api/admin/realms/import` with a manifest → get back the client secret(s). + 4. Point your app at it, run tests. + 5. Tear down: `DELETE /api/admin/realms/{slug}?hard=true`. + +.NET? Use `Modgud.Provisioning.TestKit` ([§3](#3-app-side-recipe-the-modgudprovisioningtestkit)). +Full schema/flow below; the host-routing caveat for real OAuth flows is in [§4](#4-caveat--using-the-provisioned-realm-for-oauth-flows). + +> Each test run should use a **unique realm slug** — realms are physically separate +> databases, so they're fully isolated and run in parallel. + ## The manifest contract — fetch the schema The import/apply body is a **realm manifest**. Its full JSON Schema — every field, its From c6041240de3d33938d130bc64519bdab79478d7e Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 14:02:43 +0200 Subject: [PATCH 21/21] docs(app-testing): describe how to GET an instance, not assert one exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TL;DR claimed a running instance at a fixed URL + hardcoded creds — that documents the current machine's state, which is wrong on any other box. Replace it with a state-neutral "At a glance": (1) get an instance running (build/run/bootstrap, §1), (2) the per-test flow. The bootstrap step now points at docs/getting-started/first-time-setup (the canonical recovery-CLI source) and notes the creds are an example, not a given. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/app-testing/README.md | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/dev/app-testing/README.md b/dev/app-testing/README.md index fbe90e1d..9e151169 100644 --- a/dev/app-testing/README.md +++ b/dev/app-testing/README.md @@ -9,24 +9,19 @@ realm-provisioning API (`import` / `apply` / `?prune=true` / hard-delete) and th > This uses the **locally-built `modgud:local`** image, not `ghcr.io/cocoar-dev/modgud:beta` > — the provisioning feature isn't on `:beta` yet (branch `feat/realm-declarative-provisioning`). -## TL;DR — you were given a running instance +## At a glance -If someone already started this for you, here's all you need: +1. **Get an instance running** — build the `modgud:local` image, start the stack, and create + the first control-plane admin ([§1 below](#1-one-time-setup)). The compose maps Modgud to + `http://localhost:18080` (admin UI + `/api`; in-app docs at `/docs/`). +2. **Per test run** — using the admin you created, log in, fetch the manifest schema, import a + realm with a **unique slug**, run your app's tests against it, then hard-delete it + ([§2](#2-smoke-test-the-loop-curl) for curl, [§3](#3-app-side-recipe-the-modgudprovisioningtestkit) + for the .NET `Modgud.Provisioning.TestKit`). -- **Base URL:** `http://localhost:18080` (admin UI + `/api`; in-app docs at `/docs/`) -- **Control-plane admin:** `admin` / `ABC12abc!` (full rights — create/delete any realm) -- **Flow for your tests:** - 1. Log in: `POST /api/account/login` `{"UserName":"admin","Password":"ABC12abc!"}` (keep the cookie). - 2. Learn the manifest contract: `GET /api/admin/realms/manifest-schema` (JSON Schema + example). - 3. Create a realm: `POST /api/admin/realms/import` with a manifest → get back the client secret(s). - 4. Point your app at it, run tests. - 5. Tear down: `DELETE /api/admin/realms/{slug}?hard=true`. - -.NET? Use `Modgud.Provisioning.TestKit` ([§3](#3-app-side-recipe-the-modgudprovisioningtestkit)). -Full schema/flow below; the host-routing caveat for real OAuth flows is in [§4](#4-caveat--using-the-provisioned-realm-for-oauth-flows). - -> Each test run should use a **unique realm slug** — realms are physically separate -> databases, so they're fully isolated and run in parallel. +Realms are physically separate databases → fully isolated and parallel-safe. The host-routing +caveat for driving real OAuth flows against a provisioned realm is in +[§4](#4-caveat--using-the-provisioned-realm-for-oauth-flows). ## The manifest contract — fetch the schema @@ -60,8 +55,9 @@ docker build -f docker/Dockerfile -t modgud:local . docker compose -f dev/app-testing/docker-compose.yml up -d # Create the first control-plane admin. The system realm IS the control plane, -# so a realm:admin there can call the provisioning endpoints. Idempotent-ish: -# re-running errors if the user exists — that's fine. +# so a realm:admin there can call the provisioning endpoints. This is the standard +# recovery-CLI bootstrap (see ../../docs/getting-started/first-time-setup.md for the +# concept). Pick any credentials you like — these are just an example. docker compose -f dev/app-testing/docker-compose.yml exec modgud ` dotnet Modgud.Api.dll recover bootstrap-admin ` --email admin@local --username admin --password 'ABC12abc!'