From 3b6b587f959beca3633452d47aeaeb592b8ceb60 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Wed, 23 Sep 2026 15:13:24 +0200 Subject: [PATCH] fix(apps): an App's subdomain may also be one of its own realm's domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the test instance: every draft apply on the system realm died with Application.SubdomainTaken — "That host is already a realm domain" — whatever the draft changed. The realm lists the shelf App's host among its own plain domains as well as in ApplicationDomains. That is a legal, working state (the host routes to the realm either way; the App match takes precedence in RealmCacheLookup), but ValidateOriginAsync treated ANY realm's domain list as a collision, the own one included. Since an apply replays every declared App with its settings, that one App failed every apply on the realm. Only another realm's domain makes a host ambiguous; the own realm's list is no longer a conflict. The message says "another realm" now. The realm-domain side (CheckDomainUniquenessAsync) already excluded the realm itself. Test: own-realm domain accepted, accepted again on re-apply, route written; another realm's domain still 409. Red without the fix (the exact 409 seen in the field), green with it. Co-Authored-By: Claude Fable 5.1 --- docs/admin/applications.md | 2 +- .../ApplicationSettingsAdminTests.cs | 61 +++++++++++++++++++ .../ApplicationSettingsService.cs | 12 ++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/admin/applications.md b/docs/admin/applications.md index 0fa04f01..83a07664 100644 --- a/docs/admin/applications.md +++ b/docs/admin/applications.md @@ -138,7 +138,7 @@ re-inherits the realm. | Section | What it does | | --- | --- | -| **Origin** | The App's own subdomain (e.g. `acme.cocoar.app`), which must be a child of the realm's primary domain. Setting it routes that host to this App and serves the branded login there; clearing it falls back to the tenant URL. The OIDC issuer stays the tenant's (anchored to the realm primary domain) — a subdomain is not its own issuer. | +| **Origin** | The App's own subdomain (e.g. `acme.cocoar.app`), which must be a child of the realm's primary domain. Setting it routes that host to this App and serves the branded login there; clearing it falls back to the tenant URL. The host must not belong to another realm; it may also be listed among this realm's own domains (it routes to the realm either way, the App match takes precedence). The OIDC issuer stays the tenant's (anchored to the realm primary domain) — a subdomain is not its own issuer. | | **Branding** | Product name, primary colour, logo/favicon — the look of the login + consent UI when reached via this App. | | **Email branding** | Product name, sender display name, sender address, validated reply-to address, optional subject prefix, preheader and footer used in this App's outbound emails (OTP, magic link, reset, verification, ...). The sender address falls back to the realm's, then the deployment's; a custom address must be deliverable from your mail provider (SPF/DKIM). Logo and button colour follow effective App branding. See [Transactional email](../platform/email-customization). | | **Login methods** | Enable/disable internal password/passkey and magic-link entry points, and select/order the external OIDC/SAML providers exposed to this App. The allow-list is enforced by the public list and protocol start endpoints, not only hidden in the SPA. An explicit empty provider list disables all external providers. | diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs index f3f96b8e..a007e572 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs @@ -226,6 +226,67 @@ public async Task Subdomain_Is_Unique_Across_Apps() Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); } + /// + /// Reported from the test instance: the realm listed an App's host among its own + /// plain domains as well (a legal state — the host routes to the realm either + /// way, the App match layers on top), and from then on every manifest apply died + /// on that App with SubdomainTaken ("already a realm domain"), whatever + /// the draft changed. Only ANOTHER realm's domain makes a host ambiguous. + /// + [Fact] + public async Task Subdomain_May_Be_A_Domain_Of_The_Own_Realm_But_Not_Of_Another() + { + var ct = TestContext.Current.CancellationToken; + var app = await SeedAppAsync("as-own-domain"); + var appShort = new ShortGuid(app.Id).ToString(); + var primary = await SystemPrimaryDomainAsync(); + var ownHost = $"as-own-domain.{primary}"; + var foreignHost = $"as-foreign-domain.{primary}"; + var globalStore = Factory.Services.GetRequiredService(); + var other = new Realm + { + Id = Guid.NewGuid(), Slug = "as-other-realm", DisplayName = "Other", + Domains = [foreignHost], PrimaryDomain = foreignHost, IsActive = false, + }; + + await using (var gs = globalStore.LightweightSession()) + { + var system = await gs.Query().FirstAsync(r => r.Slug == "system", ct); + system.Domains = [.. system.Domains, ownHost]; + gs.Store(system); + gs.Store(other); + await gs.SaveChangesAsync(ct); + } + + try + { + // Own realm's domain: accepted, and accepted again on the re-apply an + // idempotent manifest run is. + (await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = ownHost } }, ct)).EnsureSuccessStatusCode(); + (await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = ownHost } }, ct)).EnsureSuccessStatusCode(); + await using (var gs = globalStore.QuerySession()) + { + var system = await gs.Query().FirstAsync(r => r.Slug == "system", ct); + Assert.Equal(app.Id, system.ApplicationDomains[ownHost]); + Assert.Contains(ownHost, system.Domains); + } + + // Another realm's domain: still refused. + var foreign = await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = foreignHost } }, ct); + Assert.Equal(HttpStatusCode.Conflict, foreign.StatusCode); + Assert.Contains("another realm", await foreign.Content.ReadAsStringAsync(ct)); + } + finally + { + await using var gs = globalStore.LightweightSession(); + var system = await gs.Query().FirstAsync(r => r.Slug == "system", ct); + system.Domains = system.Domains.Where(d => d != ownHost).ToArray(); + gs.Store(system); + gs.Delete(other); + await gs.SaveChangesAsync(ct); + } + } + [Fact] public async Task Clearing_Origin_Removes_The_Global_Route() { diff --git a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs index ee5f281b..4e94b177 100644 --- a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs @@ -289,13 +289,17 @@ public async Task> ValidateOriginAsync( 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. + // Cross-realm uniqueness: the host must not be claimed by ANOTHER realm's + // plain domains or by another App's route. The own realm's plain domain + // list is not a conflict: such a host resolves to this tenant either way, + // and the App route layers on top (the App match wins in RealmCacheLookup). + // Refusing it made every manifest apply on a realm that lists an App's host + // among its own domains die on that App — whatever the draft changed. 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."); + if (r.Id != realm.Id && r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase))) + return Error.Conflict("Application.SubdomainTaken", "That host is already a domain of another realm."); foreach (var kv in r.ApplicationDomains) { if (string.Equals(kv.Key, subdomain, StringComparison.OrdinalIgnoreCase)