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..210ae6eb --- /dev/null +++ b/dev-docs/future-features/declarative-realm-provisioning.md @@ -0,0 +1,276 @@ +# 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`. | +| `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 +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. + +### 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 +> 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 diff --git a/dev/app-testing/README.md b/dev/app-testing/README.md new file mode 100644 index 00000000..9e151169 --- /dev/null +++ b/dev/app-testing/README.md @@ -0,0 +1,164 @@ +# 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`). + +## At a glance + +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`). + +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 + +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. 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!' +``` + +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: 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..abdc842a --- /dev/null +++ b/docs/admin/realm-provisioning.md @@ -0,0 +1,244 @@ +# 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. + +## 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. + +## Control-plane endpoints + +All under `/api/admin/realms`, all requiring **`realm:write`** on the +`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 | +|---|---|---| +| `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. + +## 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 +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..b7be1e38 100644 --- a/docs/reference/realm-api.md +++ b/docs/reference/realm-api.md @@ -15,8 +15,27 @@ 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. + +### 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 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.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/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.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.Api.Tests/ColdStart/RealmManifestApplierTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs new file mode 100644 index 00000000..01817def --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestApplierTests.cs @@ -0,0 +1,585 @@ +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.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 Modgud.Permissions; +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, 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_with_resolved_cross_references() + { + 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" }, + }, + 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 RealmManifestClient + { + ClientId = "acme-web", + DisplayName = "Acme Web", + ClientType = "confidential", + 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 { 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(); + + 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); + Assert.True(result.Value.ClientSecrets.ContainsKey("acme-web")); + Assert.False(string.IsNullOrWhiteSpace(result.Value.ClientSecrets["acme-web"])); + + var realms = factory.Services.GetRequiredService(); + Assert.NotNull(await realms.GetRealmBySlugAsync(slug, ct)); + + // Everything landed in the NEW realm's tenant DB (inline-consistent reads). + await InTenantAsync(factory, slug, async sp => + { + 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); + + // 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. + await InTenantAsync(factory, TenantConstants.SystemTenantId, async sp => + { + var oauth = sp.GetRequiredService(); + Assert.DoesNotContain((await oauth.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct)).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); + } + + [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: 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_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: 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() + { + 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: 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 + /// 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) + { + using var _ = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await body(scope.ServiceProvider); + } +} 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..2d92ac3b --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmManifestExportTests.cs @@ -0,0 +1,122 @@ +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.Application.DTOs.RealmSettings; +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 + + // 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: 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: 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 + { + Users = m.Users.Select(u => u.UserName == "bob" ? u with { Password = "Bobsecret1!" } : u).ToList(), + }; + Assert.False((await applier.UpdateRealmAsync(withPassword, ct: 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/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/ColdStart/RealmProvisioningEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs new file mode 100644 index 00000000..a4e561a9 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmProvisioningEndpointsTests.cs @@ -0,0 +1,260 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.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 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() + { + 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)); + } + + [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)); + }); + } + + [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 + { + 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.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.Api/Features/Admin/Apps/AppAdminService.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs new file mode 100644 index 00000000..9885df16 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs @@ -0,0 +1,274 @@ +using ErrorOr; +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 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. +/// +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))!; + } + + /// + /// 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))!; + } + + /// + /// 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 + /// 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; + } + + /// + /// 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); + +/// +/// 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 29272c54..c6fb6c63 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs @@ -1,11 +1,9 @@ using BuildingBlocks.Helper; +using ErrorOr; using Modgud.Application.DTOs.OAuth; 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; @@ -90,153 +88,66 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s .WithName("V2_App_GetById") .RequiresPermission("app:read"); - appGroup.MapPost("", async (CreateAppDto dto, IDocumentSession session) => + // 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) => { - 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.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"); - 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(); - - 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 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(); + var result = await appAdmin.DeleteAppAsync(id.Guid, ct); + if (!result.IsError) return Results.NoContent(); - 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"); @@ -262,131 +173,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/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/Features/Admin/Provisioning/RealmManifest.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs new file mode 100644 index 00000000..b458d524 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs @@ -0,0 +1,294 @@ +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, +/// 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. +/// +[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. +[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; +} + +/// 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; +} + +/// 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; } +} + +/// 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..ea6b2fca --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -0,0 +1,834 @@ +using BuildingBlocks.Helper; +using ErrorOr; +using Marten; +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.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 Modgud.Permissions; +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/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, 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 → groups. Keys (app slug, role/user key, resource:action) are +/// mapped to ids as each entity is created. +/// +public sealed class RealmManifestApplier( + IRealmProvisioningService realms, + IServiceScopeFactory scopeFactory, + ILogger logger) +{ + /// + /// Imports a brand-new realm: the slug must NOT already exist. Provisions the realm + /// 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) + 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}: {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.Groups.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; + } + } + + /// + /// 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. + /// + /// 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, bool prune = false, 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, 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, + 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) + { + 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. + 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"); + + // ── 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(new CreateOAuthApiDto + { + Name = api.Name, + DisplayName = api.DisplayName, + Description = api.Description, + 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 ?? false, + }, ct), $"api '{api.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 ?? 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}'"); + } + + // ── OAuth clients ───────────────────────────────────────────────────────── + foreach (var c in manifest.Clients) + { + 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 ?? true, + RequireConsent = c.RequireConsent ?? false, + 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[c.ClientId] = created.Value.ClientSecret; + } + + // ── 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. + 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 ─────────── + var bus = sp.GetRequiredService(); + 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); + 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"), + // 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}'"); + } + } + + 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, bool prune, 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 ?? true, + Scopes = api.Scopes, + UserClaims = api.UserClaims, + AppId = ResolveAppId(apps, api.App, ctx), + PermissionIds = ResolvePermissionIds(apps, api.App, api.Permissions, ctx), + AllowDynamicRegistration = api.AllowDynamicRegistration ?? false, + }, 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 ?? true, + Required = s.Required ?? false, + Emphasize = s.Emphasize ?? false, + ShowInDiscoveryDocument = s.ShowInDiscoveryDocument ?? true, + 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 ?? true, + RequireConsent = c.RequireConsent ?? false, + 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(); + var setPassword = 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; + + // 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; + } + + // ── 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) + { + // 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 ?? [AppSlugs.Modgud], 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); + } + } + } + + // ── 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. + 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; + 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 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) + 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/Features/Admin/Provisioning/RealmManifestExporter.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs new file mode 100644 index 00000000..ab73724c --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs @@ -0,0 +1,289 @@ +using BuildingBlocks.Helper; +using ErrorOr; +using Marten; +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; +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 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, + 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(); + + // 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); + 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). + }, + Settings = settings, + Apps = manifestApps, + Apis = manifestApis, + Scopes = manifestScopes, + Clients = manifestClients, + Roles = manifestRoles, + Users = manifestUsers, + Groups = manifestGroups, + }; + } + + /// + /// 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; + + 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/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 abf6f21e..856a91d3 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,86 @@ 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. 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, bool prune = false) => + { + 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, prune, ct); + return result.IsError ? ManifestError(result.Errors) : Results.Ok(result.Value); + }) + .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); + + // 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 @@ -295,6 +369,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, 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 new file mode 100644 index 00000000..e78d9f4e --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs @@ -0,0 +1,150 @@ +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; + } + + /// + /// 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; + } + + /// + /// 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, + /// 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..e332b191 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.Authorization.AspNetCore; -using Modgud.Authorization.Apps; using Modgud.Authorization.Services; -using Modgud.Authentication.Domain; -using Modgud.Authentication.Events; namespace Modgud.Api.Features.Roles; @@ -75,64 +73,33 @@ 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 / 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) => { - // 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.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"); - 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"); @@ -140,15 +107,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 { @@ -159,110 +134,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); - } } 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 a54dda88..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; @@ -552,6 +553,18 @@ // + IAutoMembershipRecalculator are all registered by AddModgudAuthorization // inside AddInfrastructure. Only keep app-specific wiring here. builder.Services.AddScoped(); + + // 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: // the script logs in as a regular admin and POSTs through the same @@ -1205,6 +1218,7 @@ app.MapAppSettingsEndpoints("api"); app.MapProjectionEndpoints("api"); app.MapRealmsEndpoints("api"); + app.MapRealmConfigEndpoints("api"); app.MapOAuthClientsEndpoints("api"); app.MapOAuthScopesEndpoints("api"); app.MapOAuthApisEndpoints("api"); 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; + } +} 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 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(); 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..4b2ee5ab --- /dev/null +++ b/src/dotnet/Modgud.Provisioning.TestKit/RealmManifest.cs @@ -0,0 +1,146 @@ +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; } = []; + // 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 +{ + 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; } = []; + // 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 +{ + 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; } + // 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 +{ + 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 @@ +