From be334a69dcba6b83a5199defc63ad6f25d9043f1 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 25 Jun 2026 21:28:13 +0100 Subject: [PATCH] Seed ApiResources + ApiResourceScopes, run seeder on every startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (37440171) added ApiScope rows for the application scopes (admin, moderation, performer, client, blogs). It was a partial fix: an ApiScope alone is not a valid scope from DefaultResourceValidator's point of view. The validator only recognises a scope if it can find an ApiResource that exposes it (via ApiResourceScopes). Without that link, /connect/authorize rejects the request with 'Scope X not found in store', even though the scope row exists. This is what killed the PostIt login in production. This commit: 1. Extends Constants.ApiResourcesScopes with ResourceName + ResourceDisplayName. Topology: one ApiResource per scope ('admin' resource exposes 'admin' scope, 'blogs' resource exposes 'blogs' scope, etc.) — keeps each scope's audience specific if/when we split products across separate audiences. 2. Ensures EnsureDefaultApplicationScopes also inserts the matching ApiResource rows (deduped on Name) and ApiResourceScope rows linking each resource to its scope. Idempotent: missing rows are added, nothing is removed. 3. Removes the b.UseSeeding(...) call inside AddConfigurationStore. EF Core's UseSeeding callback only fires when the database is empty, so on a live ConfigurationDb (which already had Clients and ClientScopes) it never ran — that is why the previous commit had no visible effect on production. The seeder is now invoked explicitly from MigrateDatabase via SeedConfigurationDatabase, which resolves ConfigurationDbContext from the DI and runs EnsureDefaultConfiguration on every startup, regardless of whether the database was fresh. Seeding failures are caught and logged (best-effort) so a misconfigured seeder cannot prevent the host from booting. Live data on yavsc.pschneider.fr is still missing the ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy with this commit is needed before PostIt can log in. Production fix to follow. --- src/Yavsc.Org/Contants.cs | 22 +++- src/Yavsc.Org/Extensions/HostingExtensions.cs | 103 +++++++++++++++++- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/Yavsc.Org/Contants.cs b/src/Yavsc.Org/Contants.cs index 9a9b8a09..270c8695 100644 --- a/src/Yavsc.Org/Contants.cs +++ b/src/Yavsc.Org/Contants.cs @@ -8,13 +8,20 @@ public static class Constants "profile", "openid", "offline_access", "admin", "moderation", "performer", "client" }; + // One ApiResource per application scope. Each scope is exposed by + // exactly one resource, named after the scope ("admin" -> "admin" + // resource, "blogs" -> "blogs" resource). IdentityServer8's + // DefaultResourceValidator only recognises a scope at the + // /connect/authorize endpoint if it can find an ApiResource that + // exposes it — an orphaned ApiScope row is rejected with + // "Scope X not found in store" even though the row exists. public static readonly ApiResourceScopeSpecification[] ApiResourcesScopes = { - new ApiResourceScopeSpecification { ScopeName = "admin", Description = "Admin access" }, - new ApiResourceScopeSpecification { ScopeName = "moderation", Description = "Moderation access" }, - new ApiResourceScopeSpecification { ScopeName = "performer", Description = "Performer access" }, - new ApiResourceScopeSpecification { ScopeName = "client", Description = "Client access" }, - new ApiResourceScopeSpecification { ScopeName = "blogs", Description = "Blogs access" } + new ApiResourceScopeSpecification { ScopeName = "admin", Description = "Admin access", ResourceName = "admin", ResourceDisplayName = "Admin API" }, + new ApiResourceScopeSpecification { ScopeName = "moderation", Description = "Moderation access", ResourceName = "moderation", ResourceDisplayName = "Moderation API" }, + new ApiResourceScopeSpecification { ScopeName = "performer", Description = "Performer access", ResourceName = "performer", ResourceDisplayName = "Performer API" }, + new ApiResourceScopeSpecification { ScopeName = "client", Description = "Client access", ResourceName = "client", ResourceDisplayName = "Client API" }, + new ApiResourceScopeSpecification { ScopeName = "blogs", Description = "Blogs access", ResourceName = "blogs", ResourceDisplayName = "Yavsc Blogs API" } }; } @@ -22,4 +29,9 @@ public class ApiResourceScopeSpecification { public string ScopeName { get; set; } public string Description { get; set; } + // The ApiResource that exposes this scope. IdentityServer8 requires + // scopes to be linked back to an ApiResource via the ApiResourceScopes + // table — without that link the scope is considered unknown. + public string ResourceName { get; set; } + public string ResourceDisplayName { get; set; } } diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index d460538b..38e00d24 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.FileProviders; @@ -322,7 +323,13 @@ public static class HostingExtensions sql => sql.MigrationsAssembly(migrationsAssembly)); } - b.UseSeeding(EnsureDefaultConfiguration(builder.Configuration)); + // NOTE: don't b.UseSeeding(...) here — EF Core's UseSeeding + // only runs when the database is empty, so on a live + // configuration store (clients/scopes already present) + // it never fires and missing scopes are never inserted. + // We call the seeder explicitly from MigrateDatabase after + // migrations, so it runs on every startup regardless of + // whether the database is fresh. }; }) .AddOperationalStore(options => @@ -579,6 +586,64 @@ public static class HostingExtensions }); } } + + // ApiResources — one per application scope, linked to its scope + // via ApiResourceScopes. IdentityServer8's DefaultResourceValidator + // rejects any scope that isn't backed by an ApiResource at + // /connect/authorize time ("Scope X not found in store"), even + // when the ApiScope row itself exists. Seeding the scope without + // the resource is what caused the PostIt login to die in the + // first place. + // + // The mapping is taken from Constants.ApiResourcesScopes + // (ScopeName ↔ ResourceName). We dedupe on resource name so a + // future spec that re-uses an existing resource doesn't insert + // duplicates. + var apiResources = context.Set(); + var apiResourceScopes = context.Set(); + + // Make sure every resource row referenced by the spec exists. + foreach (var resourceGroup in Constants.ApiResourcesScopes + .GroupBy(s => s.ResourceName)) + { + var spec = resourceGroup.First(); + if (!apiResources.Any(r => r.Name == spec.ResourceName)) + { + apiResources.Add(new IdentityServer8.EntityFramework.Entities.ApiResource + { + Name = spec.ResourceName, + DisplayName = spec.ResourceDisplayName, + Enabled = true, + }); + } + } + context.SaveChanges(); + + // Link each scope to its resource. We re-query both sets after + // the SaveChanges above so the newly inserted resources have + // their generated Ids. + var resourceByName = apiResources + .Where(r => Constants.ApiResourcesScopes.Any(s => s.ResourceName == r.Name)) + .ToDictionary(r => r.Name); + + foreach (var scopeSpec in Constants.ApiResourcesScopes) + { + if (!resourceByName.TryGetValue(scopeSpec.ResourceName, out var resource)) + continue; + + bool alreadyLinked = apiResourceScopes.Any(link => + link.ApiResourceId == resource.Id && link.Scope == scopeSpec.ScopeName); + + if (alreadyLinked) + continue; + + apiResourceScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiResourceScope + { + ApiResource = resource, + ApiResourceId = resource.Id, + Scope = scopeSpec.ScopeName, + }); + } context.SaveChanges(); }; } @@ -874,6 +939,42 @@ public static class HostingExtensions app.Properties["DegradedDBContext"] = ex.Message; } } + + // Run the IdentityServer configuration seeder explicitly, after + // migrations. EF Core's UseSeeding callback only fires when the + // database is empty — on a live ConfigurationDb that's been used + // for months, the seeder never runs and missing scopes/resources + // are never inserted. Calling EnsureDefaultConfiguration here makes + // the seed idempotent across restarts. + SeedConfigurationDatabase(app); + } + + private static void SeedConfigurationDatabase(IApplicationBuilder app) + { + try + { + using var scope = app.ApplicationServices + .GetRequiredService() + .CreateScope(); + + var configurationDb = scope.ServiceProvider + .GetRequiredService(); + + var configuration = scope.ServiceProvider + .GetRequiredService(); + + EnsureDefaultConfiguration(configuration)(configurationDb, true); + } + catch (Exception ex) + { + // Seeding is best-effort: a missing scope row will just leave + // the same login failure as before, no worse than today. Don't + // crash the host over it. Log so the operator sees something. + var logger = app.ApplicationServices + .GetRequiredService() + .CreateLogger("Yavsc.Org.Seeding"); + logger.LogError(ex, "ConfigurationDb seeding failed."); + } } static void LoadGoogleConfig(IConfigurationRoot configuration)