diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs index 2f6849f5..6f39c624 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs @@ -3,6 +3,7 @@ using BuildingBlocks.Helper; using Marten; using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Features.Admin.Apps; using Modgud.Api.Tests.Infrastructure; using Modgud.Application.DTOs.Applications; using Modgud.Authorization.Apps; @@ -13,25 +14,68 @@ namespace Modgud.Api.Tests.Authorization; /// -/// ADR-0011 — admin API for per-Application settings overrides -/// (GET/PATCH /api/app/{id}/settings): patch→get roundtrip across -/// sections, the Origin write also populating the global host→App routing map, -/// validation, and cross-app subdomain uniqueness. +/// ADR-0011 — an App is ONE resource: the per-App settings override is carried on the +/// unified App endpoint (POST/PUT/GET /api/app), there is no separate +/// /settings endpoint. Covers the atomic create (App + settings in one transaction, +/// and an invalid settings section rejecting the whole create), the update roundtrip across +/// sections, the Origin write also populating the global host→App routing map, validation, +/// and cross-app subdomain uniqueness. /// [Collection(IntegrationTestCollection.Name)] public class ApplicationSettingsAdminTests : IntegrationTestBase { public ApplicationSettingsAdminTests(SharedPostgresFixture fixture) : base(fixture) { } + private sealed record AppRead(string Id, string Slug, ApplicationSettingsDto? Settings); + + [Fact] + public async Task Create_With_Settings_PersistsBoth_Atomically() + { + var ct = TestContext.Current.CancellationToken; + var settings = new ApplicationSettingsDto + { + NativeGrants = new ApplicationNativeGrantsDto { Enabled = true, AccessTokenLifetimeMinutes = 10 }, + Branding = new ApplicationBrandingDto { ProductName = "Created Together" }, + }; + + var resp = await Client.PostAsJsonAsync("/api/app", + new CreateAppDto("as-create-app", "As Create App", null, [], settings), JsonOptions, ct); + Assert.True(resp.IsSuccessStatusCode, $"POST failed ({(int)resp.StatusCode}): {await resp.Content.ReadAsStringAsync(ct)}"); + + var created = await resp.Content.ReadFromJsonAsync(JsonOptions, ct); + Assert.True(created!.Settings!.NativeGrants!.Enabled); + Assert.Equal("Created Together", created.Settings.Branding!.ProductName); + + // Re-read confirms the override was committed alongside the App. + var got = await GetAppAsync(created.Id, ct); + Assert.Equal(10, got.Settings!.NativeGrants!.AccessTokenLifetimeMinutes); + } + + [Fact] + public async Task Create_With_Invalid_Settings_RejectsWholeCreate() + { + var ct = TestContext.Current.CancellationToken; + + // The App itself is valid, but the settings section is not → the whole create is + // rejected and NO App is left behind (one atomic transaction). + var resp = await Client.PostAsJsonAsync("/api/app", + new CreateAppDto("as-atomic-app", "As Atomic App", null, [], + new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } }), + JsonOptions, ct); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.False(await AppExistsAsync("as-atomic-app"), "the App must not be persisted when its settings are invalid"); + } + [Fact] - public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map() + public async Task Update_Settings_Roundtrips_And_Writes_The_Routing_Map() { var ct = TestContext.Current.CancellationToken; - var app = await CreateAppAsync("as-admin-app"); + var app = await SeedAppAsync("as-admin-app"); var appShort = new ShortGuid(app.Id).ToString(); var host = $"as-admin.{await SystemPrimaryDomainAsync()}"; - var patch = new ApplicationSettingsDto + var settings = new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Off", Enabled = true }, NativeGrants = new ApplicationNativeGrantsDto { Enabled = true, AccessTokenLifetimeMinutes = 10 }, @@ -41,12 +85,12 @@ public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map() RegistrationFields = new ApplicationRegistrationFieldsDto { Firstname = "Required", Lastname = "Off" }, Origin = new ApplicationOriginDto { Subdomain = host }, }; - var patchResp = await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", patch, JsonOptions, ct); - Assert.True(patchResp.IsSuccessStatusCode, - $"PATCH failed ({(int)patchResp.StatusCode}): {await patchResp.Content.ReadAsStringAsync(ct)}"); + var putResp = await Client.PutAsJsonAsync($"/api/app/{appShort}", + new UpdateAppDto("As Admin App", null, [], settings), JsonOptions, ct); + Assert.True(putResp.IsSuccessStatusCode, + $"PUT failed ({(int)putResp.StatusCode}): {await putResp.Content.ReadAsStringAsync(ct)}"); - var got = await (await Client.GetAsync($"/api/app/{appShort}/settings", ct)) - .Content.ReadFromJsonAsync(JsonOptions, ct); + var got = (await GetAppAsync(appShort, ct)).Settings; Assert.Equal("Off", got!.SelfRegistration!.Posture); Assert.True(got.SelfRegistration.Enabled); Assert.True(got.NativeGrants!.Enabled); @@ -69,48 +113,51 @@ public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map() } [Fact] - public async Task Patch_Rejects_Invalid_Values() + public async Task Update_Rejects_Invalid_Values() { var ct = TestContext.Current.CancellationToken; - var app = await CreateAppAsync("as-invalid-app"); + var app = await SeedAppAsync("as-invalid-app"); var appShort = new ShortGuid(app.Id).ToString(); // Bad posture. - Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", - new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } }, - JsonOptions, ct)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort, + new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } }, ct)).StatusCode); // Subdomain not under the realm's primary domain. - Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", - new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = "evil.example.com" } }, - JsonOptions, ct)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort, + new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = "evil.example.com" } }, ct)).StatusCode); // Out-of-bounds token lifetime. - Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", - new ApplicationSettingsDto { NativeGrants = new ApplicationNativeGrantsDto { AccessTokenLifetimeMinutes = 9999 } }, - JsonOptions, ct)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort, + new ApplicationSettingsDto { NativeGrants = new ApplicationNativeGrantsDto { AccessTokenLifetimeMinutes = 9999 } }, ct)).StatusCode); } [Fact] public async Task Subdomain_Is_Unique_Across_Apps() { var ct = TestContext.Current.CancellationToken; - var app1 = await CreateAppAsync("as-uniq-1"); - var app2 = await CreateAppAsync("as-uniq-2"); + var app1 = await SeedAppAsync("as-uniq-1"); + var app2 = await SeedAppAsync("as-uniq-2"); var host = $"as-uniq.{await SystemPrimaryDomainAsync()}"; - var first = await Client.PatchAsJsonAsync($"/api/app/{new ShortGuid(app1.Id)}/settings", - new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, JsonOptions, ct); + var first = await PutSettingsAsync(new ShortGuid(app1.Id).ToString(), + new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, ct); Assert.True(first.IsSuccessStatusCode); - var second = await Client.PatchAsJsonAsync($"/api/app/{new ShortGuid(app2.Id)}/settings", - new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, JsonOptions, ct); + var second = await PutSettingsAsync(new ShortGuid(app2.Id).ToString(), + new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, ct); Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); } // ── Helpers ────────────────────────────────────────────────────────────── - private async Task CreateAppAsync(string slug) + private Task PutSettingsAsync(string appShort, ApplicationSettingsDto settings, CancellationToken ct) => + Client.PutAsJsonAsync($"/api/app/{appShort}", new UpdateAppDto(appShort, null, [], settings), JsonOptions, ct); + + private async Task GetAppAsync(string appShort, CancellationToken ct) => + (await (await Client.GetAsync($"/api/app/{appShort}", ct)).Content.ReadFromJsonAsync(JsonOptions, ct))!; + + private async Task SeedAppAsync(string slug) { using var scope = Factory.Services.CreateScope(); var session = scope.ServiceProvider.GetRequiredService(); @@ -121,6 +168,14 @@ private async Task CreateAppAsync(string slug) return (await session.LoadAsync(id, TestContext.Current.CancellationToken))!; } + private async Task AppExistsAsync(string slug) + { + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + return await session.Query().Where(a => a.Slug == slug && !a.IsDeleted) + .AnyAsync(TestContext.Current.CancellationToken); + } + private async Task SystemPrimaryDomainAsync() { var globalStore = Factory.Services.GetRequiredService(); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/InviteCodeRegistrationFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/InviteCodeRegistrationFlowTests.cs index 83a350ba..e1155079 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/InviteCodeRegistrationFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/InviteCodeRegistrationFlowTests.cs @@ -166,21 +166,28 @@ public async Task InviteCode_ConfirmedUser_With_Code_Is_Plain_Login_Code_Untouch } [Fact] - public async Task InviteCode_Posture_RoundTrips_Through_Settings_Endpoint() + public async Task InviteCode_Posture_RoundTrips_Through_App_Resource() { - // Gate 1 — the new posture value survives PATCH → GET on the per-App - // settings surface (sparse, zero-migration). + // Gate 1 — the new posture value survives a unified App update → GET on the + // single App resource (settings carried inline; sparse, zero-migration). var ct = TestContext.Current.CancellationToken; var app = await CreateAppAsync("adr12-posture"); var appShort = new ShortGuid(app.Id).ToString(); - var patch = await Client.PatchAsJsonAsync( - $"/api/app/{appShort}/settings", - new { SelfRegistration = new { Posture = "InviteCode" } }, ct); - Assert.Equal(HttpStatusCode.OK, patch.StatusCode); - - var got = await Client.GetFromJsonAsync($"/api/app/{appShort}/settings", JsonOptions, ct); - Assert.Equal("InviteCode", got.GetProperty("SelfRegistration").GetProperty("Posture").GetString()); + var put = await Client.PutAsJsonAsync( + $"/api/app/{appShort}", + new + { + DisplayName = "adr12-posture", + Description = (string?)null, + Permissions = Array.Empty(), + Settings = new { SelfRegistration = new { Posture = "InviteCode" } }, + }, ct); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + + var got = await Client.GetFromJsonAsync($"/api/app/{appShort}", JsonOptions, ct); + Assert.Equal("InviteCode", + got.GetProperty("Settings").GetProperty("SelfRegistration").GetProperty("Posture").GetString()); } [Fact] diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs index 9885df16..1353c089 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs @@ -1,5 +1,7 @@ using ErrorOr; using Marten; +using Modgud.Application.DTOs.Applications; +using Modgud.Authentication.Applications; using Modgud.Authorization.Apps; using Modgud.Authorization.Events; using Modgud.Domain.OAuth.Apis; @@ -14,8 +16,16 @@ namespace Modgud.Api.Features.Admin.Apps; /// 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. +/// +/// An App is ONE resource: when / +/// is supplied, the per-App ADR-0011 settings override +/// is written in the SAME tenant transaction as the App aggregate (atomic). The only piece +/// that cannot be — the Origin subdomain, which drives the GLOBAL host→App routing +/// map in a different database — is validated up-front (so an invalid subdomain rejects the +/// whole call before any commit) and its routing applied right after the atomic commit. The +/// applier passes no settings, so its behaviour is unchanged. /// -public sealed class AppAdminService(IDocumentSession session) +public sealed class AppAdminService(IDocumentSession session, IApplicationSettingsService settingsSvc) { public async Task> CreateAppAsync(CreateAppDto dto, CancellationToken ct = default) { @@ -46,8 +56,19 @@ public async Task> CreateAppAsync(CreateAppDto dto, CancellationTok Description: dto.Description, Permissions: permissions.Value, IsSystem: false)); + + // App + its settings override commit together (atomic) — see class summary. + if (dto.Settings is not null) + { + var staged = await StageSettingsAsync(id, dto.Settings, ct); + if (staged.IsError) return staged.Errors; + } + await session.SaveChangesAsync(ct); + var originApplied = await ApplyOriginIfAnyAsync(id, dto.Settings, ct); + if (originApplied.IsError) return originApplied.Errors; + return (await session.LoadAsync(id, ct))!; } @@ -97,11 +118,52 @@ public async Task> UpdateAppAsync(Guid id, UpdateAppDto dto, Cancel session.Events.Append(id, new AppUpdatedEvent( id, dto.DisplayName, dto.Description, permissions.Value)); + + // App + its settings override commit together (atomic) — see class summary. + if (dto.Settings is not null) + { + var staged = await StageSettingsAsync(id, dto.Settings, ct); + if (staged.IsError) return staged.Errors; + } + await session.SaveChangesAsync(ct); + var originApplied = await ApplyOriginIfAnyAsync(id, dto.Settings, ct); + if (originApplied.IsError) return originApplied.Errors; + return (await session.LoadAsync(id, ct))!; } + /// + /// Validates the Origin subdomain up-front (so an invalid one rejects the whole + /// create/update before any commit) and stages every other settings section onto the + /// shared session — committed atomically with the App by the caller's SaveChangesAsync. + /// + private async Task> StageSettingsAsync(Guid appId, ApplicationSettingsDto settings, CancellationToken ct) + { + if (settings.Origin is not null) + { + var validOrigin = await settingsSvc.ValidateOriginAsync(appId, settings.Origin.Subdomain, ct); + if (validOrigin.IsError) return validOrigin.Errors; + } + var staged = await settingsSvc.StageNonOriginAsync(appId, settings, ct); + return staged.IsError ? staged.Errors : Result.Success; + } + + /// + /// Applies the Origin subdomain → GLOBAL host routing AFTER the atomic tenant commit + /// (the routing map lives in a different database, so it can't be in the tenant + /// transaction). Already validated in , so this only + /// fails on a race or infrastructure error — and then the App + its other settings are + /// already persisted (a valid state; only the subdomain route is missing). + /// + private async Task> ApplyOriginIfAnyAsync(Guid appId, ApplicationSettingsDto? settings, CancellationToken ct) + { + if (settings?.Origin is null) return Result.Success; + var r = await settingsSvc.PatchAsync(appId, new ApplicationSettingsDto { Origin = settings.Origin }, ct); + return r.IsError ? r.Errors : Result.Success; + } + /// /// The single canonical delete path for an , shared by /// and the realm-provisioning applier's prune. Refuses the diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/ApplicationSettingsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/ApplicationSettingsEndpoints.cs deleted file mode 100644 index 71821d06..00000000 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/ApplicationSettingsEndpoints.cs +++ /dev/null @@ -1,62 +0,0 @@ -using BuildingBlocks.Helper; -using Modgud.Application.DTOs.Applications; -using Modgud.Authentication.Applications; -using Modgud.Authorization.AspNetCore; - -namespace Modgud.Api.Features.Admin.Apps; - -/// -/// ADR-0011 — admin surface for a per-Application settings override (the -/// tenant-scoped ApplicationSettings doc keyed by App.Id). Sparse: -/// GET returns only what the App overrides (null section = inherits the realm); -/// PATCH replaces provided sections (a null section = no change). Setting -/// Origin.Subdomain also writes the global host→App routing map. Gated by -/// the same app:read/app:write permissions as the rest of App admin. -/// -public static class ApplicationSettingsEndpoints -{ - public static WebApplication MapApplicationSettingsEndpoints(this WebApplication application, string path) - { - var group = application.MapGroup($"{path}/app") - .WithTags("Application Settings") - .RequireAuthorization(); - - group.MapGet("{id}/settings", async ( - ShortGuid id, - IApplicationSettingsService svc, - CancellationToken ct) => - { - var result = await svc.GetAsync(id.Guid, ct); - return result.Match(Results.Ok, Problem); - }) - .WithName("V2_App_Settings_Get") - .RequiresPermission("app:read"); - - group.MapPatch("{id}/settings", async ( - ShortGuid id, - ApplicationSettingsDto dto, - IApplicationSettingsService svc, - CancellationToken ct) => - { - var result = await svc.PatchAsync(id.Guid, dto, ct); - return result.Match(Results.Ok, Problem); - }) - .WithName("V2_App_Settings_Patch") - .RequiresPermission("app:write"); - - return application; - } - - private static IResult Problem(List errors) - { - var first = errors[0]; - var status = first.Type switch - { - ErrorOr.ErrorType.NotFound => StatusCodes.Status404NotFound, - ErrorOr.ErrorType.Validation => StatusCodes.Status400BadRequest, - ErrorOr.ErrorType.Conflict => StatusCodes.Status409Conflict, - _ => StatusCodes.Status500InternalServerError, - }; - return Results.Problem(statusCode: status, title: first.Code, detail: first.Description); - } -} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs index c6fb6c63..d04d4a6f 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs @@ -1,7 +1,9 @@ using BuildingBlocks.Helper; using ErrorOr; +using Modgud.Application.DTOs.Applications; using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; +using Modgud.Authentication.Applications; using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; using Marten; @@ -25,12 +27,17 @@ public record CreateAppDto( string Slug, string DisplayName, string? Description, - List Permissions); + List Permissions, + // ADR-0011 — an App is ONE resource: the optional per-App settings override is created + // in the SAME tenant transaction as the App (see AppAdminService). Null = inherit the + // realm everywhere (the zero-config default). The applier never sends it. + ApplicationSettingsDto? Settings = null); public record UpdateAppDto( string DisplayName, string? Description, - List Permissions); + List Permissions, + ApplicationSettingsDto? Settings = null); /// /// Admin surface for managing records — the per-realm list @@ -74,34 +81,41 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s .OrderBy(a => a.Slug) .ToListAsync(); - return Results.Ok(apps.Select(MapToResponse)); + return Results.Ok(apps.Select(a => MapToResponse(a))); }) .WithName("V2_App_GetAll") .RequiresPermission("app:read"); - appGroup.MapGet("{id}", async (ShortGuid id, IDocumentSession session) => + appGroup.MapGet("{id}", async (ShortGuid id, IDocumentSession session, IApplicationSettingsService settingsSvc, CancellationToken ct) => { - var app = await session.LoadAsync(id.Guid); + var app = await session.LoadAsync(id.Guid, ct); if (app is null || app.IsDeleted) return Results.NotFound(); - return Results.Ok(MapToResponse(app)); + var settings = await settingsSvc.GetAsync(id.Guid, ct); + return Results.Ok(MapToResponse(app, settings.IsError ? null : settings.Value)); }) .WithName("V2_App_GetById") .RequiresPermission("app:read"); // 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) => + appGroup.MapPost("", async (CreateAppDto dto, AppAdminService appAdmin, IApplicationSettingsService settingsSvc, CancellationToken ct) => { var result = await appAdmin.CreateAppAsync(dto, ct); - return result.IsError ? ToErrorResult(result.FirstError) : Results.Ok(MapToResponse(result.Value)); + if (result.IsError) return ToErrorResult(result.FirstError); + var settings = await settingsSvc.GetAsync(result.Value.Id, ct); + return Results.Ok(MapToResponse(result.Value, settings.IsError ? null : settings.Value)); }) .WithName("V2_App_Create") .RequiresPermission("app:write"); - appGroup.MapPut("{id}", async (ShortGuid id, UpdateAppDto dto, AppAdminService appAdmin, CancellationToken ct) => + appGroup.MapPut("{id}", async (ShortGuid id, UpdateAppDto dto, AppAdminService appAdmin, IApplicationSettingsService settingsSvc, CancellationToken ct) => { var result = await appAdmin.UpdateAppAsync(id.Guid, dto, ct); - if (!result.IsError) return Results.Ok(MapToResponse(result.Value)); + if (!result.IsError) + { + var settings = await settingsSvc.GetAsync(id.Guid, ct); + return Results.Ok(MapToResponse(result.Value, settings.IsError ? null : settings.Value)); + } var error = result.FirstError; // The catalog-delete block carries its rich blocker list through the error @@ -155,7 +169,9 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s return application; } - private static object MapToResponse(App a) => new + // The list endpoint passes no settings (the grid doesn't show them); the detail / create / + // update responses pass the per-App settings override so the single App modal can render it. + private static object MapToResponse(App a, ApplicationSettingsDto? settings = null) => new { Id = new ShortGuid(a.Id).ToString(), a.Slug, @@ -171,6 +187,7 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s }) .ToList(), a.IsSystem, + Settings = settings, }; // Renders an AppAdminService ErrorOr error with the error code in the body. The shared diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index c7d04bc8..1e7936c2 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -1268,8 +1268,9 @@ app.MapPrincipalEndpoints("api"); app.MapRolesEndpoints("api"); app.MapGroupEndpoints("api"); + // An App is one resource: AppsEndpoints carries the per-App ADR-0011 settings override + // inline (POST/PUT/GET /api/app), so there is no separate /settings endpoint. Modgud.Api.Features.Admin.Apps.AppsEndpoints.MapAppsEndpoints(app, "api"); - Modgud.Api.Features.Admin.Apps.ApplicationSettingsEndpoints.MapApplicationSettingsEndpoints(app, "api"); // ADR-0012 — app-scoped invite codes (dual-auth: invite:write scope or invite-code:write permission). Modgud.Api.Features.InviteCodes.InviteCodeEndpoints.MapInviteCodeEndpoints(app, "api"); diff --git a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs index 7498edde..040f8ba5 100644 --- a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs @@ -23,6 +23,24 @@ public interface IApplicationSettingsService { Task> GetAsync(Guid applicationId, CancellationToken ct = default); Task> PatchAsync(Guid applicationId, ApplicationSettingsDto dto, CancellationToken ct = default); + + /// + /// Stages the per-App settings override (every section EXCEPT Origin) onto the + /// caller's shared WITHOUT committing, so it lands in the + /// same tenant transaction as the App aggregate — the unified, atomic App create/update. + /// Origin is excluded because it also drives the GLOBAL host→App routing map (a + /// different database, so inherently a separate write): validate it up-front via + /// and apply it via AFTER the + /// atomic commit. No app-existence check — the caller (AppAdminService) guarantees it. + /// + Task> StageNonOriginAsync(Guid applicationId, ApplicationSettingsDto dto, CancellationToken ct = default); + + /// + /// Read-only validation of an Origin subdomain (format, child-of-the-realm-primary, + /// cross-realm uniqueness) so a unified create/update can reject an invalid subdomain + /// up-front — before committing anything. An empty/null subdomain (a clear) is always valid. + /// + Task> ValidateOriginAsync(Guid applicationId, string? subdomain, CancellationToken ct = default); } public sealed class ApplicationSettingsService( @@ -135,14 +153,98 @@ public async Task> PatchAsync( return app is { IsDeleted: false } ? app : null; } + // ── Atomic-create staging (every section except Origin, no commit) ──────── + // REPLACE semantics: the DTO is the COMPLETE desired override state (the unified App + // create/update is a replace, not a sparse patch). A provided section sets the override; + // a NULL section CLEARS it (→ inherit the realm). This is what keeps the modal honest — + // an unchecked section round-trips back unchecked instead of as an empty-but-present + // override. (PatchAsync stays sparse for the Origin-only follow-on.) + + public async Task> StageNonOriginAsync( + Guid applicationId, ApplicationSettingsDto dto, CancellationToken ct = default) + { + var doc = await session.LoadAsync(applicationId, ct) + ?? new ApplicationSettings { Id = applicationId, CreatedAt = DateTimeOffset.UtcNow }; + if (doc.CreatedAt == default) doc.CreatedAt = DateTimeOffset.UtcNow; + + if (dto.SelfRegistration is null) doc.SelfRegistration = null; + else { var r = MapSelfRegistration(dto.SelfRegistration); if (r.IsError) return r.FirstError; doc.SelfRegistration = r.Value; } + + if (dto.NativeGrants is null) doc.NativeGrants = null; + else { var r = MapNativeGrants(dto.NativeGrants); if (r.IsError) return r.FirstError; doc.NativeGrants = r.Value; } + + if (dto.Dcr is null) doc.Dcr = null; + else { var r = MapDcr(dto.Dcr); if (r.IsError) return r.FirstError; doc.Dcr = r.Value; } + + if (dto.Cimd is null) doc.Cimd = null; + else { var r = MapCimd(dto.Cimd); if (r.IsError) return r.FirstError; doc.Cimd = r.Value; } + + if (dto.Branding is null) doc.Branding = null; + else { var r = MapBranding(dto.Branding); if (r.IsError) return r.FirstError; doc.Branding = r.Value; } + + doc.EmailBranding = string.IsNullOrWhiteSpace(dto.EmailBranding?.ProductName) + ? null + : new ApplicationEmailBranding { ProductName = dto.EmailBranding.ProductName!.Trim() }; + + if (dto.RegistrationFields is null) doc.RegistrationFields = null; + else { var r = MapRegistrationFields(dto.RegistrationFields); if (r.IsError) return r.FirstError; doc.RegistrationFields = r.Value; } + + doc.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(doc); // enrolled on the shared session; the caller commits. + return ErrorOr.Result.Success; + } + // ── Origin / global routing map ────────────────────────────────────────── + public async Task> ValidateOriginAsync( + Guid applicationId, string? subdomainRaw, CancellationToken ct = default) + { + var subdomain = subdomainRaw?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(subdomain)) return ErrorOr.Result.Success; // clearing is always valid + + var slug = TenantContext.Current; + await using var gsession = globalStore.LightweightSession(); + var realm = await gsession.Query().FirstOrDefaultAsync(r => r.Slug == slug, ct); + if (realm is null) + return Error.Failure("Application.RealmNotFound", "The current realm could not be resolved."); + + if (!HostRegex.IsMatch(subdomain)) + return Error.Validation("Application.InvalidSubdomain", "Subdomain must be a valid hostname."); + + // Must be a child of the realm's primary domain (the cookie + routing + // model: apps live under the tenant's primary domain). + var primary = realm.PrimaryDomain.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(primary) || !subdomain.EndsWith("." + primary, StringComparison.Ordinal)) + return Error.Validation("Application.SubdomainNotUnderPrimary", + $"Subdomain must be a child of the realm's primary domain ('{realm.PrimaryDomain}')."); + + // Cross-realm uniqueness: the host must not be claimed by any realm's + // plain domains or another App's route. + var allRealms = await gsession.Query().ToListAsync(ct); + foreach (var r in allRealms) + { + if (r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase))) + return Error.Conflict("Application.SubdomainTaken", "That host is already a realm domain."); + foreach (var kv in r.ApplicationDomains) + { + if (string.Equals(kv.Key, subdomain, StringComparison.OrdinalIgnoreCase) + && !(r.Id == realm.Id && kv.Value == applicationId)) + return Error.Conflict("Application.SubdomainTaken", "That host is already mapped to an application."); + } + } + + return ErrorOr.Result.Success; + } + private async Task> ApplyOriginAsync( Guid applicationId, string? subdomainRaw, CancellationToken ct) { - var slug = TenantContext.Current; var subdomain = subdomainRaw?.Trim().ToLowerInvariant(); + var valid = await ValidateOriginAsync(applicationId, subdomain, ct); + if (valid.IsError) return valid.FirstError; + + var slug = TenantContext.Current; await using var gsession = globalStore.LightweightSession(); var realm = await gsession.Query().FirstOrDefaultAsync(r => r.Slug == slug, ct); if (realm is null) @@ -159,31 +261,6 @@ public async Task> PatchAsync( ApplicationOrigin? origin = null; if (!string.IsNullOrEmpty(subdomain)) { - if (!HostRegex.IsMatch(subdomain)) - return Error.Validation("Application.InvalidSubdomain", "Subdomain must be a valid hostname."); - - // Must be a child of the realm's primary domain (the cookie + routing - // model: apps live under the tenant's primary domain). - var primary = realm.PrimaryDomain.Trim().ToLowerInvariant(); - if (string.IsNullOrEmpty(primary) || !subdomain.EndsWith("." + primary, StringComparison.Ordinal)) - return Error.Validation("Application.SubdomainNotUnderPrimary", - $"Subdomain must be a child of the realm's primary domain ('{realm.PrimaryDomain}')."); - - // Cross-realm uniqueness: the host must not be claimed by any realm's - // plain domains or another App's route. - var allRealms = await gsession.Query().ToListAsync(ct); - foreach (var r in allRealms) - { - if (r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase))) - return Error.Conflict("Application.SubdomainTaken", "That host is already a realm domain."); - foreach (var kv in r.ApplicationDomains) - { - if (string.Equals(kv.Key, subdomain, StringComparison.OrdinalIgnoreCase) - && !(r.Id == realm.Id && kv.Value == applicationId)) - return Error.Conflict("Application.SubdomainTaken", "That host is already mapped to an application."); - } - } - realm.ApplicationDomains[subdomain] = applicationId; origin = new ApplicationOrigin { Subdomain = subdomain }; } diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index dae9d141..217336f6 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -1296,7 +1296,8 @@ "systemHint": "Dies ist eine System-App des IdP. Slug, Display Name und Permission-Catalog sind im Backend fest hinterlegt — der Catalog hier ist schreibgeschützt und dient nur zur Einsicht. Änderungen an den Strings würden die RequiresPermission-Aufrufe im Backend brechen.", "tabs": { "catalog": "Permission-Catalog", - "general": "Allgemein" + "general": "Allgemein", + "settings": "Einstellungen" } }, "realms": { diff --git a/src/frontend-vue/src/models/application.ts b/src/frontend-vue/src/models/application.ts index 95e58e42..5fee18b9 100644 --- a/src/frontend-vue/src/models/application.ts +++ b/src/frontend-vue/src/models/application.ts @@ -20,6 +20,9 @@ export interface ApplicationDto { Description?: string | null Permissions: ApplicationPermissionDto[] IsSystem: boolean + // An App is one resource: the per-App ADR-0011 settings override is carried inline on + // GET {id} / create / update (null on the list endpoint, which doesn't render it). + Settings?: ApplicationSettingsDto | null } export interface ApplicationLookupDto { @@ -45,12 +48,15 @@ export interface CreateApplicationDto { DisplayName: string Description?: string | null Permissions: ApplicationPermissionInputDto[] + // Optional per-App settings override, written atomically with the App. + Settings?: ApplicationSettingsDto | null } export interface UpdateApplicationDto { DisplayName: string Description?: string | null Permissions: ApplicationPermissionInputDto[] + Settings?: ApplicationSettingsDto | null } // ── ADR-0011: per-Application settings overrides ──────────────────────────── diff --git a/src/frontend-vue/src/router/index.ts b/src/frontend-vue/src/router/index.ts index b340054b..fb18b084 100644 --- a/src/frontend-vue/src/router/index.ts +++ b/src/frontend-vue/src/router/index.ts @@ -133,7 +133,6 @@ const GROUP_MODAL_SIZE = { // Heaviest builders (wide AG-Grid catalog / 6-tab client builder) → full. const APP_MODAL_SIZE = MODAL_FULL -const APP_SETTINGS_MODAL_SIZE = MODAL_MD const CLIENT_MODAL_SIZE = MODAL_FULL const routes = [ @@ -406,21 +405,13 @@ const routes = [ meta: { routedFragments: [ { - // The :id slot is the App's Id (or "create"). Slug is - // immutable post-creation but stored on the dto. + // The :id slot is the App's Id (or "create"). One modal for the + // whole App: identity + permission catalog + ADR-0011 settings. type: 'modal', path: ':id', component: () => import('@/views/admin/apps/AppDetails.vue'), overlayOptions: { size: APP_MODAL_SIZE }, }, - { - // ADR-0011 — per-App settings overrides. Two-segment path so - // it never collides with the single-segment `:id` App modal. - type: 'modal', - path: 'settings/:id', - component: () => import('@/views/admin/apps/ApplicationSettingsModal.vue'), - overlayOptions: { size: APP_SETTINGS_MODAL_SIZE }, - }, ], }, }, diff --git a/src/frontend-vue/src/stores/applications.store.ts b/src/frontend-vue/src/stores/applications.store.ts index 2c8463d3..b558e72b 100644 --- a/src/frontend-vue/src/stores/applications.store.ts +++ b/src/frontend-vue/src/stores/applications.store.ts @@ -3,7 +3,6 @@ import { ref } from 'vue' import { useHttpClient } from '@/composables/useHttpClient' import type { ApplicationDto, - ApplicationSettingsDto, CreateApplicationDto, UpdateApplicationDto, } from '@/models/application' @@ -60,15 +59,8 @@ export const useApplicationsStore = defineStore('applications', () => { apps.value = apps.value.filter((a) => a.Id !== id) } - // ADR-0011 — per-Application settings overrides (separate from the App CRUD - // above; tenant ApplicationSettings doc + global subdomain routing map). - async function loadSettings(id: string): Promise { - return await http.addPath(id, 'settings').get() - } - - async function saveSettings(id: string, dto: ApplicationSettingsDto): Promise { - return await http.addPath(id, 'settings').patch(dto) - } + // The per-App ADR-0011 settings override rides inline on loadOne/create/update + // (an App is one resource) — there is no separate settings endpoint. return { apps, @@ -79,8 +71,6 @@ export const useApplicationsStore = defineStore('applications', () => { create, update, remove, - loadSettings, - saveSettings, } }) diff --git a/src/frontend-vue/src/views/admin/apps/AppDetails.vue b/src/frontend-vue/src/views/admin/apps/AppDetails.vue index 6b77c44c..e4c1bfad 100644 --- a/src/frontend-vue/src/views/admin/apps/AppDetails.vue +++ b/src/frontend-vue/src/views/admin/apps/AppDetails.vue @@ -4,6 +4,7 @@ import { CoarTextInput, CoarFormField, CoarNote, CoarButton, CoarTabGroup, CoarT import { CoarDataGrid, CoarGridBuilder } from '@cocoar/vue-data-grid' import { useI18n } from '@cocoar/vue-localization' import ModalLayout from '@/components/ModalLayout.vue' +import AppSettingsSections from './AppSettingsSections.vue' import { useApplicationsStore } from '@/stores/applications.store' import type { ApplicationDto, @@ -153,7 +154,8 @@ const hasIncompleteRows = computed(() => catalog.value.some((r) => )) const isSystem = computed(() => dto.value?.IsSystem === true) -const activeTab = ref<'general' | 'catalog'>('general') +const activeTab = ref<'general' | 'catalog' | 'settings'>('general') +const settingsRef = ref | null>(null) /** * Per-cell visual cue for the resource/action validation surface. The @@ -279,18 +281,24 @@ async function save() { error.value = null catalogBlockers.value = [] try { + // An App is one resource: its ADR-0011 settings override is part of the same + // create/update payload (the backend writes it in one tenant transaction). System + // apps carry no per-App settings, so omit it for them. + const settings = isSystem.value ? undefined : settingsRef.value?.build() if (isCreate.value) { await store.create({ Slug: form.value.Slug.trim(), DisplayName: form.value.DisplayName.trim(), Description: form.value.Description.trim() || null, Permissions: buildPermissionsPayload(), + Settings: settings, }) } else { await store.update(id.value, { DisplayName: form.value.DisplayName.trim(), Description: form.value.Description.trim() || null, Permissions: buildPermissionsPayload(), + Settings: settings, }) } props.close() @@ -318,9 +326,10 @@ async function save() { {{ t('common.loading', {}, 'Laden...') }}
- + {{ t('admin.apps.tabs.general', {}, 'Allgemein') }} {{ t('admin.apps.tabs.catalog', {}, 'Permission-Catalog') }} + {{ t('admin.apps.tabs.settings', {}, 'Einstellungen') }} @@ -331,7 +340,7 @@ async function save() { -
+
-
+

{{ isSystem ? t('admin.apps.permissionsHintSystem', {}, 'Permission-Catalog der System-App — read-only. Diese Einträge entsprechen 1:1 den RequiresPermission-Aufrufen im Backend-Code.') @@ -404,6 +413,11 @@ async function save() {

+ +
+ +
+

{{ error }}

diff --git a/src/frontend-vue/src/views/admin/apps/AppList.vue b/src/frontend-vue/src/views/admin/apps/AppList.vue index f16565f1..7148a778 100644 --- a/src/frontend-vue/src/views/admin/apps/AppList.vue +++ b/src/frontend-vue/src/views/admin/apps/AppList.vue @@ -120,9 +120,6 @@ onMounted(() => store.initialize()) - diff --git a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue new file mode 100644 index 00000000..a054a285 --- /dev/null +++ b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/src/frontend-vue/src/views/admin/apps/ApplicationSettingsModal.vue b/src/frontend-vue/src/views/admin/apps/ApplicationSettingsModal.vue deleted file mode 100644 index 4321bcf8..00000000 --- a/src/frontend-vue/src/views/admin/apps/ApplicationSettingsModal.vue +++ /dev/null @@ -1,379 +0,0 @@ - - - - -