# Modgud > Generated: 2026-07-18 · Source commit: 1f617aa · Product: Modgud · Canonical: https://docs.cocoar.dev/modgud/ · Version: v0.6 --- url: /getting-started.md --- # Getting Started Modgud is an OpenID-Connect-shaped identity provider that puts a multi-app permission model at its core. This section gets you from "nothing running" to "first SaaS app integrated" in a small number of pages — using the published Docker image, no source checkout required. ## Three angles to start from Pick the one that matches what you're trying to do right now: * **Run it locally** — [Quickstart (Docker)](./quickstart). Copy a compose file, `docker compose up`, create the first admin via the recovery CLI, and you land in a logged-in admin SPA on `http://localhost`. * **Integrate a SaaS app you already have** — go straight to the [SaaS Integration Walkthrough](../integrate/saas-walkthrough). It links into the relevant admin docs as you go. * **Embed Modgud into your own deployment** — [Requirements](./requirements) and [Features](./features) explain what you're getting and what infrastructure you'll need. ## What Modgud is — in one paragraph A self-hostable IdP. OAuth 2.0 + OpenID Connect server, runs on .NET 10, persists in PostgreSQL via Marten (event-sourced where it matters). Each customer / environment lives in an isolated realm with its own database. Apps within a realm declare their own permission catalogs and OAuth bindings. Tokens carry a `resource_access` claim (Keycloak-style nesting) keyed per Audience, with bypass-pre-expansion and per-RS subset narrowing — resource servers do straight exact-match against a flat permission list, no custom claim format required. ## What it isn't * Not a hosted service. You run it. * Not a user database for arbitrary domain data. Profiles only — your apps own their own tables. * Not a BFF. It issues tokens; downstream apps consume them. * Not a SAML identity provider for downstream apps — Modgud only ever issues OAuth 2.0 / OIDC tokens. It can *consume* SAML 2.0 as a service provider for federated login (see [SAML Federation](../admin/saml-federation)). ## Sections * [**Quickstart (Docker)**](./quickstart) — copy the compose file, `docker compose up`, bootstrap the first admin, sign in — in 10 minutes * [**Requirements**](./requirements) — runtime and infra checklist * [**Features**](./features) — point-by-point list of what the box delivers * [**First-time setup**](./first-time-setup) — the three bootstrap paths and when to use which --- --- url: /getting-started/quickstart.md --- # Quickstart (Docker) Get a local Modgud running, sign in for the first time, and verify the OAuth/OIDC endpoints respond — in under 10 minutes. ## Prerequisites * Docker Desktop (or Docker Engine + Compose) * A free host port 80 (the Modgud container serves both the API and the admin SPA same-origin) * About 200 MB of disk for the container + the system-realm DB This quickstart uses the **published image** `ghcr.io/cocoar-dev/modgud` — you do not clone the repo or build anything. You copy the compose file below, save it, and start it. For requirements beyond a quick local run, see [Requirements](./requirements). For a production deployment (HTTPS issuer, reverse proxy, Prometheus token), see [First-time setup](./first-time-setup) and [Deployment](../operate/deployment). ## 1. Bring up the stack Save the following as `compose.yml` in an empty directory: ```yaml services: postgres: image: postgres:17-alpine environment: POSTGRES_PASSWORD: postgres volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 10 modgud: image: ghcr.io/cocoar-dev/modgud:latest container_name: modgud environment: ASPNETCORE_ENVIRONMENT: Development # local eval only — see Deployment for production DbSettings__ConnectionString: "Host=postgres;Database=modgud;Username=postgres;Password=postgres;Keepalive=30" AppUrl: "http://0.0.0.0:8081" OpenIddict__DevelopmentMode: "true" ports: - "80:8081" depends_on: postgres: condition: service_healthy volumes: pgdata: ``` Then start it: ```bash docker compose up -d ``` This starts PostgreSQL + Modgud in the background. First boot takes ~15 seconds while Marten provisions the master DB and seeds the system realm. ::: tip Why `ASPNETCORE_ENVIRONMENT: Development` The published image runs as **Production** by default, which fail-closes on a dev-shaped config: it refuses to boot with an `http`/`localhost` issuer, with `OpenIddict__DevelopmentMode=true`, or with Prometheus enabled but no bearer token. Those guards are exactly what you want in production and exactly what gets in the way of a 10-minute local eval. Setting `Development` legitimately allows the `http://localhost` issuer and ephemeral signing keys used here. Do **not** ship this compose to production — see [Deployment](../operate/deployment). ::: ## 2. Create your first admin A fresh deployment has zero users. There is no anonymous "first-run wizard" — the very first admin is created explicitly by someone with shell access to the container, and from then on every admin is provisioned through the regular admin UI / API. For local development the simplest path is the recovery CLI in **direct mode** (sets a password right away): ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin \ --password 'StrongPass1!' ``` You should see: ``` ✓ Admin created in realm 'system': UserName: admin Email: admin@example.com Mode: Direct (password set on creation) ``` The CLI atomically creates the user, seeds the three default roles (System Admin / User Manager / Viewer) into the system realm, and adds the user to the **Administrators** group with `realm:admin`. ::: tip Password rules The CLI enforces the same Identity password policy the SPA uses (length, mixed case, digit). A weak password is rejected — see [Settings](../platform/settings) for how to relax the policy if needed. ::: ::: details Other ways to create the first admin Two more paths are available — they trade off CLI convenience against email verification: **Invite mode** (CLI, no `--password`) — the CLI writes a magic-link invite and prints the URL on stdout. You click the link, set the password yourself in the SPA. Useful when you want the recipient to own their credentials end-to-end. With no SMTP configured the email is silently dropped, but the printed URL is all you need locally. ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin # → magic-link printed on stdout; open it in your browser ``` **HTTP path** — once you already have one admin, additional realms (and their initial admins) are created through `POST /api/admin/realms` with an `InitialAdmin` payload. See [First-time setup](./first-time-setup) for the full decision tree. ::: ## 3. Sign in Open and sign in with `admin` + your password. The admin SPA is served same-origin by the Modgud container on port 80 — there is no separate frontend port in the Docker flow. You land in the admin SPA's dashboard. The sidebar shows everything because you hold `realm:admin`: * **Authorization** — Users, Service Accounts, Roles, Groups * **OAuth & Federation** — Login Providers, OAuth Clients, Scopes, APIs, Invite Codes * **System** — Apps, Realms, Realm Settings, Auth Log, Scheduled Jobs, Change Requests ## 4. Verify OIDC endpoints In a separate terminal: ```bash # Discovery document curl http://localhost/.well-known/openid-configuration | jq ``` You should see `issuer`, `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, etc. The endpoints are rooted at `http://localhost/` — Modgud resolves the realm from the **Host header**, not from a URL path segment. For `localhost` requests that's the system realm. ```bash # JWKS (signing keys) curl http://localhost/.well-known/jwks | jq '.keys[0].kid' ``` ::: tip JWKS path The discovery document advertises the JWKS endpoint at `jwks_uri`. Modgud serves it at `/.well-known/jwks` (no `.json` suffix) — use the path from the discovery document if you want to be format-agnostic. ::: You should get a key ID — that's the public key resource servers use to validate **JWT** access tokens. Note that Modgud's default token format is **Reference** (opaque); JWKS validation only applies to clients you switch to JWT (see step 6). ## 5. Try a real OAuth flow Register a client in the admin SPA: **OAuth & Federation → OAuth Clients → Create**. The create modal lets you set grants, scopes, redirect URIs, and the app at create time, so the client is functional immediately. For a quick test: 1. Set **Access Token Type = JWT** if you want a decodable token (otherwise you get an opaque reference token). 2. Add a redirect URI — e.g. the test redirect on [oidcdebugger.com](https://oidcdebugger.com). 3. Copy the discovery URL from step 4 and the client ID into oidcdebugger. Click **Send Request** in oidcdebugger → log in as `admin` → consent → you'll see an access token. If you chose JWT, decode it at [jwt.io](https://jwt.io) — `sub`, `email`, `aud`, plus a `resource_access` block once you request the `roles` scope. ## 6. Bind your first SaaS app You're now ready for the linear walkthrough that turns Modgud into the IdP for a real app of yours: [SaaS Integration Walkthrough](../integrate/saas-walkthrough). ## Optional: seed demo data (requires the repo) If you have cloned the repository (contributors only — not part of this Docker quickstart), it ships a Node script that POSTs a complete demo dataset (extra users, granular roles, auto-membership groups, OAuth clients, scopes, an API and a sample external login provider) through the regular admin API: ```bash node scripts/seed-demo.mjs ``` The script uses your admin login (defaults: `admin` / `ABC12abc!`; pass `--user=` and `--password=` to change). It is idempotent — re-running only creates what's missing. At the end it prints any generated OAuth client secrets — capture them, those values are not retrievable from the API later. This step is **optional and secondary** to the core path above, and it needs the repo checked out (it is not in the published image). ## Troubleshooting ::: details I get 401 "Invalid credentials" on the login page The bootstrap-admin command writes the user immediately. If login still fails, check `docker logs modgud` for the boot output — the admin creation also prints there. Most common cause: trying to sign in before the container finished its first migration. Wait ~15 seconds and retry. ::: ::: details Magic-link emails don't arrive With no SMTP configured, Modgud silently drops outbound email — there is no on-disk dev mailbox. For the bootstrap flow this is fine: the recovery CLI prints the invite / magic-link URL straight to stdout, and `POST /api/admin/realms` returns it in the response. To actually capture emails locally, point Modgud at a dev SMTP catcher such as [Mailpit](https://github.com/axllent/mailpit) or [smtp4dev](https://github.com/rnwood/smtp4dev) via the SMTP settings — see [Settings](../platform/settings). For real delivery, configure your production SMTP host. ::: ::: details OIDC discovery returns 404 Modgud resolves the realm from the Host header. For `localhost`, that's the system realm (its seeded domain list includes `localhost`). Check `docker logs modgud` for `RealmMiddleware` warnings if you suspect a host-resolution problem. ::: ::: details Is the container healthy? The container exposes `/health/ready` (DB + signing-cert readiness) and `/health/live` (liveness). There is no plain `/health` endpoint. ```bash curl http://localhost/health/ready curl http://localhost/health/live ``` ::: ::: details I want to start over Bring the stack down and drop **all** Modgud databases — the master infra DB `modgud`, the system-realm DB `modgud_system`, and any per-tenant DBs `modgud_` you created — then bring it back up: ```bash docker compose down docker compose up -d postgres # wait for postgres to be healthy, then drop every Modgud DB: docker exec modgud-postgres-1 \ psql -U postgres -c "DROP DATABASE IF EXISTS modgud; DROP DATABASE IF EXISTS modgud_system;" # drop any tenant DBs you provisioned, e.g.: # DROP DATABASE IF EXISTS modgud_acme; docker compose up -d # then re-run step 2 ``` (The Postgres container name follows Compose's `-postgres-1` convention — adjust if you renamed the project. List databases with `psql -U postgres -l`.) ::: ## Next steps * [First-time setup](./first-time-setup) — the bootstrap paths explained, when to use which, and the production hostname / Prometheus steps * [Concepts: Apps & resource\_access](../concepts/apps-and-resource-access) — the mental model behind the permission system * [Integrating a Resource Server](../integrate/resource-server) — wire your own ASP.NET Core backend to validate tokens * [Recovery CLI](../operate/recovery-cli) — break-glass operations beyond bootstrap --- --- url: /getting-started/requirements.md --- # Requirements What you need to run Modgud in development, and what to plan for in production. ## Local development ### Software | Component | Minimum | Notes | | --- | --- | --- | | Docker Desktop / Docker Engine | 24+ | Multi-platform images, supports both x86\_64 and arm64 | | .NET SDK | 10.0 | Only if you build/run from source rather than the container | | Node.js | 22+ | For running the Vue admin SPA in dev mode | | pnpm | 9+ | Package manager for the SPA | ### Resources * 2 GB RAM * 1 CPU core * 500 MB disk ### Ports * **80** (or whatever you map to the container's **8081**) — the published Docker image serves both the OAuth/OIDC API and the admin SPA same-origin from one container; sign in, discovery (`/.well-known/openid-configuration`) and JWKS (`/.well-known/jwks`) are all on this port * **9099 / 4300** — API / Vue admin SPA, **from-source dev only** (running the .NET host and the Vite frontend separately). These do not exist in the Docker flow * **5432** — PostgreSQL (host-side; container's internal port) ## Production ### Operating system / runtime Modgud ships as a Linux container (multi-arch). Bare-metal .NET 10 deployment is supported but undocumented. ### Database: PostgreSQL | Aspect | Recommendation | | --- | --- | | **Version** | PostgreSQL 17+ | | **Storage per realm** | Plan ~50 MB baseline + 5 KB per user + 200 bytes per auth-log entry | | **Connection pool** | One pool per master DB connection plus pools per active tenant — Marten manages internally | | **Backups** | Standard pg\_dump per database. The master DB and every tenant DB must be backed up; cross-realm restores require care | | **Replication** | Streaming replication or logical replication both fine. Marten doesn't require special config | ### TLS / certificates Modgud issues access tokens; the issuer URL must be HTTPS in production. Two common setups: * **Reverse proxy** (Nginx, Caddy, Traefik) terminates TLS, proxies HTTP to Modgud * **Container-native TLS** — pass cert paths via configuration, Kestrel terminates TLS directly Tokens are signed with RSA 2048 keys auto-rotated on first run. The signing keys are persisted in the realm's database and recreate themselves if missing. ### Email (SMTP) Required for: * Magic-link sign-in * Password reset * Email-OTP 2FA * GDPR notifications Without SMTP these flows degrade gracefully (password sign-in still works, TOTP / Passkey 2FA work) but you lose recovery capability. With no SMTP host configured, outbound email is silently dropped; the recovery CLI and the realm-creation API still print/return invite and magic-link URLs directly. For local capture, point Modgud at a dev SMTP catcher (Mailpit, smtp4dev). Configure SMTP via [Settings](../platform/settings) per realm, or instance-wide through env vars (the published image takes all config as env vars — `Section__Property`, case-insensitive — since `configuration.json` is not shipped in the image). ### Optional: external Identity Providers If you want to delegate auth to Microsoft Entra, Google, Okta, etc., you need: * Each provider's client ID + client secret + tenant ID * A reachable HTTPS callback URL (the realm's domain) * Network egress to the provider's authorization + token endpoints Per-tenant configuration via [Login Providers](../admin/login-providers). ## Capacity planning ### User count Modgud's permission resolver loads the per-realm group set into memory. Practical sweet spot: **up to ~10,000 groups per realm**. Above that, query latency on `GetUserPermissionsAsync` becomes noticeable. User count itself is unbounded — the bottleneck is groups (and to a lesser extent, roles). ### Token throughput OpenIddict + Marten can comfortably handle ~500 token requests per second per realm on modest hardware. The bottleneck is typically PostgreSQL fsync rather than token signing. ### Multi-tenancy at scale For deployments with **>50 active realms**, look at: * Connection-pool tuning (Marten allows per-realm overrides) * Tenant DB consolidation strategies (multiple realms per DB instance via schema separation — undocumented but supported) * Hot/cold realm tiering ## Network considerations ### Browser-facing endpoints The realm domain must reach the browser end-to-end via HTTPS. Common pitfalls: * **Mixed-content blocking** — admin SPA on HTTPS, API on HTTP behind a misconfigured proxy * **Cookie SameSite policies** — Modgud uses `SameSite=Strict` by default; cross-domain integrations may need to relax this via configuration * **CORS** — per-client **Allowed CORS Origins** are enforced on the OIDC endpoints. A browser-only SPA (Authorization Code + PKCE in the browser, no BFF) completes the flow as long as its exact origin is registered on its OAuth client. Omit the origin and the browser's preflight is rejected ### Server-to-server endpoints Resource servers reaching out to Modgud (e.g. for `/connect/userinfo` or `/connect/introspect`) need network reachability to the Modgud API endpoint, with the bearer token's audience matching their App slug. No special CORS — server-to-server. ## Browser support Admin SPA targets evergreen browsers — last 2 versions of Chrome, Firefox, Safari, Edge. WebAuthn/Passkey support requires: * Chrome 67+, Firefox 60+, Safari 13+, Edge 18+ * HTTPS context (or `localhost` for dev) * Platform authenticator (TPM, Touch ID, Face ID) or roaming authenticator (YubiKey) ## What's not (yet) supported * **Acting as a SAML identity provider** — Modgud consumes SAML 2.0 as an external login provider (see [SAML federation](../admin/saml-federation)), but it doesn't issue SAML assertions for downstream apps; OIDC / OAuth 2.0 only on that side. * **LDAP** — for directory sync, build a one-off ETL or use the [user editor's API](../reference/admin-api) * **Tenant-level data export** as a single archive — per-realm `pg_dump` is the path today * **Audit-log export** in a structured wire format — only via the admin UI's CSV export (manual download) --- --- url: /getting-started/features.md --- # Features A point-by-point list of what Modgud delivers out of the box. ## Authentication ### Local authentication * Username + password sign-in; passwords hashed by ASP.NET Core Identity (PBKDF2). OAuth client and API secrets are BCrypt-hashed (work factor 12). * Configurable account lockout (default: 5 failed attempts → 1 minute lock) * Password reset via emailed magic link * Email confirmation with double-opt-in for self-service email changes * Public self-registration, off by default and configurable per app: sign-in-or-sign-up on first OTP, an explicit register step, or invite-code-gated sign-up (single-use codes minted by an admin or by the consuming app's backend) * Configurable required identity fields per realm/app (username, first name, last name — each off/optional/required); email is always required ### Two-factor authentication * **TOTP** (Google Authenticator, 1Password, Authy, …) * **Email OTP** (six-digit code sent to verified address) * **WebAuthn / FIDO2 Passkeys** (Touch ID, Windows Hello, YubiKey, etc.) * **Recovery codes** (one-time backup codes for self-service recovery) * **Configurable enforcement** — Off / Optional / Required, with per-user override and a grace period ### External Identity Providers (SSO) * **Microsoft Entra ID** (Azure AD) * **Generic OIDC** (anything Discovery-compliant — Keycloak, Okta, Auth0, Cognito, etc.) * Per-IdP user-update scripts for claim → profile mapping * Just-in-time user provisioning (toggle-able) * Mixed-mode realms (Internal + External providers side by side) ### Magic-link sign-in * One-time token via email, no password required * Configurable lifetime * Single-use enforcement ## Authorization ### Multi-app permission model * **Apps** as first-class organisational containers within a realm * **Resources** declared per app * **Roles** bound to one app, holding permissions on its resources * **Groups** with `BoundTo` activation switch — wildcard `*`, specific apps, or dormant * Permission strings shaped `:` (two segments; app context implicit from the catalog container) with two bypass tiers (`realm:admin`, `:admin`) * Apps also carry their own soft configuration facet — origin, branding, and login posture — while still sharing the realm's user pool and a single `sub` per user ### Permission distribution to resource servers * **Own `resource_access` claim** (shaped like Keycloak's nested format for familiarity) emitted in `/connect/userinfo`, keyed by app slug, per-Audience * **Bypass-pre-expanded + per-RS narrowed** — consumers do straight exact-match without porting the evaluator * **`Modgud.Client.AspNetCore`** library ships an `IClaimsTransformation` that flattens `resource_access[].roles` into `ClaimTypes.Role` so `[Authorize(Roles="...")]` works on resource servers without per-endpoint code ### ABAC Modgud is a pure RBAC + grouping IAM. Row-level access policies (ABAC) live in the consuming app where the row schema lives — see [Concepts → ABAC](../concepts/abac) for the boundary and the three deployment profiles (IAM-only, code-static ABAC, admin-pluggable via local groups). ### Auto membership * Groups can compute their members from a JsEval predicate over the principal directory * Recomputes incrementally on principal changes (script-dependency tracking) * Hybrid mode: static members + automatic additions ## OAuth 2.0 / OpenID Connect ### Flows * **Authorization Code + PKCE** (web, SPA, mobile) * **Refresh Token** * **Client Credentials** (server-to-server) * **Device Code** (RFC 8628 — CLI tools, set-top boxes) with a hosted verification page for entering the user code * **Audience-restricted tokens** (RFC 8707 `resource` parameter) — a client can bind the issued token's audience to exactly the resource(s) it requested, for hard cross-RS isolation * **Native cookieless grants** — dedicated token grants for email-OTP, magic-link, and passkey sign-in, for mobile/native clients that can't hold a browser session; passkeys support a per-client WebAuthn RP-ID ### Endpoints (per realm) * `/connect/authorize`, `/connect/token`, `/connect/userinfo`, `/connect/logout`, `/connect/introspect`, `/connect/revoke` * `/connect/device`, `/connect/verify` (Device Code flow) * `/.well-known/openid-configuration`, `/.well-known/jwks` * Realm-aware issuer URLs — every realm is its own OIDC provider ### Token formats * **Reference tokens** (default) — server-side opaque, validated via introspection. Short-circuit revocation across many resource servers, and no token contents on the wire. * **JWT** — per-client opt-in (set the client's **Access Token Type = JWT**). Self-validating against JWKS. A resource server that validates locally via the JWKS endpoint needs its clients issuing JWTs. ### Standard scopes * `openid`, `profile`, `email`, `offline_access`, `roles`, `permissions` (seeded into every realm) * Plus the `resource_access` claim shape (Keycloak-style nesting, under the `roles` and/or `permissions` scopes) * `phone` and `address` are recognised but not auto-seeded — add them per-realm when needed ### App-scoped custom scopes * Define your own scopes (e.g. `billing.write`) * Bind them to apps; `/connect/authorize` rejects with `invalid_scope` if a client requests an app-scope it isn't entitled to ## Multi-tenancy ### Realms * Each tenant gets its own PostgreSQL database (`_`) * Domain-based routing — Host header decides the realm * Query-level cross-realm leakage is prevented by physical separation — every realm is its own database ### Realm management * Realm-management UI on the Control-Plane realm (the realm holding the persisted `Realm.IsControlPlane` flag — `system` by default, but the flag is **transferable** to any active realm via `recover control-plane transfer ` or `POST /api/admin/realms/{slug}/transfer-control-plane`) * Per-realm bootstrap via Control-Plane-issued magic-link invite or recovery CLI * Exactly one Control Plane per deployment, enforced on create / transfer * **Declarative realm provisioning** — export a realm as a manifest, import/apply it to create or update a realm in place, and optionally prune anything the manifest no longer lists ### Per-realm configuration * Domains, display name, description * 2FA enforcement, grace period * Sign-in cookie lifetime * SMTP settings * Profile-change approval flow * Rate-limit ceilings on auth endpoints (OTP request, magic-link request, password reset, passkey ceremonies, …), each configurable per realm ## GDPR ### Self-service * **Article 20 export** — the user downloads their full profile, sessions, login history, and OAuth-consent history as JSON * **Account deletion** — user-initiated, with email-confirmed cooldown period; user can cancel before grace expires * **Email change** — with double-opt-in to the new address ### Admin-side * **Permanent erase** — masks PII in events (Marten data-masking) and archives the user stream. Audit trail remains intact via stable IDs. * **Soft-delete** — the default; keeps records reversibly out of the way ## Operations ### Audit * **Auth log** — every authentication, profile change, admin action recorded with actor, target, IP, user agent, outcome * Retention configurable per realm * PII masking on permanent-erased users ### Admin UI * Real-time updates via SignalR — multiple admins editing simultaneously stay in sync * Granular sidebar gating based on permissions * Resource-level permissions (`user:read`, `oauth-client:write`, …) — granular admins see only what they manage ### Recovery CLI * Inside-container tool for breaking out of "no admin can sign in" situations * `bootstrap-admin`, `set-email`, `magic-link`, `reset-2fa`, `list`, `realm-add-domain`, `realm-set-primary-domain`, `control-plane transfer`, `rebuild-projections`, `migrate-cc-credentials` — all bypass the UI. See [Recovery CLI reference](../operate/recovery-cli). ### SignalR push * All admin lists update live across browser sessions * Cuts down on accidental write conflicts and "is my view stale?" doubt ### Demo seed (repo-only, optional) * A Node script (`scripts/seed-demo.mjs`, available when you have cloned the repository — not in the published image) that POSTs sample data through the regular admin API * Roles, groups, OAuth client, sample external provider — a realistic playground for dev/test ## Developer integration ### Resource server libraries * **`Modgud.Client.AspNetCore`** — drop-in `IClaimsTransformation` that flattens the per-Audience `resource_access` block onto the principal * Standard `JwtBearerHandler` for token validation; nothing custom required on the framework side ### UserInfo as the permission delivery channel * `/connect/userinfo` emits `resource_access` keyed by app slug, per Audience * Bypass-pre-expanded server-side + narrowed to each RS's declared `OAuthApi.PermissionIds` subset * Delivered via the standard OIDC UserInfo endpoint and standard JWT claims — any OIDC-aware consumer can parse it. `Modgud.Client.AspNetCore` adds the audience selection and claims projection for ASP.NET Core on top; it's not a custom protocol ## Standards * OAuth 2.0 (RFC 6749) * OAuth 2.0 PKCE (RFC 7636) * OAuth 2.0 Token Introspection (RFC 7662) * OAuth 2.0 Token Revocation (RFC 7009) * OAuth 2.0 Resource Indicators (RFC 8707) * OAuth 2.0 Device Authorization Grant (RFC 8628) * OpenID Connect Core 1.0 * OpenID Connect Discovery 1.0 * WebAuthn Level 2 (FIDO2) * TOTP (RFC 6238) * GDPR Articles 17 & 20 ## Roadmap Documented but not yet implemented: * **SCIM 2.0** for directory sync from external IdPs * **SignalR push for permission revocations** (so consumers don't have to poll UserInfo) --- --- url: /getting-started/first-time-setup.md --- # First-time setup How to bootstrap the very first admin account in a fresh deployment, and how to onboard the admin of every additional realm you create later. ## The mental model Modgud has **no anonymous setup wizard**. A freshly-deployed instance with zero users does not expose a "click here to claim the instance" form — that would be a race window where the first stranger to reach the URL becomes the global admin. Instead, the first admin is created by someone with a **proven trust boundary**: * **Container shell** (recovery CLI). Whoever can run commands inside the container is at the same trust level as someone who has the database password. That's an acceptable identity for "I'm the operator". * **An existing admin** (HTTP API). Once at least one admin exists in the deployment's Control-Plane realm, every new realm's first admin is bootstrapped by that existing admin via the regular admin API. There are three concrete paths. Pick by your scenario: | Scenario | Use | | --- | --- | | Local dev / first install on a self-hosted box | **Recovery CLI — direct mode** | | Operator delegates the first sign-in to someone else (e.g. handing the system to a customer admin) | **Recovery CLI — invite mode** | | Provisioning a new tenant realm in an already-running deployment (SaaS or multi-environment self-hosted) | **HTTP API — `POST /api/admin/realms`** | ::: tip Running a single-tenant deployment? If your deployment hosts one app for one company (no SaaS, no per-customer isolation), you don't need to provision additional realms — the system realm is fully featured and works on its own. See [Single-tenant mode](single-tenant-mode) for the recipe. ::: All three paths end up with the same shape inside the realm: an `ApplicationUser`, the three default roles (System Admin / User Manager / Viewer), and an `Administrators` group containing the new user with `realm:admin` — exactly what every other admin in the system has. ## Prerequisite — add your public hostname to the system realm ::: warning Production deployments must do this BEFORE the first admin bootstrap The system realm is auto-created on first boot with a hardcoded dev-friendly domain list — `system.localhost`, `localhost`, `127.0.0.1`. `RealmMiddleware` matches incoming requests against that list to resolve which realm a request belongs to. A request to `https://auth.example.com/...` against an unmodified system realm gets rejected as "no realm" and you can't reach the SPA, the login page, or even the bootstrap-magic-link. Add your real public hostname first: ```bash # 1. Register the public hostname on the system realm docker exec modgud \ dotnet Modgud.Api.dll recover realm-add-domain \ --slug system \ --domain auth.example.com # 2. Make it the realm's primary so outbound email links (magic-link, # password reset, invites) resolve to the public host, not localhost docker exec modgud \ dotnet Modgud.Api.dll recover realm-set-primary-domain \ --slug system \ --domain auth.example.com # 3. Restart the container to pick up the change docker restart modgud ``` `realm-add-domain` is idempotent — re-running with the same domain is a no-op. `realm-set-primary-domain` requires the domain to already be on the realm (run `realm-add-domain` first); it also re-points the WebAuthn relying-party ID, so existing passkeys are invalidated by a primary-domain change. List the current domains with `recover realm-list`. Remove with `recover realm-remove-domain --slug system --domain auth.example.com` (you cannot remove the current primary — re-point it first). Skip this section if you're on the default `localhost` Docker quickstart — the seeded domains already cover `localhost`, `127.0.0.1`, and `system.localhost`. ::: ::: warning Production boot guards (fail-closed) The published image runs as **Production** and refuses to boot on a dev-shaped config. Before the container will start in production you must satisfy all of these: * `OpenIddict__DevelopmentMode` is `false` (the default) — ephemeral keys are rejected. * If Prometheus scraping stays enabled (the default), `Observability__Prometheus__BearerToken` is set to a strong random string. Otherwise set `Observability__Prometheus__Enabled=false`. An unauthenticated `/metrics` endpoint on a public host leaks realm-labelled telemetry, so the guard blocks boot until you pick one. A misconfigured value throws at startup with a descriptive message rather than silently yielding an insecure IdP. See [Deployment](../operate/deployment) for the full env-var reference. ::: ## Path A — Recovery CLI, direct mode The simplest path for local development and self-hosted first-installs. Sets the password right away — no email roundtrip needed. ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin \ --password 'StrongPass1!' \ [--realm system] ``` The `--realm` flag defaults to `system`. You only need it for non-system tenants (rare from the CLI — usually you'd use Path C for those). Output: ``` ✓ Admin created in realm 'system': UserName: admin Email: admin@example.com Mode: Direct (password set on creation) ``` Sign in immediately at the realm's host — `http://localhost/` for the default Docker quickstart. ::: tip Password rules apply The CLI enforces the same Identity password policy the SPA uses (length ≥ 8, mixed case, at least one digit). A weak password is rejected with a clear error — no privileged bypass. See [Settings](../platform/settings) to relax the policy if your operational needs require it. ::: ## Path B — Recovery CLI, invite mode Same CLI, but **without** `--password`. Useful when the operator (you) shouldn't know the admin's password — e.g. when handing off a customer's instance. ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --email max@acme.com \ --username max \ [--realm system] ``` Output: ``` ✓ Bootstrap-invite issued for realm 'system': UserName: max Email: max@acme.com Expires: 2026-05-12 10:26:51 +00:00 Link: http://localhost/bootstrap?token=… ``` The CLI: 1. Writes a **`PendingAdminInvite`** record into the realm's tenant DB (single-use, 7-day expiry, hashed token). 2. Sends a **magic-link email** to the recipient (if SMTP is configured). 3. Prints the magic-link URL on stdout regardless — useful for SMTP-less dev setups or air-gapped operations. The recipient opens the link, lands on `/bootstrap?token=…` in the SPA, sets their own password, and is auto-signed-in. The token is revoked on first successful use. If the link expires or gets lost, run the same CLI command again — it revokes any open invite for that email and issues a fresh one. ## Path C — HTTP API (Control-Plane admin issues an invite) Used for **every realm beyond the first**. Once you have at least one admin in the Control-Plane realm, you create new tenant realms (and their first admins) through `POST /api/admin/realms` from the Control-Plane host. This is the SaaS-friendly path: a customer registers, you provision their realm by calling one endpoint, the customer gets the magic link in their inbox and never shares a password with you. The Control-Plane admin sends: ```http POST /api/admin/realms HTTP/1.1 Host: auth.example.com # the Control-Plane host Content-Type: application/json Cookie: Modgud.Auth=… # the CP-admin's session cookie { "Slug": "acme", "DisplayName": "Acme Corp", "Domains": ["auth.acme.com"], "InitialAdmin": { "UserName": "max", "Email": "max@acme.com", "Firstname": "Max", "Lastname": "Mustermann" } } ``` Response (201 Created): ```json { "Realm": { "Slug": "acme", "DisplayName": "Acme Corp", "Domains": ["auth.acme.com"], "IsControlPlane": false, "IsActive": true, "CreatedAt": "..." }, "InitialAdminInvite": { "UserName": "max", "Email": "max@acme.com", "ExpiresAt": "...", "MagicLinkUrl": "https://auth.acme.com/bootstrap?token=…" } } ``` Behind the scenes, atomically with the realm creation: 1. The tenant DB is provisioned and the realm-internal `modgud` app is seeded. 2. A `PendingAdminInvite` is written into the new tenant DB. 3. The magic-link email is sent to `InitialAdmin.Email`. The magic-link URL is also returned in the response — useful when SMTP isn't reachable in the calling environment. Treat the URL as secret-equivalent until it's been clicked. To re-issue a fresh token (e.g. operator pressing the button after an expired link is reported): ```http POST /api/admin/realms/acme/resend-bootstrap-invite ``` The previous invite is revoked, a fresh `MagicLinkUrl` is returned. The recipient identity (UserName + Email + Firstname + Lastname) is reused from the original invite — no `body` needed. ::: warning Email is mandatory The HTTP API requires `InitialAdmin.UserName` and `InitialAdmin.Email`. There is no way to create a realm without a recipient — a realm with no admin path would be an orphaned shell. If the recipient's email turns out to be wrong, delete the realm and provision a fresh one (the soft-delete leaves data for forensics; see [Realms admin](../admin/realms)). ::: ## After the first admin is in You're now signed in. The admin SPA dashboard shows: * Sidebar with every section visible — you hold `realm:admin`, the wildcard bypass. * The `modgud` system app already registered (seeded by `AppRealmSeeder` on realm creation). Recommended next steps: 1. **Enable 2FA on your admin account** — Profile → Security → TOTP or Passkey. 2. **Configure SMTP** — Settings → SMTP, then send a test email. Without real SMTP, outbound email is silently dropped (there is no on-disk dev mailbox); the recovery CLI and realm-creation API still surface invite / magic-link URLs directly. For local capture, point Modgud at a dev SMTP catcher such as Mailpit or smtp4dev. 3. **Seed demo data** (optional, dev/test only, repo checkout required) — run `node scripts/seed-demo.mjs` to fill the realm with users, groups, OAuth clients and a sample external IdP. Not in the published image. 4. **Bind your first SaaS app** — [SaaS Integration Walkthrough](../integrate/saas-walkthrough). 5. **Configure external SSO** (optional) — [Login Providers](../admin/login-providers). 6. **Plan additional realms** — [Realms admin](../admin/realms). ## Lost the admin account? If the only admin in a realm loses their access, no UI flow can restore them — but the recovery CLI can. Run the same `bootstrap-admin` command again with a fresh email/username; it adds you to the existing `Administrators` group rather than duplicating it. See [Recovery CLI](../operate/recovery-cli) for related commands (`reset-2fa`, `magic-link`, `set-email`). ## Tips ::: tip Always set an email Without an email address you have no recovery channel — no magic link, no password reset. Always set one on the first admin and verify SMTP works before you need it. ::: ::: tip One Control-Plane realm per deployment Exactly one realm in a deployment is the Control Plane — the realm carrying the persisted `Realm.IsControlPlane` flag. The `system` realm is stamped with it at first boot, so it's the default anchor, but the flag is **transferable**: a deployment that starts single-tenant can later hand cross-realm administration to a different realm via `recover control-plane transfer ` or `POST /api/admin/realms/{slug}/transfer-control-plane`. Once moved, the original system realm becomes an equal, deletable peer. The "exactly one" invariant is enforced on create and transfer. See [Concepts: Control Plane / Data Plane](../concepts/control-plane). ::: --- --- url: /getting-started/single-tenant-mode.md --- # Single-tenant mode Modgud is multi-tenant by design — every realm gets its own PostgreSQL database, hostname routing, OAuth-client + user store. But the multi-tenant architecture is **opt-in**: for a "one app, one company" deployment the system realm is enough on its own. ## When this fits * You're running modgud as the IdP for **your own** apps and users, not hosting tenants for third parties. * You don't need per-customer data isolation. * The deployment has one public hostname (e.g. `auth.acme.com`) and every user signs in there. If any of those don't fit, you're a SaaS or multi-customer scenario and want one realm per tenant. See [Multi-realm deployment](../operate/realms) for that pattern. ## What you get The system realm is a **fully-featured realm** that behaves identically to any tenant realm for everyday IdP work — plus the control-plane functions on top. | Feature | Available in system realm | |---|---| | Users, Groups, Roles, Permissions | ✅ | | OAuth clients for your apps | ✅ | | OAuth scopes, resource APIs | ✅ | | Login providers (Internal, OIDC federation) | ✅ | | Custom permissions, auto-membership scripts | ✅ | | Magic-link, 2FA, Passkeys, email-OTP | ✅ | | `/api/admin/realms` (cross-realm management) | ✅ control-plane only | | `control-plane:*` permission namespace | ✅ control-plane only | The two control-plane-only items don't get in the way for a single-tenant deployment — they just sit there unused. ## Setup For a quick local eval, use the [Quickstart](quickstart) compose as-is — the system realm is all you get out of the box. For a real single-tenant **production** deployment, run the published image with every env var the Production boot guards require: ```bash docker run -d \ --name modgud \ -p 80:8081 \ -v cocoar-keys:/app/data/keys \ -e DbSettings__ConnectionString="Host=db.internal;Database=modgud;Username=modgud;Password=…;Keepalive=30" \ -e ProxyAllowedNetworks="10.0.0.0/8" \ -e Observability__Prometheus__BearerToken="$(openssl rand -hex 32)" \ ghcr.io/cocoar-dev/modgud:latest ``` Only two of these are boot-enforced guards that fail closed: `OpenIddict__DevelopmentMode` must be `false` (the default), and Prometheus must either carry a strong `BearerToken` or be turned off entirely with `-e Observability__Prometheus__Enabled=false`. The other two settings above are still important, just not startup checks: the issuer is derived per request from the realm's Host header rather than checked at boot, so it's on you to make sure `https://auth.example.com` is what actually resolves; and omitting `ProxyAllowedNetworks` doesn't fail startup, it silently falls back to a sentinel network that never matches, so forwarded headers from your reverse proxy get rejected instead of honoured. The `-v cocoar-keys:/app/data/keys` volume persists the signing keys across restarts. Configuration is by env var only — `configuration.json` is not shipped in the published image, and Cocoar.Configuration v6 binds `Section__Property` case-insensitively. See [Deployment](../operate/deployment) for the full guard list. ```bash # Add the public hostname to the system realm, make it the primary # (so outbound email links resolve), then restart to pick it up docker exec modgud dotnet Modgud.Api.dll \ recover realm-add-domain --slug system --domain auth.example.com docker exec modgud dotnet Modgud.Api.dll \ recover realm-set-primary-domain --slug system --domain auth.example.com docker restart modgud # Bootstrap the first admin into the system realm (default) docker exec modgud dotnet Modgud.Api.dll \ recover bootstrap-admin \ --email admin@example.com --username admin --password 'StrongPass1!' ``` That's it. From the browser: 1. `https://auth.example.com/login` → sign in as `admin` 2. Set up 2FA (the grace-period dialog appears on first login) 3. Admin → Users → invite your team 4. Admin → OAuth clients → register the apps that will sign in against this IdP 5. Admin → Roles + Groups → wire up app-specific permissions ## What to avoid * **Don't grant `control-plane:*` permissions to regular users.** The default seeding doesn't — `realm:admin` (in the seeded Administrators group) is the only privileged role. Custom roles you create yourself shouldn't list `control-plane:realm:read` or `control-plane:realm:write` unless the user genuinely is a deployment-level admin. * **Don't deactivate or delete the realm that currently holds the Control-Plane flag.** While a realm is the Control Plane, deactivating or deleting it is blocked by service-level guards (the deployment would lose its cross-realm admin surface). In a single-tenant deployment that's the system realm. The Control-Plane flag is transferable to another active realm first (`recover control-plane transfer `) if you genuinely need to retire the original — see [Concepts: Control Plane](../concepts/control-plane). ## Growing into multi-tenant later If a single-tenant deployment later needs to host a second tenant (merger, white-label rollout, …), nothing has to change in the existing system realm. You just create a new realm via `POST /api/admin/realms` (or the Admin UI), give it its own hostname, and the existing users / clients / scopes in the system realm stay where they are. The new realm gets its own PostgreSQL database (`_`), its own hostname-routing entry, its own everything. Cross-realm isolation is enforced at the database level for queries and connections — a query that's missing its tenant scope still only runs against the single realm's own database, so it can't reach another tenant's data. That's a guarantee about query-level bugs specifically, not a blanket promise that no bug anywhere in the application layer could ever cross a realm boundary — see [Concepts: Realms](../concepts/realms#cross-realm-isolation) for how the other surfaces (permissions, tokens, cookies, SignalR) are isolated. --- --- url: /concepts/glossary.md --- # Glossary Terms in modgud and their counterparts in other identity systems. ## Core terms ### Realm An isolated identity boundary. Each realm has **its own PostgreSQL database** (`_`), its own users, roles, OAuth clients, and login providers. Mapping to other systems: | modgud | Keycloak | Auth0 | Azure AD | |---|---|---|---| | Realm | Realm | Tenant | Tenant (Directory) | The **system realm** is the first realm, created automatically on first boot. It starts as the **Control-Plane** realm — flagged `IsControlPlane = true` — meaning only its users may create further realms. Exactly one realm per deployment is the Control Plane. The realm boundary is the **domain** (Host header), not the URL path. Realm `acme` lives under `acme.example.com`, the system realm under `system.example.com` or `localhost`. ### Application A **soft facet within a realm** — not an isolation boundary. An Application owns a permission catalog (its `:` entries) and, since ADR-0011, an optional login experience: its own subdomain, branding, email branding, and sparse per-app overrides of the realm's self-registration / native-grant / DCR / CIMD policy. All apps in a realm **share the realm's user pool** — one account, one `sub`, no shadow users. Tenant vs. Application: the **realm (tenant)** is the hard boundary (own DB, signing keys, OIDC issuer, user pool, apex); an **Application** is a refinement inside it. Promote an App to its own realm only when you need independent key rotation, breach containment, or data isolation. In code: `Modgud.Authorization.Apps.App` (the catalog discriminator) plus the tenant-scoped `ApplicationSettings` document (the override doc, keyed by `App.Id`). ### SelfRegPosture How an Application triggers a passwordless self-registration: `Off` (none), `JitOnOtp` (sign-in-or-sign-up on an unknown email — the consumer default), `ExplicitEndpoint` (a deliberate separate registration step, so sign-in stays strict for known users only), or `InviteCode` (invite-only — an unknown email can only self-register with a valid, unused invite code; everyone else gets the same anti-enumeration response as `Off`). Lives in an App's self-registration settings. ### User A human or service account inside a realm. Users belong to exactly one realm. Identical usernames in different realms are different accounts. In code: `Modgud.Authentication.Domain.ApplicationUser` (ASP.NET Core Identity user). ### Group An organisational unit. Groups have members (users or other groups) and carry `PermissionRole` references. Groups exist in two modes: * **Manual** — admin maintains the member list * **Auto** — a membership script determines members dynamically See [Auto membership](/concepts/auto-membership). ### PermissionRole A named bundle of permissions. Binds to one App (or to the realm when `IsRealmAdmin = true`) and references a list of the App catalog's `PermissionIds`: ``` Name: "User Manager" AppId: PermissionIds: [user:read, user:write] ``` ### Permission A two-segment string `:` inside an App's catalog — the App context is implicit from the catalog container. Examples: `user:read` (in the `modgud` app), `invoice:write` (in a `billing` app), `realm:admin` (realm-constant bypass). Permissions flow exclusively through groups: ``` User → Group → Role → Permission ``` Two bypass tiers: `:admin` (resource-wide within the calling app) and `realm:admin` (realm-wide emergency exit). See [Permissions & gating](/concepts/permissions) for the full evaluator + UserInfo-emission story. ### Session A server-side record (`UserSession` Marten document) of an active login. Tracks IP, browser, OS, device type, `LastActiveAt`, `ExpiresAt`. Users can revoke their own sessions; admins can force-logout users. The browser, OS, and device type are detected from the request's user agent. *** ## OAuth / OIDC terms ### Client (OAuth application) An external application that requests user logins or API access. Created per realm — the same `client_id` in realm A and realm B are different clients. Configurable per client: * **Client ID** — public identifier (e.g. `my-app`) * **Client Secret** — private key (for confidential clients) * **Redirect URIs** — allowed callback URLs * **Grant Types** — which flows are allowed * **Access Token Type** — Reference (default) or JWT ### Scope A permission boundary that a client can request. Scopes appear in the token; resource servers decide based on the scopes whether the request is OK. Default scopes (set per realm at realm provisioning): * `openid` — required for OIDC, returns the user ID * `profile` — first name, last name * `email` — email address * `roles` — role memberships * `offline_access` — enables refresh tokens ### API (resource) A protected backend API. Has an identifier (`audience` claim in the token) and a list of scopes it supports. In code: `OAuthApiAggregate`. ### Grant Type | Grant Type | Use case | |---|---| | **Authorization Code + PKCE** | Web apps, SPAs, mobile apps | | **Client Credentials** | Machine-to-machine, background services | | **Refresh Token** | Renew expired access tokens | ::: warning No Implicit, no ROPC Modgud supports neither Implicit Flow nor Resource Owner Password Credentials (ROPC). Both are considered insecure and are deprecated in OAuth 2.1. ::: ### Token types | Type | What it is | |---|---| | **Access Token** | Access to APIs. Reference (opaque, via introspection) or JWT (self-contained) | | **Identity Token** | Who signed in — consumed by the client | | **Refresh Token** | Get a new access token without a fresh login | ### Access token format Configurable per client: | Format | How it works | Best for | |---|---|---| | **Reference** (default) | Opaque string. APIs validate via the introspection endpoint. | SPAs, mobile, public clients — instant revocation. | | **JWT** | Self-contained, signed token. APIs verify locally. | Trusted backend services — no introspection roundtrip. | ::: tip Which one when? **Reference tokens** are the safe default. Revoke a reference token and it is dead immediately. JWTs cannot be revoked — they are valid until they expire. Use JWT only for trusted services where the introspection roundtrip gets in the way. ::: *** ## Login providers An authentication method that users can use. A single `LoginProvider` aggregate per entry, with a `Type` discriminator. Configurable per realm. | Type | Status | Description | |---|---|---| | **Internal** | Wired up | Built-in username/password. Auto-seeded once per realm, marked `IsBuiltIn=true`, not editable from the admin UI. | | **Oidc** | Wired up | External OIDC IdPs (Entra ID, Google, Auth0, ...). Authority + client ID + secret + UserUpdateScript. | | **Saml** | Wired up | External SAML 2.0 IdPs. See [SAML federation](/admin/saml-federation). | | **Ldap** / **Kerberos** | Reserved | Enum values exist; create endpoint rejects with `LoginProvider.TypeNotSupported`. The shape ships now so the FE doesn't have to add a "not supported yet" UI per type later. | Configured Oidc providers automatically show "Login with {Provider}" buttons in the login UI. Internal never produces an SSO button — it backs the local username/password form. *** ## Terms in code | Term in code | Term in docs/UI | Where | |---|---|---| | `TenantId` | Realm slug | Marten/Wolverine, infrastructure layer | | `Principal` | User or group or service account | Authorization slice (polymorphic) | | `Person` | User read model in the authorization slice | A subclass of Principal | | `Aggregate` | Event-sourced entity | Domain layer | | `*State` | Inline projection for sync consistency | Infrastructure layer | | `*ListReadModel` / `*DetailsReadModel` | Async projection for read optimization | Infrastructure layer | | `LoginProvider` | Login provider configuration (Internal / Oidc / ...) | Authentication slice | ::: info "Realm" vs. "Tenant" User-facing it is **Realm** everywhere. The code uses **Tenant** in the infrastructure layer (`TenantId`, `ITenantSessionFactory`, `MasterTableTenancy`), because that is what Marten and Wolverine call it. Same thing, two names. ::: --- --- url: /concepts/apps-and-resource-access.md --- # Apps and resource\_access This page explains the mental model behind Modgud's permission system: what an "App" is, how it relates to OAuth concepts, how Modgud's own per-audience authorization claim — shaped like Keycloak's nested `resource_access` format for familiarity — works, and how the permission resolver gets from a logged-in user to a concrete answer. ## The four-axis model OAuth/OIDC officially knows four roles: Resource Owner (the user), Client, Authorization Server, Resource Server. Modgud adds a fifth concept that the OAuth spec doesn't model — the **App**. ``` Realm │ ┌───────────┴───────────────┐ │ │ Identity Apps │ (the IAM axis) ┌────────────┼─────────────┐ │ │ │ │ ┌────┼─────────────┐ Users Groups PermRoles │ │ │ App Resources Roles (per app) │ ┌───────┼───────┐ │ │ │ OAuth OAuth Scopes Clients APIs (per app) (n:m) (1:n) ``` Why an App layer? Because in OAuth a **Resource Server** is just a Resource Server — `acme-api` is one thing. But organisationally, "Acme as a product" might be many resource servers (api, search, files), share resources/roles across them, and need a coherent permission story regardless of which microservice the user is hitting. **The App is the organisational clamp.** | Concept | Purpose | OAuth analog | | --- | --- | --- | | **App** | Organisational identity for a SaaS product, owns Resources + Roles | none (IAM-specific) | | **OAuth Client** (`OAuthApplication`) | Identity that requests tokens (frontend, CLI, mobile) | OAuth Client | | **OAuth API** (`OAuthApi`) | Identity that authenticates as a resource server | OAuth Resource Server | | **OAuth Scope** | What a token may do (gross-grained) | OAuth Scope | | **Group** | Org-level user collection (mailing-list semantics) | none | | **PermissionRole** | Bundle of permissions for one app | Role | | **Permission** | Smallest unit, shape `:` within an app catalog | Permission | Every artefact below the realm sits on one of these axes. App-scoped artefacts (`PermissionRole.AppId`, `OAuthScope.AppId`, `OAuthApi.AppId`) reach back up to the App; `Group.BoundTo` is the activation switch ("is this group active in app X?"). ## The App's second facet: a login experience (ADR-0011) The permission clamp above is the App's *first* facet. Since ADR-0011 an App is also a **soft origin/login facet** within the realm: an optional own subdomain, branding, email branding, and sparse per-app overrides of self-registration / native-grant / DCR / CIMD policy. The key word is **soft**. The realm (tenant) stays the **hard** boundary — own database, signing keys, OIDC issuer, user pool, apex. An App does not get its own user pool or its own issuer: all apps in a realm share one account namespace (one `sub` everywhere, no shadow users), and a request on an App subdomain still mints tokens under the **tenant's** canonical issuer (the realm primary domain). The App only changes the *experience* (which host serves login, what it's branded as, whether unknown emails can self-register) — never the identity of the user behind it. Resolution is by signal, ordered in time: **Host** (an App subdomain pins the App) → **`client_id`** (a client's `AppIds`) → **scope/audience**. The first signal wins and later signals must be consistent with it (Host pins App X + a `client_id` for App Y → rejected). The settings themselves live in a tenant-scoped `ApplicationSettings` document and merge field-by-field over the realm defaults. See [Admin → Applications](../admin/applications#application-settings). ## Why apps and resource servers aren't 1:1 Two real-world deviations from "one App = one Resource Server": **Microservice apps.** Acme's backend might be split into `acme-api`, `acme-search`, `acme-files`. All three are different OAuth API identities (each with its own secret, its own audit identity), but they share the same App `acme` — so a user is `Editor in Acme` and that role works regardless of which microservice handles a given HTTP request. **Multi-app frontends.** A unified webshop frontend might call into a `shop` app, a `payments` app, and an `inventory` app. The frontend has *one* OAuth Client (one user-facing identity), but the client is linked to all three Apps via its `AppIds` list. The issued token then carries `resource_access` blocks for all three; each backend reads its own block. The two flexibilities together let Modgud represent any reasonable architecture without forcing you into "everything is one app" or "split everything into separate clients". ## Permission resolution: step by step Given a `(userId, appSlug)` pair (e.g. `(alice, "acme")`), what permissions does the user effectively hold? ``` 1. BFS user → groups (transitive: User in A; A in B; A and B both count) 2. Filter groups (g.BoundTo contains "*" OR appSlug) 3. Collect role IDs (g.RoleIds for each surviving group) 4. Load roles (drop deleted) 5. Filter to this app (r.AppId == app.Id OR r.IsRealmAdmin) 6. For each role: resolve PermissionIds → catalog strings ("invoice:read", "invoice:write", …) 7. Distinct → result ``` Two filters, not one: BoundTo on the group, AppId on the role. They serve different purposes — BoundTo is "is this group active here?", AppId is "is this role about this app?". The resolver lives in `Modgud.Authorization.Services.PermissionService`. It runs IdP-side for both consumers, but the bypass tiers (`realm:admin`, `:admin`) are handled differently by each: * **In-process gates** (`.RequiresPermission(...)`) get the raw markers back from the resolver and check them lazily at gate time — `realm:admin` or `:admin` in the user's permission set is enough to pass, with no expansion into concrete strings. * **The per-Audience `resource_access` block** on `/connect/userinfo` bypass-pre-expands those same markers into concrete catalog strings before the token is emitted (see below), so token consumers never have to special-case them. ## The token shape When a user logs in via an OAuth Client linked to apps `[billing, shipping]`, the access token's `/connect/userinfo` response (with appropriate scopes granted) contains a nested claim shaped like Keycloak's `resource_access` format: ```json { "sub": "abc123…", "email": "alice@example.com", "name": "Alice", "resource_access": { "billing": { "roles": ["Editor"], "permissions": ["invoice:read", "invoice:write"] }, "shipping": { "roles": ["Viewer"], "permissions": ["shipment:read"] } } } ``` Each resource server reads its own block. The Billing-API sees `resource_access["billing"]`; the Shipping-API sees `resource_access["shipping"]`. Neither sees the other's data magnified — they each have it side-by-side, but consume just their own. The `Modgud.Client.AspNetCore` helper lib's `IClaimsTransformation` takes the matching audience block and flattens its roles onto `ClaimTypes.Role`, so `[Authorize(Roles="Editor")]` works out of the box without per-endpoint plumbing. ### What gets emitted is opt-in by scope * `scope=roles` → emit the `roles` array per Audience block. * `scope=permissions` → emit the `permissions` array per Audience block (bypass-pre-expanded and narrowed to that RS's `OAuthApi.PermissionIds` subset). Without those scopes, the block is omitted (or empty). Clients ask for exactly what they need; tokens stay lean. ### Per-RS subset narrowing Each `resource_access` block is narrowed to the OAuthApi's declared `PermissionIds` subset of the App's catalog. A microservice within a multi-resource-server App only sees the permissions it declared as its gating surface — strings from a sibling microservice within the same App are excluded. No cross-RS leaks. ## What's *not* in the token A few things are deliberately absent from UserInfo: * **Group memberships.** Organisational signal, not authorisation. Also app-scoped via BoundTo, which UserInfo's flat shape can't express cleanly. Groups stay IAM-side. * **Cross-app roles for apps the calling client isn't linked to.** The token only carries `resource_access` blocks for the apps the issuing client knows about. * **`realm:admin` as a literal string.** It's bypass-pre-expanded into concrete catalog strings before emission, so consumers do straight exact-match without needing to mirror the evaluator's bypass logic. Anything that's "what may this user do" and stable enough to ride along with the identity → goes in the token. Group memberships and other organisational signal → IAM admin endpoints. ## Design decisions worth knowing These are non-obvious choices the resolver makes. Knowing them avoids "why doesn't this work" moments: **`Group.BoundTo = []` ≠ `BoundTo = ["*"]`.** Empty means *dormant for permission purposes* — the group exists for org/mailing-list reasons but contributes zero to authorisation. Wildcard means *active in every app* (rare, mostly the realm-admin group). **Permissions are not cascaded when BoundTo changes.** Removing an app from `BoundTo` *deactivates* the group in that app — it does NOT strip the group's roles. You can re-add the app and the group is immediately active again. Reduces accidental data loss in admin operations. **`Role.AppId` is fixed.** Once a role is created, its app affiliation cannot change — moving permissions across apps means cloning the role under a new AppId. Rare operation, easy to spot in audit logs. **Bypass tiers are pre-expanded server-side.** Token consumers never see `realm:admin` or `:admin` as literal strings — Modgud expands them into the concrete catalog entries before emission. The client just checks `permissions.includes("invoice:write")` and is done. **`OAuthApplication.AppIds` is `n:m` (a client can be linked to many apps).** **`OAuthApi.AppId` is `1:1` (a resource server belongs to one app).** Asymmetric on purpose: client-side aggregation (one frontend, many resource servers) is normal; server-side aggregation would muddle the audit trail. ## Glossary * **Realm** — top-level tenant. Own database, own users, own apps. * **App** — organisational identity for a SaaS product within a realm. * **OAuth Client** — token requester. Has `AppIds: List` (n:m). * **OAuth API** — token-validating server identity. Has `AppId: Guid` (1:1). * **OAuth Scope** — gross-grained capability claim. Has `AppId: Guid?` (null = global, e.g. `openid`). * **Group** — user collection. Has `BoundTo: string[]` (which apps it's active in). * **PermissionRole** — bundle of permissions. Has `AppId: Guid?` (null when `IsRealmAdmin = true`). * **Permission** — `:` string within one App's catalog. App context is implicit from the catalog container. * **`resource_access`** — Modgud's own per-audience UserInfo claim, shaped like Keycloak's nested format, keyed by app slug, with bypass-pre-expanded permissions narrowed per-RS. --- --- url: /concepts/realms.md --- # Realms ## What is a realm? A realm is a **fully autonomous identity provider**. It is the fundamental isolation boundary in modgud. Per realm: * its own **PostgreSQL database** (`_`) * its own **users and groups** * its own **roles and permissions** * its own **OAuth clients, scopes, APIs** * its own **OIDC discovery endpoint** * its own **login providers** (Internal + OIDC IdPs) * its own **cookie domain** * its own **auth rate-limit ceilings** (per-IP request limits on login/register/etc., overridable per realm) Each realm looks like a standalone modgud installation — because that is essentially what it is. ::: tip Realm (tenant) vs. Application The realm is the **hard** boundary above. An **Application** is a **soft facet** *within* a realm — it can carry its own subdomain, branding and a per-app override of self-registration / native-grant / DCR / CIMD policy, but it shares the realm's user pool, signing keys and OIDC issuer (a request on an App subdomain still mints tokens under the realm's canonical issuer). Promote an App to its own realm only when you need independent key rotation, breach containment, or data isolation. See [Apps & resource access](./apps-and-resource-access#the-apps-second-facet-a-login-experience-adr-0011) and [Admin → Applications](../admin/applications#application-settings). ::: ## Domain-based routing Modgud identifies the realm via the **HTTP Host header** — not via URL paths. Each realm has one or more configured domains. ``` acme.example.com → Realm "acme" auth.acme.example.com → Realm "acme" (second domain for the same realm) finance.example.com → Realm "finance" system.example.com → System realm localhost → System realm (single-tenant fallback in dev) ``` `RealmMiddleware` (in `Modgud.Api.Middleware`) runs before all other middlewares and: 1. Reads `request.Host.Host` 2. Looks up a match in `IRealmCache` 3. Sets `HttpContext.Items["TenantId"] = realm.Slug` 4. If no match → `404` The cache is warmed at boot and invalidated on realm CUD. ::: tip Single-tenant fallback in dev If only one realm is active AND the host is a localhost variant (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`), the cache returns that single realm — even if it does not have the localhost domain in its list. This makes a single-realm dev boot work without a hosts-file entry. ::: ## Database-per-tenant via Marten Modgud uses Marten's `MasterTableTenancy`: ```mermaid graph TD Master[(
= Master DB)] Master -->|realms.mt_tenant_databases| System[(_system)] Master -->|realms.mt_tenant_databases| Acme[(_acme)] Master -->|realms.mt_tenant_databases| Finance[(_finance)] Master -->|global Schema| GlobalRealm["Realm-Documents
(IGlobalStore)"] System -->|tenant data| SystemUsers[Users, Groups, OAuth, ...] Acme -->|tenant data| AcmeUsers[Users, Groups, OAuth, ...] Finance -->|tenant data| FinanceUsers[Users, Groups, OAuth, ...] ``` | Database | Contents | |---|---| | `` (Master) | Schema `realms.mt_tenant_databases` (tenant registry) + schema `global` (Realm documents) | | `_system` | System realm data (users, groups, ...) — its own physical DB, like every other realm | | `_` | A separate physical DB per additional realm | ::: info Master DB vs. system realm The master DB is pure control-plane infrastructure — the tenant registry (`realms.mt_tenant_databases`) + the global Realm store (schema `global`) + Wolverine durability. It is **not** a tenant. Every realm, including the bootstrap `system` realm, lives in its own `_` database. That makes the system realm an equal, deletable peer so the [control plane](./control-plane.md) can be transferred off it. ::: ### Tenant resolution in code `TenantedSessionFactory` (Marten `ISessionFactory`) reads the `TenantId` from `HttpContext.Items` and opens a tenant-scoped session: ```csharp public IDocumentSession OpenSession() => _store.LightweightSession(ResolveTenantId()); private string ResolveTenantId() => _httpContextAccessor.HttpContext?.Items[TenantConstants.HttpContextTenantIdKey] as string ?? TenantConstants.SystemTenantId; ``` Every `IDocumentSession`/`IQuerySession` injection is therefore automatically realm-scoped. Background services (without HttpContext) fall back to the system tenant. ### GlobalStore for realm documents The `Realm` document itself cannot live in the tenant store — otherwise there would be a chicken-and-egg problem. It lives in a separate Marten store (`IGlobalStore`) that writes to schema `global` of the master DB. `RealmCache` loads the realm list from there. ## Realm lifecycle ### 1. First-time bootstrap On first start: 1. **Create the master DB and the `_system` DB** (raw SQL, because Marten cannot `CREATE DATABASE` on an active connection) 2. **Apply the Marten schema** → `realms.mt_tenant_databases` is created 3. **Register the system tenant** → `tenancy.AddDatabaseRecordAsync("system", systemCs)` (pointing at `_system`, not the master DB) 4. **Apply the Marten schema again** → per-tenant tables for the system realm, in its own DB 5. **Seed the system realm document** in `IGlobalStore`, stamped `IsControlPlane = true` 6. **Seed default OAuth scopes + the Internal login provider** 7. **Seed the `modgud` and `control-plane` apps** into the system tenant DB 8. **Warm `RealmCache`** 9. The instance is ready, but has zero users — the first admin is created via the [recovery CLI](../operate/recovery-cli) or, for additional realms, by an existing CP-admin via `POST /api/admin/realms`. See [First-time setup](../getting-started/first-time-setup). ### 2. Create additional realms Only users holding `realm:write` in the Control-Plane app context — which only exists on the Control-Plane realm — can do this. See [Control Plane](./control-plane.md) for the cross-realm admin model — in short: realm CRUD lives on a dedicated app slug (`control-plane`) that is only seeded into the Control-Plane realm's DB, and the routing layer 404s the endpoint on tenant hosts. ```http POST /api/admin/realms { "Slug": "acme", "DisplayName": "Acme Corp", "Domains": ["acme.example.com"], "InitialAdmin": { "UserName": "max", "Email": "max@acme.com" } } ``` Backend: 1. Validates `slug` (regex, no reserved word). New realms are never the control plane — the flag defaults to false and there is no create-time switch; the role only moves via [transfer](./control-plane.md#transferring-the-control-plane). 2. `CREATE DATABASE _acme` (raw SQL). 3. `tenancy.AddDatabaseRecordAsync("acme", connStringForAcme)`. 4. `Storage.ApplyAllConfiguredChangesToDatabaseAsync()`. 5. **`OAuthRealmSeeder`** → 5 default scopes + Internal login provider. 6. **`AppRealmSeeder`** → registers the `modgud` app in the new tenant DB. The `control-plane` app is **not** seeded — it only exists in the Control-Plane realm. 7. Save the `Realm` document in `IGlobalStore`. 8. `RealmCache.Invalidate()`. 9. **Bootstrap-invite** issued atomically: writes a `PendingAdminInvite` into the new tenant DB, sends a magic-link email to `InitialAdmin.Email`, returns the URL in the API response. The recipient clicks the magic-link, lands at `/bootstrap?token=…`, sets a password, and is auto-signed-in with `realm:admin`. The first user creation runs through `RealmAdminBootstrapper`, which atomically seeds the three default roles and adds the user to the `Administrators` group. ### 3. Deactivate a realm ```http PATCH /api/admin/realms/{slug} { "isActive": false } ``` `RealmCache` filters on `IsActive = true` — inactive realms are no longer resolved, all requests to the domain land at `404`. The data stays in the DB. ::: danger Do not deactivate the system realm The system realm must not be deactivated — otherwise you have no way back into the system. ::: ### 4. Hard-delete a realm ```http DELETE /api/admin/realms/{slug}?hard=true ``` Without `hard=true`, `DELETE` behaves exactly like the deactivation above — reversible, data stays in the DB. With `hard=true`, the realm is removed for good: its tenant database is dropped and the global `Realm` record is deleted. There is no undo. Hard-delete is refused for the Control-Plane realm, so a deployment can never delete its own administration surface. ### 5. Declarative provisioning (import / apply / export) Beyond the one-field-at-a-time `POST`/`PATCH` above, a realm's entire configuration — settings, Apps, OAuth APIs/Scopes/Clients, roles, users, groups — can be described as one **manifest** document and applied in a single call: * `POST /api/admin/realms/import` — creates a **brand-new** realm from a complete manifest. Fails if the slug already exists; a failed import rolls the realm back so it's never left half-provisioned. * `POST /api/admin/realms/{slug}/apply` — applies a manifest to an **existing** realm as an in-place merge/upsert; it never drops the database. Add `?prune=true` to make it a full sync that also removes entities absent from the manifest — infrastructure essentials (the system App, standard scopes, service-account clients) and every `realm:admin`-carrying group/user are protected from ever being pruned, so an admin can't accidentally lock themselves out. * `GET /api/admin/realms/{slug}/export` — exports the realm's current configuration as a manifest (structure only, never secrets or password hashes). Round-trips with `apply`: export, edit, re-apply. * `GET /api/admin/realms/manifest-schema` — the manifest's JSON Schema, generated from the live contract, so a caller can author a valid manifest without reading source. A realm admin (someone holding `realm:admin` inside their own realm, without any Control-Plane access) gets the same export/apply/prune workflow scoped to just their own realm, under `/api/admin/realm-config/*` — they can fully manage their realm's own configuration and entities, but can't create, delete, or touch any other realm. ## OIDC endpoints per realm Since each realm has its own domain, it also has its own OIDC endpoints: | Endpoint | Acme | |---|---| | Discovery | `https://acme.example.com/.well-known/openid-configuration` | | Authorize | `https://acme.example.com/connect/authorize` | | Token | `https://acme.example.com/connect/token` | | UserInfo | `https://acme.example.com/connect/userinfo` | | End Session | `https://acme.example.com/connect/logout` | | Introspect | `https://acme.example.com/connect/introspect` | | Revoke | `https://acme.example.com/connect/revoke` | The `RealmIssuerHandler` (an OpenIddict pipeline hook) makes sure the discovery document emits the correct issuer. Tokens from realm A are not valid in realm B — the issuer mismatch is enough to reject them. ## Cross-realm isolation | Surface | Isolation mechanism | |---|---| | User data | Database-per-tenant, physical DB boundary | | Permissions | Per-tenant Marten sessions, no cross-tenant joins | | Tokens | Issuer-claim check + per-realm OpenIddict stores | | Cookies | Cookie domain per realm | | SignalR | Hub connection is auth-gated and runs in the realm context resolved from the authenticated host | Query-level cross-realm mixing is prevented by physical separation — each realm's data lives in its own database, so a query can't reach across the boundary even by accident. The application-layer surfaces above (permissions, tokens, cookies, SignalR) are isolated by per-realm scoping enforced in code, and are covered by tests rather than by the database boundary itself. --- --- url: /concepts/control-plane.md --- # Control Plane / Data Plane Modgud separates **cross-realm administration** (realm CRUD, the first-run setup wizard) from **tenant self-service** (everything else) on three independent layers. A request that hits a Control-Plane endpoint from a tenant host has to defeat all three to succeed — and they're deliberately decoupled so a regression in one doesn't open the others. ## Why bother Every realm in modgud is a fully autonomous IdP — its own DB, users, OAuth clients, login providers (see [Realms](./realms.md)). But some operations are inherently cross-realm: * **Realm CRUD** — `POST /api/admin/realms` provisions a *new* tenant DB and seeds the initial admin via an emailed bootstrap invite (see "First-admin onboarding" below). * **Declarative realm provisioning** — importing, applying, and exporting a realm from a manifest (see [Realm provisioning](/admin/realm-provisioning)) — lives under the same `/api/admin/realms/*` route group and the same three-layer defence described below. It doesn't belong on a tenant. A tenant should not even be able to *discover* that a global admin surface exists at this hostname. ## Model Exactly **one** realm per deployment is the Control Plane — the realm that carries the **stored** `Realm.IsControlPlane` flag: ```csharp public bool IsControlPlane { get; set; } // stored, transferable ``` The bootstrap (`system`) realm is stamped with the flag at first boot (`EnsureSystemRealmExistsAsync`), but the slug is only the default anchor *name* — it no longer determines control-plane status. The flag is **transferable** to any active realm, so a deployment that starts single-tenant can later hand cross-realm administration to a different realm and let the original system realm become an equal, deletable peer. ### Authority = realm:admin in the flag-holding realm There is deliberately **no** `controlplane:admin` permission. Cross-realm authority is the ordinary `realm:admin` permission *within whichever realm holds the flag*. That removes a privilege-escalation vector: a delegable cross-tenant permission could be self-granted by a tenant admin through normal role assignment, whereas a flag that only a control-plane-gated operation (or the operator CLI) can move cannot. As a consequence, transferring the flag hands cross-realm administration to the target realm's existing `realm:admin` users with no permission migration. (The transfer also re-seeds the `control-plane` app catalog into the target realm so *scoped* `control-plane:realm:*` roles can be granted there too.) ### The "exactly one" invariant It is enforced defensively, not by a DB constraint: * `TransferControlPlaneAsync` clears the flag on every other holder in the same transaction — self-healing an accidental multi-holder state down to exactly the target. * At boot, `EnsureSystemRealmExistsAsync` adopts the flag onto the system realm **only when no realm currently holds it**. This is the load-bearing guard that makes a transfer durable across reboots — without it every boot would steal the flag back to `system`. `RealmProvisioningService` still blocks deactivating or deleting the realm that currently holds the flag — losing it would lock the deployment out of cross-realm administration. ::: tip Naming The permission namespace is `control-plane:*`, deliberately decoupled from the product slug `modgud`. If the IdP product is ever rebranded, cross-realm permissions don't need a migration. ::: ## Three-layer defence ```mermaid graph TD A[Request: GET /api/admin/realms
Host: acme.example.com] --> B B[1. RealmMiddleware
resolves Host → TenantInfo] --> C C{2. ControlPlaneGateMiddleware
Path is CP-only +
TenantInfo.IsControlPlane?} C -->|no| D404["404 Not Found"] C -->|yes| E E[3. AuthN + AuthZ runs] --> F F{4. RequireControlPlaneFilter
endpoint-level pin} F -->|no| D404 F -->|yes| G G{5. Permission check
control-plane:realm:read?} G -->|no| D403[403 Forbidden] G -->|yes| H[Endpoint runs] style D404 fill:#fee style D403 fill:#fee ``` ### Layer 1 — Routing gate `ControlPlaneGateMiddleware` (in `Modgud.Api/Middleware`) runs **before** authentication. For paths under `/api/admin/realms`, it inspects the resolved `TenantInfo` and 404s the request when `IsControlPlane=false` (or when no tenant resolved at all — fail-closed). **404, not 403**: the existence of the endpoint must be invisible to tenants. A portscan of `tenant-a.example.com` looks identical to a server that never had those endpoints. ### Layer 2 — Endpoint filter `RequireControlPlaneFilter` (in `Modgud.Infrastructure/Realms`) is attached to the route group of every Control-Plane-only endpoint — currently `/api/admin/realms/*`. It performs the same `IsControlPlane` check the routing gate does. This is **belt and suspenders**: a future routing-table change can't quietly leak the surface, and a future endpoint added without the routing prefix doesn't slip past the gate. Either layer alone closes the gap; both together mean a single mistake doesn't open it. ### Layer 3 — Permission namespace The permissions `control-plane:realm:read` and `control-plane:realm:write` live on a separate `App` slug. `AppRealmSeeder` only registers the `control-plane` app **into the Control-Plane realm's tenant DB**: ```csharp // AppRealmSeeder.SeedAsync — called once per realm DB, on creation await SeedAppIfMissingAsync(session, slug: AppSlugs.Modgud, ...); if (isControlPlane) { await SeedAppIfMissingAsync(session, slug: AppSlugs.ControlPlane, ...); } ``` A tenant realm doesn't have the app registered. A `Group` or `Role` in a tenant DB can't grant `control-plane:realm:write` because the `PermissionService` validates against the tenant's own resource registry — and that registry doesn't list the `control-plane` app. ## Transferring the control plane The flag moves via two paths, both of which clear every other holder in one transaction: * **In-app:** `POST /api/admin/realms/{slug}/transfer-control-plane` — POST to the realm that should *become* the control plane, from the current control-plane host (the route group's `RequireControlPlaneFilter` enforces the latter). Gated by `control-plane:realm:write`. * **Operator break-glass:** `recover control-plane transfer ` (and `recover control-plane list` to see the current holder) — for when the control-plane realm has no usable admin. See [Recovery CLI](../operate/recovery-cli). After a transfer the **old** host 404s `/api/admin/realms` (its realm is no longer the control plane) and the **new** host's `realm:admin` users gain the surface. Plan the move so the target realm already has at least one `realm:admin`, otherwise the new control plane is management-empty until you recover one via the CLI. ## Hostname routing — DB is source of truth The system realm is seeded with the localhost-style domains `["system.localhost", "localhost", "127.0.0.1"]` so a fresh checkout boots without any ENV setup. For a deployed installation, the operator adds the public hostname via the Recovery CLI: ```bash docker exec modgud dotnet Modgud.Api.dll \ recover realm-add-domain --slug system --domain auth.example.com ``` The `IRealmCache` is invalidated immediately — no container restart needed. From the next request onwards, `Host: auth.example.com` resolves to the system realm and `ControlPlaneGateMiddleware` lets `/api/admin/realms/*` through. There's no separate ENV variable mirroring the hostname list. The realm's own `Domains` field is the single source of truth — kept in the DB next to the rest of the realm metadata. ## First-admin onboarding A freshly provisioned realm has no users. There is **no anonymous "first-run" wizard** — that would be a "first-come-takes-the-instance" race window. Three explicit-trust paths replace it: ### Path 1 — Recovery CLI, direct password (operator-local) Filesystem trust. The operator runs: ```bash docker exec dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin \ --password 'StrongPass1!' \ --realm system ``` Atomic seed of `ApplicationUser` (Identity-Password-Rules enforced — the CLI does NOT bypass policy), the three default roles (System Admin / User Manager / Viewer) and the Administrators group. Idempotent: re-running for a second admin appends them to the existing group instead of duplicating. ### Path 2 — Recovery CLI, invite mode (delegated trust) Same CLI without `--password`. The CLI writes a `PendingAdminInvite` into the tenant DB and prints the magic-link URL on stdout (also sent by email when SMTP is configured). The recipient clicks, sets a password via `/bootstrap?token=...`, gets auto-signed in. ```bash dotnet Modgud.Api.dll recover bootstrap-admin \ --email max@acme.com \ --realm acme ``` ### Path 3 — HTTP, control-plane admin issues an invite `POST /api/admin/realms` is the only HTTP path that creates a realm. It is CP-only (gated by all three layers above) and now requires `InitialAdmin: { UserName, Email, Firstname?, Lastname? }`. The backend atomically: 1. Creates the realm (DB, OAuth scopes, login providers, app seeding) 2. Switches into the new tenant via `TenantContext.Enter(slug)` 3. Issues a `PendingAdminInvite` and sends the email 4. Returns `{Realm, InitialAdminInvite { UserName, Email, ExpiresAt, MagicLinkUrl }}` The SPA reveals the `MagicLinkUrl` once after creation — useful in SMTP-less dev and air-gapped scenarios where the email won't arrive. A `POST /api/admin/realms/{slug}/resend-bootstrap-invite` endpoint issues a fresh token (and revokes any open ones) for the same recipient identity if the original is lost. ### Token lifecycle * 32-byte URL-safe random plaintext, SHA-256-hashed in the DB * 7-day TTL (`PendingAdminInvite.DefaultExpirationDays`) * Single-use: `UsedAt` is set on success; reuse → 400 `BootstrapInvite.TokenUsed` * Reissue revokes prior open invites for the same email — there is at most one consumable invite per recipient per realm ### Anti-race-window The "elimination" of SETUP-01 is not just an upgrade of the gate — the gate itself is gone. None of the three paths is anonymous and unauthenticated: * Path 1 + 2: filesystem trust (whoever can `docker exec` already owns the host) * Path 3: authenticated CP-admin trust (already proved their identity via the regular login) * The bootstrap endpoint that sets the password (`POST /api/account/bootstrap-admin`) IS anonymous, but only consumes a token that one of the trusted paths already issued. Without a valid token the endpoint can't elevate anyone — same posture as a password-reset link. ## What a tenant sees The SPA reads `IsControlPlane: bool` from the anonymous `/api/app-info` endpoint: | Host | Sidebar shows "Realms" | `/api/admin/realms` | |---|---|---| | auth.example.com (CP) | ✅ if user has `control-plane:realm:read` | 200 OK | | acme.example.com (tenant)| Never | 404 Not Found | ## Layer-by-layer test pinning | Layer | Tests | Where | |---|---|---| | Routing gate | `ControlPlaneGateMiddlewareTests` | `Modgud.Tests.Unit/Api/Middleware/` | | Endpoint filter | `RealmsEndpointsTests.RequireControlPlaneFilterTests` | `Modgud.Tests.Unit/Api/Features/Admin/` | | End-to-end | `ControlPlaneSeparationTests` (tenant→404, CP→OK, deactivate/delete-CP blocked, app-info IsControlPlane) + `ControlPlaneTransferTests` (flag move + clear-others, missing/inactive-target guards, boot durability guard, gate-follows-the-flag) | `Modgud.Api.Tests/Security/` | | Realm-cache resolution | `RealmCacheLookupTests` | `Modgud.Tests.Unit/Realms/` | A regression in any one layer is caught by the layer's tests; a regression in middleware ordering or wiring is caught by the end-to-end suite. --- --- url: /concepts/authentication.md --- # Authentication Modgud has two orthogonal authentication axes: 1. **First-party login** — the user signs in to modgud itself (admin UI, profile, setup). Cookie-based, no token in the browser. 2. **OAuth/OIDC server** — external apps let users sign in via modgud. Authorization Code + PKCE, classic. Both share the same login methods under the hood. ## First-party login Implemented in the **Authentication slice** (`Modgud.Authentication`). Endpoints mounted under `/api/account/...`. ### Login methods | Method | When | Cookie lifetime | |---|---|---| | **Password** | Default, allowed at AuthLevel 0/1 | Session or 30 days (RememberMe) | | **TOTP** | Second factor after password | Inherits from the password step | | **Email OTP** | Second factor — or as an alternative login | Inherits from the password step | | **Passkey (FIDO2)** | Second factor — or as a sole login (passwordless) | Always 30 days (persistent) | | **Magic Link** | Email with single-use token; can also be sent by an admin | Always 30 days | | **OIDC External** | Federated login via Entra ID, Google, ... | 30 days | See [Login flows](/integrate/login-flows) for details. ### Authentication level Configured globally via `IAuthSettings.AuthenticationMinimumLevel`: | Level | Effect | |---|---| | 0 = None | Password-only allowed — no enforcement | | 1 = SecureLogin (default) | User must have 2FA or a passwordless method | | 2 = Passwordless | Password login disabled — only Magic Link + Passkey | At level >= 1 the `TwoFactorEnforcementMiddleware` runs and blocks authenticated requests from users without 2FA (with a grace period). ### Cookies | Cookie | Purpose | SameSite | Lifetime | |---|---|---|---| | `Modgud.Auth` | Main session (HttpOnly) | Lax | Session or 30 days | | `Modgud.2FA` | UserId between password step and 2FA step | Strict | 5 min | | `Modgud.External` | OIDC callback holder | Lax | 10 min | | `Modgud.Session` | Only for passkey attestation options | Strict | 5 min idle | `SameSite=Lax` on the main session cookie is required so that OIDC redirect-back navigations carry the cookie (top-level GET → cookie sent). Cross-site POSTs are still blocked by `SameSite=Lax`, plus the `CsrfDefenseMiddleware` rejects state-changing requests whose `Sec-Fetch-Site` indicates cross-origin. In production all cookies are `Secure`. In dev `Secure=None` so the Vite dev server (`http://localhost:4300`) can write them. ## OAuth 2.0 / OIDC server Modgud is at the same time a full-fledged OpenID Connect provider for external apps. Implemented via **OpenIddict 7** with its own Marten-based stores (no Entity Framework). ### Flows ```mermaid sequenceDiagram participant App as External App participant Auth as modgud participant User App->>Auth: GET /connect/authorize?...&code_challenge=... Auth->>User: Login page (if needed) User->>Auth: User signs in (password + 2FA) Auth->>Auth: Consent (implicit or explicit) Auth->>App: Redirect with ?code=... App->>Auth: POST /connect/token (code + verifier) Auth->>App: access_token + id_token + refresh_token ``` Supported: **Authorization Code + PKCE**, **Client Credentials**, **Refresh Token**. Not supported: Implicit Flow, ROPC. See [OAuth & OIDC](/concepts/oauth) and [OAuth implementation](/integrate/oauth) for details. ### Native app grants Native and headless clients (mobile apps, CLIs) can sign in directly against `/connect/token` without ever holding a browser cookie, using one of three cookieless grants: email OTP (`urn:cocoar:otp`), magic link (`urn:cocoar:magic`), or passkey (`urn:cocoar:passkey`). A signed-in native client can also list and revoke its own passkeys via `GET`/`DELETE /connect/passkey`. These grants are opt-in per OAuth client and disabled by default. See [Native app integration](/integrate/native-apps) for the full flows. ### Per-realm isolation Each realm is its own OIDC provider with its own discovery document at `https:///.well-known/openid-configuration`. Tokens from realm A do not work in realm B — the issuer check blocks them. This is implemented by the `RealmIssuerHandler` (an OpenIddict pipeline hook): at boot there is a static issuer; the handler overrides it per request with `BaseUri` (the current realm domain). ## Multi-factor authentication Three independent 2FA methods, freely combinable: | Method | How it works | |---|---| | **TOTP** | Authenticator app (Google Authenticator, Authy) — RFC 6238 | | **Email OTP** | One-time code by email to the verified address | | **WebAuthn/Passkey** | Hardware keys (YubiKey) or platform authenticators (TouchID, Windows Hello) | Plus **recovery codes** as a last-resort backup. ::: warning Passkeys are bound to the realm's primary domain A passkey is registered against a WebAuthn relying-party ID, and Modgud uses the realm's **PrimaryDomain** as that ID. A passkey therefore only works when the user reaches the realm on its primary domain — not via a secondary domain in the realm's `Domains` list — and changing the realm's PrimaryDomain invalidates every existing passkey (affected users must re-register). See [Realms — primary domain](/operate/realms#primary-domain). ::: ## External login (OIDC IdPs and SAML) Users can sign in via external OIDC providers (Entra ID, Google, Auth0, ...). Configurable per realm. 1. Admin creates a `LoginProvider` of `Type = Oidc`: authority, client ID, client secret, `UserUpdateScript` 2. Login page automatically shows buttons for enabled OIDC providers 3. Click → OIDC Authorization Code + PKCE → IdP login 4. On callback: `ExternalLoginProcessor` runs * Looks up `ExternalIdentityLink` (issuer + subject) → existing user or JIT-create * `UserUpdateScript` (Jint) maps claims to user fields 5. If the user has 2FA enabled, the normal 2FA flow runs afterwards 6. Login cookie is set (always 30 days) See [Login providers (OIDC)](/integrate/login-providers) for details. Modgud also supports **SAML 2.0** as an external login provider type (`LoginProvider` of `Type = Saml`), for IdPs that only speak SAML. It follows the same JIT-create-on-first-login shape as OIDC. See [SAML federation](/admin/saml-federation) for setup. ## Account lifecycle | How does a user enter the system? | Mechanism | |---|---| | Self-registration | Registration form (when enabled for the realm) | | External login | OIDC IdP → JIT-create on first login | | Admin-created | Admin creates the user via the UI | | Setup | First-time setup — the first user becomes system admin | Lifecycle states: * **Active** — normal state * **Locked** — by account lockout (5 failed logins → 1 min) * **Soft-deleted** — `IsDeleted = true`, all data preserved, reactivatable * **GDPR-erased** — stream archived, PII masked, irreversible (Article 17) Self-registration can also be gated by an invite code: an Application can require a valid, unused, single-use code before an unknown email is allowed to self-register, while already-known users keep signing in normally. See [Applications](/admin/applications) for how to turn this on per Application. --- --- url: /concepts/groups-and-authorization.md --- # Authorization (RBAC) Modgud is a pure **RBAC + grouping** Identity & Access Management system. It answers `(user, app, permission)` — nothing more. Row-level access policies (ABAC) deliberately stay **outside** the IAM and live in the consuming app where the row schema is. See [ABAC and the IAM boundary](./abac) for the rationale and the three deployment profiles. ## RBAC: User → Group → Role → Permission Permissions flow exclusively through groups: ``` User ──► Group ──► PermissionRole ──► ":" within an App catalog ``` There are no direct `User → Role` assignments and no `User → Permission` overrides. The resolution path: 1. Find every group the user is in (transitively, including nested groups). 2. Filter to groups whose `BoundTo` includes the requested app (or the `*` wildcard). 3. Collect the role ids on those groups. 4. Filter to roles whose `AppId` matches the requested app — plus any role with `IsRealmAdmin = true`. 5. Resolve each role's `PermissionIds` → catalog strings. 6. Bypass-pre-expand and run the evaluator below. ## Permission format Permission strings inside an App's catalog are **two segments**: ``` : ``` The App context is implicit from the catalog container — the string itself never carries an app slug. When the resolver sweeps a user's effective permissions for a given app, it works against that App's catalog; when UserInfo emits a per-Audience block, the audience determines which app's catalog applies. | Example | Meaning | | --- | --- | | `user:read` | Read users (in whichever App's catalog defines it) | | `oauth-client:write` | Manage OAuth clients | | `invoice:read` | Read invoices (e.g. in a `billing` app's catalog) | | `realm:admin` | Realm-wide bypass — everything in every app | | `:admin` | Resource-wide bypass for that resource | ### Bypass tiers — exactly two `PermissionEvaluator.Evaluate(grants, needed)` returns true when: 1. the user holds `realm:admin`, **or** 2. the user holds `needed` directly, **or** 3. the user holds `:admin` for the same resource. There is **no app-wide bypass tier** (`:admin`). Bypass is either realm-wide or resource-wide; nothing in between. `realm:admin` is intentionally narrow — only the System Admin default role carries it. For the full evaluator + emission story (per-Audience UserInfo, bypass-pre-expansion, per-RS subset narrowing) see the canonical [Permissions reference](./permissions). ## Apps and BoundTo The IAM hosts an arbitrary number of consuming apps in one realm; each is identified by a slug (`modgud`, `acme`, `billing`, …). PermissionRoles bind to one app (via `AppId`); groups carry an activation list (via `BoundTo`). A group's `BoundTo` field is the **activation switch**: it lists the app slugs in which the group's roles take effect. | BoundTo | Effect | | --- | --- | | `["*"]` | Wildcard — active in every app. Typical for the realm-admin group. | | `["acme"]` | Roles only contribute when an `acme`-scoped permission is being resolved. | | `["acme", "billing"]` | Active in both apps; same role assignments contribute in either resolution. | | `[]` | Dormant — the group exists for organisational/mailing purposes only and contributes no permissions. | Removing an app from a group's BoundTo is a non-destructive deactivation: role assignments stay; re-adding the app reactivates them immediately. ## Default roles per realm | Role | Permissions | | --- | --- | | **System Admin** | `IsRealmAdmin = true` (the realm-wide bypass) | | **User Manager** | `user:read`, `user:write`, `session:read`, `session:write`, `authorization-group:read`, `permission-role:read`, `auth-log:read`, `audit-log:read` (in the `modgud` app) | | **Viewer** | `user:read`, `authorization-group:read`, `permission-role:read` (in the `modgud` app) — read-only on Users, Groups, Roles | The first-time-setup admin lands in the System Admin group with `BoundTo: ["*"]`, so they immediately see every app. ## Groups `Group` is the carrier of permissions. A group has: * `Name`, `Description` * `MembershipMode` — `Manual` or `Auto` * `MemberIds` — users or other groups (nested) * `RoleIds` — references to `PermissionRole`s * `BoundTo` — app slugs in which the group is active (see above) * Optional: `MembershipScript` (when membership is Auto) * Optional: `Email` + `EmailMode` for distribution-list semantics ### Manual vs Auto * **Manual** — the admin maintains `MemberIds` directly. * **Auto** — a JsEval predicate (`MembershipScript`) decides which principals match. Re-evaluated on every relevant principal mutation; dependency-tracking skips re-runs when the changed property doesn't appear in the script. The membership script only sees IAM-owned fields (`DisplayName`, `Email`, `IsActive`, `ExternalIdentities`, `AccountName`). It must not — and cannot — read app-specific schema; that would re-couple the IAM to every consumer's schema. See [ABAC and the IAM boundary](./abac). ### Nested groups A group can contain other groups. The permission-resolution BFS treats them polymorphically (`IPrincipalWithMembers`), with cycle-detection via a visited set. ``` "All Staff" (Manual) ├── "Engineering" (Auto: matches engineers) ├── "Sales" (Auto: matches sales) └── "Support" (Auto: matches support) ``` ## What this architecture is *not* * **No deny rules.** Only positive grants; effective access is the union over all the user's groups. * **No implicit grants.** Group membership grants nothing on its own; roles must be explicitly assigned. * **No direct user-to-role.** Everything routes through groups. * **No row-level rules.** ABAC stays in the app; the IAM keeps `(user, app, permission)` as its sole answer surface. * **No app-wide bypass tier.** Just realm-wide and resource-wide. ## Sidebar mirror The Vue admin shell mirrors the same logic 1:1: each sidebar item declares the permission it requires, the backend evaluates the same string. The single source of truth is the permission constant — frontend gating cannot drift from backend gating because both consult the identical literal. ```ts { section: 'authorization', label: 'nav.users', icon: 'users', path: '/admin/users', requirePermissions: ['user:read'] } ``` A user with only `user:read` (in the `modgud` app context) sees just "Users" in the sidebar — no OAuth, no System. --- --- url: /concepts/permissions.md --- # Permissions & gating Modgud uses **granular per-resource gating**: every endpoint and every sidebar item checks a single permission string, and the same evaluator runs IdP-side (Authorization slice) and resource-server-side (Modgud.Client.AspNetCore). ## Permission format **Two segments:** `:`. The string carries no app slug. The app context is **implicit from the caller**: * For in-process gates inside Modgud, the gate's audience is the Modgud app itself. * For resource-server gates, the audience is the RS's own App slug (resolved from the access token's `aud` claim by the time the request reaches the gate). This is enforced at write-time: catalog entries are validated against the regex `^[a-z0-9-]+:[a-z0-9-]+$` — exactly two lowercase segments, hyphens allowed inside a segment, no colon-prefix. Modgud's own admin surface uses two App slugs internally: * **`modgud`** — the realm-internal admin surface (users, groups, roles, OAuth clients, login providers, etc.). Seeded into every realm. * **`control-plane`** — the cross-realm admin surface (realm CRUD). Seeded **only** into the realm flagged `IsControlPlane = true`. A consuming SaaS app gets its own App slug and registers its own catalog of `:` permissions. The slug is the *implicit context* — it never appears in the permission string itself. | Permission (catalog string) | Meaning | |---|---| | `user:read` | Read user list/detail | | `user:write` | Create/edit users | | `user:admin` | Resource-wide bypass for all user actions | | `oauth-client:read` | Read OAuth clients | | `oauth-client:write` | Create/edit OAuth clients | | `permission-role:read` | Read roles | | `authorization-group:write` | Create/edit groups | | `login-provider:read` / `:write` | Login-provider management | | `auth-log:read` | Read the auth log | | `gdpr:admin` | Permanent-erase GDPR operations | | `realm:read` / `realm:write` | Realm CRUD (control-plane app) | | `realm:admin` | **Realm-wide bypass** (every app, every resource, every action) | `realm:admin` is the one exception: it's a *realm-constant* — a PermissionRole carries `IsRealmAdmin = true` and the resolver injects the literal grant. It bypasses every check anywhere in the realm. ## Bypass tiers Only **two** tiers, both checked by `PermissionEvaluator`: | Grant | Effect | |---|---| | `realm:admin` | Everything in every app — the realm-wide emergency exit | | `:admin` | All actions on that resource in the calling app | `Evaluate(grants, "user:read")` returns true when: 1. the user holds `realm:admin`, **or** 2. the user holds `user:read` directly, **or** 3. the user holds `user:admin` (resource-wide bypass). There is **no app-wide bypass tier** (`:admin` was discussed but never shipped — bypass is either realm-wide or resource-wide, nothing in between). Per-area owners typically get per-resource `:admin` (e.g. an OAuth owner gets `oauth-client:admin` + `oauth-scope:admin` * `oauth-api:admin`, but not `user:admin`). The canonical evaluator implementation lives in `Modgud.Permissions.Abstractions/PermissionEvaluator.cs` — pure, no I/O, reused on both ends of the wire. ## Resources ### `modgud` app catalog (realm-internal — every realm) | Resource | What for | |---|---| | `app` | App registration management | | `user` | User management (`ApplicationUser`) | | `permission-role` | Role management | | `authorization-group` | Group management | | `session` | Per-user session management | | `service-account` | Service-account identity layer | | `auth-log` | Read AuthLog | | `audit-log` | Read the audit log (admin actions, distinct from AuthLog) | | `gdpr` | Permanent-erase GDPR operations | | `oauth` | OAuth admin surface umbrella | | `oauth-client` | OAuth client management | | `oauth-scope` | OAuth scope management | | `oauth-api` | OAuth API resource management | | `login-provider` | Internal/external login providers | | `realm-settings` | Per-realm settings (self-reg, DCR, branding) | | `asset` | Asset library | | `observability` | Read-only observability view | | `scheduled-job` | Quartz scheduled job admin | | `inbox-settings` | Inbox retention configuration | ### `control-plane` app catalog (only on the Control-Plane realm) | Resource | What for | |---|---| | `realm` | Realm CRUD (`/api/admin/realms/*`) | The `control-plane` slug is intentionally decoupled from the product name. If the IdP is ever rebranded, cross-realm permissions don't need a migration. ## How resource servers receive permissions Resource servers read permissions from a standard OIDC UserInfo call. For each audience (`aud`) the access token names, Modgud emits a **`resource_access`** block on `/connect/userinfo`, shaped like Keycloak's nested format: ```json { "sub": "…", "resource_access": { "billing-api": { "permissions": ["invoice:read", "invoice:write"], "roles": ["billing-owner"] }, "shipping-api": { "permissions": ["shipment:read"], "roles": [] } } } ``` What's in the block: * **Bypass-pre-expansion** — `realm:admin` is expanded server-side into every concrete catalog string of every reachable App; a `:admin` bypass is expanded into every `:*` string in the App's catalog. Consumers do straight exact-match — no PermissionEvaluator port required client-side. * **Per-RS-subset narrowing** — each audience block is narrowed to `OAuthApi.PermissionIds` (the catalog subset the resource server declared as its gating surface). Anything outside that subset is excluded — no permission strings leak from one microservice into a sibling's block. * **Roles vs Permissions** are gated by separate scopes (`roles`, `permissions`) — request `scope=permissions` to see the permissions block; without it you get just the roles list. The `Modgud.Client.AspNetCore` helper lib's `IClaimsTransformation` flattens the matching audience block onto the principal so standard ASP.NET Core `[Authorize(Roles="…")]` and `RequiresPermission(…)` work out of the box. ## Backend gating: `RequiresPermission` Endpoints gate via a `RouteHandlerBuilder` extension: ```csharp app.MapGet("/api/user", async (...) => { ... }) .RequiresPermission("user:read"); app.MapPost("/api/user", async (...) => { ... }) .RequiresPermission("user:write"); app.MapPost("/api/admin/realms", async (...) => { ... }) .RequiresPermission("realm:write"); // control-plane app context ``` The filter (`PermissionEndpointFilter`): 1. Reads `ClaimTypes.NameIdentifier` from `HttpContext.User` 2. Resolves the user's effective permissions via `IPermissionService.GetUserPermissionsAsync(userId, appSlug)` (BFS through groups, BoundTo-filtered, role-filtered by AppSlug, already bypass-pre-expanded) 3. Calls `PermissionEvaluator.Evaluate(grants, "user:read")` ## Frontend gating: sidebar + buttons The `auth.store.ts` (Pinia) loads the effective permissions of the current user at login and uses the same evaluator logic: ```typescript // grants: string[] e.g. ["user:read", "user:write"] function hasPermission(needed: string): boolean { const grants = permissions.value if (grants.includes('realm:admin')) return true // tier 1 if (grants.includes(needed)) return true // exact match const parts = needed.split(':') if (parts.length === 2) { // tier 2 if (grants.includes(`${parts[0]}:admin`)) return true } return false } ``` Sidebar items in `views/admin/AdminView.vue` declare which permissions make them visible: ```typescript const allNavItems: NavItem[] = [ { section: 'authorization', label: 'nav.users', icon: 'users', path: '/admin/users', requirePermissions: ['user:read'] }, { section: 'oauth', label: 'admin.oauthClients.title', icon: 'app-window', path: '/admin/oauth/clients', requirePermissions: ['oauth-client:read'] }, { section: 'system', label: 'admin.realms.title', icon: 'globe', path: '/admin/realms', requirePermissions: ['realm:read'] }, { section: 'system', label: 'nav.settings', icon: 'settings', path: '/platform/settings', requirePermissions: ['realm:admin'] }, // ... ] ``` Sections are hidden when all their items are filtered out. A user with only `user:read` sees just the Authorization section with "Users" — no OAuth, no System. ## Control-Plane separation Because the `control-plane` App catalog is **only** seeded into the Control-Plane realm's tenant DB, a tenant realm physically cannot grant `realm:*` permissions under the control-plane context — the resource registry in that tenant DB doesn't list the `control-plane` App, so the backend permission validator rejects the grant. That's the third of three layers protecting the cross-realm admin surface. The other two: 1. **`ControlPlaneGateMiddleware`** — runs before authentication. Returns 404 on `/api/admin/realms/*` from non-CP hosts. The route is discoverable only on the Control-Plane realm. 2. **`RequireControlPlaneFilter`** — per-endpoint filter on the realm admin route group. Same 404 behaviour, even if the routing layer were misconfigured. See [Concepts: Control Plane / Data Plane](./control-plane) for the full defence-in-depth diagram. ## Default roles The first admin in every realm is created via one of the [bootstrap paths](../getting-started/first-time-setup). Atomic with the user creation, three default `PermissionRole`s are seeded (idempotent — re-bootstrapping doesn't duplicate them): ### System Admin ``` IsRealmAdmin: true ``` The new admin is added to the **Administrators** group with `BoundTo: ["*"]` (active in every app), and that group carries the System Admin role. Realm-wide bypass — sees and can do everything in every app. ### User Manager ``` AppId: PermissionIds:[user:read, user:write, session:read, session:write, authorization-group:read, permission-role:read, auth-log:read, audit-log:read] ``` Maintains users + groups + sessions, reads roles + auth log + audit log. ### Viewer ``` AppId: PermissionIds:[user:read, authorization-group:read, permission-role:read] ``` Read-only auditor. Admins can adjust these roles or create more — they aren't hard-coded. ## Permission resolution in detail ``` Request with cookie/bearer comes in ↓ PermissionEndpointFilter ↓ ClaimTypes.NameIdentifier → UserId needed permission ":" (2 segments) app context = the calling app's slug (modgud, control-plane, billing-api, …) ↓ IPermissionService.GetUserPermissionsAsync(userId, appSlug) ├── BFS through all group memberships (transitive, with visited set) ├── filter to groups whose BoundTo contains appSlug or "*" ├── for each group: load PermissionRole refs ├── filter to roles whose AppId == this app (or IsRealmAdmin = true) ├── for each role: resolve PermissionIds → catalog strings ├── bypass-pre-expand: realm:admin → all reachable catalog strings; │ :admin → all :* in this app's catalog └── Set of fully-expanded permissions ↓ PermissionEvaluator.Evaluate(grants, ":"): has "realm:admin"? → ✓ has the exact permission? → ✓ has ":admin"? → ✓ otherwise → 403 ``` Resolution is scoped per request, not cached. That is intentional: permissions change live (an admin removes a user from a group), and Modgud is not performance-critical (admin UI traffic, not a hot path). If that ever changes: an `IMemoryCache` with sliding expiration (e.g. 30 seconds) and cache invalidation on `GroupMembershipRecomputedEvent` would suffice. --- --- url: /concepts/auto-membership.md --- # Auto-Membership A group is either `Manual` (an admin maintains `MemberIds` directly) or `Auto` (a membership script decides the members dynamically). Orthogonally, an `Auto` group may also be marked **`ExternallyDrivable`**, which lets a federated login confer membership *for that session only* — see [Externally-driven membership](#externally-driven-membership-federation) below. ## Manual mode ``` Group "Backend Team" MembershipMode: Manual MemberIds: [, , ] ``` Admins add and remove members via the UI. Nothing happens automatically. ## Auto mode ``` Group "Active Staff" MembershipMode: Auto MembershipScript: (p) => Type.Is(p, 'person') && p.IsActive MemberIds: [] ``` `MemberIds` is maintained by the system, not the admin. On every relevant event (user created / updated / deleted) the script is re-evaluated. ## Membership script A TypeScript arrow function from a principal record to `boolean`. It is evaluated against each candidate principal; returning `true` means "is a member". ```typescript (p) => Type.Is(p, 'person') && p.IsActive && p.Email != null && p.Email.endsWith('@acme.com') && p.AccountName !== 'svc-bot' ``` `Type.Is(p, 'person')` is the type guard — it narrows `p` to a person principal (the same predicate works on groups and service accounts, so guard first). The fields a script can read on a person are the persisted `Person` columns: | Field | Type | Notes | |---|---|---| | `p.IsActive` / `p.IsDeleted` | `boolean` | lifecycle flags | | `p.AccountName` | `string?` | login name | | `p.Firstname` / `p.Lastname` | `string?` | display name parts | | `p.Acronym` | `string?` | short initials | | `p.Email` | `string?` | primary email | | `p.NormalizedUserName` / `p.NormalizedEmail` | `string?` | upper-cased, for case-insensitive compares | | `p.ExternalIdentities` | `{ LinkId, LoginProviderId, Issuer }[]` | the IdP links the user holds — query with `.some(...)`, see below | > These are the **only** durable fields. There is no `OrganizationalUnit`, `Department`, or generic `externalClaims` dictionary on a person — scripts that reference them either fail to transpile or silently never match. To drive membership from an upstream IdP's groups, use an `ExternallyDrivable` group and `p.ExternalGroups` (below). ### Reading `p.ExternalIdentities` (durable IdP links) `p.ExternalIdentities` is the durable list of external-identity links on the person — one entry per linked IdP, each `{ LinkId, LoginProviderId, Issuer }`. Unlike `p.ExternalGroups` (the *ephemeral, session-only* group surface for `ExternallyDrivable` groups), `ExternalIdentities` is a **persisted** field, so a normal `Auto` group (not `ExternallyDrivable`) can key on it — e.g. "everyone federated through a given IdP": ```typescript // "Member if linked to the Entra tenant IdP." (p) => Type.Is(p, 'person') && p.ExternalIdentities.some(x => x.Issuer === 'https://login.microsoftonline.com//v2.0') ``` > **Use `.some(x => ...)`, not `.length`.** Membership on a sub-collection must be expressed as `.some(predicate)` — that is what translates to SQL in the durable batch engine. A count form like `p.ExternalIdentities.length > 0` does **not** translate and silently never matches in the batch engine, so avoid it. The membership-script editor's IntelliSense exposes the element fields (`LinkId` / `LoginProviderId` / `Issuer`). Linking or unlinking an external identity re-evaluates `p.ExternalIdentities` scripts (see [Recompute triggers](#recompute-triggers-durable-groups)). The membership-script editor's IntelliSense is generated from the real CLR types, so the available members are always in sync — use it to discover the surface. ### How it runs (the batch engine) The membership script is translated into a single database query that matches every person record against the predicate in one pass, and the result becomes the new `MemberIds`. This is the durable path: it writes `MemberIds` and only ever sees the persisted `Person` fields (it cannot see the ephemeral federation surface, which isn't stored). ## Externally-driven membership (federation) An `Auto` group can additionally be marked **`ExternallyDrivable`**. Such a group is **skipped by the batch engine** (it never writes durable `MemberIds`) and is instead evaluated **in memory, at login time**, by the federation deriver — but only when the login arrives through a provider the realm admin marked `TrustForAuthorization`. The match is **session-scoped**: it lives on the sign-in only, is unioned into the access decision while the session/grant is valid, and disappears when the session ends. The session is the lease — nothing is persisted, and the upstream group names never leave Modgud (they are expanded into Modgud roles/permissions before any token or UserInfo response, the hub boundary). On top of the durable `Person` fields, an `ExternallyDrivable` script may read the ephemeral federation surface: | Field | Type | Notes | |---|---|---| | `p.ExternalGroups` | `string[]` | the current provider's `groups` claim for this login (always an array — use `.includes(...)`) | | `p.Source` | `string` | the source tag of this login: `"local"` or `"provider:"` | ```typescript // "Place this login into the group if the upstream IdP put them in 'entra-admins'." (p) => Type.Is(p, 'person') && p.IsActive && p.ExternalGroups.includes('entra-admins') // Scope a rule to one provider via p.Source (v1 has no declarative per-provider // binding yet — the script scopes itself): (p) => p.Source === 'provider:acme-entra' && p.ExternalGroups.includes('finance') ``` Key rules: * **Live-only.** `p.ExternalGroups` reflects *this* login's provider only — a password (local) login carries an empty array, so it never picks up a previously-seen IdP's groups (no stale-admin trap). * **Never durable.** A match here is never written to `MemberIds`; it exists only for the session. * **`realm:admin` is local-only.** A group whose roles confer `realm:admin` **cannot** be marked `ExternallyDrivable` (the editor blocks it and the API rejects it), and even an inherited ancestor that confers `realm:admin` is stripped from a session-sourced grant. Manage realm-admin membership manually. `:admin` and below may be externally driven. ## Recompute triggers (durable groups) The durable engine listens for person-mutation events and re-evaluates the affected `Auto` (non-`ExternallyDrivable`) groups: | Event | Action | |---|---| | user created | check auto-groups whose predicate matches → on match: add | | user updated | re-check auto-groups → add or remove based on the new state | | user deleted | remove from all auto-groups | | external identity linked / unlinked | re-check auto-groups (e.g. `p.ExternalIdentities` scripts) → add or remove | | membership script changed | full recompute pass for that one group | ## Dependency tracking (selective recompute) Recomputing every auto-group on every heartbeat update would be wasteful. So per script, the **set of read properties** is recorded when it is saved: ```typescript // Script (p) => p.IsActive && p.Email != null && p.Email.endsWith('@acme.com') // Dependencies ["IsActive", "Email"] ``` On a user update, only groups whose dependency set intersects the changed fields are re-checked. Example: a user updates `LastLoginAt` (not a person field a script reads) → `IsActive`/`Email` unchanged → the group above is not re-evaluated even though the update event fired. ## Failure handling If the script throws (a translator error, or a runtime error during compile), the recompute fails closed: the Group projection records the error in `MembershipLastError` and keeps the previous `MemberIds`. The admin sees the error in the group detail view. A successful recompute clears `MembershipLastError` and writes the new `MemberIds`. ## Nested auto-groups An auto-group can have another group (manual or auto) as a member: ``` "All Staff" (Manual) Members: ["Engineering", "Sales", "Support"] ← three auto-groups "Engineering" (Auto) Script: (p) => Type.Is(p, 'person') && p.AccountName != null && p.AccountName.startsWith('eng-') ``` The permission BFS expands this without special-casing — `IPrincipalWithMembers` is polymorphic, with cycle detection via a visited set. Session-derived membership inherits the same way: a session-matched child group still confers its parent groups' roles for that session. ## Initial recompute When an admin creates a new auto-group (or changes the script), an initial full pass runs — a single query matches the script against every person record, sets `MemberIds`, and fires the recompute event. Modgud is sized for mid-sized org charts (a few thousand users per realm), where this is sub-second; it is not built for million-row tenants. ## Example setup ``` Group "Active Engineers" (Auto) Script: (p) => Type.Is(p, 'person') && p.IsActive && p.AccountName != null && p.AccountName.startsWith('eng-') Roles: ["Code Repo Reader", "CI Trigger"] Group "Entra Admins" (Auto, ExternallyDrivable) Script: (p) => Type.Is(p, 'person') && p.ExternalGroups.includes('entra-admins') Roles: ["Tenant Operator"] ``` When a new engineer is provisioned (a person with `AccountName` `eng-…` is created): 1. The create event fires. 2. The durable engine evaluates the non-drivable auto-scripts: "Active Engineers" matches → the user is added to `MemberIds`; a recompute event fires. 3. SignalR pushes the change to admin browsers → the group list updates live (via `useEntityService` subscriptions). 4. The user inherits "Active Engineers"' permissions immediately. When that same user later signs in through the trusted EntraID provider and the assertion carries `groups: ["entra-admins"]`: 1. The federation deriver evaluates the `ExternallyDrivable` "Entra Admins" script in memory → match. 2. "Tenant Operator" is unioned into **this session's** access — no `MemberIds` write. 3. The grant carries it for the session's lifetime; the next password login (or a login through an untrusted provider) carries it no more. --- --- url: /concepts/abac.md --- # ABAC and the IAM boundary **Modgud is a pure RBAC + grouping IAM.** It does **not** evaluate row-level access policies on behalf of consuming apps. ABAC ("can user X read row Y?") is the responsibility of the app that owns the data. This page explains where the line is, why it sits there, and how an app can layer ABAC on top of what Modgud provides. ## What Modgud gives you * **Identity** — who the user is, with their stable id and verified contact info. * **Groups** — organisational membership, including transitive sub-groups, manual or auto-managed. * **Roles** — bundles of `:` permissions inside one App's catalog (the App context is implicit). * **Resolution** — a single decision per `(user, app, permission)`, propagated via the per-Audience `resource_access` block on `/connect/userinfo`. That's the whole authorisation surface from the IAM. Every grant the IAM emits is **schema-free**: there is no `tenantId`, no `ownerId`, no row-level filter. Only "user X holds permission `app:resource:action` in app A". ## What ABAC needs that the IAM cannot give ABAC questions are about *attributes of the protected row*: "is the user the owner?", "is the row in the same tenant?", "is the project not archived?". The fields those questions read live in the **consuming app's schema** — the IAM has never seen them and shouldn't, because: 1. **Schema drift.** If the IAM held an access script that reads `row.tenant`, every change to the app's data shape becomes an IAM change. The IAM stops being a stable contract and turns into a satellite of every app it serves. 2. **Operational coupling.** An app team can't ship a model change without coordinating with whoever maintains the IAM scripts. 3. **Boundary erosion.** "What is row-level access?" is a domain question. Once the IAM starts answering it for one app, every other app expects the same — the IAM owns more and more domain logic until it stops being an IAM. So the rule is simple: **anything that names a field of an app row stays in that app**. ## Three profiles for app teams How an app actually does ABAC depends on what its admins need to configure. ### Profile 1 — IAM-only (no ABAC) The app's permission model is fully expressible as `(role × resource × action)`. The IAM token is enough; the app does plain `[Authorize(Roles = "Editor")]` checks. This is the right default. Most apps live here. **When this stops being enough:** the moment your endpoint logic asks "*which* todos may this user see?" — that question reads `todo.responsibleId == user.Id`, which is row data, so it leaves the IAM. ### Profile 2a — Code-static ABAC The row-level rules are domain logic, not configuration. They live in the app's code as ordinary `WHERE` clauses or specifications: ```csharp var visible = db.Todos.Where(t => t.OrgId == user.OrgId && (t.OwnerId == user.Id || t.IsPublic)); ``` No JsEval, no scripts, no admin-editable predicates. If the rule changes, you ship code. This covers the vast majority of "I need ABAC" cases. Reach for Profile 2b only when admins genuinely need to author predicates without a developer in the loop. ### Profile 2b — Admin-editable ABAC (local groups + IAM mapping) Common in enterprise IAMs: the IAM hands out *coarse* group membership, and the app keeps its own *narrow* groups whose membership is wired to the IAM groups. Active Directory has done this for decades. ``` IAM (Modgud) App ───────────────── ─── Group "All Editors" ─maps→ LocalGroup "Editors of Vienna Office" + ABAC: row.officeId == "vienna" ``` The app stores its own JsEval (or any script-engine) policies on its own group entities, evaluates them at query time, and treats the IAM group as a membership condition. The IAM stays out of the row-data conversation entirely; the app's admin UI is what surfaces the predicate authoring. This is essentially Profile 2a with admin-pluggable predicates. The infrastructure (script engine, dependency tracker, recompute pipeline) is the same shape as `MembershipScript` in this repo — copy that pattern when you need it. ## What about membership scripts in Modgud? Group **membership scripts** (`MembershipMode = Auto`) stay. They're not ABAC: they decide *who is in this IAM group*, using only fields the IAM itself owns (display name, email, IsActive, external identities). No app schema is involved, no schema drift, no boundary violation. The distinction is: | Question | Where it lives | | --- | --- | | "Is this user in the *Vienna* IAM group?" | IAM (membership script — uses IAM-owned fields only) | | "Can this user see *this todo row*?" | App (Profile 1, 2a, or 2b) | ## Practical guidance * Start every app at Profile 1. Don't reach for ABAC until the IAM token genuinely cannot answer the access question. * When you need ABAC, default to Profile 2a (code). It's faster to build, faster to test, and there is no second policy store to back up. * Only adopt Profile 2b when admins must author predicates without a developer round-trip. That's a real but narrow need. * Whatever profile you're in, the IAM token is still the source of identity and coarse roles. The app composes its row-level decisions on top. ## Why this matters for Modgud Keeping ABAC out of the IAM is what lets Modgud serve many apps with stable contracts. Adding row-level scripts back in would re-couple the IAM to every consumer's schema and turn it into a brittle, app-specific service. The boundary is intentional, not a missing feature. --- --- url: /concepts/oauth.md --- # OAuth 2.0 & OpenID Connect ## Overview Modgud is a full-fledged OAuth 2.0 authorization server and OpenID Connect provider. Implemented via **OpenIddict 7** with its own Marten-based stores (`MartenApplicationStore`, `MartenScopeStore`, `MartenAuthorizationStore`, `MartenTokenStore`) — no Entity Framework. Terminology (Client, Scope, API, Grant Type, token types) in the [Glossary](/concepts/glossary#oauth-oidc-begriffe). ## The three actors | Actor | Role | Example | |---|---|---| | **User** | The person signing in | Someone using your app | | **Client** | The application requesting access | SPA, mobile app, backend service | | **API** | The protected service | A billing API, an order API | Modgud sits in the middle — it authenticates the user, issues tokens to the client, and the API verifies the tokens. ## Supported flows ### Authorization Code + PKCE (for user apps) Standard for web apps, SPAs, mobile. PKCE (Proof Key for Code Exchange) is **enforced** (`RequireProofKeyForCodeExchange`). ```mermaid sequenceDiagram participant App as Client App participant Auth as modgud participant User App->>Auth: GET /connect/authorize
(client_id, code_challenge, scopes) Auth->>User: Login + 2FA (if not yet) User->>Auth: Sign-in Auth->>Auth: Consent (implicit or explicit) Auth->>App: Redirect with ?code=... App->>Auth: POST /connect/token
(code + code_verifier) Auth->>App: access_token + id_token + refresh_token ``` ### Client Credentials (for services) Machine-to-machine. The service authenticates directly with client ID + secret, no user involved: ```http POST /connect/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials client_id=my-service client_secret=... scope=billing.read ``` ### Refresh Token Enabled for clients that request `offline_access`. Refresh tokens are reference tokens, stored server-side in `OpenIddictTokenDocument`. ### Device Code (RFC 8628) For devices with no browser or limited input — CLIs, TVs, input-constrained appliances. The client polls `/connect/token` while the user completes the flow on a separate device. Endpoint shape + verification UI documented in [Reference → OAuth API](/reference/oauth-api). ### Native cookieless grants (`urn:cocoar:*`) For native/mobile apps that want a passwordless sign-in without ever holding a browser cookie, Modgud accepts three custom grant types directly at `/connect/token`: `urn:cocoar:otp` (email + one-time code), `urn:cocoar:magic` (magic-link token), and `urn:cocoar:passkey` (WebAuthn assertion). Each is opt-in per client. See [Native apps](/integrate/native-apps) for the request/response shape. ## Dynamic Client Registration (DCR) In addition to admin-created clients, Modgud supports [**Dynamic Client Registration**](/concepts/dynamic-client-registration) (RFC 7591) — software registers itself against the IdP without an administrator pre-provisioning it. This is the protocol path MCP agents use to attach to MCP servers without per-agent onboarding. DCR-registered clients are constrained to public PKCE + Authorization-Code/Refresh-Token only — no `client_credentials`, no secrets, no implicit/hybrid flows. The feature is **off by default** on every realm; turning it on is a triple opt-in (realm master + per-API + per-scope). See the [concept page](/concepts/dynamic-client-registration) for the design rationale and the [admin setup guide](/admin/dynamic-client-registration) for the operational checklist. Modgud also supports [**Client ID Metadata Documents**](/admin/client-id-metadata-documents) (CIMD), a newer, no-registration-endpoint alternative aimed at the same MCP use case — a client identifies itself with an HTTPS URL instead of a stored client record. ::: warning No Implicit, no ROPC Modgud rejects Implicit Flow and Resource Owner Password Credentials. Both are considered insecure — OAuth 2.1 deprecates them. ::: ## Token validation How an API validates an access token depends on the configured token format (settable per client): | Token type | How the API validates | |---|---| | **Reference Token** (default) | Calls modgud's introspection endpoint — gets back user info, scopes, expiry. Can be revoked instantly. | | **JWT** | Verifies the signature locally with the signing key from the JWKS endpoint. No roundtrip needed, but revocation only works via expiry. | Which one when? See [Glossary > Access token format](/concepts/glossary#access-token-format). ## Per-realm isolation Every realm has its own OAuth configuration: * Clients from realm A cannot authenticate against realm B * Tokens from realm A are invalid in realm B (issuer check) * Each realm has its own discovery endpoint * The issuer claim in tokens contains the realm domain Two realms can both have a client with `client_id=my-app` — those are different clients. Implementation: `RealmIssuerHandler` (an OpenIddict pipeline hook) overrides the static issuer per request with `BaseUri` (= the realm domain). ## Consent flow Configurable per client: | Consent Type | Behaviour | |---|---| | `implicit` | The user never sees a consent page. Authorization runs through automatically. | | `explicit` | The user must confirm every scope on the consent page. Previous approvals are remembered. | For `explicit`: 1. `/connect/authorize` checks for existing permanent authorizations 2. If none → it mints a server-side `ConsentTicket` (bound to the current subject, with the requested scopes and the original authorize query locked in) and redirects to `/consent?ticket=...` 3. `ConsentEndpoints` (`GET /connect/consent?ticket=…` then `POST /connect/consent`) resolves the ticket, shows scope details, and processes the decision — the SPA never sees the raw authorize URL 4. Approved scopes are intersected with the locked-in requested set (no scope expansion possible) and stored as a permanent authorization 5. With `prompt=none` and no existing consent → `consent_required` error ## Scopes & API resources Default scopes (seeded per realm at provisioning): | Scope | Purpose | |---|---| | `openid` | Required for OIDC, returns the user ID | | `profile` | First name, last name | | `email` | Email address | | `roles` | Role memberships | | `offline_access` | Enables refresh tokens | **Custom scopes** can be created per realm by an admin, e.g. `billing:read`, `repo:write`. They can define `UserClaims` — when a token includes such a scope, the specified claims are packed into the token. **API resources** represent protected APIs. Per API: * Identifier (`audience` claim) * List of supported scopes * `UserClaims` that should land in tokens for this API ## Discovery privacy `scopes_supported` in `/.well-known/openid-configuration` lists **only the scopes a realm has explicitly published**. Privacy is opt-in, not opt-out: standard OIDC scopes are public, and admin-created scopes also default to `ShowInDiscoveryDocument = true` (visible). The one exception is the implicit-scope-per-API bootstrap path, which seeds its scope with `ShowInDiscoveryDocument = false` so a one-click API setup doesn't leak the resource-server name into public metadata. Background: * RFC 8414 §3 declares `scopes_supported` as `RECOMMENDED`, not `MUST` — publishing every scope is allowed but not required. * In multi-tenant SaaS, leaking which APIs a tenant operates is information disclosure with no upside: clients learn the scopes they need from the resource server's integration docs, not from discovery. * The realm-DB scope validation is the access control. Hiding from discovery is defense-in-depth — an attacker can still guess scope names and probe `/connect/token`. Admins toggle per scope via the **`Show in discovery document`** flag (see [OAuth Scopes admin](/admin/oauth-scopes#discovery-visibility)). Implementation: `RealmScopesSupportedHandler` (an OpenIddict pipeline hook in `Modgud.Infrastructure/OpenIddict/`) overrides the discovery handler so the realm-DB-backed scope set is filtered on this flag. ## Token lifetimes Configured in `OpenIddictSettings` (overridable per client): | Token | Default | Setting key | |---|---|---| | Access Token | 60 min | `AccessTokenLifetimeMinutes` | | Refresh Token | 14 days | `RefreshTokenLifetimeDays` | | Authorization Code | 5 min | `AuthorizationCodeLifetimeMinutes` | ## Signing | Mode | Configuration | |---|---| | Development | Ephemeral signing/encryption keys (auto-generated, lost on restart) | | Production | X.509 certificate from file (`SigningCertificatePath`) | In dev mode every client app has to refresh its token validation after each modgud restart (JWKS changes). In production the certificate is persistent — a restart changes nothing. ## Admin UI The admin area (`/admin/oauth/...`) has list and detail views for: * **Clients** — application registrations with secrets, redirect URIs, grant types, per-client token settings * **Scopes** — permission definitions (built-in + custom) with UserClaim mappings * **APIs** — protected API resources with scopes and UserClaims Gating: `oauth-client:read`/`:write`, `oauth-scope:read`/`:write`, `oauth-api:read`/`:write` (deletes are gated by `:write`, there is no separate `:delete` tier). Per-resource admin bypass via `oauth-client:admin` etc. As with every permission string, the `modgud` app context is implicit and never part of the string itself — see [Permissions & gating](/concepts/permissions#permission-format). --- --- url: /concepts/dynamic-client-registration.md description: >- What DCR is, the MCP use case that makes it relevant again, and how Modgud's "anonymous but triple-gated" stance compares to other IdPs. --- # Dynamic Client Registration **Dynamic Client Registration** (DCR, [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) is the OAuth feature that lets an application register *itself* as a client of the authorization server — instead of an administrator pre-creating the client by hand and emailing credentials around. This page explains what that means, why it matters again in 2026, and where Modgud sits on the spectrum of "anyone can register" vs "only admins can". If you're looking for the **setup checklist** to enable DCR on your realm, jump to [Admin → Dynamic Client Registration](/admin/dynamic-client-registration). ::: info Not the same as user self-registration DCR registers **software** (an OAuth client). User self-registration registers **people** (accounts). Two unrelated concepts that happen to share the word "register". ::: ## What gets registered A normal OAuth client is created the way every other admin object gets created: a human logs into the IdP, fills out a form, and ends up with a `client_id`, optional `client_secret`, redirect URIs, and a grant-type allowlist. That client lives in the IdP's database. Every new app you want to integrate adds one such row. DCR replaces *that part* with an HTTP call. An application POSTs a JSON payload to `/connect/register`: ```http POST /connect/register HTTP/1.1 Content-Type: application/json { "client_name": "Acme MCP Browser", "redirect_uris": ["https://acme.example/cb"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" } ``` …and gets back a `client_id` it can immediately use to start an Authorization-Code + PKCE flow. No admin in the loop. The catch — and it's the whole game — is *who* is allowed to send that POST, and *what* the resulting client is allowed to do. ## Why DCR exists at all DCR was originally specified for ecosystems where the **count of clients is unbounded and the IdP operator can't reasonably onboard each one individually**. Three historical waves drove it: 1. **eHealth interoperability** (mid-2010s) — every clinic's EHR software needed to attach to regional patient-record APIs. Hand-registering thousands of vendor clients didn't scale. 2. **SaaS-to-SaaS integration onboarding** (late 2010s) — when your customers want to plug a Zapier-style automation into your API and you don't want to be the bottleneck. 3. **MCP and AI agents** (2024 onwards) — the most recent and probably the loudest. The [Model Context Protocol](https://modelcontextprotocol.io) spec's 2025-06-18 authorization revision says authorization servers and clients SHOULD support DCR for the "agent attaches to a tool server" handshake, because the universe of agents is open-ended and growing weekly — the current draft downgrades that to MAY and points implementers toward Client ID Metadata Documents instead. The third one is what makes DCR interesting again in 2026, and is the design target for Modgud's implementation. ## The MCP scenario in one diagram A user pastes an MCP-server URL into Claude Code (or Cursor, or claude.ai, or any other MCP-aware host). They've never seen this server before, the server's operator has never heard of this particular agent. The agent has to negotiate access on the fly: ```mermaid sequenceDiagram participant User participant Agent as MCP Agent participant Server as MCP Server participant Modgud User->>Agent: "Connect to https://mcp.example.com" Agent->>Server: GET /resource (no token) Server-->>Agent: 401 + WWW-Authenticate (auth-server URL) Agent->>Modgud: GET /.well-known/oauth-authorization-server Modgud-->>Agent: discovery JSON (incl. registration_endpoint) Agent->>Modgud: POST /connect/register (client_name, redirect_uri, …) Modgud-->>Agent: 201 Created (client_id) Agent->>Modgud: /connect/authorize (PKCE, resource=mcp.example.com) Modgud->>User: Consent screen ([unverified] marker) User->>Modgud: Approve Modgud-->>Agent: code → token (audience-bound) Agent->>Server: GET /resource (Bearer …) Server-->>Agent: 200 OK ``` Without DCR step 6 is a 404 and the user has to call the realm admin. With DCR enabled, the whole handshake completes in seconds without anyone touching an admin console. ## The trust problem Once you let unknown software register itself, three obvious worries: 1. **Brand impersonation.** An attacker registers a client named "Cocoar Mail" hoping users will hit Approve on the consent screen thinking it's first-party. 2. **Spray / spam.** A misbehaving agent (or an attacker) registers thousands of clients, filling the DB and inflating audit noise. 3. **Privilege creep.** A registered client requests `realm:admin` or similarly high-trust scopes and trips up a user into granting them. Different IdPs answer this in different ways. The two main schools: | School | Stance | Trade-off | |---|---|---| | **Initial-Access-Token (RFC 7591 §3.1)** | An admin issues a one-time token; the registering software must present it. | Solves trust by re-centralising it — but defeats the "agent attaches without admin involvement" use case. Common in eHealth + SaaS onboarding. | | **Anonymous + structural limits** | Registration is open, but *what registered clients can do* is heavily constrained at the server end. | Preserves the spontaneous-onboarding use case. Requires careful constraint design. The MCP-friendly choice. | Modgud picks the second school and adds belt-and-braces to make it safe. The [Admin doc](/admin/dynamic-client-registration#triple-opt-in-design) walks through the operator-facing knobs; the rest of this page explains the *primitives* those knobs control. ## How Modgud constrains DCR clients Five structural rules apply to *every* DCR-registered client, unconditionally: 1. **Public PKCE only.** `token_endpoint_auth_method` is forced to `none`. The IdP never issues a `client_secret` to a DCR client, so it physically cannot use confidential flows. 2. **No `client_credentials` grant.** The grant-type allowlist is `{authorization_code, refresh_token}`. Anonymous registration would otherwise be a free pass to mint machine-to-machine tokens — so the validator rejects anything else outright. Service Accounts have their own admin-only provisioning path ([Service Accounts](/admin/service-accounts)). 3. **No implicit / hybrid flows.** `response_types` is locked to `{code}` only. PKCE + Authorization Code is the only path. 4. **Audience-bound tokens.** The agent must pass `resource=` when requesting authorization, and the resulting token is bound to that API only. A code grabbed by an attacker can't be replayed against an unrelated resource. 5. **`[unverified]` marker on consent.** Every DCR-registered client shows up on the consent screen with the marker plus a callout warning the user to verify the name. `AllowRememberConsent` is forced off so the prompt fires on every authorize hit until the agent itself caches the user's decision. On top of these immovable rules, the realm admin chooses **three opt-in toggles** (realm master, per-API, per-scope) — see the [Admin doc](/admin/dynamic-client-registration#triple-opt-in-design) for the exact gating logic. ## CIMD: the spec-preferred path, with DCR as fallback Modgud also supports **Client ID Metadata Documents** (CIMD), the client-identification mechanism the MCP authorization spec's current draft steers implementers toward for this same use case, with DCR retained as the compatibility fallback for clients that don't support it yet. Instead of registering a client record via `/connect/register`, a CIMD client publishes a metadata document at an HTTPS URL and uses that URL *as* its `client_id` — the IdP fetches and validates the document on demand, and stores nothing. Both claude.ai and ChatGPT prefer CIMD when a server advertises support for it, falling back to DCR otherwise. See [Admin → Client ID Metadata Documents](/admin/client-id-metadata-documents) for the full mechanics and how it compares to DCR. ## How other IdPs handle DCR Where Modgud sits on the spectrum, compared to other commonly-used identity providers as of early 2026: | IdP | RFC 7591 | Default mode | Notes | |---|---|---|---| | **Modgud** | ✅ Full | Anonymous + triple-opt-in | MCP-tuned; `[unverified]` marker on consent; audit + GC on stale clients | | **Keycloak** | ✅ Full | Configurable: anonymous, initial-access-token, or trusted-host | Most flexible OSS option | | **Auth0** | ✅ | Initial-access-token by default; "dynamic application registration" is a SaaS feature | Aimed at customer-of-customer onboarding | | **Okta** | ✅ | API-token required | Same shape as Auth0 — admin-gated | | **Ory Hydra** | ✅ Full | Multiple access-control modes (public, none, access-token) | OSS, configurable like Keycloak | | **Authentik** | ✅ | Configurable per provider | OSS | | **Zitadel** | ⚠️ Partial | Application creation via API, not full RFC 7591 | Works but isn't standards-compliant | | **IdentityServer (Duende)** | ❌ Not built-in | Custom implementation needed | Community samples exist; no stock support | | **Azure AD / Entra ID** | ❌ | App registration via Microsoft Graph (admin auth) | Not RFC 7591 | | **AWS Cognito** | ❌ | App-client creation via Admin API only | Admin-gated, proprietary shape | | **Google Identity Platform** | ❌ | No public registration endpoint | Console-only | The pattern: the **OSS / enterprise-IdP** crowd (Keycloak, Hydra, Authentik, Modgud) supports DCR, while the **cloud-vendor IdPs** (Azure, AWS, Google) do not. Among the OSS ones, most default to admin-gated registration via initial-access-token. Modgud's choice to default to anonymous-with-constraints — rather than admin-gated — is the MCP-friendly outlier, and the rationale is the use case above: agent-attaches-to-tool needs to work without an admin in the loop. ## When you want DCR enabled Enable it when **at least one of these is true**: * You're running an MCP server and want AI agents (Claude Desktop, Cursor, Continue, claude.ai, …) to attach without per-agent admin onboarding. * You're exposing an OAuth-protected API to a long tail of unknown client apps (typical SaaS integration marketplace pattern). * You want each downstream installation of *your* product to register itself against *your* identity provider as part of first-run setup. Leave it off (the default) if every client of your APIs is something *you* control or *you* manually onboard. Modgud was built so the non-DCR path stays straightforward — the admin UI for OAuth Clients is the single source of truth when DCR isn't enabled. ## Related * [Admin → Dynamic Client Registration](/admin/dynamic-client-registration) — operational setup: enabling the feature, sizing rate-limits, managing the registered-client surface. * [Client ID Metadata Documents](/admin/client-id-metadata-documents) — the spec-preferred, no-registration-endpoint mechanism for the same MCP clients, with DCR as the fallback. * [OAuth & OIDC](/concepts/oauth) — the larger flow context DCR plugs into. * [Service Accounts](/admin/service-accounts) — the admin-only path for machine-to-machine identities, deliberately separate from DCR. * [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) — the protocol spec. * [Model Context Protocol — Authorization](https://modelcontextprotocol.io/specification/draft/basic/authorization) — the MCP-side spec covering DCR for agent attachment (SHOULD in the 2025-06-18 revision, downgraded to MAY in the current draft in favor of CIMD). --- --- url: /concepts/tokens.md --- # Sessions & Tokens ## Sessions (first-party login) When a user signs in to modgud (admin UI, OAuth login page), a **session** is created as a `UserSession` Marten document. Sessions track: * IP address * Browser, browser version * Operating system, OS version * Device type (desktop, mobile, tablet) * `CreatedAt`, `LastActiveAt`, `ExpiresAt` The `User-Agent` string is split and maintained with **UAParser**. Sessions are realm-scoped (one session per realm per browser). A login in realm A does not affect realm B — a user can be signed in to multiple realms at the same time, each with its own session. ### Session self-service Signed-in users see, under `/profile/sessions`: * All active sessions * Browser, OS, IP, "active now" or "X minutes ago" * Per session: "Sign out this session" * Global button: "Sign out everywhere except here" Endpoints: ```http GET /api/auth/sessions DELETE /api/auth/sessions/{id} DELETE /api/auth/sessions ``` ### Admin variant ```http GET /api/admin/users/{id}/sessions # gated on session:read DELETE /api/admin/users/{id}/sessions # force logout, gated on session:write ``` Admin needs `session:read` (list) or `session:write` (force logout), or the matching `:admin` bypass tier. The granular split lets a help-desk role read sessions without being able to terminate them. ## OAuth tokens When an external app authenticates a user via OAuth, it receives tokens. Three kinds: ### Access Token What the app sends to the API to prove access. Configured per client as one of two formats: | Format | Looks like | API validation | |---|---|---| | **Reference** (default) | Opaque string — not decodable | API calls modgud's introspection endpoint | | **JWT** | Signed JSON token — decodable | API verifies the signature locally | * **Short-lived** — typically 60 min (configurable per client) * **Reference tokens are revocable instantly** — JWTs only via expiry ### Identity Token A signed JWT that tells the client **who is signed in**. Contains user info per the granted scopes (name, email, roles). Read by the client, not sent to APIs. ### Refresh Token Lets the app fetch new access tokens without signing the user in again. Only issued when `offline_access` is granted. * Long-lived (days to weeks, configurable) * **Single-use with rotation** — every use returns a new refresh token and invalidates the old one * Revocable at any time #### Replay and races * **Reuse of an already-redeemed refresh token** is detected with strict, zero-leeway checking and revokes the **entire token family**: every sibling token sharing the same authorization — refresh tokens and reference access tokens alike — plus the parent authorization itself. The client has to run a fresh grant; there's no partial recovery. * **A benign race** — two near-parallel refreshes presenting the same not-yet-redeemed token (e.g. a mobile client double-sending the request) — does **not** trigger that teardown. Strict optimistic concurrency on the token document lets exactly one request win; the loser just gets a plain `invalid_grant` for that one request, while the winner's new token pair and the authorization survive untouched. * **Already-issued JWT access tokens are the exception**: they have no store document to invalidate, so they keep validating until their own (short) expiry regardless of a refresh-token replay on the same family — one more reason to pair JWT access tokens with short lifetimes. * The "family" is the OpenIddict authorization id, carried across every rotation — it's what ties a chain of rotated refresh tokens (and any reference access tokens issued alongside them) together for revocation purposes. ## Token revocation | Token type | How to revoke | Effect | |---|---|---| | **Reference access token** | `POST /connect/revoke` | Invalid immediately | | **JWT access token** | `POST /connect/revoke` | Takes effect only at expiry — the JWT remains valid until then | | **Refresh token** | `POST /connect/revoke` | Invalid immediately, no new access tokens possible | | **Session** (first-party cookie) | Logout or via session management | Cookie invalid, the user has to sign in again | Refresh-token reuse detection (above) triggers the same effects automatically and cascades them across the whole token family, without a client ever calling `/connect/revoke` itself. ## Token storage Reference tokens and refresh tokens are stored as `OpenIddictTokenDocument` in Marten (per tenant DB). Direct document storage — no event sourcing, because tokens are short-lived and ephemeral. Authorizations (consent records, permanent grants) are `OpenIddictAuthorizationDocument` — also direct storage. Tokens and authorizations are realm-isolated per tenant DB. ## SignalR and sessions The Vue admin frontend uses **SignalARRR** (typed bidirectional RPC over SignalR) for live updates. The SignalR connection is built up **after** login, with the active auth cookie. On logout the frontend performs a `window.location` reload instead of a Vue Router navigation — otherwise an old subscription would still be attached to the old user. The SignalR group is realm-scoped (each realm has its own hub channel). There are no cross-realm notifications. --- --- url: /concepts/security-model.md --- # Security model ## Overview This page is one aggregated, honest view of Modgud's OAuth 2.0 / OpenID Connect and tenant-isolation security posture: what's supported, what's deliberately rejected, and what's tracked but not yet built. It's written for security reviewers and integrators evaluating Modgud before connecting a client or resource server. Every row and pointer below links back to the page that documents the mechanism in depth — treat this page as the index, not the primary source. ## Mechanism matrix | Mechanism | Status | Default | Note | |---|---|---|---| | [Authorization Code + PKCE](./oauth#authorization-code-pkce-for-user-apps) | Supported | `S256` required, `plain` removed | Enforced for every client, public or confidential — there is no opt-out. | | Implicit flow | Not supported | — | Deliberately rejected; OAuth 2.1 deprecates it. | | ROPC (Resource Owner Password Credentials) | Not supported | — | Deliberately rejected; OAuth 2.1 deprecates it. | | [Client Credentials](./oauth#client-credentials-for-services) | Supported | Via Service Accounts only | Structurally tied to [Service Accounts](/admin/service-accounts) — a standard client can never carry `client_credentials`, and a Service-Account-linked client can carry nothing else. | | [Device flow (RFC 8628)](./oauth#device-code-rfc-8628) | Supported | Opt-in per client | Hosted user-verification page at `/connect/verify`. | | Native cookieless grants (`urn:cocoar:*`) | Supported | Off | `otp`, `magic`, and `passkey` variants; each must be opted in on both the realm and the individual client before it's usable. See [Native apps](/integrate/native-apps). | | [Refresh tokens](./tokens#refresh-token) | Supported | On when `offline_access` is granted | Reference tokens, single-use rolling rotation, zero reuse leeway. A replayed token revokes the whole family — every sibling token plus the parent authorization — not just itself. | | RFC 9207 `iss` in authorization responses | Supported | Always on | Present on the success path and on the consent-deny error path alike. | | Redirect URI validation | Supported | Exact string match | Applies to `redirect_uri` and `post_logout_redirect_uri` alike; no prefix or wildcard matching. | | RP-initiated logout | Supported | `id_token_hint` mandatory | Stricter than the OIDC baseline, where the hint is optional. Logout also fully revokes the session's tokens, not just the cookie. | | Back-channel / front-channel logout | Not supported | — | No server-initiated logout propagation to relying parties yet. Tracked in [#118](https://github.com/cocoar-dev/modgud/issues/118). | | PAR (RFC 9126) | Not supported | — | Pushed authorization requests. Tracked in [#118](https://github.com/cocoar-dev/modgud/issues/118). | | DPoP (RFC 9449) | Not supported | — | Sender-constrained tokens. Tracked in [#118](https://github.com/cocoar-dev/modgud/issues/118) as the intended complement to rejecting mTLS client auth. | | mTLS client authentication | Not supported | — | Deliberately rejected, not merely unimplemented. | | Token Exchange (RFC 8693) | Not supported | — | No delegation or impersonation grant today. | | [Dynamic Client Registration](/admin/dynamic-client-registration) | Supported | Off | Triple opt-in (realm + per-API + per-scope). Public PKCE clients only, no secrets. The compatibility fallback for agents that don't support CIMD. | | [Client ID Metadata Documents](/admin/client-id-metadata-documents) | Supported | Off | The spec-preferred onboarding path for MCP clients; no stored client record, SSRF-hardened fetch of the client-supplied metadata document. | | [Access token formats](./tokens#access-token) | Supported | Reference tokens | JWT is a per-client opt-in — signed only, never encrypted. A resource server can validate a JWT locally via JWKS, but JWT revocation only takes effect at expiry, unlike a reference token's instant revocation. | ## Trust boundaries * **Realm = hard boundary.** Every realm has its own physical PostgreSQL database, its own RSA signing key for access and ID tokens, and its own `/.well-known/openid-configuration` + JWKS — a token from one realm cannot validate against another realm's discovery document, structurally, not just by convention. See [Realms](./realms) and [Key material](/operate/key-material). * **App = soft facet.** An Application can carry its own subdomain, branding, and login / native-grant / DCR / CIMD policy, but it shares its realm's user pool, signing keys, and OIDC issuer. Promoting an App to its own realm is how you get independent key rotation or breach containment. * **One instance-global exception.** The OpenIddict signing and encryption certificate pair (`signing.pfx` / `encryption.pfx`) is shared by every realm on the instance. It only ever protects artifacts redeemed at the IdP itself — authorization codes, device codes, refresh tokens — never bearer material handed to a third party, so giving each realm its own certificate here would add validation surface without buying additional isolation. See [Key material](/operate/key-material) for the full rotation and blast-radius picture. ## Threat-model pointers | Surface | Mitigation | Details | |---|---|---| | Token theft | Refresh-token rotation with family revocation on replay bounds a stolen refresh token's blast radius; pair JWT access tokens with short lifetimes since they can't be revoked before expiry. | [Sessions & Tokens](./tokens) | | DCR / CIMD abuse | Both off by default, gated per realm + API + scope; CIMD's outbound fetch is SSRF-hardened; accepted residual risks (brand impersonation, targeted phishing via redirect URI) are documented rather than hidden. | [Dynamic Client Registration](/admin/dynamic-client-registration), [Client ID Metadata Documents](/admin/client-id-metadata-documents) | | Membership scripts | Auto-membership predicates run through a sandboxed TypeScript-to-LINQ translator, tested against an adversarial suite covering resource exhaustion, native-host escape, type confusion, cross-tenant probing, injection, and information disclosure. | [Automated tests](/contribute/testing/automated-tests) | | Tenant isolation | Realm boundaries are physical database separation, not a query filter. | [Realms](./realms) | | Operational security | Per-realm rate-limit ceilings on auth endpoints, a 7-day security log for threat signals, and a separate GDPR-aware audit trail for admin/config changes. | [Auth Log](/admin/auth-log) | ## Verification Claims on this page aren't just prose — they're pinned by CI-gated tests that fail the build if the behavior regresses. * **OWASP Top 10 (2021) subset** — `OwaspTop10Tests`, 12 tests under the `OWASP=Top10` xUnit trait, part of the integration suite. * **Security-audit regression suite** — the `SecurityAuditWave*Tests` classes, one per remediation wave, guarding against re-introducing a previously-fixed finding. * **PKCE enforcement pin** — `PkceRequirementPinTests` asserts `S256` is present and `plain` is absent from the discovery document's supported code-challenge methods. * **CodeQL** — C# and JavaScript, `security-extended` query pack, runs on every pull request plus a weekly scheduled scan. The configuration is public. * **Dependency gates** — every pull request blocks on `dotnet list package --vulnerable` and `pnpm audit`; GitHub Dependabot alerts catch new CVEs in between. Found something this page doesn't cover, or a gap in the above? See the [security policy](https://github.com/cocoar-dev/modgud/blob/develop/SECURITY.md) for how to report it. --- --- url: /operate/deployment.md --- # Docker & deployment ## Prerequisites You deploy the **published image** `ghcr.io/cocoar-dev/modgud` (pull a pinned tag like `:1.0.0`, or `:latest`). You do **not** build from source to run Modgud — the only external dependency you provision is PostgreSQL. | Dependency | Version | Purpose | |---|---|---| | PostgreSQL | 17+ | DB (document + event store + per-tenant DBs) | | Docker | 20+ | Container runtime | ## Configuration Modgud uses **Cocoar.Configuration v6** with layered binding. Settings are loaded from multiple sources, each overriding the previous: 1. `data/configuration.json` (defaults, committed) 2. `data/configuration.local.json` (gitignored, local overrides) 3. Environment variables (highest priority) ::: warning Production runs on env vars + class defaults, **not** on the committed `configuration.json` The published Docker image deliberately does **not** ship `data/configuration.json` (the csproj has `Never` on it). The committed file is for local dev only. In a deployed container the configuration comes entirely from env vars layered on top of the class defaults in `StartUpConfiguration` / `AppSettings` / etc. This means an operator who looks at `data/configuration.json` in the repo to "see the prod defaults" is looking at the wrong file — the prod defaults are the property initialisers in the C# settings classes, and the only thing the operator can override at deploy time is via env vars. Anything you'd expect to tweak (the SMTP settings, the OpenIddict issuer, the magic-link rate limit, the `AuthenticationMinimumLevel`) needs an explicit env var. ::: ### Settings classes | Class | JSON section / ENV prefix | |---|---| | `StartUpConfiguration` | Top-level (no prefix) — `AppUrl`, `DbSettings.ConnectionString`, `Logging`, `CertPath`, ... | | `EmailConfiguration` | `Email:` — `Provider` (Postmark/Smtp), `Postmark.*`, `Smtp.*` | | `MagicLinkConfiguration` | `MagicLink:` — `Enabled`, `ExpirationMinutes`, `RateLimitMinutes` | | `EmailOtpConfiguration` | `EmailOtp:` — `ExpirationMinutes`, `RateLimitMinutes` | | `AppSettings` | `AppSettings:` — `AuthenticationMinimumLevel`, `MagicLinkSelfService`, `TwoFactorGracePeriodDays` | | `OpenIddictSettings` | `OpenIddict:` — `*LifetimeMinutes`, `DevelopmentMode`, `SigningCertificatePath` | | `ObservabilitySettings` | `Observability:` — `Prometheus.Enabled`, `Prometheus.BearerToken`, `Otlp.*`, `ErrorFeed.*` | The token issuer is **not** a global setting — there is no `Issuer` or `PublicUrl` key. Modgud is multi-tenant: each realm carries its own `PrimaryDomain` (managed in the admin UI or the Recovery CLI), and the issuer is derived per request from that domain / the request host on every path — the discovery document, the token `iss` claim, and token validation. What you must get right for a correct issuer is therefore (1) each realm's domain and (2) the reverse proxy forwarding the real public host (see `ProxyAllowedNetworks` below), **not** any issuer config value. ### Example `configuration.json` ```json { "AppUrl": "http://0.0.0.0:8081", "DbSettings": { "ConnectionString": "Host=postgres;Port=5432;Database=modgud;Username=postgres;Password=postgres" }, "AppSettings": { "AuthenticationMinimumLevel": 1, "MagicLinkSelfService": false, "TwoFactorGracePeriodDays": 30 }, "Email": { "Provider": "Smtp", "Smtp": { "Host": "smtp.example.com", "Port": 587, "UseSsl": true, "UserName": "noreply@example.com", "Password": "...", "FromAddress": "noreply@example.com", "FromName": "Modgud" } }, "MagicLink": { "Enabled": true, "ExpirationMinutes": 15, "RateLimitMinutes": 2 }, "EmailOtp": { "ExpirationMinutes": 10, "RateLimitMinutes": 2 }, "OpenIddict": { "AccessTokenLifetimeMinutes": 60, "RefreshTokenLifetimeDays": 14, "AuthorizationCodeLifetimeMinutes": 5, "DevelopmentMode": false }, "Observability": { "Prometheus": { "Enabled": true, "BearerToken": "" } } } ``` ::: info OpenIddict signing + encryption certificates Both `OpenIddict.SigningCertificatePath` and `OpenIddict.EncryptionCertificatePath` are **optional**. When unset they default to `data/keys/signing.pfx` and `data/keys/encryption.pfx` respectively, resolved relative to the app's working directory (`/app/` in the Docker image). When the resolved file is missing on disk at startup, modgud auto-generates a passwordless self-signed PFX in place and logs a startup warning naming the path. The cert persists across container restarts as long as the directory is on a persistent volume — see the Docker Compose example below for the `cocoar-keys` volume. This means: for a self-hosted Beta deployment you don't need to provision certs ahead of time. The container generates them on first start. For Cloud / managed deployments, point the path at a Key-Vault-mounted directory with the production cert pre-placed — the auto-gen never fires when the file already exists. Convention: passwordless PFX, file-system permissions (0600 on Linux) protect the key. Mirrors the `cocoar-secrets` CLI tool's recommendation (see `Cocoar.Configuration.Secrets.Cli`). To convert a password-protected PFX from elsewhere: `cocoar-secrets convert-cert -i in.pfx --ipass -o out.pfx`. ::: ::: info Database naming `DbSettings.ConnectionString` points at the master DB — pick any name you like (the convention is `modgud`). The master DB holds only control-plane infrastructure (the tenant registry + the global Realm store + Wolverine durability); it is **not** a tenant. Every realm lives in its own `_` DB, including the bootstrap system realm (`_system`, created at first boot). So for a master DB called `modgud` you get `modgud_system`, `modgud_acme`, `modgud_finance`. Back up the master DB **and** every `modgud_` DB (system included — that's where system-realm users and keys live). ::: ## Docker image You run the official published image — it bundles backend (.NET) + the built Vue SPA (as static `wwwroot/` content). Pull it; don't build it. ``` ghcr.io/cocoar-dev/modgud:1.0.0 # Pinned version — recommended for production ghcr.io/cocoar-dev/modgud:latest # Latest release — convenient for evaluation ``` Multi-arch: **linux/amd64** + **linux/arm64**. Pin a specific tag in production so an `:latest` re-pull can't move the runtime under you. ::: tip Production runs fail-closed The published image ships `ASPNETCORE_ENVIRONMENT=Production`, and Production **refuses to boot** if any of the following is true (the boot validator throws with an actionable message): * `OpenIddict.DevelopmentMode` is `true`; * the Prometheus scrape endpoint is enabled (the default) but no `Observability.Prometheus.BearerToken` is set. So every production recipe **must** make a choice on Prometheus: either set `Observability__Prometheus__BearerToken=` or set `Observability__Prometheus__Enabled=false`. The recipes below set the bearer token. ::: ### Minimum env vars For a production run you must supply, at minimum: * **`DbSettings__ConnectionString`** — Postgres master DB. Realms get per-tenant DBs auto-provisioned with the slug appended. * **`ProxyAllowedNetworks`** — comma-separated CIDR list of reverse- proxy IPs. Required so `X-Forwarded-Proto`/`-Host` are honoured for cookie-Secure decisions **and the per-realm token issuer** (the issuer is derived from the forwarded host); forwarded headers from any IP outside the list are rejected. Fail-closed: if this is **unset** in Production, *all* forwarded headers are rejected — the app then sees Kestrel's own scheme/host, so behind a TLS-terminating proxy the issuer and outbound links would be wrong until you set it. There is no issuer config value — see "token issuer" above. * **`Observability__Prometheus__BearerToken`** — a strong random string protecting the `/metrics` scrape endpoint (or set `Observability__Prometheus__Enabled=false` to drop the endpoint entirely; one of the two is mandatory in Production). Everything else has sensible defaults: * `ASPNETCORE_ENVIRONMENT` defaults to `Production` (set in the image). * `AppUrl` defaults to `http://0.0.0.0:8081` (Kestrel listens on **8081**). * `OpenIddict__SigningCertificatePath` and `OpenIddict__EncryptionCertificatePath` default to `data/keys/{signing,encryption}.pfx` and are **auto-generated** as passwordless self-signed PFXes on first boot when missing. Mount a volume at `/app/data/keys` so they persist across container restarts — otherwise every restart regenerates the OpenIddict cert and **invalidates all live refresh tokens and authorization codes** (per-realm RSA signing keys and DataProtection keys live in Postgres and already survive restarts; the static OpenIddict cert is the one that needs the volume). * `OpenIddict__DevelopmentMode` defaults to `false` (production shape — real signing keys, transport-security required). ### Local evaluation quickstart For a throwaway local trial against a non-public host you can keep it minimal — but note Production still enforces an HTTPS issuer and a Prometheus token, so set them here too (or disable Prometheus): ```bash docker run -d \ --name modgud \ -p 8081:8081 \ -v cocoar-keys:/app/data/keys \ -e DbSettings__ConnectionString="Host=your-postgres;Database=modgud;Username=postgres;Password=..." \ -e ProxyAllowedNetworks="10.0.0.0/24" \ -e Observability__Prometheus__Enabled="false" \ ghcr.io/cocoar-dev/modgud:latest ``` The [Docker Compose recipe](#docker-compose-canonical-production-reference) below is the canonical production shape — prefer it over this one-liner for anything beyond a quick look. ::: tip ENV variable casing Cocoar.Configuration v6 binds environment variables **case-insensitively**, so the section and property names need not match the C# casing exactly — `DbSettings__ConnectionString` and `DBSETTINGS__CONNECTIONSTRING` bind to the same setting, as do `AppUrl` and `APPURL`. Two underscores (`__`) are the section separator; a single underscore is literal. PascalCase is a readability convention, not a correctness requirement. The full list of bindable settings is in the Settings classes table above. ::: ### First-time bootstrap The system realm is seeded automatically with the localhost-style domains `["system.localhost", "localhost", "127.0.0.1"]`. To make the public hostname route to the system realm, add it via the Recovery CLI, then restart so the in-process realm cache picks up the change: ```bash docker exec modgud dotnet Modgud.Api.dll \ recover realm-add-domain --slug system --domain auth.example.com # Make it the realm's primary domain so outbound email links # (magic-link, invite, password-reset) resolve to the public host: docker exec modgud dotnet Modgud.Api.dll \ recover realm-set-primary-domain --slug system --domain auth.example.com # The CLI runs as a separate process; the running server's realm # cache doesn't see the change until restart: docker compose restart auth ``` Then create the first admin user (the `system` slug is the default, so `--realm system` is implicit): ```bash docker exec modgud dotnet Modgud.Api.dll \ recover bootstrap-admin \ --email admin@example.com --username admin --password 'StrongPass1!' ``` Open `https://auth.example.com/` in the browser and sign in. ### Docker Compose (canonical production reference) This is the recommended production shape: a pinned image tag, an HTTPS issuer, a persisted keys volume, and a Prometheus bearer token. It expects TLS to be terminated by the reverse proxy in front of it (see [Reverse proxy](#reverse-proxy-nginx)); the container itself serves plain HTTP on 8081. ```yaml services: postgres: image: postgres:17-alpine environment: POSTGRES_PASSWORD: postgres volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s retries: 10 auth: image: ghcr.io/cocoar-dev/modgud:1.0.0 # pin a version in production expose: - "8081" # Kestrel listens on 8081; the reverse proxy talks to it on this port environment: DbSettings__ConnectionString: "Host=postgres;Database=modgud;Username=postgres;Password=postgres" ProxyAllowedNetworks: "10.0.0.0/24" # adjust to your reverse proxy CIDR — also pins the per-realm token issuer (forwarded host) # Mandatory in Production: protect the /metrics scrape endpoint, or set # Observability__Prometheus__Enabled=false to drop it. Boot fails otherwise. Observability__Prometheus__BearerToken: "${PROMETHEUS_TOKEN}" # strong random string # Email is optional but recommended — magic-link, forgot-password, # invite, email-OTP all need a working SMTP relay. mailpit is fine # for a trial; switch to a real relay before going live. Email__Provider: "Smtp" Email__Smtp__Host: "mailpit" Email__Smtp__Port: "1025" volumes: - cocoar-keys:/app/data/keys # persists the auto-generated OpenIddict cert across restarts depends_on: postgres: condition: service_healthy mailpit: image: axllent/mailpit:latest ports: - "8025:8025" volumes: pgdata: cocoar-keys: ``` `ASPNETCORE_ENVIRONMENT` defaults to `Production` (set by the image's `ENV` directive), `AppUrl` defaults to `http://0.0.0.0:8081`, and `OpenIddict__DevelopmentMode` defaults to `false` — none of those need to appear in the Compose file unless you want to override them. ## TLS Modgud can terminate TLS itself (Kestrel with a cert) or run behind a reverse proxy (Nginx, Sophos XG, ...). ### Own TLS termination ```yaml auth: image: ghcr.io/cocoar-dev/modgud:latest ports: - "443:443" environment: AppUrl: "https://0.0.0.0:443" CertPath: "/secrets/auth.pfx" # Kestrel TLS cert (separate from OpenIddict signing/encryption) CertPassword: "..." # optional — passwordless PFX is supported volumes: - ./certs:/secrets:ro ``` If `AppUrl` is HTTPS and `CertPath` is not set, modgud generates a self-signed cert at `certs/modgud.pfx` (fine for test setups, but browsers will warn). ::: tip Three different certificate slots * **`CertPath` / `CertPassword`** — the TLS cert Kestrel uses when it terminates HTTPS itself. Only relevant when not behind a reverse proxy. * **`OpenIddict.SigningCertificatePath`** — the JWT signing key. Auto-generated when missing (see "OpenIddict signing + encryption certificates" tip earlier in this page). * **`OpenIddict.EncryptionCertificatePath`** — separate key for token encryption (OAUTH-05 recommendation). Auto-generated too. The TLS cert and the OpenIddict signing cert are different files; don't reuse one for both. The OpenIddict ones are passwordless by convention; the Kestrel TLS cert can have a password (legacy support — Let's Encrypt typically delivers passwordless). Both OpenIddict certs support zero-downtime rotation: list the outgoing file's path in `OpenIddict.PreviousSigningCertificatePaths` / `OpenIddict.PreviousEncryptionCertificatePaths` (comma-separated env vars) alongside the new active path, and it stays trusted for validation/decryption during the overlap window — see [Key material](./key-material#global-openiddict-signing-certificate) for the full rotation procedure. ::: ### Reverse proxy (Nginx) ```nginx server { listen 443 ssl http2; server_name auth.example.com; ssl_certificate /etc/ssl/certs/auth.example.com.crt; ssl_certificate_key /etc/ssl/private/auth.example.com.key; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; location / { proxy_pass http://auth:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /signalr { proxy_pass http://auth:8081; proxy_set_header Host $host; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } ``` Important: * **`X-Forwarded-Proto`** — otherwise Kestrel thinks the request is HTTP and OpenIddict builds HTTP URLs into the discovery document * **`X-Forwarded-For`** — the backend uses this for session IP tracking + security-audit attribution * **WebSocket upgrade** for `/signalr` — otherwise no live-update stream Modgud respects forwarded headers via `UseForwardedHeaders` in `Program.cs`. ## Multi-realm deployment Each realm needs its own domain pointing at modgud: ``` A record auth.example.com → modgud container A record acme.example.com → modgud container (same IP) A record finance.example.com → modgud container (same IP) ``` TLS termination must cover all domains (wildcard cert or SAN cert). In the reverse proxy: ```nginx server { listen 443 ssl; server_name *.example.com; # ... as above } ``` `RealmMiddleware` sees the relevant Host header and routes against the correct tenant DB. ## Database auto-provisioning On first start (or after every image update): 1. The master DB **and** the `_system` DB are created if missing (`CREATE DATABASE`) 2. Marten schema is applied (idempotent) → the tenant registry table 3. System tenant is registered in `realms.mt_tenant_databases`, pointing at its own `_system` DB (the master DB is pure control-plane infra) 4. Marten schema is applied again (per-tenant tables for the system realm, in its own DB) 5. System realm document is seeded (stamped as the control plane) 6. Default scopes + internal LoginProvider are seeded 7. RealmCache is warmed up Additional realms are created at runtime, either one at a time via `POST /api/admin/realms`, or declaratively: `POST /api/admin/realms/import` and `POST /api/admin/realms/{slug}/apply` accept a realm manifest (realm + Apps + OAuth clients + users in one document) for import/upsert, with a matching `GET /api/admin/realms/{slug}/export` and a `GET /api/admin/realms/manifest-schema` for the manifest's JSON Schema. ::: warning Multi-pod deployments When several modgud instances boot in parallel, schema apply can race. In practice this is not an issue today (Marten is idempotent + Postgres locks help), but for very large setups a separate migration phase is preferable: `AutoCreate.None` in the pods + a `migrate` sidecar/job that applies the schema once before the pod rollout. ::: ## Health checks There are two probe endpoints (both anonymous, no realm routing required). There is **no** `/health` endpoint — point your orchestrator at these: ```bash curl http://localhost:8081/health/live # liveness — "the process answers" curl http://localhost:8081/health/ready # readiness — DB connection + OpenIddict cert ready ``` * **`/health/live`** runs no dependency checks; it returns `200` as long as the process is up. Use it as the liveness probe. * **`/health/ready`** returns `200` only when the master DB connection and the OpenIddict signing/encryption certificate are both ready. Use it as the readiness probe (gate traffic on it). ## SignalR Modgud pushes live updates over `/signalr/ui` (typed RPC via SignalARRR). Reverse proxies need upgrade headers (see above). The connection is auth-gated — the user must be logged in before it's established. ## Security headers Modgud doesn't set its own security headers — that's the job of the reverse proxy or a fronting WAF. Recommendations: ``` X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() Strict-Transport-Security: max-age=31536000; includeSubDomains ``` ## Email provider Modgud ships two outbound providers — pick whichever your infrastructure already gives you. Switch between them by flipping `Email__Provider`; the unused section is ignored. ### SMTP ```yaml environment: Email__Provider: "Smtp" Email__Smtp__Host: "smtp.example.com" Email__Smtp__Port: "587" Email__Smtp__UseSsl: "true" Email__Smtp__UserName: "noreply@example.com" Email__Smtp__Password: "${SMTP_PASSWORD}" Email__Smtp__FromAddress: "noreply@example.com" Email__Smtp__FromName: "Modgud" ``` ### Postmark ```yaml environment: Email__Provider: "Postmark" Email__Postmark__ServerToken: "${POSTMARK_TOKEN}" Email__Postmark__FromAddress: "noreply@example.com" Email__Postmark__FromName: "Modgud" Email__Postmark__MessageStream: "outbound" # default; e.g. "broadcast" for bulk-streams ``` ### Dev In Development env, an `InMemoryEmailService` is registered in addition that keeps mails in memory — the `/api/dev/emails` endpoint shows them. Useful for E2E tests in Docker without an SMTP relay. ### No email configured The container keeps running (magic-link / forgot-password / invite simply fail to send), but the logger warns at boot. Email is **optional** in the sense of "the host won't crash without it" — but every user-facing recovery flow needs it, so configure something before you go live. ## Recovery CLI in the container The Recovery CLI runs the same binary in command mode instead of starting Kestrel — pass `recover ` to `dotnet Modgud.Api.dll`. The CLI is for two situations: 1. **First-time bootstrap** — set up the system realm's public domain and create the first admin (covered in [Quick start](#quick-start) above). 2. **Break-glass recovery** — all admins locked out, 2FA reset, projection rebuild. Reference (`docker exec modgud dotnet Modgud.Api.dll recover help` prints the same): | Verb | Purpose | |---|---| | `list` | List all users (UserName · Email · Active · Admin · 2FA · Passkeys) | | `reset-2fa ` | Disable TOTP + Email-OTP + delete all Passkeys | | `set-email ` | Update the user's email address | | `magic-link ` | Generate a one-time login URL and print it | | `bootstrap-admin --email --username [--password]` | Create the first admin in a realm. With `--password` direct mode; without, invite mode (prints magic-link URL). | | `realm-list` | Show every active realm with its slug and domains. | | `realm-add-domain --slug --domain` | Add a domain to a realm's `Domains` list. After running, restart the container so the in-process realm cache picks up the change. | | `realm-remove-domain --slug --domain` | Remove a domain. Same restart requirement. Refuses to remove the realm's primary domain — re-point it first. | | `realm-set-primary-domain --slug --domain` | Set the realm's primary domain (the origin outbound email links resolve to). The domain must already be in the realm's `Domains` list (add it with `realm-add-domain` first). Changes the WebAuthn RP — existing passkeys are invalidated. Restart to refresh the realm cache. | | `control-plane transfer ` | Relocate the control-plane role to another realm (`control-plane list` shows the current holder). | | `rotate-signing-key` | Rotate the realm's per-realm RSA signing key (global flag `--realm`). | | `rebuild-projections` | Rebuild all Marten projections. | Global flag `--realm ` for the user-management verbs (defaults to `system`). ```bash # A few representative invocations: docker exec modgud dotnet Modgud.Api.dll recover list docker exec modgud dotnet Modgud.Api.dll recover realm-list docker exec modgud dotnet Modgud.Api.dll recover \ realm-add-domain --slug system --domain auth.example.com docker exec modgud dotnet Modgud.Api.dll recover \ realm-set-primary-domain --slug system --domain auth.example.com docker exec modgud dotnet Modgud.Api.dll recover reset-2fa admin ``` --- --- url: /operate/backend-architecture.md --- # Backend architecture Modgud is **not** classically layered (Domain → Application → Infrastructure). Instead, the core features are organised as vertical slices, with additional IdP-specific layers on top. ## Project layout ``` src/dotnet/ ├── Modgud.Authentication/ ← Slice (Login, 2FA, OIDC, GDPR, Sessions) ├── Modgud.Authorization/ ← Slice (Groups, Roles, Permissions) ├── Modgud.Domain/ ← Realm, OAuth, LoginProvider domain ├── Modgud.Application/ ← DTOs, service interfaces ├── Modgud.Infrastructure/ ← OpenIddict stores, tenancy, realm cache, Wolverine handlers ├── Modgud.Permissions.Abstractions/ ← Shared permission evaluator (realm:admin / :admin bypass tiers) ├── Modgud.Client.AspNetCore/ ← Published NuGet package: ASP.NET Core integration for resource servers ├── Modgud.Provisioning.TestKit/ ← Published NuGet package: throwaway realms for integration tests ├── Modgud.Api/ ← Minimal API endpoints, middleware, setup, SignalR hub ├── Modgud.Api.Tests/ ← Integration tests (Testcontainers + PostgreSQL) └── Common/ ← Shared utilities (PathHelper, Optional, ...) ``` ## Component diagram ```mermaid graph TB subgraph FrontEnd ["Frontend (Vue)"] SPA["Vue SPA + Pinia + SignalARRR client"] end subgraph Api ["Modgud.Api"] MW[RealmMiddleware] Endpoints[Minimal API endpoints
per feature in Features/] Hub[UIHub - SignalR] Setup[Bootstrap + master tenancy + seeding] end subgraph Slices ["Slices (reusable across apps)"] Authn[Modgud.Authentication
Login, 2FA, OIDC, GDPR] Authz[Modgud.Authorization
Groups, Roles, Permissions] end subgraph Infra ["Modgud.Infrastructure"] Tenancy[TenantedSessionFactory
+ MasterTableTenancy] OpenIddictStores[Marten OpenIddict stores
Application/Scope/Auth/Token] Realms[RealmCache + RealmProvisioning] IGlobalStore[IGlobalStore - Realm documents] end subgraph DataLayer ["Marten + PostgreSQL"] Master[(Master DB
+ realms.mt_tenant_databases
+ global schema)] TenantA[(_acme)] TenantB[(_finance)] end SPA <-->|Cookie + SignalR| MW MW --> Endpoints MW --> Hub Endpoints --> Authn Endpoints --> Authz Endpoints --> OpenIddictStores Authn --> Tenancy Authz --> Tenancy OpenIddictStores --> Tenancy Realms --> IGlobalStore Tenancy --> Master Tenancy --> TenantA Tenancy --> TenantB IGlobalStore --> Master Setup --> Master Setup --> Realms ``` ## Request lifecycle ``` Browser → ASP.NET Core ↓ UseRouting ↓ UseMiddleware ← sets HttpContext.Items["TenantId"] ↓ UseSession ↓ UseAuthentication ← cookie auth ↓ UseAuthorization ↓ UseMiddleware ← blocks users without 2FA at level ≥ 1 ↓ Endpoint routing ↓ Endpoint with RequiresPermission(...) ← per-resource gating ↓ Handler ↓ IDocumentSession ← TenantedSessionFactory reads TenantId ↓ Marten query against tenant DB ↓ Response ``` `TenantedSessionFactory` is registered as a Marten `ISessionFactory` (`AddMarten(...).BuildSessionsWith()`), so every `IDocumentSession`/`IQuerySession` injection is automatically tenant-scoped. ## Wolverine CQRS CQRS commands and queries are dispatched via Wolverine's `IMessageBus`: ```csharp var result = await _messageBus.InvokeAsync>( new CreateUserCommand(...)); ``` Handlers are auto-discovered. Modgud runs with `DurabilityMode.Solo` (in-memory, local) — no external message broker required. The Marten outbox is still active for event side-effects: SignalR notifications fire after `SaveChangesAsync` via `ProjectionSideEffects`. Codegen mode is environment-aware. Production runs `TypeLoadMode.Dynamic` — Wolverine/Marten handler classes are generated in memory on first use and never written to disk, so the container never tries to write into its read-only application directory. Local dev and tests run `TypeLoadMode.Auto`, which generates into `Internal/Generated/` on first boot and reuses it on the next. ## Marten usage Modgud uses three Marten patterns: ### 1. Document storage Classic Marten document store for ephemeral or security-sensitive data — no event sourcing. | Document | Contents | |---|---| | `ApplicationUser` | ASP.NET Identity user | | `UserSecurityData` | Password hash, TOTP key, recovery codes, passkey credentials | | `UserSession` | Active login session | | `EmailOtpChallenge`, `MagicLinkChallenge` | Ephemeral challenges | | `PasskeyCeremony`, `PasskeyEnrollCeremony` | Ephemeral, single-use passkey login/enrollment ceremony state (native/bearer flow only — the cookie-based web flow keeps its ceremony state in ASP.NET Core session) | | `OpenIddictAuthorizationDocument`, `OpenIddictTokenDocument` | OAuth tokens + authorizations | ### 2. Inline projections (`*State`) Synchronous within the `SaveChanges` transaction. Guarantee that the next read after a write sees the new state. Used for validation and identity stores. | Projection | What it holds | |---|---| | `OAuthApplicationState` | OpenIddict application state | | `OAuthScopeState` | OpenIddict scope state | | `OAuthApiState` | API resource state | | `LoginProvider` | Internal/external login provider config — applied directly onto the document by `LoginProviderProjection` (no separate aggregate/state split) | ### 3. Event-sourced aggregates OAuth domain aggregates are fully event-sourced via Marten: | Aggregate | Events | |---|---| | `OAuthApplicationAggregate` | Created, Updated, Deleted, Renamed, ... | | `OAuthScopeAggregate` | Created, ResourcesChanged, ... | | `OAuthApiAggregate` | Created, Updated, Scopes-Changed, ... | Login providers are event-sourced too, but skip the Aggregate+State split: `LoginProviderProjection` applies the events directly onto the `LoginProvider` document (see inline projections above). User events are emitted by the Authentication slice (`UserCreated`, `UserUpdated`, `UserPasswordChanged`, `UserLoggedIn`, ...). The slice itself stores identity through the `ApplicationUser` document; the events are kept separately for audit and for the `PrincipalProjection` (see Authorization slice). ## OpenIddict stores Modgud implements all four OpenIddict stores as Marten-backed stores, in `Modgud.Infrastructure/OpenIddict/`: | Store | Backing | |---|---| | `MartenApplicationStore` | `OAuthApplicationState` inline projection (event-sourced via aggregate) | | `MartenScopeStore` | `OAuthScopeState` inline projection (event-sourced via aggregate) | | `MartenAuthorizationStore` | `OpenIddictAuthorizationDocument` (direct storage) | | `MartenTokenStore` | `OpenIddictTokenDocument` (direct storage) | Plus two pipeline hooks: * `RealmIssuerHandler` — overwrites `context.Issuer` with the per-request `BaseUri` (= realm domain). This way every realm has its own discovery document. * `AccessTokenTypeHandler` — switches between reference tokens and JWT per client. ## Setup bootstrap `Program.cs` runs an explicit bootstrap path at startup (before `app.Run()`): 1. **Create the master DB and the `_system` DB** (raw SQL, because Marten can't `CREATE DATABASE` on its own connection) 2. **Apply Marten schema** (`Storage.ApplyAllConfiguredChangesToDatabaseAsync`) → `realms.mt_tenant_databases` is created 3. **Register system tenant** (`tenancy.AddDatabaseRecordAsync("system", systemCs)`) pointing at its own `_system` DB — the master DB is pure control-plane infra (registry + global Realm store + Wolverine durability) and holds no tenant content 4. **Apply Marten schema again** → per-tenant tables for the system realm, in its own DB 5. **Seed system realm document** (`EnsureSystemRealmExistsAsync`), stamped as the control plane 6. **Seed default OAuth scopes + internal login provider** (`OAuthRealmSeeder.SeedAsync`) 7. **Seed the system Apps** (`AppRealmSeeder.SeedAsync`), including the control-plane App, so App-scoped permissions resolve before the first realm is created 8. **Warm up RealmCache** Only after this does Kestrel start listening. ## Recovery CLI The Authentication slice ships a break-glass CLI. Instead of starting Kestrel, the image can run in the container with the `recover` subcommand: ```bash dotnet Modgud.Api.dll recover list dotnet Modgud.Api.dll recover reset-2fa dotnet Modgud.Api.dll recover set-email dotnet Modgud.Api.dll recover magic-link dotnet Modgud.Api.dll recover rebuild-projections dotnet Modgud.Api.dll recover control-plane list dotnet Modgud.Api.dll recover adopt-tenant [domain] ``` Helps with lockouts: all 2FA lost, no admin left, projection corrupted — all solvable via container exec. ## Frontend integration The Vue frontend lives at `src/frontend-vue/` and is served from the container as static `wwwroot/` content via `app.UseSpaUI()`. The SignalR hub is mounted at `/signalr/ui` (`MapHARRRController`). ## Testing Integration tests (`Modgud.Api.Tests`) use: * **Testcontainers** — PostgreSQL in Docker, started automatically on test runs * **WebApplicationFactory** — in-process hosting of the API with cookie auth * **Per-test-class DB isolation** — each test class gets its own DB * **Shared PostgreSQL container** — one container instance for all test collections, parallelised * **In-process test IdP** — external-login tests either construct principals directly or exercise a lightweight Kestrel-hosted stand-in OIDC server; no third-party mocking library is involved * **Wolverine/Marten codegen** (`TypeLoadMode.Auto`) — generated on the first boot into the test working tree and reused, so repeat runs skip Roslyn compilation --- --- url: /operate/database.md --- # Persistence (Marten) Modgud uses [Marten](https://martendb.io/) as a document DB and event store on top of PostgreSQL. Marten manages its own schema — no manual EF Core migrations. ## Multi-tenant setup Marten `MasterTableTenancy` with database-per-tenant. The master DB (convention: `modgud`) holds only control-plane infrastructure; each realm gets its own physical database named `_` — so `modgud_system` for the bootstrap system realm, then `modgud_` per realm. Details: [Multi-tenancy / Realms](/operate/realms). ## Schema management Marten runs with `AutoCreate.CreateOrUpdate`. On boot: ```csharp await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); ``` That creates or updates all tables, indexes, functions and projection tables. After a code change to documents/aggregates: just restart — Marten detects the schema drift and applies it. ::: warning Development vs production In production you should set `AutoCreate.None` and apply schema changes explicitly via `await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync()` in a controlled migration phase — otherwise a multi-pod deployment race-conditions on schema apply. ::: ## Three Marten patterns ### 1. Document storage Classic Marten document store for ephemeral or security-sensitive data — no event sourcing. | Document | Contents | Indexes | |---|---|---| | `ApplicationUser` | ASP.NET Identity user | `NormalizedUserName` (unique), `NormalizedEmail` | | `UserSecurityData` | Password hash, TOTP key, recovery codes, passkey credentials | Same id as the user | | `UserSession` | Active session tracking (UAParser) | `UserId`, `LastActiveAt` | | `EmailOtpChallenge` | 6-digit OTP hash + expiry | `UserId` | | `MagicLinkChallenge` | Token hash + expiry | `UserId` | | `PasskeyCeremony`, `PasskeyEnrollCeremony` | Single-use passkey login/enrollment ceremony state (native/bearer flow only — the cookie-based web flow keeps its ceremony state in ASP.NET Core session) | TTL ~5 min | | `OpenIddictAuthorizationDocument` | OAuth consent records | `ApplicationId`, `Subject` | | `OpenIddictTokenDocument` | Reference tokens, refresh tokens | `ApplicationId`, `Subject`, `ReferenceId` | | `SecurityAuditEntry` | Streamless security / ops events (unknown-actor logins, probes, rate-limit hits, recovery-CLI actions). Lives in the **system DB**, attributed to a realm via `Realm`; short hard-retention prune (no per-subject erase) | `Realm`, `Timestamp` | | `UserDeletionState` | GDPR delete workflow state | `UserId` | | `UserChangeRequest` | Profile self-service pending changes | Per `(UserId, Type)` | | `Principal` (polymorphic) | Person + Group + ServiceAccount | `mt_doc_type` discriminator | | `PermissionRole` | RBAC role definitions | Per realm | | `RealmSettings` | Realm-admin-owned config (self-registration, rate-limit overrides, branding, required-identity-fields, ...) | Singleton per tenant | | `ApplicationSettings` | Per-App config overrides, merged field-by-field over `RealmSettings` | One per App | | `RegistrationInviteCode` | Single-use registration invite code (invite-code-gated self-registration) | `AppId`, code hash | | `PendingAdminInvite` | One-shot invite for the first admin in a freshly provisioned realm | Token hash | | `Realm` (in `IGlobalStore`) | Tenant metadata in master DB | Schema `global` | ### 2. Inline projections (`*State`) Synchronous within the `SaveChanges` transaction. Guarantee that the next read after a write sees the new state. Used for validation and for the OpenIddict stores. | Projection | Aggregate | Used by | |---|---|---| | `OAuthApplicationStateProjection` → `OAuthApplicationState` | `OAuthApplicationAggregate` | `MartenApplicationStore` (OpenIddict) | | `OAuthScopeStateProjection` → `OAuthScopeState` | `OAuthScopeAggregate` | `MartenScopeStore` (OpenIddict) | | `OAuthApiStateProjection` → `OAuthApiState` | `OAuthApiAggregate` | API resource management | | `LoginProviderProjection` → `LoginProvider` | (no separate aggregate — events apply directly onto the document) | Login provider resolution | | `PrincipalProjectionBase` → `Principal` (polymorphic) | abstract — app extension | Authorization slice | | `PermissionRoleProjection` | Permission role aggregate | Authorization slice | | `ExternalIdentityLinkProjection` | (no aggregate, plain doc apply) | OIDC login | ### 3. Async read models (`*ListReadModel`, `*DetailsReadModel`) Async projections running in a background daemon (`DaemonMode.HotCold`); denormalised views for API responses. In tests they run inline for deterministic behaviour. | Projection | Purpose | |---|---| | `UserListReadModel` | Admin user grid | | `UserDetailsReadModel` | Admin user details | | `GroupListReadModel`, `GroupDetailsReadModel` | Admin group views | | `RoleListReadModel` | Admin role grid | | `AuthAuditViewProjection` → `AuthAuditView` | Per-realm tenant audit feed — one metadata-only row per audited event, projected from the user- and config-aggregate streams. Rebuildable; inherits GDPR masking from the source events | ## Event-stream example User lifecycle (written by the Authentication slice): ``` Stream: v1: UserCreatedEvent { Id, Firstname, Lastname, Acronym, Email } v2: UserPasswordChangedEvent { UserId } v3: UserLoggedInEvent { UserId, IpAddress, Method } v4: UserProfileUpdatedEvent { UserId, Firstname, Lastname, Acronym } v5: UserLoggedInEvent { UserId, IpAddress, Method } ... ``` `PrincipalProjectionBase` (abstract) consumes these events and writes them into the `mt_doc_principal` table as the `Person` subclass. That's the bridge to the Authorization slice: the slice needs `Person` records for email routing and membership predicates, the app fills them from the events. ## Security data separation **Security-sensitive data does NOT land in the event stream.** Instead of `UserPasswordChanged(UserId, NewPasswordHash)` there's `UserPasswordChanged(UserId)` and the hash is written in parallel into `UserSecurityData` (plain document, same id). Same approach for: | Data | Where | |---|---| | Password hash | `UserSecurityData.PasswordHash` | | TOTP authenticator key | `UserSecurityData.AuthenticatorKey` | | Recovery codes | `UserSecurityData.RecoveryCodes` | | Passkey credentials (public key, sign count) | `StoredPasskeyCredential` (separate doc, per user) | | OIDC login-provider client secret | `LoginProvider.ClientSecretEncrypted` (encrypted at rest, inline on the document — not event-sourced) | The benefit: GDPR erase and stream replay are safe — no re-applying of masked hashes. ## Indexes and filtered unique constraints Soft-delete is everywhere, but only the email address is reusable immediately after a soft-delete — the username stays reserved by a plain unique index until the account is permanently erased. Solution for email: a **filtered unique index** using a PostgreSQL partial index: ```csharp schema.For() .UniqueIndex(x => x.NormalizedUserName) // plain unique — reserved even after soft-delete .Index(x => x.NormalizedEmail, idx => { idx.IsUnique = true; idx.Predicate = "(data ->> 'NormalizedEmail') IS NOT NULL " + "AND COALESCE((data ->> 'IsDeleted')::boolean, false) = false"; }); ``` In SQL: ```sql CREATE UNIQUE INDEX ... ON mt_doc_applicationuser ((data ->> 'NormalizedEmail')) WHERE (data ->> 'NormalizedEmail') IS NOT NULL AND COALESCE((data ->> 'IsDeleted')::boolean, false) = false; ``` This way a soft-deleted user's email can be claimed by a new signup right away, without colliding with active users — while the username remains reserved until permanent erase. ## GDPR via Marten ### Data masking ```csharp options.Events.AddMaskingRuleForProtectedInformation(e => new UserCreatedEvent(e.Id, new Optional("[DELETED]"), new Optional("[DELETED]"), new Optional("[DELETED]"), new Optional("[DELETED]"))); options.Events.AddMaskingRuleForProtectedInformation(e => new UserLoggedInEvent(e.UserId, IpAddress: null, e.Method)); ``` Only takes effect when the stream is **archived** (`ArchiveStream`) — live events are not touched. ### Stream archival In the GDPR confirm-delete flow: ```csharp session.Events.ArchiveStream(userId); await session.SaveChangesAsync(); // Archived events are gone from normal read-model queries. // Compliance queries (Events.QueryAllRawEvents()) still see them — masked. ``` ## Serialization Marten is configured with `System.Text.Json`: ```csharp options.UseSystemTextJsonForSerialization(configure: o => { o.PropertyNamingPolicy = null; // Exact property names — no camelCase o.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; o.Converters.Add(new JsonStringEnumConverter()); }); ``` Enums are stored as strings (readable in the DB inspector). ## Important tables per tenant DB | Table | Contents | |---|---| | `mt_events` | Event store (all domain events, JSON data) | | `mt_streams` | Stream metadata (aggregate id, version, type) | | `mt_doc_applicationuser` | Identity user documents | | `mt_doc_usersecuritydata` | Password hashes, TOTP keys etc. | | `mt_doc_principal` | Polymorphic: Person + Group + ServiceAccount | | `mt_doc_permissionrole` | RBAC roles | | `mt_doc_oauthapplicationstate` | OpenIddict application inline projection | | `mt_doc_oauthscopestate` | OpenIddict scope inline projection | | `mt_doc_oauthapistate` | API resource inline projection | | `mt_doc_loginprovider` | Login provider config (inline projection) | | `mt_doc_openiddicttokendocument` | Reference tokens, refresh tokens | | `mt_doc_openiddictauthorizationdocument` | OAuth authorizations (consent records) | | `mt_doc_realmsettings` | Realm-admin-owned config | | `mt_doc_applicationsettings` | Per-App config overrides | | `mt_doc_auth_audit_view` | Per-realm tenant audit feed (`AuthAuditView` projection — metadata only) | | `mt_doc_usersession` | Active sessions | In the master DB additionally: | Table | Contents | |---|---| | `realms.mt_tenant_databases` | Marten tenant registry | | `global.mt_doc_realm` | Realm documents | In the system DB (`_system`) additionally: | Table | Contents | |---|---| | `mt_doc_security_audit_entry` | Cross-realm streamless security / ops audit (`SecurityAuditEntry` — short hard-retention prune) | ## Backing up realms Modgud does not ship a backup scheduler — use your existing PostgreSQL backup tooling (`pg_dump`, `pg_basebackup`, a managed Postgres provider's snapshot feature, WAL-archiving, whatever your operations team already runs). What's specific to Modgud is *what* to back up: * the **master DB** (convention: `modgud`) — control-plane infra: the tenant registry (`realms.mt_tenant_databases`) and the global Realm store (`global.mt_doc_realm`); * **`_system`** — the bootstrap system realm, including its users and its share of the cross-realm security audit (`mt_doc_security_audit_entry`); * **every `_` DB** — one per realm. Because each realm is a physically separate database, backup and restore granularity falls out of the schema for free: `pg_dump` a single `_` database to back up (or restore) just that tenant, without touching any other realm's data. A minimal per-database dump loop: ```bash for db in modgud modgud_system modgud_acme modgud_finance; do pg_dump -Fc -h localhost -U postgres "$db" > "${db}_$(date -u +%Y%m%dT%H%M%SZ).dump" done ``` Restore is the mirror image — a standard `pg_restore` (or `psql < dump.sql`) into a freshly created database of the same name, for the realm database(s) plus the master and system databases. That covers the data plane; see the notes below for consistency before restoring more than a single realm. On next boot the app reconnects, finds its schema and data already there, and the [database auto-provisioning](./deployment#database-auto-provisioning) sequence is a no-op for anything that already exists. Key material is split across two places, both of which need their own backup coverage: * **Per-realm RSA signing keys and DataProtection keys live in Postgres** (in the tenant DB), so they're already covered by the per-database dumps above and survive a restore the same way the rest of the realm's data does. * **The OpenIddict signing/encryption certificates** (`signing.pfx`, `encryption.pfx`) live on disk, not in the database — see [OpenIddict signing + encryption certificates](./deployment#minimum-env-vars) and the `cocoar-keys` volume in the [Docker Compose reference](./deployment#docker-compose-canonical-production-reference). Back up that volume too, or accept that losing it invalidates live refresh tokens and authorization codes (a new cert auto-generates on next boot if the file is missing — it just isn't the *same* cert). There is no Modgud-native scheduling, verification, or point-in-time-recovery layer on top of this — it's standard Postgres operations against a schema that happens to make per-tenant isolation easy. See the [roadmap](../roadmap#deliberate-non-goals) for why this is a deliberate scope decision rather than a gap. ### Consistency and restore notes * **Per-database dumps taken at different times can disagree** — e.g. the master's tenant registry (`realms.mt_tenant_databases`) can reference a realm DB that was created (or dropped) between two dump runs in the loop above. For a single-realm restore that's usually fine — you're restoring one `_` DB back to a known point, independent of the others. For a **full-instance restore**, prefer a consistent point in time across every database: a filesystem/provider snapshot that covers all of them atomically, or `pg_dump` runs taken while Modgud is stopped. * **Restore with the same Modgud version that produced the backup**, then upgrade afterwards — don't restore an older backup directly into a newer version's schema expectations. * **The signing/encryption PFX volume must match the database state it's restored alongside.** A mismatched pair (old keys against new data, or vice versa) invalidates live authorization codes and refresh tokens — see [Key material](./key-material) for what's bound to what. * **A backup only counts once a restore of it has been tested.** An untested dump is a hope, not a backup. --- --- url: /operate/realms.md --- # Multi-tenancy / Realms Modgud uses a **realm model** for multi-tenancy. Each realm is a fully autonomous Identity Provider with its own database, users, roles, OAuth configuration, and login providers. ::: info "Realm" vs. "tenant" User-facing it's called **realm** everywhere (UI, docs). The code uses **tenant** in the infrastructure layer (`TenantId`, `ITenantSessionFactory`, `MasterTableTenancy`), because that's what Marten/Wolverine call it. `TenantId` = realm slug. ::: ## Domain-based routing Realms are identified by the **Host header**, not by URL path. Each realm has one or more configured domains: | Hostname | Realm | |---|---| | `system.example.com` | System realm | | `acme.example.com` | Acme realm | | `auth.acme.example.com` | Acme realm (second domain) | | `localhost` (dev, single-realm) | System realm (single-tenant fallback) | `RealmMiddleware` (`src/dotnet/Modgud.Api/Middleware/RealmMiddleware.cs`) runs as the very first middleware: ```csharp public async Task InvokeAsync(HttpContext context) { var path = context.Request.Path.Value; if (SkipPaths.Any(p => path.StartsWith(p))) { await _next(context); return; } var hostname = context.Request.Host.Host; var resolution = await _realmCache.ResolveAsync(hostname); if (resolution is null) { context.Response.StatusCode = 404; return; } var tenantInfo = resolution.Tenant; context.Items[TenantConstants.HttpContextTenantIdKey] = tenantInfo.Slug; context.Items[TenantConstants.HttpContextTenantInfoKey] = tenantInfo; // Set only when the host is an Application's own subdomain (see // "Applications and domain routing" below). if (resolution.ApplicationId is { } applicationId) context.Items[TenantConstants.HttpContextApplicationIdKey] = applicationId; // Ambient AsyncLocal so code without an HttpContext (background services, // Wolverine handlers) can still see which realm is active; restored when // the request scope unwinds. using var _ = TenantContext.Enter(tenantInfo.Slug); await _next(context); } ``` Skip paths: `/health`, `/swagger`, `/openapi`, `/_framework` — these run without realm context. `/signalr` is deliberately **not** skipped: SignalR connections still need a resolved realm so the auth cookie (encrypted with that realm's own keys) can be decrypted on `/signalr/*/negotiate`. ### Single-tenant fallback in dev If only **one** realm is active AND the host is a localhost variant (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`), the cache returns that realm — even if it doesn't list the localhost domain. This way a single-realm dev boot works without a hosts-file entry. ### Primary domain While a realm may route from several domains, exactly one of them is its **PrimaryDomain** — the canonical public host. Any host in `Domains` resolves the realm for *inbound* requests, but the PrimaryDomain is what Modgud uses whenever it has to *emit* a host: magic-link and bootstrap-invite URLs, and the **WebAuthn relying-party ID** that binds passkeys. A realm always has a PrimaryDomain (it defaults to the first domain at creation) and it must be one of `Domains`. Re-point it from the admin UI's domain picker or via the [Recovery CLI](recovery-cli) `realm-set-primary-domain`; because it is the passkey RP ID, changing it invalidates every passkey in the realm. ### Applications and domain routing A realm can also give one of its [Applications](../admin/applications) its own subdomain (e.g. `billing.acme.example.com`). Resolving that host still lands on the realm — same tenant DB, same user pool, same OIDC issuer — but the middleware additionally pins which Application the request is for, so app-specific branding and login-experience settings apply. This is a routing refinement layered on top of the realm/domain mechanism above, not a second isolation boundary. ## RealmCache `RealmCache` (`Modgud.Infrastructure/Realms/RealmCache.cs`) holds a snapshot of the domain → realm mappings in memory: ```csharp private sealed record CacheSnapshot( ConcurrentDictionary ByDomain, ConcurrentDictionary ByApplicationDomain, TenantInfo? SingleActiveRealm, DateTimeOffset LoadedAt); ``` Loads all active realms from `IGlobalStore` (see below) at startup. Invalidated on realm CUD (Create/Update/Delete via the admin API), and also revalidated on a 60-second timer regardless — so a change made on another node of a multi-node deployment is picked up within that window even without a cross-node cache invalidation. ## Database-per-tenant via Marten Modgud uses Marten's `MasterTableTenancy`: ```mermaid graph TD subgraph Master["Master DB () — pure control-plane infra"] Tenancy["Schema: realms
realms.mt_tenant_databases"] GlobalSchema["Schema: global
(Realm documents)"] end subgraph System["_system"] SystemData["System realm data"] end subgraph Acme["_acme"] AcmeData["Acme tenant data"] end subgraph Finance["_finance"] FinanceData["Finance tenant data"] end Tenancy -.->|Lookup| System Tenancy -.->|Lookup| Acme Tenancy -.->|Lookup| Finance ``` | Database | Contents | |---|---| | `` (master) | `realms.mt_tenant_databases` (tenant registry) + schema `global` (Realm documents) + Wolverine durability — pure control-plane infra, **not** a tenant | | `_` | A dedicated physical DB per realm, including the bootstrap `system` realm (`_system`) | The master DB holds **no** tenant data. Every realm — including the bootstrap `system` realm — lives in its own `_` database, so the system realm is an equal, deletable peer and the [control plane](../concepts/control-plane) can be transferred off it. ## TenantedSessionFactory A Marten `ISessionFactory` implementation (`Modgud.Infrastructure/Persistence/Tenancy/TenantedSessionFactory.cs`) that reads the `TenantId` from `HttpContext.Items`: ```csharp public IDocumentSession OpenSession() => _store.LightweightSession(ResolveTenantId(forWrite: true)); public IQuerySession OpenQuerySession() => _store.QuerySession(ResolveTenantId(forWrite: false)); private string ResolveTenantId(bool forWrite) { var explicitTenant = TenantContext.CurrentOrNull ?? _httpContextAccessor.HttpContext? .Items[TenantConstants.HttpContextTenantIdKey] as string; return explicitTenant ?? FallbackTenantId(forWrite); } ``` An ambient `TenantContext.CurrentOrNull` (set by `RealmMiddleware`, or explicitly via `TenantContext.Enter(...)` for a deliberate cross-realm operation) is checked before `HttpContext.Items`, which carries the same value on the common request path. Wired up via: ```csharp builder.Services.AddMarten(...) .BuildSessionsWith(); ``` This way every `IDocumentSession`/`IQuerySession` injection is automatically realm-scoped. When neither signal resolves a tenant, the fallback splits by intent: * **No `HttpContext` at all** (background service, hosted service, CLI, test) — falls back to the system tenant. This is the intended, load-bearing path for infrastructure jobs and single-realm boots. * **An in-flight HTTP request with no resolved realm** — this can only happen on a realm-agnostic skip-path (`/health`, `/openapi`, …), since every routed request is resolved or 404'd by `RealmMiddleware`. A **write** in that state throws instead of silently landing in the system tenant's database; a **read** falls back to the system tenant with a warning logged. ## IGlobalStore The `Realm` document itself can't live in the tenant store — chicken-and-egg. It lives in a separate Marten store (`IGlobalStore`) against schema `global` of the master DB: ```csharp public sealed record TenantInfo(string Slug, bool IsControlPlane, bool IsActive, string? PrimaryDomain = null); public class Realm { public Guid Id { get; set; } public string Slug { get; set; } // = TenantId, immutable, reserved if "system" public string DisplayName { get; set; } public string? Description { get; set; } public string[] Domains { get; set; } // ["acme.example.com", ...] public string PrimaryDomain { get; set; } // must be one of Domains — see "Primary domain" above public Dictionary ApplicationDomains { get; set; } // subdomain -> Application id // Stored, transferable: exactly one realm carries the flag. The // bootstrap "system" realm is stamped at first boot, but the role // can be moved to any active realm. public bool IsControlPlane { get; set; } public bool IsActive { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; } } ``` `RealmCache` loads the realm list from `IGlobalStore`. ## Bootstrap order In `Program.cs` (before `app.Run`): 1. **Create the master DB and `_system`** (raw SQL) 2. **Apply Marten storage** → `realms.mt_tenant_databases` is created 3. **Register the system tenant in the tenancy table** (`tenancy.AddDatabaseRecordAsync("system", systemCs)` — pointing at `_system`, not the master DB) 4. **Apply Marten storage again** → the system tenant gets per-tenant tables in its own DB 5. **Seed system realm document** (`EnsureSystemRealmExistsAsync`) 6. **OAuthRealmSeeder** seeds 6 default scopes (`openid`, `email`, `profile`, `roles`, `offline_access`, `permissions`) + internal LoginProvider into the system tenant 7. **Warm up RealmCache** 8. **Check the recovery-CLI path** or start Kestrel ::: warning Upgrading across the system-DB split Older deployments ran the system realm **inside** the master DB. This version moves it to its own `_system` database. On a fresh install — or when you can recreate data — nothing is needed; the boot block provisions the new layout. For an existing deployment with data you must relocate the system realm's data **before** first boot: 1. Stop the app and terminate open connections, then `CREATE DATABASE "_system" TEMPLATE "";` 2. Repoint the registry **before** the first boot (a boot-time self-correction is not single-boot-safe — writes in the stale window strand in the master DB): `UPDATE realms.mt_tenant_databases SET connection_string = replace(connection_string, 'Database=;', 'Database=_system;') WHERE tenant_id = 'system';` 3. Deploy the new code; verify the system realm resolves and data is intact. 4. Optionally clean the now-duplicated `mt_*` tables out of the master DB (table-precise — do **not** `DROP SCHEMA public`, it is shared with Wolverine infra). ::: ## Realm CRUD Endpoints under `/api/admin/realms` — gated by `realm:read` / `realm:write` (catalog entries in the `control-plane` App, which is only seeded on the Control-Plane realm). Only reachable on the **Control-Plane realm** (the realm holding the control-plane flag). On any other host: 404 (the existence of the surface is hidden from tenant realms — see [Concepts: Control Plane](../concepts/control-plane)). ### Create `POST` requires an `InitialAdmin` payload — a realm with no admin path would be unreachable. ```http POST /api/admin/realms { "Slug": "acme", "DisplayName": "Acme Corp", "Domains": ["acme.example.com"], "InitialAdmin": { "UserName": "max", "Email": "max@acme.com" } } ``` `IsControlPlane` is **not** in the request body — new realms are never the control plane. The role only moves via [transfer](../concepts/control-plane#transferring-the-control-plane). Backend: 1. Validates `slug` (regex, reserved-words check). 2. `CREATE DATABASE _acme` (raw SQL). 3. `tenancy.AddDatabaseRecordAsync("acme", connStringForAcme)`. 4. `Storage.ApplyAllConfiguredChangesToDatabaseAsync()`. 5. **`OAuthRealmSeeder`** seeds 6 default scopes + the Internal login provider into the new tenant DB. 6. **`AppRealmSeeder`** seeds the `modgud` app. The `control-plane` app is **only** seeded when the new realm is itself the Control Plane. 7. `Realm` document persisted in `IGlobalStore`. 8. `RealmCache.Invalidate()`. 9. **Bootstrap-invite** issued atomically: a `PendingAdminInvite` is written into the new tenant DB, the magic-link email is sent, and the URL is returned in the response (`InitialAdminInvite.MagicLinkUrl`). The recipient consumes the invite at `POST /api/account/bootstrap-admin` on the new realm's host (anonymous, rate-limited under `bootstrap`), sets a password, gets auto-signed-in. Atomic with that consume, `RealmAdminBootstrapper` creates the user, seeds the three default `PermissionRole`s (System Admin / User Manager / Viewer) and adds the user to the `Administrators` group with `realm:admin`. If the invite link gets lost or expires before it's consumed, `POST /api/admin/realms/{slug}/resend-bootstrap-invite` revokes the previous invite and issues a fresh one for the same recipient, with a new 7-day expiry. ### Update ```http PATCH /api/admin/realms/{slug} { "displayName": "Acme Corporation", "domains": ["acme.example.com", "auth.acme.com"] } ``` `Slug` is immutable. ### Soft-delete (deactivate) ```http PATCH /api/admin/realms/{slug} { "isActive": false } ``` `RealmCache` filters on `IsActive = true` — all requests to the realm domain land on `404`. Data is preserved. ::: danger System realm The system realm cannot be deactivated — the endpoint blocks that. ::: ### Hard-delete ```http DELETE /api/admin/realms/{slug}?hard=true ``` Escalates from the reversible soft-delete above to a destructive delete that drops the realm's tenant database. Refused for the Control-Plane realm. Without `?hard=true`, `DELETE` behaves the same as the soft-delete (`isActive = false`). ### Declarative provisioning (import / apply / export) Beyond the one-field-at-a-time Create/Update above, the same `/api/admin/realms` group also accepts a **manifest** — a single JSON document describing a realm's apps, OAuth clients/scopes/APIs, roles, users and groups: * `POST /import` — create a brand-new realm from a manifest. * `POST /{slug}/apply` (optionally `?prune=true` for a full sync that also removes anything absent from the manifest) — apply a manifest to an existing realm in place. * `GET /{slug}/export` — export a realm's current shape as a manifest. * `GET /manifest-schema` — the manifest's JSON Schema. See [Declarative Realm Provisioning](../admin/realm-provisioning) for the full walkthrough. ## Cookies and sessions in a multi-realm setup Since each realm has its own domain, cookies are automatically realm-isolated by the browser's cookie-domain rule. A login on `acme.example.com` sets a cookie for exactly that domain — it isn't sent on `finance.example.com`. No path acrobatics required. Sessions (`UserSession` documents) live per realm in the tenant store. A user logged in to two realms has two separate sessions, in two separate DBs. --- --- url: /operate/observability.md --- # Observability OpenTelemetry-based metrics + tracing + an in-app live activity view. Modgud emits a dedicated `Modgud` meter for IdP-domain events (logins, token minting, DCR, GDPR, 2FA enforcement, realm provisioning) on top of the standard ASP.NET Core instrumentation. Metrics go out via a Prometheus scrape endpoint; both metrics and traces can also push to an OTLP collector. ::: warning `/metrics` is sensitive — gate it The Prometheus scrape endpoint is **not** an admin-permissioned API — it lives outside the cookie-auth pipeline so Prometheus servers (which have no cookies) can reach it. Gate it via a **bearer token** (built in) plus a reverse-proxy / firewall that keeps it off the public internet. The boot-validator refuses to start the API if Prometheus is enabled and the bearer token is empty when `ASPNETCORE_ENVIRONMENT` is `Production`. Any other environment (Development, Staging, etc.) is not gated by this check. ::: Permissions for the in-app live view: `observability:read`. The `realm:admin` bypass grants it. ## Surfaces | Surface | Path | Auth | | --- | --- | --- | | Prometheus scrape | `/metrics` (default) | Static **bearer token** — set via `Observability__Prometheus__BearerToken`. Mismatch returns 404 (not 401) so the endpoint's existence stays unconfirmed. Constant-time compare. | | OTLP push (metrics + traces) | configurable endpoint (default `http://127.0.0.1:4317`) | Whatever the collector requires. Off by default; turn on when you actually have a collector (Tempo, Honeycomb, …). | | OTLP **log** export | same OTLP endpoint | Off by default — **same** `Observability__Otlp__Enabled` gate. Logs go through an OTel Collector whose redaction processor strips PII before OpenObserve. See [Logs — export & redaction](#logs-export-redaction). | | In-app live view | `/platform/observability` (Admin SPA) | Cookie auth + `observability:read`. Realm-scoped — each admin sees only their own realm. | | REST snapshot | `GET /api/admin/observability/snapshot?windowMinutes=15` | Same as in-app view. Returns event-type counts, login outcome breakdown, per-minute sparkline. | | REST activity feed | `GET /api/admin/observability/activity?limit=50` | Same. Most-recent first, last 60 min, capped at 200. | | REST error feed | `GET /api/admin/observability/errors?limit=50` | Same. Recent operational errors (warnings/errors logged by the app) for the caller's realm, newest first. | | Live push (SignalR) | `ObservabilityHub.Subscribe()` | Same. Streams new events for the subscriber's realm. The in-app view uses this — no polling. | | Live error push (SignalR) | `ObservabilityHub.LogsSubscribe()` | Same. Streams new operational-error entries for the subscriber's realm as they're logged. | ## Configuration `AppSettings` section `Observability` (in `configuration.json` or `configuration.local.json`, with ENV overrides — remember **PascalCase**, `Observability__Prometheus__BearerToken` not all-caps). ```jsonc "Observability": { "ServiceName": "modgud", // resource attribute on every exported metric/span "SamplingRatio": 1.0, // 0.0–1.0; lower in prod to keep trace volume sane "Prometheus": { "Enabled": true, // default on "Path": "/metrics", // scrape path "BearerToken": "" // REQUIRED in Production; empty = boot fails }, "Otlp": { "Enabled": false, // default off — gates metrics, traces AND logs "Endpoint": "http://127.0.0.1:4317", // gRPC by default (127.0.0.1, not localhost — see note) "Protocol": "Grpc" // or "HttpProtobuf" }, "ErrorFeed": { "Enabled": true, // default on — captures into the per-realm live error feed "MinimumLevel": "Error", // minimum Serilog level captured "SourcePrefix": "Modgud", // only loggers whose SourceContext starts with this feed the buffer "CapacityPerRealm": 100 // bounded ring buffer size per realm } } ``` `ErrorFeed` powers the "Recent errors" panel and `LogsSubscribe()` stream in the in-app live view — it's local-only (an in-memory buffer plus the SignalR hub), so it works independently of `Otlp.Enabled` and needs no collector. ::: tip One gate for all three signals `Otlp.Enabled` turns on metrics, traces **and** log export together — there is no separate logs flag by design. With it off, Serilog stays Console + File and nothing leaves the box; no collector / OpenObserve is required. Use a bare base `host:port` endpoint for either protocol — the log sink derives the per-signal path itself (and trims a `/v1/logs` suffix if you add one). ::: ::: warning Plaintext / local collectors Against a **plaintext `http://`** collector the metrics/traces exporters speak HTTP/2 cleartext (h2c), which the app enables automatically for `http://` endpoints (`Http2UnencryptedSupport`). Two gotchas for a **local** collector: prefer **`127.0.0.1`** over `localhost` (a `localhost` → IPv6 `::1` resolution can hang the exporter against an IPv4-only Docker port map until the 10 s export timeout), and remember the export is best-effort — a wrong endpoint drops telemetry silently. A production collector should use **TLS (`https://`)**, which negotiates HTTP/2 natively and needs none of this. ::: ::: tip Set the bearer in env, not in the JSON The committed `configuration.json` ships with an empty `BearerToken` on purpose — so secrets don't land in source control. Production deployments must set `Observability__Prometheus__BearerToken=` in the container's environment. ::: ## Prometheus scrape config Prometheus needs to send the bearer token on every scrape. Two equivalent shapes: ```yaml # prometheus.yml — inline credentials scrape_configs: - job_name: modgud metrics_path: /metrics bearer_token: static_configs: - targets: ['modgud.internal:8081'] ``` ```yaml # prometheus.yml — file-mounted secret scrape_configs: - job_name: modgud metrics_path: /metrics bearer_token_file: /run/secrets/modgud_metrics_token static_configs: - targets: ['modgud.internal:8081'] ``` The mismatch-returns-404 behaviour means a misconfigured scrape job looks identical to "endpoint doesn't exist" — which is correct, both should be triaged the same way. ## What's emitted (the `Modgud` meter) All counters; tag keys listed; cardinality is bounded by design (realm count + finite outcome / type sets — no user-controlled strings ever land in a tag). | Metric | Tags | Counts | | --- | --- | --- | | `modgud.logins.total` | `realm`, `method`, `outcome` | Login attempts. `method` ∈ {password, magic\_link, passkey, mfa, email\_otp, external}; `outcome` ∈ {success, failure, locked, 2fa\_required, requires\_setup}. | | `modgud.token.minted.total` | `realm`, `grant_type`, `client_type` | OAuth/OIDC tokens issued. `client_type` ∈ {confidential, public, dcr, cimd}. | | `modgud.token.refresh.rejected.total` | `realm` | Refresh-token grant rejected (reuse-detection / expired / revoked — OpenIddict 7 doesn't separate them). Spikes worth alerting on. | | `modgud.two_factor.enforcement.blocked.total` | `realm` | Requests blocked by the 2FA enforcement middleware after grace expiry. | | `modgud.dcr.registration.total` | `realm`, `outcome` | Dynamic-client-registration attempts. `outcome` ∈ {success, rate\_limited, policy\_denied, invalid\_request}. | | `modgud.dcr.rate_limit.hit.total` | `realm`, `scope` | Rate-limit hits during DCR. `scope` ∈ {realm, client}. | | `modgud.realm.provisioned.total` | — | Realms provisioned. | | `modgud.gdpr.request.total` | `realm`, `type` | GDPR self-service requests. `type` ∈ {export, delete, mask}. | In addition to the IdP-domain meter, the standard ASP.NET Core, HTTP-client, and runtime instrumentations are on — so HTTP server timings, GC pressure, thread-pool depth, etc. land in `/metrics` automatically. ## Alerts worth wiring A baseline for owner-operator deployments (you can refine later): * **Login failure rate spike** — derived rate of `modgud.logins.total{outcome="failure"}` vs `outcome="success"`. Sustained imbalance for several minutes suggests brute-force or a broken upstream. * **Refresh-token rejection spike** — `modgud.token.refresh.rejected.total`. Baseline is non-zero (legitimate expiry); spikes above baseline are the signal. * **DCR rate-limit hits** — `modgud.dcr.rate_limit.hit.total` going up means someone is trying to spray new clients. Sometimes legitimate (an MCP integration onboarding), sometimes not. * **Instance down** — Prometheus's own `up{job="modgud"} == 0`. Pairs with an external uptime probe to catch the case where the whole box is gone. ## In-app live view `/platform/observability` shows: * **Headline counters** for the rolling window (default 15 min; selector for 1–60). * **Login outcome breakdown** — success vs failure vs locked vs 2fa-required. * **Per-minute sparkline** of login attempts. * **Live activity feed** — every event the meter emits, newest first, streamed via SignalR. The page subscribes once at mount and updates in real time; no polling. * **Recent errors** — a live feed of application warnings/errors logged for the realm (see the error-feed configuration below), newest first, also streamed via SignalR. Each realm-admin sees only their own realm. The cross-realm aggregate ("global-ops view") is a planned follow-up. ## Tracing When `Otlp.Enabled = true`, OpenIddict-token-issuance, ASP.NET request handling, and HTTP-client outbound calls each emit spans with the `service.name` resource attribute. Trace context propagates standard W3C `traceparent` headers, so spans from your downstream APIs (resource servers, MCP servers) reconnect to the auth-server span automatically. `SamplingRatio` controls how much survives. Default 1.0 is fine for dev; production with traffic should drop it to keep trace volume sane (0.1 is a reasonable starting point). ## Logs — export & redaction {#logs-export-redaction} Logs are the third OTel signal. Serilog stays the in-process logger (Console + File); when `Otlp.Enabled = true` an OTLP sink **also** ships every log record to the OTLP endpoint. Records are **realm-tagged** (the `Realm` property from the realm enricher, `system` for background work) and **trace-correlated** (the active `trace_id`/`span_id` ride along), so a log line in the backend links straight to its request span and is filterable per realm. The destination is **[OpenObserve](https://openobserve.ai/)**, reached through an **OpenTelemetry Collector** that sits between the app and the backend. ::: danger The redaction guarantee lives at the collector PII (emails, JWTs, `Bearer`/`Basic` credentials, IPv4/IPv6 addresses, and usernames) is stripped by a **transform/OTTL processor in the collector**, not by the app. This is deliberate: it is a *pipeline guarantee* that holds even if a call site forgets to mask. The app-side `LogPiiMasking.MaskEmail` stays as a **belt** (defense in depth) but is no longer the thing correctness depends on. The processor only redacts the log **body** and top-level string **attribute values** — resource attributes (`service.version`, …) are left alone so e.g. a version `1.0.0.0` isn't mistaken for an IP. The exact field set is **versioned** (`redaction-ruleset: v2`) in [`docker/otel-collector/otel-collector-config.yaml`](https://github.com/cocoar-dev/modgud/blob/develop/docker/otel-collector/otel-collector-config.yaml) and pinned by an end-to-end test (`OtelLogsRedactionTests`) that runs a real collector and asserts PII is gone before export. **If you fork the ruleset, bump the version and re-run that test.** Two limits worth knowing, both because the targeted values have no machine-recognisable shape: a **username inlined into free-text prose** other than the `User=` form, and a **nested/destructured (`{@…}`) attribute value**, are out of the collector's reach — log `user.Id` (a GUID) instead of the login identifier, and don't destructure objects that may carry PII. The username **attribute** (`UserName`/`Actor`) and the `User=` body form *are* covered. ::: ### Failure modes The export is **best-effort and lossy by design**. It must never be load-bearing — the tenant audit (`/admin/audit`, `/admin/auth-log`) is a separate, durable pipeline and is unaffected whether export is on or off. | Situation | What happens | What to do | | --- | --- | --- | | Gate off (default) | No export. Serilog Console + File only. No collector needed. | Nothing — this is the safe default. | | Gate on, collector unreachable | The OTLP sink retries with backoff and drops on overflow. **The app keeps running**; local Console + File still have everything. | Alert on the collector being down; logs are not lost locally. | | Gate on, collector up but **redaction processor removed/misconfigured** | Logs reach OpenObserve **unredacted** — a silent PII leak. | This is the one to guard. Run the **shipped** config; treat the ruleset version as an audited artifact; keep the e2e redaction test green in CI; monitor collector pipeline health. | | Gate on, `OPENOBSERVE_*` env unset | An unset value expands to empty: the collector still starts **and still redacts**, but export then fails and records are dropped (app + local Console/File unaffected). | Set `OPENOBSERVE_LOGS_ENDPOINT` + `OPENOBSERVE_AUTHORIZATION`; smoke-check that records land. | | Background / startup logs | Carry `realm=system` (no tenant context yet). | Expected — `system` is the infrastructure catch-all, not a tenant. | ### Local stack (for trying it out) [`docker/docker-compose.observability.yml`](https://github.com/cocoar-dev/modgud/blob/develop/docker/docker-compose.observability.yml) brings up the Collector + OpenObserve so you can watch redacted logs land: ```bash docker compose -f docker/docker-compose.observability.yml up -d # then run the API with export on, pointed at the collector: # Observability__Otlp__Enabled=true # Observability__Otlp__Endpoint=http://127.0.0.1:4317 # OpenObserve UI: http://localhost:5080 (dev creds are in the compose file) ``` The collector deployment topology in production (sidecar vs shared, the OpenObserve org/RBAC layout, retention) is an ops decision — the shipped collector config is the redaction contract, not a deployment prescription. --- --- url: /operate/recovery-cli.md --- # Recovery CLI The recovery CLI is a break-glass tool. It runs **inside the container**, using the configured database connection — there's no network surface, no auth bypass. It exists for the situations where the admin UI cannot help: no admin can sign in, the projections desynced after a schema change, an old OAuth client needs to be migrated onto a linked Service Account, etc. Most invocations write an entry to the security audit log (see [Audit trail](#audit-trail) below); a few read-only commands don't. ## Entry point ```bash dotnet Modgud.Api.dll recover [args...] [--realm ] ``` The `--realm` flag defaults to `system`. Commands that don't need a tenant context (the `realm-*`, `control-plane`, and `adopt-tenant` commands carry their own `--slug`) ignore it. For tenant-scoped commands the named realm is resolved up front: * A misspelled or unknown `--realm` **fails fast** with `error: Realm '' not found.` and a non-zero exit code — it never silently acts on the wrong tenant. * When `--realm` is omitted **and more than one realm exists**, the CLI prints a `note:` to stderr naming the realm it defaulted to, so a multi-realm operator is never surprised. With a single realm the default is unambiguous and stays quiet. Every command exits `0` on success and a non-zero code on failure (a validation error, an unknown realm, or an unknown command); error text is written to stderr. ## Commands ### `list` List every active user with `UserName · Email · Active · Admin · 2FA · Passkeys`. ```bash dotnet Modgud.Api.dll recover list ``` `Admin` means the user holds `realm:admin` (typically via the System Admin role inside the seeded Administrators group). ### `reset-2fa ` Disable TOTP and Email-OTP, delete every stored passkey credential, and clear the grace-period stamp so the user gets a fresh secure-setup window on next login. ```bash dotnet Modgud.Api.dll recover reset-2fa alice ``` ### `set-email ` Update the user's email and append a `UserUpdatedEvent` so projections * SignalR-driven admin grids refresh live. ```bash dotnet Modgud.Api.dll recover set-email alice alice@example.com ``` ### `magic-link ` Issue a one-time magic-link URL and print it to stdout. Useful for nudging a locked-out user back in without resetting their password. ```bash dotnet Modgud.Api.dll recover magic-link alice ``` ### `rebuild-projections` Rebuild all Marten projections (inline + async). Bootstrap path for the first migration after a breaking schema change — runs without any admin authentication. ```bash dotnet Modgud.Api.dll recover rebuild-projections ``` ### `bootstrap-admin` Create the first admin in a realm. Default realm: `system`. Two modes — **Direct** (password set immediately) and **Invite** (a magic-link URL is printed and emailed if SMTP is configured). ```bash # Direct mode dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin \ --firstname Admin \ --lastname User \ --password 'ChangeMe1!' # Invite mode (no --password) dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin ``` Flags: | Flag | Required | Notes | |---|---|---| | `--email` | yes | Email — required in both modes. | | `--username` | no | Defaults to the local-part of the email. | | `--firstname` | no | Optional. | | `--lastname` | no | Optional. | | `--password` | no | If present: Direct mode. Validated against the configured Identity password rules. If absent: Invite mode. | | `--realm ` | no | Defaults to `system`. | ### `migrate-cc-credentials` For every OAuth client that still has the `client_credentials` grant without a linked Service Account, auto-provision a Service Account named `legacy.{clientId}` and backfill the link so the standard SA-managed mutation guard applies. Idempotent — already-linked clients are skipped; existing `legacy.*` SAs are re-used. ```bash dotnet Modgud.Api.dll recover migrate-cc-credentials --realm system ``` ### `realm-list` List every active realm with its slug, display name, primary domain, and configured domains (the control-plane realm is marked `[CP]`). Useful first probe after a fresh deploy — shows the system realm's seeded localhost domains so you know which Host header to use. ```bash dotnet Modgud.Api.dll recover realm-list ``` ### `realm-add-domain` Add a domain to an active realm's `Domains` list. Typically used once after a fresh deploy to add the production hostname to the system realm. ```bash dotnet Modgud.Api.dll recover realm-add-domain \ --slug system \ --domain auth.example.com ``` Flags: * `--slug ` — required. * `--domain ` — required. Stored verbatim; case-insensitive match at request time. ### `realm-remove-domain` Remove a domain from an active realm's `Domains` list. No-op if not present. Guarded: you cannot remove a realm's **last** domain, nor its **PrimaryDomain** — re-point the primary with `realm-set-primary-domain` first. ```bash dotnet Modgud.Api.dll recover realm-remove-domain \ --slug system \ --domain old.example.com ``` ### `realm-set-primary-domain` Re-point a realm's **PrimaryDomain** — its canonical public host. The PrimaryDomain is the single domain (out of the realm's `Domains`) that Modgud uses for every outbound link (magic-links, bootstrap-invites) and as the **WebAuthn relying-party ID**. The new primary must already be in the realm's `Domains`; add it with `realm-add-domain` first (there is no silent add). ```bash dotnet Modgud.Api.dll recover realm-set-primary-domain \ --slug system \ --domain auth.example.com ``` Flags: * `--slug ` — required. * `--domain ` — required. Must already be one of the realm's domains. ::: danger Changing the primary invalidates passkeys Because the PrimaryDomain is the WebAuthn relying-party ID, changing it **invalidates every passkey registered for the realm** — affected users must re-register their passkeys on next sign-in. Other login methods (password, TOTP, Email OTP, magic-link) are unaffected. The CLI prints this warning and writes it to the audit log. ::: ### `control-plane list` / `control-plane transfer ` Inspect or relocate the [control-plane](../concepts/control-plane) role (the realm that hosts cross-realm administration). `list` prints the current holder; `transfer` moves the stored `IsControlPlane` flag to another realm, clearing every other holder in one transaction. ```bash dotnet Modgud.Api.dll recover control-plane list dotnet Modgud.Api.dll recover control-plane transfer acme ``` Break-glass for when the control-plane realm has no usable admin: the target realm's existing `realm:admin` users gain cross-realm administration. There is deliberately **no** `grant` subcommand — authority is `realm:admin` within the flag-holding realm, so there is nothing to grant, only the flag to move. Restart the running container afterwards so its in-process realm cache picks up the change. ### `adopt-tenant [domain]` Register an **already-existing** tenant database (`_`) as a realm — the migration counterpart to creating a realm via the API. It does **not** `CREATE DATABASE`; restore the dump into the target DB first, then adopt it. Errors if the database is missing or a realm with the slug already exists. Schema is applied idempotently (existing data is kept). ```bash dotnet Modgud.Api.dll recover adopt-tenant acme "Acme Corp" acme.example.com ``` ### `rotate-signing-key` Rotate a realm's OpenIddict signing key: generates a fresh RSA keypair and retires the previous active key into a 30-day verification-overlap window so tokens already issued stay valid until they expire. Running API instances pick up the new key within about a minute. ```bash dotnet Modgud.Api.dll recover rotate-signing-key --realm acme ``` ### `help` Show the usage summary. ```bash dotnet Modgud.Api.dll recover help ``` ## Running a command at container startup (`STARTUP_COMMAND`) For orchestrators where overriding the container's command/entrypoint is awkward (Portainer, some Compose setups), set the `STARTUP_COMMAND` environment variable to a recover command. On boot — **after** the master + system tenant are provisioned — the value is split into argv and run; the process then **idles** (it never starts Kestrel and never exits) so a restart policy can't crash-loop it. ```yaml # docker-compose.yml (excerpt) environment: STARTUP_COMMAND: 'recover control-plane transfer acme' ``` Check the logs, then **remove the variable and redeploy** to resume normal web serving. `STARTUP_COMMAND` is only consulted when no CLI command args are present, and is a raw environment variable (not a `Cocoar.Configuration` key). Multi-word arguments work when double-quoted (e.g. a realm display name). ## Audit trail {#audit-trail} Most recovery commands write a `Recovery . ...` entry to the security audit log, surfaced in the admin UI's auth log (`GET /api/admin/auth-log`). These entries are logged at `Warning` level, including failures — there is no separate `Error` level for a failed recovery command. Purely read-only commands (`list`, `realm-list`) don't write an audit entry, and most usage/validation failures (unknown realm, bad flags, guard violations) are only printed to the console, not recorded. ## When to reach for the CLI * **No admin can sign in** → `bootstrap-admin` (Direct mode) creates a fresh admin in one shot. * **A user lost their 2FA device** → `reset-2fa ` then `magic-link ` so they can log in and re-enrol. * **Production hostname doesn't route to a realm** → `realm-list` to confirm what's configured, then `realm-add-domain` to bind the new hostname, then `realm-set-primary-domain` to make it the realm's canonical primary. * **Marten projections out of sync after a schema change** → `rebuild-projections`. * **Legacy `client_credentials` clients fail mutation guard** → `migrate-cc-credentials` provisions the linked SA they need. * **Suspected signing-key compromise, or routine key hygiene** → `rotate-signing-key` issues a fresh key while honoring in-flight tokens. For the operational story of first-time admin setup (when there's no admin yet to invite anyone), see [First-time setup](../getting-started/first-time-setup). --- --- url: /operate/key-material.md --- # Key material Modgud signs, encrypts, and stores several distinct kinds of key material, spread across two very different places: some of it lives in Postgres alongside the rest of a realm's data, some of it lives on disk as `.pfx` files. That split matters operationally — the two halves have different backup requirements, different rotation mechanics, and different blast radii when something goes wrong. This page is the map: what each key protects, where it physically lives, how it rotates, and what breaks if it's lost. ## At a glance | Key material | Protects | Lives in | Rotation | Loss | |---|---|---|---|---| | Per-realm RSA signing key | Access & ID tokens (that realm only) | That realm's tenant DB | Manual, 30-day overlap | Fresh key auto-bootstraps; that realm's outstanding access/ID tokens die | | OpenIddict signing certificate (`signing.pfx`) | Authorization codes, device codes, refresh tokens (all realms) | Disk volume `data/keys/` | Manual file swap, overlap via `PreviousSigningCertificatePaths` | Auto-regenerates; **every** realm's outstanding codes and refresh tokens fail at once | | OpenIddict encryption certificate (`encryption.pfx`) | Same artifacts, wrapped as JWE (access tokens excluded) | Disk volume `data/keys/` (falls back to the signing cert if unset) | Manual file swap, overlap via `PreviousEncryptionCertificatePaths` | Auto-regenerates; **every** realm's outstanding codes and refresh tokens fail at once | | Per-tenant DataProtection key ring | Auth cookies, antiforgery tokens, stored login-provider client secrets | That realm's tenant DB | Automatic, ~90-day framework default | Silent logout + that realm's stored provider secrets become undecryptable | | WebAuthn passkey key pairs | Passkey login ceremonies | That realm's tenant DB (public key only — the private key never leaves the authenticator) | None — re-enrolment only | Affected user re-enrols a passkey | The rest of this page walks through each row. ## Per-realm RSA signing key Every realm has its own RSA keypair, generated on first boot and stored as a `RealmSigningKey` document in that realm's own tenant database. This key signs **access tokens and ID tokens** — the tokens that leave the IdP and cross the trust boundary to a resource server or client application. It's also the only key material published in that realm's `/.well-known/openid-configuration` JWKS document. Each realm's JWKS contains exclusively its own key(s), never another realm's — which is what makes the isolation structural rather than a matter of convention: a resource server validating against realm B's JWKS has no way to accept a token signed by realm A's key, because that key was never in scope. Rotating one realm's signing key has zero effect on any other realm. Rotation is manual — there's no scheduled auto-rotation. Trigger it either from **Realm Settings** in the admin UI or with the Recovery CLI: ```bash dotnet Modgud.Api.dll recover rotate-signing-key --realm acme ``` A rotation generates a fresh keypair and immediately makes it the active signing key, but the **previous** key isn't deleted — it's retired and kept in the JWKS for a 30-day verification-overlap window, so access/ID tokens issued just before the rotation stay validatable until they naturally expire. A daily janitor job hard-deletes retired keys once their overlap window has elapsed, so retired private key material doesn't accumulate indefinitely in the tenant DB. Because the key lives in Postgres, it restores exactly the way the rest of a realm's data does: restore the tenant DB dump and the key (active and any still-in-overlap retired keys) comes back with it. If the tenant DB is lost outright and rebuilt from scratch instead of restored, a fresh key bootstraps automatically — but every access and ID token outstanding for that realm at the time is now unverifiable and the affected users simply have to sign in again. ## Global OpenIddict signing certificate Separately from the per-realm keys above, there is exactly **one** OpenIddict signing certificate per instance (`signing.pfx`, on disk under `data/keys/`, auto-generated on first boot if missing — see [Docker & Deployment](./deployment#minimum-env-vars)). It's shared by every realm on that instance. This certificate signs only artifacts that are redeemed **at the IdP itself**: authorization codes, device codes, and refresh tokens. None of those are meant to be validated by a third party — a client only ever holds an opaque reference id and hands it back to Modgud's own `/connect/token` or `/connect/revoke` endpoints. That's a deliberate design choice: giving each realm its own certificate here would add validation surface without buying any additional isolation, since these artifacts never leave the IdP as bearer material in the first place. Consequently this certificate is **never** published in any realm's JWKS. Rotation is a manual file swap. To roll it without invalidating everything in flight, place the new file and list the old one in `OpenIddict.PreviousSigningCertificatePaths` so both are trusted during the transition, then drop the old path once you're confident nothing still depends on it. Because this certificate is shared instance-wide, losing it (or letting a container restart regenerate it because the `data/keys/` volume wasn't persisted) has an instance-wide effect: a fresh self-signed certificate auto-generates, and **every realm's** outstanding authorization codes and refresh tokens fail simultaneously — affected users re-authenticate, in-flight authorization-code exchanges fail. Access and ID tokens are unaffected, since those are signed by the per-realm key above, not this one. ## Global OpenIddict encryption certificate OpenIddict also wraps the same IdP-internal artifacts (authorization codes, device codes, refresh tokens) as JWE using a second certificate, `encryption.pfx` — again one per instance, again on disk under `data/keys/`, falling back to the signing certificate if `OpenIddict.EncryptionCertificatePath` is left unset. **Access tokens are deliberately excluded** — they stay signed-only (JWS, no encryption layer) precisely so that any resource server capable of validating a JWKS-published signature can verify them locally, without needing this certificate at all. Rotation is a manual file swap, same as the signing certificate. To roll it without invalidating everything in flight, place the new file and list the old one in `OpenIddict.PreviousEncryptionCertificatePaths` so both are tried when decrypting an incoming artifact during the transition, then drop the old path once you're confident nothing still depends on it. (Previously this certificate had no overlap mechanism at all — every rotation required a maintenance window; resolved by [#125](https://github.com/cocoar-dev/modgud/issues/125).) Because this certificate is shared instance-wide, losing it (or letting a container restart regenerate it because the `data/keys/` volume wasn't persisted) has an instance-wide effect: a fresh self-signed certificate auto-generates, and **every realm's** outstanding authorization codes and refresh tokens fail simultaneously — affected users re-authenticate, in-flight authorization-code exchanges fail. The overlap mechanism above only helps a *planned* rotation where the old file is still around to list; it doesn't help an unplanned loss of both files. ## Per-tenant DataProtection key ring Each realm also has its own ASP.NET Core DataProtection key ring, stored as `DataProtectionKeyDocument` documents in that realm's tenant database (see [Persistence (Marten)](./database)). This ring protects the first-party auth cookie, antiforgery tokens, and any stored login-provider client secrets (OIDC/SAML federation config) for that realm. It rotates automatically on the ASP.NET Core framework's own schedule (a new key roughly every 90 days by default) — there's no manual step and nothing to remember here day to day. Because the ring lives in Postgres, it restores with the tenant DB dump the same way everything else in this list does; losing the tenant DB without a restore means a fresh ring is generated, every existing session cookie for that realm silently stops validating (affected users are logged out, not shown an error), and any stored login-provider secrets encrypted under the old ring become permanently undecryptable. There's an optional extra layer on top: set `DataProtection__CertificatePath` (and optionally `DataProtection__CertificatePassword`) to an operator-supplied certificate, and every realm's key ring gets wrapped at rest with it — so a raw tenant-DB dump exposes ciphertext instead of usable key material. This is genuinely instance-wide: if you configure it and then lose *that* certificate, **no** realm's ring can be unwrapped anymore, which is a much bigger outage than any single tenant DB issue. If you leave it unset, the per-database boundary between realms is the protection instead, and there's no extra certificate to lose. ## WebAuthn passkey key pairs Passkey (WebAuthn) credentials work the other way around from everything above: Modgud only ever stores the **public** key half, per user and per credential, in that realm's tenant database. The private key is generated and held by the user's authenticator (a hardware key, a platform authenticator, a password manager) and never transmitted to or stored by Modgud at all — the server's job is limited to verifying signatures against the stored public key during login. There's no rotation concept for a passkey — a credential is valid until the user removes it or the authenticator is lost, and recovery is always re-enrolment rather than any kind of key recovery. ## Cross-realm isolation, honestly Tokens that actually leave the IdP and get handed to a resource server or client — access tokens and ID tokens — are cryptographically realm-separated: each realm has its own signing key, published only in that realm's own JWKS, so a token from one realm is structurally incapable of validating against another realm's discovery document. Everything that stays inside the IdP — authorization codes, device codes, and refresh tokens, plus their JWE wrapping — shares one certificate pair for the whole instance, by design, because those artifacts are never handed to a resource server as bearer material in the first place. The trade-off is honest: a compromise of that certificate pair affects every realm's in-flight internal artifacts at once, not just one tenant's. In practice that means the `data/keys/` PFX volume deserves the same operational care — backup, access control, monitoring — as the Postgres databases themselves, even though it's "just two files." ## See also * [Backing up realms](./database#backing-up-realms) — how the DB-persisted keys ride along with the regular per-tenant `pg_dump` backups, and why the PFX volume needs a separate backup step of its own. * [Recovery CLI: `rotate-signing-key`](./recovery-cli#rotate-signing-key) — the command-line path to rotating a realm's signing key. * [Sessions & Tokens](../concepts/tokens) — the token formats (reference vs. JWT), lifetimes, and revocation paths that these keys underpin. --- --- url: /operate/feature-flags.md --- # Feature Flags Operator-level toggles for features that aren't ready for general exposure yet. Lives in `AppSettings.Features` — **not** per-tenant. The operator (whoever sets the deployment's configuration) decides; realm admins can't override. ::: info Why operator-level Some features ship with the editor side functional but the runtime side still missing, or with a beta integration where we want to gather real feedback before exposing it. The flag keeps the surface invisible to tenant admins until the operator is comfortable; once flipped, the feature behaves as documented on its own page. ::: ## Setting flags Configure via `configuration.local.json` (gitignored) or an environment variable. Env-var binding is **case-insensitive** — see [the convention note below](#env-var-casing). ```jsonc // configuration.local.json { "AppSettings": { "Features": { "PageBuilder": true } } } ``` ```bash # or via env (double-underscore as section separator; casing is not significant): AppSettings__Features__PageBuilder=true ``` Flags are read at startup; no hot-reload. A flip requires a restart. ## Current flags | Flag | Default | Effect | | --- | --- | --- | | `PageBuilder` | `false` | Visibility of the [Customization → Pages](../platform/pages) editor. While off: sidebar tile hidden, `/platform/customization/pages` routes redirect to Branding, `/api/admin/customization/pages/*` returns 404, `RealmSettingsDto` omits the Pages section. While on: editor mounts and persists. **Runtime rendering of stored schemas on /login etc. is a separate future feature and is not gated by this flag.** | ## Defense in depth For each gated feature the flag fires at every layer the surface touches: 1. **SPA sidebar** — the navigation entry is hidden via `requireFeature` in `AdminView.vue`. 2. **Vue-router** — `beforeEnter` guards on the gated routes redirect to a visible sibling so deep-links don't dead-end on a blank screen. 3. **Backend endpoints** — return **404 Not Found** (not 403 / 401) so curl-callers see "no such endpoint", not "permission denied". 4. **DTO masking** — surfaces that aggregate multiple sub-documents (e.g. `GET /api/admin/realm-settings`) emit the gated section as empty so the SPA can't fingerprint stored data. The stored data itself persists across flips — turning a flag off doesn't delete anything from the tenant DB. Flipping it back on surfaces the existing data unchanged. ## ENV-var casing Cocoar.Configuration v6 (the binding layer Modgud uses) binds environment variables **case-insensitively**. The section/property names need not match the JSON or C# casing — `AppSettings__Features__PageBuilder=true` and `APPSETTINGS__FEATURES__PAGEBUILDER=true` bind to the same flag. Two underscores (`__`) are the section separator; a single underscore is literal. PascalCase is a readability convention only, not a correctness requirement. This applies to every config-bound type, not just feature flags. The same rule covers `DbSettings__ConnectionString`, `Observability__Prometheus__BearerToken`, etc. ## Adding a flag (developer note) For repository contributors — flags follow a deliberate pattern: 1. Add a property to `Modgud.Api.FeatureFlags` with a `false` default. 2. The `IFeatureFlags` abstraction in `Modgud.Authentication` mirrors the property (read-only) so the Authentication slice can gate surfaces without depending on the Api project. 3. Wire it everywhere: sidebar `requireFeature` (with a matching `'PageBuilder' | …` union member in `NavItem`), Vue-router `beforeEnter` guard, backend endpoint 404 short-circuit, DTO masking. 4. Add tests in `Modgud.Api.Tests/Authorization/` covering on/off paths. 5. Document the flag in this file plus its feature page. Don't add a flag for "I'm not sure if I want this enabled" — flags are commitment-eating maintenance work. Add them only when there's a concrete reason the feature isn't ready for general exposure (beta integration, half-built runtime, customer-specific). --- --- url: /admin.md --- # Administration overview The administration area appears in the sidebar as soon as your account holds **at least one admin read permission** (see [Roles](./roles)). Realm administrators with `realm:admin` see everything; "granular" admins (e.g. a user manager) only see the areas they have rights for. ::: tip First time setting this up? If you've just installed Modgud and want to bind your first SaaS app, start with the [SaaS App Integration Walkthrough](../integrate/saas-walkthrough) — it's the linear path. ::: ## Areas ### Identity & Access * [Users](./users) — create, edit, lock, unlock, GDPR-erase accounts * [Roles](./roles) — permission bundles per app * [Groups](./groups) — who is a member of what role; static or scripted ### Apps Modgud is **multi-app capable**: every SaaS application in a realm is registered as its own App with its own resources, roles, and OAuth bindings. * [Applications](./applications) — register apps and curate their permission catalogs; each App can also override a slice of realm config (branding, self-registration, native grants, DCR, CIMD) on its **Settings** tab — unset fields inherit the realm ### OAuth & OpenID Connect Modgud is not just a login frontend — it's a full **OAuth 2.0 / OpenID Connect provider** built on OpenIddict. Third-party apps sign in via OIDC instead of maintaining their own user databases. * [OAuth Clients](./oauth-clients) — apps that sign in through the IdP (web, mobile, CLI) * [OAuth Scopes](./oauth-scopes) — which capabilities (scopes) are available? * [OAuth APIs (Resource Servers)](./oauth-apis) — register backends that validate tokens * [Dynamic Client Registration](./dynamic-client-registration) — let AI agents (Claude Code, Cursor, MCP clients) register themselves as OAuth clients * [Invite Codes](./invite-codes) — mint and manage single-use codes for invite-gated self-registration ### Federation & Realms * [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 Per-realm look and feel. SPA-shell branding plus a beta page-builder editor. * [Branding](../platform/branding) — product name, primary color, logo, favicon * [Asset Library](../platform/assets) — upload images for branding (and, later, page schemas); SVG sanitisation built in * [Pages (Beta)](../platform/pages) — drag-and-drop editor for login / logout / forgot-password; gated behind a [feature flag](../operate/feature-flags) while the runtime renderer is still being built ### Operations * [Observability](../operate/observability) — OpenTelemetry metrics + tracing + in-app live activity feed * [Logs](./auth-log) — a combined **Audit** tab (GDPR audit trail of user/config changes) and **Security** tab (login events, lockouts, rejected logins), gated separately * [Change Requests](./change-requests) — approve profile changes (when the approval flow is enabled) * [Settings](../platform/settings) — 2FA enforcement, grace period, SMTP, … * [Feature Flags](../operate/feature-flags) — operator-level toggles for beta / WIP surfaces * [Recovery CLI](../operate/recovery-cli) — when the UI no longer responds ## Permissions: the two-segment model Modgud manages permissions as **`:`** strings. The app is never part of the string — it comes from context: a Role belongs to exactly one App, so the same string means different things depending on which App's Role grants it. Examples: | Permission | Meaning | | --- | --- | | `user:read` | Read the user list (via a Role in the `modgud` app) | | `oauth-client:write` | Manage OAuth clients (via a Role in the `modgud` app) | | `todo:write` | Write todos (via a Role in the `acme-tasks` app) | | `realm:admin` | **Realm-wide bypass** — everything in every app | | `user:admin` | Resource-wide bypass for "user", within the app that granted it | Two bypass tiers keep permission lists short: * **`realm:admin`** — realm-wide. Whoever holds it may do anything in any app. * **`:admin`** — resource-wide, within the app the grant came from. ::: info Who is a realm admin? The first admin in every realm — created via the recovery CLI or the Control-Plane-issued bootstrap invite (see [First-time setup](../getting-started/first-time-setup)) — is automatically placed into the `Administrators` group whose `BoundTo: ["*"]` wildcard makes them effective in every app. Add more admins by putting users into that group (or any other group with equivalent rights). ::: ## Granular gating The sidebar automatically hides everything you can't read. Examples: * **Realm admin** (`realm:admin`) — sees and may do everything, in every app * **User manager** in modgud — `user:read` + `:write` + `session:read` + `auth-log:read` → only the user/session area * **OAuth manager** in modgud — `oauth-client:*` + `oauth-scope:*` + `oauth-api:*` → only the OAuth area * **Acme-Tasks Editor** (in the `acme-tasks` app) — `todo:write` + `project:write` → not an admin in modgud, but very much in `acme-tasks` ## Typical workflows ### Bind a new SaaS app Full step-by-step walkthrough: [SaaS App Integration](../integrate/saas-walkthrough) — realm admin → app → OAuth client → resource server → group/role → backend code. ### Onboard a new employee 1. [Create the user](./users) (first name, last name, email) 2. **Send the sign-in link** — the user sets their password and 2FA themselves 3. Add them to the right [groups](./groups) — those already carry the right roles + BoundTo to the right apps 4. Done — the user can log in and has the right permissions in every connected app ### Wire up external SSO (Microsoft Entra) Full step-by-step walkthrough: [Login Providers](./login-providers). ### Run multiple tenants Each tenant gets its own [realm](./realms) — own database, own users, own roles. Routing is per subdomain (`tenant1.auth.acme.example`, `tenant2.auth.acme.example`). ### Admin locked out [Recovery CLI](../operate/recovery-cli) — a shell tool inside the container that bypasses the UI and writes directly to the database. ## Cloning entities Most admin list views support **right-click → Clone** on a row (Applications, OAuth Clients, OAuth Scopes, OAuth APIs, Roles, Groups) — it opens the Create modal pre-filled from the source, so standing up a near-duplicate (or an effective rename, since some slugs are immutable) doesn't mean retyping everything by hand. ## Real-time updates Most admin lists (Users, OAuth Clients/Scopes/APIs, Service Accounts, Scheduled Jobs, Invite Codes, …) refresh themselves automatically when another admin (or you in a second tab) changes something — a live push channel, no manual reload needed. A few areas aren't push-driven yet and either poll on an interval or need a manual refresh: Applications, the Logs page, and Change Requests. --- --- url: /admin/users.md --- # Users Administration → **Users**. ![User list](/screenshots/admin-benutzer-liste.png) ## User list Columns: a password-set indicator, *Username*, *First Name*, *Last Name*, *Acronym*, *Active*, *Lifecycle* (only populated for users pending deletion — see below), *Email*. Filters: * **Search** across username, email, first/last name * **Show recycle bin** — a toggle (with a count badge) that reveals users pending deletion. Those rows carry a **Lifecycle** badge — *Recycle bin* (an admin scheduled it) or *Self-deletion* (the user did) — plus the deletion deadline. See [recycle bin & permanent erase](#recycle-bin-permanent-erase) below. Double-click a row to open the detail dialog. Right-click a row for a context menu with **Set Password**, **Send Magic Link**, **Show IdP Claims**, and the recycle-bin actions described below. ## Creating a user **Create** button at the top right. Required: * **Username** (unique, lower-case recommended) Optional but recommended: * **First name**, **Last name** * **Email** — **unique per realm**; without it, magic links and reset emails are impossible. An address is freed for reuse only once its previous owner is permanently erased (see [recycle bin & permanent erase](#recycle-bin-permanent-erase)). ::: info Required fields can be stricter Email is always required. Whether Username, First name and Last name are optional or required is controlled by the realm's (or per-App's) [Registration Fields](./realm-settings#registration-fields) policy — the same policy that governs self-registration also applies here, so an admin creating a user is held to the same requirements. ::: ::: tip Initial password vs. magic link Two ways to give a new user their first access: 1. **Set an initial password** — type a temporary password and share it with the user via a secure channel. They change it on first login. 2. **Send a sign-in link** — Modgud emails a one-time magic link. The user clicks it, lands logged in, sets their own password. Option 2 is more convenient and safer — no cleartext password travels through chat or email. ::: ## The user dialog Tabs: ### General Master data: first name, last name, acronym, email, username, **active flag**, and (once an email is set) an email-verified override. ::: warning Changing email as admin If you change the email **directly as an admin**, it takes effect immediately — **no double-opt-in**. Make sure the address is correct, otherwise you lock the user out (reset links would go to the wrong address). If the user changes their email themselves, double-opt-in to the new address kicks in automatically — see [Profile](../end-user/profile#change-email-double-opt-in). ::: ### Direct Groups Assignment to [groups](./groups) the user is a **manual, direct** member of. Group membership determines roles and therefore permissions. ### Effective Read-only view of every group the user effectively belongs to — direct, inherited through group nesting, and auto-computed by membership scripts — with a badge showing how each one was reached. ### Security Overview of the user's 2FA status: * **2FA status** — a badge showing which methods are active, whether the user is exempt, or that 2FA isn't configured yet. * **Grace period** (when 2FA is required but not yet set up) — days remaining before enforcement kicks in, with **Reset grace** and **Force immediate enforcement** actions. * **Individual policy override** — a per-user grace-period-days override, and a checkbox to exempt this user from the 2FA requirement entirely (for service-style accounts or migrated legacy users). Use sparingly; changes here are audited. Actions that live elsewhere but affect the same user: **Set password** and **Send Magic Link** are right-click actions on the user list (see [User list](#user-list) above), not fields inside this tab. ## Viewing a linked external identity's claims Right-click a user on the list → **Show IdP Claims** opens a standalone panel with the raw and mapped claims from that user's most recent external (OIDC/SAML) login. Useful for debugging when SSO-side fields are missing or wrong. ## Sessions Modgud tracks sign-in sessions per user (device, browser, IP, last activity), but today there is no admin-UI surface to list or end another user's sessions — that view only exists for end users managing their own sessions, under **Profile → Sessions** (see [Profile](../end-user/profile#sessions)). If you need to force a user out of all their sessions as an admin today, deactivating the account (see below) revokes live access immediately. ## Account lockout After too many failed sign-in attempts, Modgud temporarily locks the account for a short, fixed cooldown — it clears itself automatically after a few minutes, with no admin action needed. There is currently no manual "lift lockout" control in the admin UI. ## Recycle bin & permanent erase Deleting a user is **reversible until it isn't.** Instead of an immediate hard delete, Modgud moves the account to a **recycle bin**: it is deactivated and scheduled for permanent erasure after a retention window, during which you can restore it. Only the final erase is irreversible. The retention window and auto-purge behaviour are configured per realm under [Realm Settings → Account Deletion](./realm-settings#account-deletion). This is the same lifecycle a user enters when they [delete their own account](../end-user/profile#privacy) — the difference is the initiator (and that a self-deleting user stays able to sign in to cancel, whereas an admin-binned user is deactivated). ### Move to the recycle bin List → right-click an active user → **Delete (recycle bin)**. Effect: * The account is **deactivated** — it can no longer sign in. * It is scheduled for permanent erasure at the end of the realm's admin-retention window. * Live access is revoked (sessions end, tokens stop working). The record, its email, **and its external identity links are kept** so a clean restore is possible — the deactivation alone blocks any login (including through a linked IdP). No PII is masked at this stage; that happens only at permanent erase. * Its profile is **frozen** (read-only) while pending — restore it first to edit again. * The email address stays reserved (no one else can register it) until the account is permanently erased. Reveal pending users with the **Show recycle bin** toggle on the list. Their **Lifecycle** badge reads *Recycle bin* (admin-initiated) or *Self-deletion* (the user requested it), with the deadline. ### Restore Show recycle bin → right-click the pending user → **Restore**. The pending deletion is cancelled and the account is reactivated. An admin can restore **either** kind of pending deletion — including cancelling a user's own self-service deletion as a support escape hatch. ::: warning Username & email must still be free If someone registered the same username or email in the meantime, the restore fails — resolve the conflict first. ::: ### Delete permanently (force-delete) Show recycle bin → right-click the pending user → **Delete permanently**. You're asked for a reason (recorded in the [auth log](./auth-log)) and a final confirmation. This empties the recycle bin for that user **now**, ahead of the retention deadline. If **auto-purge** is enabled for the realm (default), a scheduled job erases recycle-bin accounts automatically once their retention window elapses — so you don't have to empty the bin by hand. ::: warning Final — no restore Permanent erase is the actual deletion under GDPR Article 17 ("right to be forgotten"). There is no going back. ::: What happens technically: * All PII fields (name, email, phone, profile name) are replaced by markers (`***ERASED***`) in events — Marten's built-in GDPR mechanism. * The event stream is archived so derived views no longer see the user. * **External identity links are scrubbed too** — both links the user still has connected and any they had disconnected (their PII is found via the user's own stream and masked), so no IdP-linkage data survives the erase. * The user record is flagged deleted and its email is nulled — which **releases the email** for reuse. * The auth log keeps the user ID for correlation but no cleartext PII. When to use force-delete instead of letting auto-purge run: * A GDPR erasure request that must be honoured immediately. * Freeing a reserved username or email address right away. * Data cleanup after test or demo setups. ::: tip Deactivate is a separate action **Deactivate** (General tab / *Active* flag) suspends an account indefinitely with no deletion intent and no timer — fully reversible, email kept. **Delete** is the stronger action that *includes* deactivation plus a countdown to erasure. ::: ## Editing a user's profile on their behalf If a user can't get in themselves, you can adjust their master data on the **General** tab. These changes bypass the approval flow (if enabled) and the email double-opt-in — be careful. ::: info Audit Every admin action against a user is recorded in the [auth log](./auth-log) as an **admin action** with your admin name. ::: --- --- url: /admin/service-accounts.md description: >- Machine identities that authenticate via OAuth client_credentials, sit in the same Group/Role/Permission model as humans, and produce clean audit trails. --- # Service Accounts A **Service Account** (SA) is a non-human principal — a build agent, an integration, a scheduled job. It carries a stable account name, lives in the same `Principal → Group → Role → Permission` model as a `Person`, but has no email, no password, no MFA. Machines authenticate by exchanging an OAuth `client_secret` for an access token; Modgud then resolves the token's `sub` claim to the owning Service Account so audit logs read `ci.build-agent did X` instead of `client_id=4f7a9b…e3 did X`. Create a Service Account whenever a non-interactive caller — CI runner, scheduled sync, server-to-server integration — needs to act against a Modgud-protected API. ## Surface * Admin grid: **`/admin/service-accounts`** * Permissions: `service-account:read` (view), `service-account:write` (create, edit, delete, issue credentials, rotate, delete credentials) * Backing API group: `/api/service-account` (and `/api/service-account/{id}/credentials` for the credential children) ## Two layers: ServiceAccount vs OAuth Client A working M2M setup needs two objects living in two layers. The split is deliberate. | Layer | Object | Answers | | --- | --- | --- | | Authorization / identity | **ServiceAccount** | *Who* is acting — stable identity for audit logs, group membership, role and permission grants. | | Credential / wire | **OAuth Client** (`client_credentials`) | *How* it authenticates — `client_id`, `client_secret`, scopes, lifetimes, rotation. | ### Why both Modgud has a unified permission model that has to work identically for humans and machines: * `alice` (Person) → group `data-engineers` → role `data-read` → permission `acme-tasks:data:read` * `ci.build-agent` (ServiceAccount) → group `data-engineers` → role `data-read` → permission `acme-tasks:data:read` If the OAuth client carried the machine identity directly, the entire group/role/permission graph would have to be duplicated on the client side, and audit logs would surface opaque `client_id` strings. The industry pattern is consistent: Keycloak auto-creates a hidden "service account user" behind every `client_credentials` client, AWS IAM separates Roles from access keys, GCP IAM separates Service Accounts from JSON keys. Modgud surfaces both layers explicitly because admins need to manage them — but the SA is the user-facing concept and the OAuth client is an implementation detail of "how does this SA authenticate". ## Strict grant separation A single OAuth client serves **exactly one** identity model: | Client kind | Allowed grants | Linked SA? | Token `sub` | | --- | --- | --- | --- | | User-facing | `authorization_code` (+ `refresh_token`, `device_code`, …) | must be null | `Person.Id` of the logged-in user | | Service-account credential | `client_credentials` only | required | `ServiceAccount.Id` | ::: warning No mixing A client with both `authorization_code` and `client_credentials` enabled would make `sub` ambiguous (logged-in user on the user flow, …what? on the M2M flow). The OAuth admin endpoints reject this combination at validation time, with a clear error, before the client is ever saved. ::: Concretely: * An SA-managed client (`LinkedServiceAccountId != null`) is **read-only via `/admin/oauth/clients`**. Mutations route through the SA-scoped credential endpoints so the link can't be silently dropped. Attempting `PUT /api/oauth/client/{id}` on an SA-managed client returns `CannotMutateServiceAccountManagedClient`. * Adding `client_credentials` to a client that has no linked SA is rejected — no ownerless M2M clients. * The `authorization_code` / `device_code` / `implicit` paths refuse to issue a token whose client has a `LinkedServiceAccountId`. ::: tip Dual-role BFF backends A backend-for-frontend that both **brokers user login** (redeeming a native grant like `urn:cocoar:otp` server-side) **and** acts **machine-to-machine** (e.g. minting invite codes via `client_credentials`) needs **two** clients, not one — a login client carrying the native grant **plus** a separate SA-linked client for `client_credentials`. The single shared secret lives on the M2M client; the login client can be public (one secret total) or confidential (client-auth on the redeem). See [Native app integration → server-side BFF](../integrate/native-apps#2-create-the-oauth-client). ::: ## Creating a Service Account 1. Open `/admin/service-accounts` and click **Create**. 2. Fill in: * **Account name** — lowercase letters, digits, dots, hyphens or underscores; 2-64 chars; starts with a letter or digit. This is the audit-log handle (`ci.build-agent`, `integrations.acme-tasks`, `nightly.sync`). Unique across the whole principal table — a Person and a ServiceAccount can't share an account name, because both can act as the login handle in different contexts. * **Purpose** (optional) — free text. Pure documentation; the authorization layer never reads it. 3. **Create**. The SA exists but has no credentials yet — services can't authenticate as it. The new account appears in the grid. Open it to manage credentials, group membership, and the active toggle (deactivating an SA causes its `/connect/token` requests to be refused immediately, even with otherwise-valid credentials). ## Issuing credentials (the OAuth client behind the SA) Credentials are managed exclusively from the **SA detail modal → Credentials** section. The global `/admin/oauth-clients` grid shows the resulting clients with an **M2M** column listing the linked SA name, but double-clicking an SA-managed row navigates straight back to the SA modal — there's only one place to edit them. To issue a credential: 1. Open the SA, scroll to **Credentials**, click **Issue credential**. 2. Pick the scopes the caller needs and (optionally) the Apps the credential is bound to. Grant types are system-pinned to `["client_credentials"]` and not user-editable. 3. **Save**. The server creates a confidential OAuth client with: * `client_id` auto-generated as `{AccountName}.{8-char-suffix}` (e.g. `ci.build-agent.k7f2x9n3`) * `client_secret` hashed in the database, shown **once** in a copy-to-clipboard panel with a "won't be shown again" warning * `LinkedServiceAccountId` pointing at this SA 4. Copy the secret into the caller's secret store (GitHub Action secret, Kubernetes secret, vault entry, …). Closing or refreshing the modal loses it permanently — at that point only **Rotate secret** can mint a new one. ### Rotate and delete * **Rotate secret** generates a fresh `client_secret`, invalidates the old one immediately, and surfaces the new one in the same one-time-display panel. Use it for periodic rotation or after suspected exposure. * **Delete** removes the OAuth client. Tokens issued before the delete remain valid until their natural expiry (Modgud does not currently revoke outstanding tokens at delete time); no new tokens can be minted. ### 1:N One SA can own multiple credentials. Useful for: * **Zero-downtime rotation** — issue a second credential, switch the caller, delete the old one. * **Per-caller scope narrowing** — `ci.build-agent.read` with `builds:read` only, `ci.build-agent.write` with `builds:write`; both log as `ci.build-agent`. * **Multiple environments under one identity** — dev-CI, staging-CI, prod-CI share the audit name `ci.build-agent` but hold independent secrets. `N:1` (multiple SAs sharing one OAuth client) is forbidden by the same `sub`-ambiguity argument that bans mixed-grant clients. ## How a caller gets a token Plain OAuth `client_credentials` — no SA-specific wire protocol: ```bash curl -X POST https://idp.example.com/connect/token \ -d "grant_type=client_credentials" \ -d "client_id=ci.build-agent.k7f2x9n3" \ -d "client_secret=" \ -d "scope=builds:write" \ -d "resource=https://acme-tasks.example.com" ``` The token endpoint loads the OAuth client, follows `LinkedServiceAccountId` to the SA, verifies the SA isn't deleted or inactive, and issues an access token whose `sub` is the SA's `Id`. ## Token contents For an SA-issued token: * `sub` — `ServiceAccount.Id` * `name` — `ServiceAccount.AccountName` * `scope` — exactly what was requested (and allowed by the linked client) * `resource_access` — per-audience `roles` and `permissions` blocks built from the SA's group/role/permission chain, embedded directly in the access token. The `client_credentials` flow has no UserInfo round-trip in practice, so the resource server gets everything it needs from the JWT itself. The shape mirrors what human tokens carry via UserInfo per audience. The downstream API validates the token, reads `sub`, and gates access exactly the same way it does for a Person — the permission evaluator doesn't care whether the principal is a Person or a ServiceAccount. ## Group and role membership A Service Account can be added to any group from `/admin/groups` the same way a Person can. JsEval auto-membership scripts can target SAs too: they receive `principal.type == "service-account"` and can branch on `accountName`, `purpose`, or any group/permission predicate. Concretely, granting permissions to a Service Account is the same three-step path as for a human: put it in a group, give the group a role, give the role the permissions it needs. There is no special "service-account-only role" — humans and machines pull from the same role catalogue. ## Audit log Every token issue, group membership change, credential rotation, and delete attributes to the SA's `AccountName`, not to the raw `client_id`. The auth log shows entries like `ci.build-agent triggered build` rather than `client_id=4f7a9b…e3 triggered build`. Downstream apps that log against `sub`/`name` claims get the same readable handle. ## Cascade delete Deleting a Service Account (`DELETE /api/service-account/{id}`) cascade-deletes every credential owned by the SA in one transaction, then soft-deletes the SA itself. The response includes `DeletedCredentialCount` so the UI can confirm the blast radius. Soft-delete (rather than hard-delete) keeps audit-log references resolvable — historical entries still hydrate the SA name. There is no "unlink credential" operation. The only way to detach a credential from its SA is delete-and-reissue under a different SA. ## Migrating clients created before Service Account credentials Realms that existed before the Service-Account-credentials feature shipped may still hold standalone `client_credentials` clients with no `LinkedServiceAccountId`. The token endpoint falls back to the legacy `sub = client_id` behaviour for these so production callers keep working, but their tokens skip the SA-derived `resource_access` block and they show up in audit as raw client IDs. To migrate them in one shot, run the recovery CLI: ```bash dotnet Modgud.Api.dll recover migrate-cc-credentials [--realm ] ``` For each un-linked `client_credentials` client the command auto-provisions a Service Account named `legacy.{clientId}`, links the client to it, and leaves a re-runnable trail (already-linked clients are skipped; existing `legacy.*` SAs are re-used). Defaults to the `system` realm; pass `--realm` to scope to a specific tenant. After migration, rename the SA from the admin UI or merge it into a properly-named one. ## Related * [OAuth Clients](./oauth-clients) — the global grid that lists user-facing clients alongside SA-managed credentials with an M2M column linking back here. * [Groups](./groups) — where SAs pick up roles and permissions. * [Applications](./applications) and [OAuth Scopes](./oauth-scopes) — the resources and scopes a credential's tokens can target. * [Auth Log](./auth-log) — filter for the SA's account name to see every action it has taken. --- --- url: /admin/roles.md --- # Roles A **role** bundles permissions for one app. Users receive roles only through their [groups](./groups) — never directly. ![Roles list](/screenshots/admin-rollen-liste.png) ## The permission model ``` User ↓ membership (transitive BFS) Group(s) ↓ does BoundTo contain the requesting app? (otherwise: dormant) active group(s) ↓ roles Role(s) (linked to one Application) ↓ filter: Role.AppId == requesting app? (or the role is a realm-admin role) Permission(s) → resource:action ``` Effect: a user is `Editor in Acme-Tasks` because 1. they are a member of a group `Acme-Tasks Team`, 2. the group has `BoundTo: ["acme-tasks"]`, 3. the group references a role `Acme-Tasks Editor` linked to the Acme-Tasks Application, 4. the role grants the catalog entries `todo:read` and `todo:write` from that Application. ## Permission format: two segments Modgud manages permissions as **`resource:action`** strings, scoped to whichever Application the role belongs to (see [Concepts → Permissions & gating](../concepts/permissions) for the full model): | Permission | Meaning | | --- | --- | | `user:read` | Read the user list — in whichever app the role is linked to | | `oauth-client:write` | Edit OAuth clients — same | | `todo:write` | Write todos — same, if granted on an Acme-Tasks-linked role | The app is never part of the string — it comes from the role's Application link (or, for the built-in `modgud`/`control-plane` admin surfaces, from the endpoint being called). Plus two bypass tiers: * **`realm:admin`** — realm-wide. The holder may do anything in any app. Set via the role's **Privileged role** flag, not a catalog entry. * **`:admin`** — resource-wide, within the role's linked Application (e.g. `user:admin` bypasses both `user:read` and `user:write`). There is no app-wide bypass tier — bypass is either realm-wide or resource-wide, nothing in between. ## Standard roles (after setup) When the first admin in a realm is created (recovery CLI or HTTP bootstrap-invite — see [First-time setup](../getting-started/first-time-setup)), Modgud atomically seeds three roles: | Role | Application link | Effect | | --- | --- | --- | | **System Admin** | none — **Privileged role** flag set | realm-wide bypass (`realm:admin`) | | **User Manager** | modgud | `user:read/write` + `session:read/write` + `authorization-group:read` + `permission-role:read` + `auth-log:read` + `audit-log:read` | | **Viewer** | modgud | read-only on `user`, `authorization-group`, `permission-role` | Run `node scripts/seed-demo.mjs` after first login and you'll get additional roles for realistic test setups (see `data/demo-seed.json` for the manifest). ## Resources available per app What resources an app has is defined by the app itself — see [Applications](./applications). The system app `modgud` has these built in: | Resource | Typical actions | | --- | --- | | **app** | read, write, admin (for app management itself) | | **user** | read, write | | **service-account** | read, write | | **role** | read, write | | **authorization-group** | read, write | | **permission-role** | read, write | | **session** | read, write | | **auth-log** | read | | **audit-log** | read | | **gdpr** | admin | | **oauth** | admin | | **oauth-client** | read, write | | **oauth-scope** | read, write | | **oauth-api** | read, write | | **login-provider** | admin, read, write | | **realm-settings** | read, write | | **asset** | read, write | | **observability** | read | | **scheduled-job** | read, write | | **inbox-settings** | read, write | The **realm** resource (realm CRUD) lives in a separate `control-plane` app, seeded only into the control-plane realm — see [Realms](./realms) — not in `modgud`. External apps (Acme-Tasks, Knowledge, …) bring their own resources, defined in their App record. ## Creating or editing a role Administration → **Roles** → **Create**, or double-click an entry. ![Role detail](/screenshots/admin-rolle-detail.png) The modal has two tabs: **General** * **Name** (unique per realm) * **Description** (optional) * **Application** — which app does this role belong to? Pick "— None (realm-admin role)" for a pure bypass role (only meaningful together with **Privileged role** below); otherwise a role belongs to exactly one Application. * **Privileged role** — a checkbox, independent of the Application link. Grants `realm:admin` — the realm-wide bypass. Reserved for the System Admin role. **Permissions** A checklist of the linked Application's permission catalog, one row per `resource:action` entry. Check as many as the role should grant — there's no per-resource limit, so a role can (and often does) span several resources of the same app in one go. ### Multi-resource roles A role isn't limited to one resource. The seeded **User Manager** role, for example, checks entries across `user`, `session`, `authorization-group`, `permission-role`, `auth-log` and `audit-log` — all from the `modgud` catalog, all on one role. ## Cloning a role To make a variant of a role — say a tighter copy of an existing one — right-click it in the list → **Clone**. The Create modal opens pre-filled: the linked Application, the selected permission subset and the realm-admin flag are copied; only the **Name** is blank. Give the copy a new name, adjust the permission selection, and create. ## Cross-app roles (special case) A role is always linked to exactly one Application (or none, for a pure realm-admin role) — there's no way to check permissions from two different apps' catalogs on the same role. To grant someone rights in both, say, `modgud` and Acme-Tasks, create two roles (one per app) and put both in the same group — or in two groups, if you also want their `BoundTo` scoping to differ. Cleaner to understand and audit than a single sprawling role either way. ## Bypass roles A role becomes a bypass role through either of two mechanisms: | Mechanism | Effect | | --- | --- | | **Privileged role** checkbox set | realm-wide bypass (`realm:admin`) — works in every app, ignores the Application link | | A catalog entry with action `admin` checked (e.g. `user:admin`) | resource-wide bypass — every action on that resource, within the role's linked Application | There's no app-wide bypass in between — a role is either realm-wide or scoped down to individual resources. On setup exactly one user is seeded as realm admin (System Admin role + the realm's admin group, `BoundTo: ["*"]`). Grant sparingly — realm-admin is the nuclear option. ## Deleting a role List → right-click → **Delete**. ::: warning Soft delete Roles are soft-deleted. Groups that referenced the role keep the entry technically — but the role contributes no permissions any more. To remove a role cleanly, remove it from all groups first. ::: ## Tips ::: tip Keep roles narrow Many small roles, each tied to a clear resource, compose freely into groups. A "SuperAdmin" role with every permission is usually a design smell; use `realm:admin` for that, or combine specialised roles in an admin group. ::: ::: tip Per-app roles Roles for Acme-Tasks link to the Acme-Tasks Application, not `modgud`. They show up in the right permission lists, and `[Authorize(Roles = "...")]` in the Acme-Tasks backend finds them via the `resource_access["acme-tasks"]` claim in the token. ::: --- --- url: /admin/groups.md --- # Groups Groups are the **organisational layer** in Modgud. They serve two distinct purposes — and you decide per-group which one applies via the **Active in applications** field. 1. **Authorisation grouping.** Members of the group inherit the group's roles in the apps the group is bound to. 2. **Mailing-list / distribution semantics.** Even a group with no roles and no app binding can carry an email address, expand to its members, and be addressed by notification flows. A user can be a member of any number of groups; a group can be a member of another group (transitive resolution). ![Groups list](/screenshots/admin-gruppen-liste.png) ## Why groups? Roles answer "what may you do"; groups answer "who is this user, organisationally". Splitting the two means you can change a person's department without touching their permissions, or change a permission set without re-onboarding everyone. The strict path from user to permission is: ``` User → Group(s) → Role(s) → Permission(s) ``` Direct user-to-role or user-to-permission assignments don't exist. Membership-via-group is the sole route. ## Creating a group Administration → **Groups** → **Create**. Tabs in the detail dialog: | Tab | Content | | --- | --- | | **General** | Name, description, **Active in applications**, membership mode | | **Members** | Manual user / sub-group assignment (when membership is Static) | | **Script** | JsEval membership script (when membership is Auto) | | **Roles** | Which roles does the group carry? | | **Effective** | The fully expanded member list | ::: info No row-level ABAC in IAM Modgud groups deliberately carry no row-level access policies. Whether the user may see a particular row depends on app-specific data the IAM neither owns nor wants to know — that decision lives in the consuming app. See [Concepts → ABAC](../concepts/abac). ::: ### General * **Name** (unique) * **Description** (optional) * **Membership mode**: * **Manual** — you maintain members manually on the Members tab * **Auto** — membership is computed by a JsEval script over the principal directory * **Active in applications** — *(MultiSelect)* which apps does this group take effect in? See below. #### Active in applications — the activation switch A group can have members and roles without taking effect for permissions. The decision is in **Active in applications**: | Selection | Effect | | --- | --- | | **★ All apps (\*)** | Wildcard — the group is active in **every** app. Typical for the realm-admin group. | | One or more concrete apps | The group only contributes when the requesting app is in this list. | | **empty** | Group is *dormant* for permission purposes — it counts nowhere. Useful for purely organisational groups like mailing lists ("HR team", "Vienna office"). | **Practical behaviour:** you can temporarily remove an app from the list (e.g. during maintenance) without losing role assignments. Re-adding the app reactivates the group immediately. BoundTo changes never cascade-delete the group's roles. **Default for new groups:** empty (dormant). A freshly created group is not active in any app until you explicitly pick one or more apps (or the **★ All apps (\*)** wildcard) here — don't forget this step, or the group's roles will never take effect. ## Static membership Tab **Members** shows two listboxes (drag-and-drop): all principals (users + sub-groups) on the left, current members on the right. Sub-group memberships **are transitive**. If `Vienna Office` contains `Sales-Vienna` which contains user `Max`, Max effectively belongs to all three. ## Auto membership (membership scripts) Switch the mode to **Auto** to enable the **Script** tab. There you write a JsEval expression that returns `true`/`false` per principal: ```typescript // Example: "all users with an @acme.com email" (p) => Type.Is(p, 'person') && p.Email != null && p.Email.endsWith('@acme.com') ``` The script is recompiled and re-evaluated whenever a principal is created or changed (including on external-identity link/unlink). The membership script only sees the fields the IAM itself owns (display name, email, IsActive, linked external identities via `p.ExternalIdentities`, account name, …) — never any app-specific data, since that would couple the IAM to every app's schema. See [Concepts → Auto-Membership](../concepts/auto-membership) for the full field surface (and why sub-collections use `.some(...)`, not `.length`), and [Concepts → ABAC](../concepts/abac) for why row-level ABAC stays out of the IAM. ## Assigning roles Tab **Roles**: pick the roles the group should carry. A group can hold roles from **multiple apps** simultaneously — but they only contribute in apps where the group's BoundTo matches the role's AppSlug. > Example: a group `DevOps Team` with `BoundTo: ["acme", "knowledge"]` and roles `[acme-admin, knowledge-author]`. When a `acme` permission lookup runs, only the `acme-admin` role contributes. When a `knowledge` permission lookup runs, only `knowledge-author` does. ## Effective members Tab **Effective** shows the fully expanded list — direct members plus everyone reached through nested groups, with a "via" hint pointing at the first nested-group hop. Useful for sanity checks before granting a powerful role. ## Cloning a group To make a near-identical group, right-click it in the list → **Clone**. The Create modal opens pre-filled: members, assigned roles, the membership type (manual or the auto-membership script), email mode and the `BoundTo` app list are all copied; only the **Name** is blank. A cloned auto-group starts without the source's last-evaluation error — it re-evaluates on first save. ## Deleting a group List → right-click → **Delete**. ::: warning Soft delete Groups are soft-deleted. Users who were members keep the membership entry technically — but the group contributes nothing any more. To clean up properly, also remove the group from any parent groups first. ::: ## Email & notifications Groups can carry an email address (Tab General, optional). Notification flows can address `@…` and Modgud resolves the recipient list: * **Shared** mode — mail goes to the group's own address (a shared mailbox, distribution list) * **Expand to members** mode — mail goes to each member's individual email, recursively across nested groups Cycle-safe: a group `A` containing `B` containing `A` is detected; expansion stops at the first revisit. ## What happens when a user is in multiple groups? All rights are **unioned**. If you're in two groups with different roles, you hold the combined permissions. There's no priority between groups. If two groups bring different `BoundTo` lists, both are evaluated independently — the user is "active" in any app that any of their groups covers. --- --- url: /admin/oauth-clients.md --- # OAuth Clients An **OAuth client** is an app that signs in to Modgud as the identity provider and authenticates its own users via OAuth 2.0 / OpenID Connect. Examples: * A web app using Single Sign-On * A mobile app fetching tokens for its own API * A CLI tool with the device-code flow * A server-to-server job using client-credentials ![OAuth clients list](/screenshots/admin-oauth-clients.png) ## Relationship to Applications Every OAuth client can be linked to **zero, one, or more [Applications](./applications)** (n:m, multi-select dropdown in the detail modal). The link controls two things: 1. **Token contents** — on `/connect/userinfo`, the issued token carries a `resource_access` block per linked app, with the user's app-specific roles. Resource servers read their own block (Keycloak convention). 2. **Scope restriction** — the client may only request scopes that belong to one of its apps (or are global, like the OIDC standard scopes `openid`, `email`, `profile`, `roles`, `offline_access`). The default case is **one client → one app** (`acme-web` belongs to `acme`). Multi-app clients exist for bundle frontends that talk to several resource servers at once. ::: tip First time? Use the [SaaS App Integration Walkthrough](../integrate/saas-walkthrough) for the linear path through your first integration. ::: ## Creating a client Administration → **OAuth → Clients** → **Create**. The create modal exposes the full configuration up front — identity on the left, and tabs for **Grants**, **Scopes**, **Redirect URIs** and **Apps** on the right. (These used to be edit-only, so a freshly created client was born non-functional.) Set them at create time and the client is usable immediately. ::: tip authorization\_code clients: two create-time requirements For an `authorization_code` client the Create button stays disabled until you have both: at least one **Redirect URI** (URLs tab) and the **`authorization_code`** grant (Grants tab). This stops you from silently producing a client that can't complete a login. ::: ### Required fields * **Client ID** — unique technical identifier (`web-app-prod`, `mobile-ios`, …). Sent in every OAuth request. * **Display Name** — what the user sees on the consent screen * **Client type** — see below ### Client types There are exactly two client types — `public` and `confidential`: | Type | For | Secret? | | --- | --- | --- | | **Confidential** | Server-side web apps (ASP.NET, Node, Rails) — can store secrets | Yes | | **Public** | SPAs and mobile apps — can't safely store secrets | No, PKCE only | ::: tip Machine-to-machine? Use a Service Account There is no separate "service" client type. For server-to-server flows with no user involved, create a [Service Account](./service-accounts) — it owns a confidential client wired to the `client_credentials` grant. The standard create-client form deliberately can't produce a client-credentials client on its own (see the grant-type rules below). ::: ### Consent type | Type | Behaviour | | --- | --- | | **Implicit** | First-party app — no consent screen, immediate redirect | | **Explicit** | The user must click "Allow" once per scope set | | **External** | Consent is obtained out-of-band; Modgud doesn't intervene | ### Applications The **Applications** multi-select binds the client to one or more apps. Empty means realm-wide (no app context — good for a tool that genuinely doesn't belong to any specific app). Picking multiple apps means: when this client requests a token and asks for the `roles` scope, the issued token's UserInfo carries a `resource_access` block for each picked app. That's how multi-app frontends work. ### Redirect URIs One per line. Modgud strictly checks that the redirect URI presented in the auth request is one of these. For SPAs and mobile use a deep link (`com.example.app:/oauth/callback`) or a HTTPS callback page on your domain. ### Access Token Type New clients default to **JWT**. Two options: | Type | What it is | Validation | | --- | --- | --- | | **JWT** (default) | Self-contained signed token — the claims are inside the token | The resource server validates it locally against the realm's signing key (JWKS); no callback to Modgud | | **Reference** | Opaque random string — carries no claims | The resource server must call `/connect/introspect` on every request to resolve it | A resource server built with ASP.NET Core's `AddJwtBearer` expects a **JWT** — that's the right pick for the common case. Use **Reference** only when you specifically want every token resolvable/revocable at the introspection endpoint and you've wired the RS to call it. ### Allowed CORS Origins One origin per line (e.g. `https://app.acme.example.com`). This field is **enforced** — it's not decorative. For a browser-only SPA doing Authorization Code + PKCE with no backend-for-frontend, Modgud emits the CORS headers on the credentialed OIDC endpoints (`/connect/token`, `/connect/userinfo`, `/connect/revoke`) **only** when the request's `Origin` is one of these registered values, so the flow can complete cross-origin. (The public metadata endpoints — `/.well-known/openid-configuration` and `/.well-known/jwks` — are readable from any origin regardless.) ::: tip Changes take effect within ~60 s The allowed-origins set is cached per realm for about a minute, so after adding an origin give it up to ~60 s before the browser flow starts succeeding. ::: ### Allowed grant types Pick the grants the client actually needs (multi-select). There are **no silent defaults** — a client created with zero grants can't mint any token, so the Create button stays blocked until at least one grant is picked. Common combinations: | Combo | Use case | | --- | --- | | `authorization_code, refresh_token` | Web app / SPA / mobile (with PKCE on public clients) | | `client_credentials` | Machine-to-machine — but only via a [Service Account](./service-accounts) (see below) | | `urn:ietf:params:oauth:grant-type:device_code` | A CLI tool or other input-constrained device — see the [device flow reference](../reference/oauth-api#device-flow) | | `urn:cocoar:otp`, `urn:cocoar:magic`, `urn:cocoar:passkey` | Native passwordless grants for first-party mobile/desktop apps (realm must have Native Passwordless Grants enabled under Realm Settings) — see [Native app integration](../integrate/native-apps) | ::: warning No hybrid user-flow + client-credentials clients A client is **either** a user-flow client (`authorization_code` / `refresh_token` / `device_code` / …) **or** a machine-to-machine client (`client_credentials`) — never both. The split is structural, enforced at the create/update endpoint: * `client_credentials` requires the client to be linked to a [Service Account](./service-accounts); the standard create-client form has no such link field, so it rejects a bare `client_credentials` selection. * A Service-Account-linked client may carry **only** `client_credentials` — adding any user-flow grant alongside it is rejected. To get machine-to-machine tokens, create a Service Account; it provisions the confidential client + `client_credentials` grant for you. ::: ### Lifetimes The **Token Lifetimes** tab is edit-only (it appears once a client exists, not on the create form). Each field is **entered in seconds**; leaving it empty falls back to the IdP default. The defaults are: | Field | Default | In seconds | | --- | --- | --- | | **Access Token Lifetime** | 60 min | `3600` | | **Authorization Code Lifetime** | 5 min | `300` | | **Identity Token Lifetime** | OpenIddict default (no Modgud override) | — | | **Sliding Refresh Token Lifetime** | OpenIddict default (no Modgud override) | — | Access-token, authorization-code and refresh-token defaults are set globally on the IdP (`AccessTokenLifetimeMinutes`, `AuthorizationCodeLifetimeMinutes`, `RefreshTokenLifetimeDays`). The identity-token and sliding-refresh fields have no Modgud-level default — leave them blank unless you have a specific reason to override OpenIddict's built-in value. ## Editing / regenerating Open a client by double-click. Most fields can be edited live; **Client ID** is immutable after creation. The **Regenerate Secret** button at the bottom rotates the client secret. Old secret stops working immediately, new one is shown once — copy it now. ## Cloning a client **Client ID** is immutable, so to stand up a near-identical client — or to effectively rename one — clone it. List → right-click → **Clone**. The Create modal opens pre-filled: scopes, grants, redirect URIs, app links, token lifetimes and the rest are copied; only **Client ID** is blank (enter a new one). The **client secret is not copied** — a fresh one is generated on create and shown once, exactly as for a brand-new client. DCR registration metadata and any Service-Account link are dropped, so the copy is a plain admin-created client. ## Deleting List → right-click → **Delete**. Soft-deleted entries can still be queried for audit purposes but are excluded from the OAuth flow. ## Tips ::: tip One client per integration, not per environment Use a single client `acme-web` and configure multiple redirect URIs for prod/staging/dev — instead of three separate clients. Easier to maintain, fewer secrets to rotate. ::: ::: warning Don't share secrets A client secret is the proof a confidential client is legitimate. Don't paste it into source control, email it, or include it in JS bundles. Use environment variables / secret stores. ::: --- --- url: /admin/oauth-scopes.md --- # OAuth Scopes **Scopes** define what permissions an OAuth client may request from the user — and which resources (APIs) the resulting token may target. ![OAuth scopes list](/screenshots/admin-oauth-scopes.png) ## Standard scopes (seeded per realm) Every realm is seeded with these six scopes — they're created at realm provisioning and you don't need to manage them: | Scope | Contents | | --- | --- | | `openid` | Subject (user ID) — required for any OIDC request | | `profile` | First/last name, preferred username | | `email` | Email address + `email_verified` flag | | `offline_access` | Allows issuing refresh tokens | | `roles` | Triggers the `resource_access` block with the user's roles per Audience | | `permissions` | Triggers the `resource_access` block with the user's bypass-pre-expanded permissions narrowed to the calling RS's subset | The OIDC-standard `phone` and `address` scopes are recognised by OpenIddict but **not** auto-seeded — add them manually per realm if you need to expose those claims. ## Defining your own scopes For your own APIs/resources you define custom scopes — e.g. `acme.read`, `acme.write`, `crm.api`. Administration → **OAuth → Scopes** → **Create**. ### Fields * **Name** — the technical scope string, exactly as it appears in `scope=…` requests (e.g. `acme.read`) * **Display Name** — appears on the consent screen ("Read Acme") * **Description** — plain-language explanation on the consent screen ("Allows the Acme app to read your tasks") * **Application** — the [App](./applications) this scope belongs to. Empty = global (cross-app, like the standard OIDC scopes) * **Resources** — list of resource URIs (audience) for which tokens with this scope are issued ### Application binding App-scoped scopes can only be requested by OAuth clients whose `AppIds` list contains the same App. The standard OIDC scopes are global (`AppId = null`), so any client may request them. If a client requests an app-scoped scope it isn't entitled to, `/connect/authorize` rejects with `invalid_scope`. ### Resources (audience) A **resource URI** identifies the resource server (API) that accepts tokens. Example: * Scope: `acme.read` * Resource: `https://api.acme.example.com` When a client requests `scope=acme.read` and gets back an access token, the token's `aud` claim contains `https://api.acme.example.com` — the Acme API checks exactly that during token validation and rejects everything else. ::: warning Audience mismatch If the resource URI here is spelled differently from how the API checks during validation (e.g. `http` vs. `https`, trailing slash, port differences), every API request fails with `401 Unauthorized — invalid audience`. Keep both sides in sync. ::: ### Discovery visibility Every scope has a **`Show in discovery document`** flag. When `true`, the scope's name is listed in the realm's `/.well-known/openid-configuration` under `scopes_supported`. When `false`, the scope still works for normal client requests, but is not advertised publicly. Discovery visibility is **opt-out, not opt-in** — anything you create normally is visible: * **OIDC standard scopes** (`openid`, `profile`, `email`, `offline_access`, `roles`, `permissions`) default to `true`. * **Scopes you create in the admin UI** (including app- / API-scoped ones) also default to `true`. Untick the flag if you'd rather keep a scope name out of public metadata. * **Implicit scopes auto-created from an [OAuth API](./oauth-apis)** (the one-click "Create implicit scope" path) are the only exception — they default to `false`, so a one-click bootstrap doesn't leak the resource server's name into public discovery. You can flip the flag on them afterwards if you want them advertised. Hiding a scope from discovery is privacy-by-default that prevents drive-by enumeration of which APIs a tenant operates; it is not access control (see the tip below). ::: tip Hiding is tenant isolation, not security Hiding scopes from discovery is defense-in-depth. An attacker can still try arbitrary `scope=` values at the token endpoint — they'll just have to guess instead of reading the list. The realm-DB validation is the actual access control. ::: ## Allowing a scope on a client In the [OAuth client](./oauth-clients) → tab **Scopes** → add the new scope to "Allowed scopes". Only then may the client include it in its authorisation request. ## Cloning a scope Scope **Name** is immutable, so to make a variant of an existing scope, clone it. List → right-click → **Clone**. The Create modal opens pre-filled — display name, description, resources, user claims, the app binding and all the flags are copied; only **Name** is blank. A standard OIDC scope can be cloned too — the copy is an ordinary editable scope. ## Deleting a scope List → right-click → **Delete** (soft delete). ::: warning Active tokens stay valid Already-issued tokens carrying the deleted scope remain valid until their lifetime expires — deletion only affects newly issued tokens. For compromised scopes, also revoke active tokens or set the shortest practical token lifetime. ::: ## Tips ::: tip Scope granularity A rule of thumb: one scope per semantic operation, not per endpoint. Example: * good: `acme.read`, `acme.write`, `acme.admin` * bad: `acme.task.list`, `acme.task.detail`, `acme.task.create`, `acme.task.update`, … Too granular = the consent screen becomes unreadable. Too coarse = apps need more power than they should. ::: ::: tip Dot namespacing Convention: name scopes `.` (`acme.read`, `crm.write`). Makes it obvious in consent screens and token inspectors which scope belongs to which API. ::: --- --- url: /admin/oauth-apis.md --- # OAuth APIs (Resource Servers) An **OAuth API** in Modgud is the registration of a **resource server** — an API that wants to validate access tokens issued by Modgud and use them to authorise requests. ::: info OAuth API vs OAuth Client * **OAuth Client** = the app that performs the user login and **gets** tokens * **OAuth API** = the API that **validates** tokens and authorises requests against them An app can be both (e.g. a BFF pattern: user-login as a client, its own API as an API). ::: ![OAuth APIs list](/screenshots/admin-oauth-apis.png) ## When do I need an OAuth API registration? For most cases — a SaaS app that validates Modgud tokens — yes, you register an OAuth API for it. The registration is what lets Modgud emit a tailored `resource_access` block for this RS on `/connect/userinfo`. Specifically, it's required when: * You want **per-Audience permission narrowing** in `resource_access` blocks. The RS declares its `PermissionIds` subset of the App's catalog, and the IdP narrows each user's emission to that subset. * The API wants to **authenticate against the OAuth server itself** (e.g. for token introspection) * You want **multi-secret support** (several parallel valid secrets, e.g. for seamless rotation) * The API needs **explicit scope lists** for discovery ## Relationship to Applications Every OAuth API belongs to **exactly one [Application](./applications)**. A microservice architecture under one app — e.g. `acme-api`, `acme-search`, `acme-files` all linked to the App `acme` — works because permissions stay app-centric: each microservice gets its own `PermissionIds` subset of the same App catalog, and the IdP narrows its `resource_access[acme]` emission accordingly. ## Creating an API Administration → **OAuth → APIs** → **Create**. ### Required fields * **Audience (aud)** — technical identifier (e.g. `acme-api`). Used in `aud` claims when the token is issued. * **Display Name** — UI label * **Application** — which App does this RS belong to? Required for per-Audience subset narrowing. * **Description** — optional ### PermissionIds The subset of the linked App's catalog this RS gates on. Used by the IdP to narrow the `resource_access` block in UserInfo for this audience — sibling RSs under the same App don't see each other's permissions in the user's claims. Default at creation: full catalog. Tighten to a strict subset for microservices that only need a slice. ### Scopes A list of scope names this API understands. Any token whose `scope` claim contains one of these is considered "for this API". Used for OIDC discovery and resource indication. #### One-click implicit scope In the API detail modal there is a **Create implicit scope** button when the API has no scope with the same name yet (it hits `POST /api/admin/oauth/apis/{id}/create-implicit-scope`). Clicking it creates a real `OAuthScope` row with: * `Name` = API name * `Resources` = `[]` (so the audience matches the API) * `Enabled = true`, `ShowInDiscoveryDocument = false` (private by default, see below) * Linked to the same App as the API Why you usually want this: without a scope whose `Resources` lists the API name, a token requested for this API carries no matching `aud` claim, and the IdP emits no `resource_access` block for the API. The implicit scope is what couples the two — once a client requests `scope=`, the issued token gets `aud=` and the RS's `resource_access` block is populated. It is the fast path for the common 1:1 case: an API and a scope that always go together. After creation the button disappears (re-check via API list reload). The implicit scope is otherwise a normal scope row — editable, deletable, and requestable by clients via `scope=`. ::: tip When to keep things separate Two situations warrant a manually-created additional scope on top of the implicit one: * **Granularity** — `.read` / `.write` / `.admin` against the same audience. Differentiates capabilities via `scp`, not `aud`. * **Multi-RS scope** — one scope name pointing to multiple APIs (`scope=admin` → `aud: [policy-api, audit-api]`). Edge case but valid. ::: ### User claims Optional list of claim types this API expects in tokens. Used by some IdP-side filtering mechanisms; for most setups, leave empty. ## How a resource server authenticates against Modgud An OAuth API has **no credential surface of its own**. When the resource server needs to call Modgud directly (e.g. token introspection), it does so via OAuth using a confidential [OAuth Client](./oauth-clients) linked to a [Service Account](./service-accounts): the client requests an access token via Client-Credentials and uses it as a bearer like any other token. There is no per-API shared secret to rotate. ## Editing Most fields can be edited live; **Audience (aud)** is immutable after creation. Changing the linked **Application** is allowed but be careful — the RS's scope-resolution and the per-Audience `resource_access` shape immediately switch to the new app context. ## Cloning an API **Audience (aud)** is immutable, so to make a near-identical resource server, clone it. List → right-click → **Clone**. The Create wizard opens pre-filled — display name, description, scopes, user claims, the linked Application and its catalog subset are copied; only **Audience (aud)** is blank. API secrets are not copied; the copy starts with none. ## Deleting List → right-click → **Delete**. Soft-deleted; the OAuth API is no longer usable but the aggregate stream is retained for audit. ## Common patterns ### One app, one resource server Default for most SaaS apps: create one OAuth API named after the app's slug, link it to the App, and pick the catalog subset it gates on. ### One app, multiple resource servers (microservices) Each microservice gets its own OAuth API entry with its own narrower `PermissionIds` subset of the App's catalog. All link to the same App. Per-Audience narrowing in UserInfo means a token used against microservice A only carries A's permission subset, not B's — even when both are under the same App. ### Multi-tenant API If the same API logic serves multiple realms, each realm gets its own OAuth API entry. Modgud's tenancy already enforces realm separation at the database level, so a query-level lookup can't reach another realm's tokens — each realm's OpenIddict store lives in its own database. ## Tips ::: tip Audit trail RS-Auth-protected endpoint calls log the calling RS's name. Useful when several microservices share one App and you want to know which specific RS made a given request. ::: ::: tip Two distinct identities A user bearer token identifies the user; the RS-as-OAuth-client identity (a Client-Credentials access token minted via a Service Account) identifies the RS itself. They sit on independent authentication axes — both can be relevant on the same request. ::: --- --- url: /admin/invite-codes.md --- # Invite Codes An **invite code** is a single-use, app-scoped token that gates self-registration under the `InviteCode` posture (ADR-0012): an unknown email can only become an account by presenting a valid, unused, unexpired code on its native sign-up request. This page covers the dedicated **Invite Codes** admin surface — minting, listing, revoking, and reading back a code's status. For the posture itself (what it means, how it compares to `Off` / `JitOnOtp` / `ExplicitEndpoint`) see [Applications → Self-registration posture](./applications#self-registration-posture). ::: tip Not a general invite system Modgud's invite code only decides **who may exist** — it creates a passwordless account and nothing else. What the invite is actually *for* (a beta list, a paid seat, a team) stays entirely in the consuming app; Modgud only ever learns `(email, code, appId)`. ::: ## Surface * Admin grid: **`/admin/invite-codes`** (sidebar: OAuth & Federation → Invite Codes) * Permissions: `invite-code:read` (view), `invite-code:write` (mint, revoke) — both live in the `modgud` system app's catalog, so they're granted the same way as `user:read` or `oauth-client:write`, independent of which app the codes themselves belong to * Backing API: * `GET /api/admin/invite-codes` — every app's codes in the current realm, newest first. This is what the admin grid loads; there is no M2M equivalent for this realm-wide view, so it gates on `invite-code:read` only. * `POST /api/app/{appId}/invite-codes` — mint N codes for one app. Body: `Count`, optional `BoundEmail`, optional `ExpiresInDays`. * `GET /api/app/{appId}/invite-codes` — list one app's codes (metadata only). * `DELETE /api/app/{appId}/invite-codes/{id}` — revoke an unused code. The three app-scoped endpoints accept **either** an admin cookie session holding `invite-code:read`/`invite-code:write`, **or** a `client_credentials` bearer token carrying the app-bound `invite:read`/`invite:write` OAuth scope — see [Minting from a backend (M2M)](#minting-from-a-backend-m2m) below. The admin grid only ever uses the first two (list-all + mint); a backend integration typically only ever needs mint. ## Turning it on Invite codes only have an effect once an [Application](./applications)'s self-registration posture is set to `InviteCode`: 1. Open the app from **Administration → Applications**, switch to its **Settings** tab, and turn on the **Self-registration** override. 2. Set **Posture** to **Invite code (invite-only)**. 3. Save. With that in place, the app's native sign-up endpoint (`POST /api/account/native/otp/request`) creates an account only when the request's `InviteCode` field carries a code that redeems for this app; everything else is silently treated the same as `Off` (see [Code redemption](#code-redemption-and-anti-enumeration) below). No further app configuration is required to mint from the admin UI — the two setup steps in [Minting from a backend (M2M)](#minting-from-a-backend-m2m) are only needed if a backend should mint codes itself. ## The Invite Codes list The grid loads **every app's codes for the realm in one call** and filters client-side by the same header App selector the OAuth Clients / Scopes / APIs grids use — pick an app to narrow the view, or leave it on "all" to see the whole realm. Selecting the built-in "global" option shows nothing, because a code is always bound to exactly one app. | Column | Meaning | | --- | --- | | App | The owning Application (resolved from `AppId`) | | Bound to | The email the code is restricted to, or "Bearer (anyone)" when unset | | Status | `Open`, `Used`, or `Expired` — computed, not stored | | Created | When the code was minted | | Expires | When an unused code stops working | | Created by | The subject that minted it — an admin's user id or the ServiceAccount that called the M2M endpoint | Double-click a row to open its **details** (adds who/when it was redeemed, once known). The list updates live: minting or revoking a code — from this admin session, another admin's session, or a backend calling the M2M endpoint — pushes a change over the realm's SignalR stream and the grid reloads automatically, no manual refresh needed. ## Minting codes Click **Mint codes** (top-right of the grid, or the empty-state call to action) to open the mint dialog: 1. **App** — which Application these codes belong to. Pre-filled from the header's current App selection if one is active. Codes are single-use and permanently bound to this app. 2. **How many** — number of codes to generate in one batch (default 1). 3. **Expires in (days)** — code lifetime; defaults to **14 days** if left blank (the same default the API applies when the field is omitted entirely). 4. **Bind to email (optional)** — leave blank to mint bearer codes (anyone holding the code can redeem it); fill in an address to restrict a code to that exact recipient. The email is normalised (trimmed, lower-cased) before comparison at redemption time. Click **Mint codes** to generate the batch. The plaintext codes are displayed **exactly once**, in a copyable block, with a **Copy all** button — closing the dialog or navigating away loses them for good, because the server only ever stores a SHA-256 hash. If you need more codes later, mint a fresh batch; there is no way to recover or re-display an already-minted plaintext. ## Revoking a code Right-click a row → **Revoke** to delete a code before anyone uses it. The UI only allows this on codes still showing **Open** — attempting it on a `Used` or `Expired` row shows a blocking message instead of deleting. (Server-side the only hard rule is that a *used* code can never be deleted; an expired-but-unused code is technically still revocable through the API, the UI just doesn't expose that case since letting it expire has the same practical effect.) Revoking uses the code's own app internally, so it works regardless of which app the header selector currently shows. ## Code redemption and anti-enumeration Redemption happens **implicitly** on the native passwordless sign-up path — there is no dedicated "redeem" endpoint. The mobile/SPA client that owns the invite passes it as the `InviteCode` field on `POST /api/account/native/otp/request` (see [Integrate → Native apps](../integrate/native-apps)); Modgud then: 1. Looks up the code by its hash for the request's app. 2. Rejects (silently — see below) if the code doesn't exist, is already used, is expired, or is bound to a different email than the one signing up. 3. Marks the code used **before** the account is created, under optimistic concurrency, so two simultaneous redemption attempts of the same bearer code can't both succeed — the loser sees the same silent rejection as an invalid code. 4. Creates the passwordless account and emails it the registration OTP, exactly as under the `JitOnOtp` posture. Every failure mode (missing code, wrong code, already used, expired, email mismatch, lost the concurrency race) is **indistinguishable from the `Off` posture** to the caller — the endpoint always returns the same generic "if your email is registered…" message. This keeps the invite-only posture from becoming an oracle for which codes are valid or which emails are already known. A **confirmed, existing user** signing in through the same endpoint never needs a code at all — the code is ignored on that path, invite codes only gate the creation of brand-new accounts. ## Minting from a backend (M2M) If a consuming app should mint its own codes — for example, a user inviting a teammate from inside the product — set up the machine path once: 1. Create an OAuth scope named **`invite:write`** (add `invite:read` too if the backend also needs to list its own app's codes) bound to the target app (its App-ID set) under [OAuth Scopes](./oauth-scopes). Scope names are unique per realm, so name it per app in a multi-app realm. 2. Give a [Service Account](./service-accounts) a `client_credentials` credential carrying that scope. 3. Have the backend call `POST /api/app/{appId}/invite-codes` with its `client_credentials` access token, e.g. `{ "Count": 5, "BoundEmail": null, "ExpiresInDays": 7 }`. The `{appId}` in the path must match one of the apps the token's client is bound to (`AppIds`) — a token that carries `invite:write` but targets a different app is rejected with `403`, never silently redirected to the caller's own app. The response carries the plaintext codes exactly once, same as the admin dialog; only the hash is persisted. A backend that also needs to broker its own user login (e.g. a BFF redeeming a native grant) needs a **second**, separate OAuth client for this `client_credentials` leg — see [Service Accounts → strict grant separation](./service-accounts#strict-grant-separation). ## Hygiene sweep Used and expired invite codes are hard-deleted automatically by the daily `account-lifecycle-sweep` [scheduled job](./scheduled-jobs#account-lifecycle-sweep-—-account-lifecycle-sweep) — this is pure housekeeping, not a correctness requirement, since an expired-but-unpruned code already fails validation on its own. The sweep's summary (including the invite-codes-pruned count) lands in the [Auth Log](./auth-log) alongside the rest of that job's account-lifecycle counters. ## Related * [Applications → Self-registration posture](./applications#self-registration-posture) — the four postures and how `InviteCode` compares to `Off` / `JitOnOtp` / `ExplicitEndpoint` * [Integrate → Native apps](../integrate/native-apps) — the native passwordless sign-up flow that redeems a code * [Service Accounts](./service-accounts) — issuing the `client_credentials` credential a backend uses to mint codes itself * [OAuth Scopes](./oauth-scopes) — creating the app-bound `invite:write` / `invite:read` scopes for the M2M path * [Scheduled Jobs](./scheduled-jobs) — the sweep that prunes used/expired codes * [Auth Log](./auth-log) — where the sweep's counters show up --- --- url: /admin/dynamic-client-registration.md --- # Dynamic Client Registration **Dynamic Client Registration** (DCR, [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) lets a piece of software register itself as an OAuth client without an administrator pre-provisioning it. Modgud ships an MCP-flavoured subset focused on **AI agents and MCP servers**: public PKCE clients only, no client secrets, audience-bound tokens. ::: warning Off by default Every realm starts with DCR disabled. The `POST /connect/register` endpoint refuses requests, and the discovery document omits `registration_endpoint` — visitors can't tell whether the feature exists per realm. ::: ::: info Different from User Self-Registration DCR registers **software** (an OAuth client). [Self-Registration](./realm-settings#self-registration) registers **people** (user accounts). Two unrelated concepts sharing the word "register". ::: ::: tip Per-Application override The DCR policy below is the **realm default**. An individual [Application](./applications#application-settings) can override it per-app (enable/disable, token lifetimes, rate limits, reserved-name blocklist). The garbage-collection interval stays realm-only — the GC job iterates per realm. ::: ## When to enable it Enable DCR when **you want AI agents you don't pre-trust** to be able to attach to your MCP server (or other OAuth-protected API) without an administrator walking each one through client creation. Typical example: a user pastes your MCP server's URL into Claude Code, Cursor, Continue, or claude.ai. The MCP-spec authorization flow goes: 1. Agent hits the MCP server with no token → 401 with `WWW-Authenticate: resource_metadata="…"`. 2. Agent fetches the protected-resource metadata, learns this realm is the auth server. 3. Agent fetches `/.well-known/oauth-authorization-server` → sees `registration_endpoint`. 4. Agent `POST`s its name + redirect URI to `/connect/register` → gets back a `client_id`. 5. Agent runs Authorization-Code + PKCE with `resource=` → audience-bound access token. Without DCR **or** [CIMD](/admin/client-id-metadata-documents), step 4 isn't possible and every agent has to be onboarded manually. DCR is the stored-client fallback for agents that don't support CIMD; with one pre-registered client an admin can pilot the integration, but "anyone with an agent attaches" needs DCR or CIMD. ## Triple opt-in design Anonymous registration is gated **three times**. All three must be on for a DCR-registered client to be able to mint usable tokens. | Layer | Where | Default | | --- | --- | --- | | Realm master toggle | [Realm Settings → Dynamic Client Registration](./realm-settings) tab | Off | | Per-API allow-list | [OAuth APIs](./oauth-apis) → **Allow DCR** checkbox per row | Off | | Per-Scope allow-list | [OAuth Scopes](./oauth-scopes) → **Dynamic Client Registration** toggle per row | Off | The master toggle just turns the registration endpoint on. The per-API flag controls which resource servers a DCR client can target with `resource=`. The per-scope flag controls which scopes a DCR client can ever request. A DCR-registered client that sets `resource=` to an API that hasn't ticked **Allow DCR** is rejected at the token endpoint with `invalid_target`. ### How the per-Scope flag interacts with app-scoped scopes Most scopes you create in Modgud are **app-scoped** — they belong to one [Application](./applications) (`Scope.AppId` is set). Non-DCR clients are restricted to scopes whose `AppId` matches one of their own linked Apps. **DCR clients have no `AppId`** by design (they're realm-wide public PKCE clients), so the per-Scope `Allow DCR Clients` flag replaces the app-link check for them: * **Global scopes** (`AppId = null` — the OIDC standards `openid`, `email`, `profile`, … plus any cross-app scope you create): always reachable by DCR clients. * **App-scoped scope with `Allow DCR Clients = true`**: reachable by DCR clients. The realm-admin has explicitly opted this scope in for anonymous-registrant access. * **App-scoped scope with `Allow DCR Clients = false`** (default): `/connect/authorize` rejects the request with `invalid_scope` before the user ever sees the consent screen. The agent gets a clear error description. The combined effect: enabling DCR safely requires you to walk through your existing scopes once and decide which ones agents are allowed to ask for. Until you tick `Allow DCR Clients` on at least one app-scoped scope (or create a fresh global scope), DCR clients can only request the OIDC standard scopes. ## Enabling DCR for a realm 1. **Realm Settings → Dynamic Client Registration** → enable. 2. Set: * **Access-token lifetime** (default 15 min) — shorter than admin-created clients on purpose; a leaked token has a smaller blast radius. * **Refresh-token lifetime** (default 7 d). Rotation is global-on at the server level. * **GC TTL** (default 90 d) — unused DCR clients get soft-deleted after this. * **Per-IP rate-limit** (default 5/h), **Per-realm rate-limit** (default 100/d) — caps spray. * **Reserved names** — substring blocklist for `client_name`. NFKC-normalised + case-insensitive. Use it for your own trademark plus anything you don't want impersonated ("Cocoar", "Anthropic", …). 3. **OAuth APIs → your MCP-server API** → tick **Allow DCR**. 4. **OAuth Scopes → the scope(s) the MCP server gates** → enable the **Dynamic Client Registration** toggle. After these four steps, an agent that POSTs to `/connect/register` with a valid payload gets a `client_id` back and can complete the full auth-code + PKCE flow against your opted-in API. ## What's accepted at `/connect/register` | Field | Rule | | --- | --- | | `redirect_uris` | At least one. Each must be HTTPS, OR `http://localhost`, `http://127.0.0.1`, `http://[::1]`. No custom URI schemes (`com.example.app://`). No fragments. | | `client_name` | Required. ≤ 80 chars. ASCII / Latin-1 only after NFKC normalisation. Must not match a substring on the realm's reserved-names list (case-insensitive). | | `token_endpoint_auth_method` | Must be `none` (or omitted). Public PKCE only — no secret-storage. | | `grant_types` | Subset of `{authorization_code, refresh_token}`. | | `response_types` | Subset of `{code}`. No implicit / hybrid flows. | On success the endpoint returns `201 Created` with the assigned `client_id` per RFC 7591 §3.2.1. On rejection it returns `400 Bad Request` with `{ error, error_description }` per §3.2.2. Hitting the rate-limit returns `429`. ## Consent screen for DCR clients DCR-registered clients always go through the explicit consent screen, with two extra cues: * **`[unverified]`** marker next to the client name. * Warning callout: *"This app registered itself — verify the name carefully before authorizing."* `AllowRememberConsent` is forced off for DCR clients, so the AS never skips consent for a new authorization request — a fresh authorize flow always shows the consent screen again. Clients avoid repeated prompts by retaining and refreshing the authorization they already obtained (typical pattern: the agent caches its own consent decision and reuses the refresh token instead of re-authorizing). ## Audit log Every DCR-related event lands in the auth log with a `DCR ` prefix in its message. The [Auth Log](./auth-log) grid's category filter chips are derived from whatever event categories are present, so look for DCR events under the **operations** (and, for rejected registrations, **security-ops**) chip rather than a dedicated DCR chip. | Event | When | | --- | --- | | `DCR client registered` | Successful registration. Fields: IP, Realm, ClientId, ClientName. | | `DCR registration rejected` | Validation rejected. Fields: IP, Reason (`MissingRedirectUri`, `InvalidRedirectUri`, `ClientNameReservedName`, …), ClientName. | | `DCR rate-limit triggered` | Per-IP or per-realm cap hit. | | `DCR client first used` | First successful token-issue for the new `client_id`. Cleanest signal that the registration was real, not bot noise. | | `DCR client garbage collected` | GC sweep soft-deleted a stale client. Fields: ClientId, RegisteredAt, LastUsedAt, TtlDays. | ## Managing DCR-registered clients The standard [OAuth Clients](./oauth-clients) grid carries a **DCR** column (●) and a **"DCR only"** filter chip. Clicking a DCR client opens the regular detail modal with an additional **Registration Info** tab showing: * Registration timestamp (UTC) * Source IP at registration time * Last successful token-issue timestamp You can delete a DCR client like any other — useful if a name slipped past the reserved-names list. The garbage collector also sweeps inactive ones automatically (default 90 days since last token issue). ## What's NOT in v1 Deferred features with clearly-defined add-on paths: * **`software_statement` (RFC 7591 §2.3)** — vendor-signed JWT that replaces `[unverified]` with `[verified by Anthropic]` etc. Add when a real vendor publishes a stable signing key. * **Initial Access Token (RFC 7591 §3.1)** — admin-issued token required to register. Useful for paranoid realms; defeats the "agent attaches without admin involvement" use case. * **Approval workflow** — DCR clients land in `pending` until admin reviews. * **RFC 7592 management endpoints** — `GET/PUT/DELETE /connect/register/{id}` for updating already-registered clients. Re-registration is the v1 strategy. * **Custom URI schemes** — `com.example.app://callback` for native apps. ## Accepted risks * **Brand impersonation via creative `client_name`** — the reserved-names list catches direct hits, NFKC + Latin-1 catches lookalikes within Latin-1, but a sophisticated lookalike that doesn't match a configured term still passes. The `[unverified]` marker is the final defence, and it relies on the user actually pausing at consent. * **Targeted phishing via HTTPS redirect** — attacker registers a client with `redirect_uri=https://attacker.example/grab`, then social-engineers a specific user to click through. The triple-opt-in constrains *which* resources/capabilities they can reach; the consent marker warns the user; no further filtering in v1. * **Resource + scope targeting is the actual safety primitive** — a DCR client's token is audience-bound to a specific opted-in API AND can only request opted-in scopes. Even if a code is grabbed, the resulting token can't be replayed against unrelated APIs and can't carry high-trust scopes. --- --- url: /admin/client-id-metadata-documents.md --- # Client ID Metadata Documents (CIMD) **Client ID Metadata Documents** ([`draft-ietf-oauth-client-id-metadata-document`](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/), adopted by the IETF OAuth WG) let a piece of software identify itself as an OAuth client by **publishing a metadata document at an HTTPS URL** — and using that URL *as* its `client_id`. The authorization server fetches and validates the document on demand. There is no registration request, no client secret, and no stored client record: the client's **metadata and display identity** are anchored to the HTTPS origin hosting the document. The client itself remains a public PKCE client and performs no cryptographic client authentication in v1 — see [What's NOT in v1](#what-s-not-in-v1) for the `private_key_jwt` option under consideration for v2. CIMD is the **MCP-preferred** client-onboarding path; both claude.ai and ChatGPT support it and fall back to [Dynamic Client Registration](./dynamic-client-registration) when a server doesn't advertise CIMD. ::: warning Off by default Every realm starts with CIMD disabled. A CIMD `client_id` URL is not resolved (the authorize request fails as "unknown client"), and the discovery document omits `client_id_metadata_document_supported` — visitors can't tell whether the feature exists per realm. ::: ::: info CIMD vs DCR [DCR](./dynamic-client-registration) mints and **stores** a client record from a `POST /connect/register`. CIMD stores **nothing**: the `client_id` URL is fetched, validated, and turned into an in-memory client for the duration of the flow. CIMD removes the open registration endpoint entirely; in exchange the server makes an outbound HTTPS request to a client-controlled URL, which is hardened against SSRF (see below). ::: ::: tip Per-Application override The CIMD policy below is the **realm default**. An individual [Application](./applications#application-settings) can override it per-app (enable/disable, token lifetimes). ::: ## When to enable it Enable CIMD when you want **AI agents you don't pre-trust** to attach to your MCP server using the modern, standardised path — without an administrator onboarding each one and without minting a stored record for every stranger. Typical example: a user adds your MCP server to claude.ai. The MCP authorization flow goes: 1. Agent hits the MCP server with no token → 401 with `WWW-Authenticate: resource_metadata="…"`. 2. Agent fetches the protected-resource metadata, learns this realm is the auth server. 3. Agent fetches `/.well-known/oauth-authorization-server` → sees `client_id_metadata_document_supported: true`. 4. Agent uses its own published metadata URL (e.g. `https://claude.ai/.well-known/oauth-client`) **as the `client_id`** and runs Authorization-Code + PKCE with `resource=`. 5. Modgud fetches that URL, validates the document, and issues an audience-bound access token. ## How a CIMD `client_id` is resolved When a client presents an HTTPS-URL `client_id` that isn't a known stored client, and the realm has CIMD enabled, Modgud: 1. Validates the URL shape — `https` scheme, has a path, no fragment, no userinfo, no dot-segments. 2. Fetches it over an **SSRF-hardened** transport (see below), capped at **5 KB**, `Accept: application/json`, ~5 s timeout, **no redirects**. 3. Validates the document (table below). 4. Synthesizes an in-memory public PKCE client with a deterministic id derived from the URL, JWT access tokens, and the redirect URIs / scopes the document declares. 5. Caches the validated document per URL, respecting `Cache-Control` (clamped to between 5 minutes and 24 hours; failures are never cached). No database row is created. A refresh after the cache expires re-fetches and re-validates the live document — if the URL becomes unreachable, refresh fails and the client must re-authenticate. ::: tip Metadata refresh trade-offs This is fail-closed by design: once a cache entry expires, an unreachable metadata URL fails the operation rather than serving the stale, previously-validated document (no stale-if-error fallback). A brief outage of the metadata host can therefore surface as failed authorizations or refreshes for that client until the document is reachable again. For an unauthenticated, self-asserted client, that's the intended trade-off — integrity over availability. ::: ## Opt-in design CIMD shares DCR's resource/scope opt-in surface — flipping the master toggle does not, by itself, expose anything. | Layer | Where | Default | | --- | --- | --- | | Realm master toggle | [Realm Settings → Client ID Metadata Documents](./realm-settings) tab | Off | | Per-API allow-list | [OAuth APIs](./oauth-apis) → **Allow DCR** checkbox per row | Off | | Per-Scope allow-list | [OAuth Scopes](./oauth-scopes) → **Allow DCR Clients** checkbox per row | Off | A CIMD client must send a `resource=` parameter, and the target resource server must have **Allow DCR** enabled — otherwise the token endpoint rejects with `invalid_target`. App-scoped scopes are reachable only when **Allow DCR Clients** is ticked, exactly as for DCR clients (a CIMD client is realm-wide and has no App link of its own). Global scopes (`openid`, `email`, `profile`, …) are always reachable. ## Enabling CIMD for a realm 1. **Realm Settings → Client ID Metadata Documents** → enable. 2. Set: * **Access-token lifetime** (default 15 min) — shorter than admin-created clients on purpose; a leaked token from an unverified, domain-bound client has a smaller blast radius. * **Refresh-token lifetime** (default 7 d). 3. **OAuth APIs → your MCP-server API** → tick **Allow DCR**. 4. **OAuth Scopes → the scope(s) the MCP server gates** → tick **Allow DCR Clients** (for app-scoped scopes). ## What's accepted in the metadata document | Field | Rule | | --- | --- | | `client_id` | Required. Must string-equal the URL the server dereferenced (RFC 3986 §6.2.1 exact match). | | `redirect_uris` | At least one. Each must be HTTPS, OR `http://localhost`, `http://127.0.0.1`, `http://[::1]`. No fragments. Exact-match at `/connect/authorize`. | | `token_endpoint_auth_method` | `none` or omitted. **v1 is public-only** — a `client_secret*` method or any `client_secret` field is rejected. | | `grant_types` | Subset of `{authorization_code, refresh_token}`; must include `authorization_code`. | | `response_types` | Subset of `{code}`. | | `scope` | Optional, space-delimited. The scopes the client may request (still subject to the opt-in gates above). | | `client_name` | Optional. Used as the display name; the consent screen also shows the URL hostname regardless. | A document that fails any rule is rejected and never cached; the authorize request fails as "unknown client". ## SSRF hardening The server fetches a **client-controlled URL**, so the fetcher is locked down: * **HTTPS only**, no redirects (a 30x to an internal host can't be followed). * DNS is resolved by the fetcher, and the connection is pinned to the resolved IP — any **private, loopback, link-local, unique-local, CGNAT, multicast, or documentation** address is refused **at connect time**, closing the DNS-rebinding window. * **5 KB** body cap, ~5 s timeout, `Accept: application/json`. This is why CIMD is opt-in per realm: only enable it if you're comfortable with the realm making outbound HTTPS requests to client-supplied domains. ## Consent screen for CIMD clients A CIMD client always reaches the explicit consent screen on first authorize, with two extra cues: * The **`client_id` hostname** (e.g. `claude.ai`) shown prominently — the domain that owns the document. Verify it matches the app you intended, not just the self-asserted display name. * An **`[unverified]`** marker + warning callout, the same treatment self-registered clients get. ## What's NOT in v1 * **`private_key_jwt`** — confidential CIMD clients (asymmetric client auth via a `jwks_uri` in the document). v1 is public PKCE only. Deferred to v2, which will also revoke on `jwks_uri` change. ## Accepted risks * **Targeted phishing via HTTPS redirect** — a domain owner can publish a document with `redirect_uri=https://attacker.example/grab`. The opt-in gates constrain which resources/scopes the client can reach; the consent hostname + `[unverified]` marker are the user-facing defence. * **Availability coupling** — if the client's metadata URL is unreachable when a cache entry expires, refresh fails until it's back. This is inherent to fetch-on-demand registration. * **Resource + scope targeting is the actual safety primitive** — like DCR, a CIMD client's token is audience-bound to a specific opted-in API and can only request opted-in scopes, so a grabbed code can't be replayed against unrelated APIs. --- --- url: /admin/login-providers.md --- # Login Providers ::: tip Looking for the technical reference? This page is the admin UI walkthrough. For the provider model, dynamic scheme registration, `UserUpdateScript` runtime, and `ExternalIdentityLink` schema see [Guide → Login Providers](/integrate/login-providers). ::: A **login provider** is a way for users to authenticate with Modgud. Today three types are wired up: * **Internal** — built-in username + password, auto-seeded once per realm * **OIDC** — external IdPs (Microsoft Entra ID, Google, Auth0, Keycloak, any OIDC-compliant provider) * **SAML 2.0** — enterprise IdPs (Microsoft Entra ID Enterprise Apps, ADFS, Okta, any SAML-compliant provider). See [SAML federation](./saml-federation) for the SAML-specific walkthrough. Future types — **LDAP**, **Kerberos** — are reserved at the API level and will surface in the picker once the backend handlers ship. ![Login providers list](/screenshots/admin-login-provider.png) ## The Internal provider Each realm is provisioned with a single Internal login provider. It lives in the same admin list as every external provider but is locked from edits: * It cannot be deleted, disabled, or duplicated. The list shows it with a **System** badge. * Trying to create a second Internal entry fails with `LoginProvider.InternalAlreadyExists`. * Trying to edit it fails with `LoginProvider.InternalNotEditable`. The Internal provider is what backs the username + password form on `/login`, the recovery CLI's break-glass admin, and the password-reset flow. ::: tip Keep Internal alive until SSO is fully proven For most corporate setups: keep Internal enabled (for break-glass admin access), add OIDC for everyone else. Once external SSO is fully rolled out and tested across all roles, the Internal provider can stay as a guarded fallback — there is no UI option to delete it on purpose. ::: ## OIDC providers External IdPs let users sign in via SSO instead of maintaining a local password — Modgud retains control over groups, roles, and sessions. ### What the external IdP handles — what Modgud keeps **The IdP handles:** * Authentication (who are you? — password, MFA, biometric) * User-property updates on every login (first/last name, email) **Modgud retains control over:** * Group and role assignment (manual admin management or automatic via membership scripts) * Permissions * Account lifecycle (admins can disable any user even without the IdP) * Audit trail of every login ::: warning IdP claims ≠ automatic roles A user who's in the Entra "Administrators" group does **not** automatically get the `Admin` role in Modgud. You either add them manually to a Modgud group with the right role, or write a membership script that classifies them. This is deliberate — it protects against staleness (IdP group revoked while user offline → unclear when it takes effect) and gives you the final word on access. ::: ### Wiring up Microsoft Entra ID — step by step #### 1. In Entra (Azure portal) **Create an App Registration** 1. Azure Portal → **Microsoft Entra ID** → **App registrations** → **+ New registration** 2. Name: e.g. "Modgud" 3. **Supported account types**: "Accounts in this organizational directory only" (single-tenant) 4. **Redirect URI**: leave empty — we'll fill this in later 5. **Register** **Write down** * **Application (client) ID** — you'll need it as the *Client ID* in Modgud * **Directory (tenant) ID** — you'll need it as the *Tenant ID* **Create a client secret** 1. **Certificates & secrets** → **Client secrets** → **+ New client secret** 2. Name + expiry (24 months recommended; note the rotation date) 3. **Add** 4. **Copy the Value column immediately** — Entra shows the secret only once #### 2. In Modgud **Add the login provider** 1. Admin → **Login Providers** → **Add provider.** A single modal opens — flavor picker in the header, all tabs (General, Connection, User Update Script, Linking & Policies) visible. 2. **Flavor** (header dropdown): *OIDC · Microsoft Entra ID*. Switching flavor in this modal re-seeds the flavor-derived defaults (Scopes, default User Update Script, button icon) without touching what you've already typed in Display Name / Description. 3. **General** tab: enter **Display Name** (e.g. "Company SSO"), a **Slug**, + optional Description. The slug is a short, URL-safe identifier (lowercase letters/digits/hyphens, 3-64 chars) that becomes part of the Redirect URI (`/signin-oidc/`). It is **immutable after create** — pick a stable name (e.g. `company-sso`); typing a Display Name first lets Modgud suggest one. The Redirect URI field appears AFTER first save. 4. **Connection** tab: * **Tenant ID** (Entra-specific): paste from Entra. * **Client ID**: from Entra. * **Scopes**: `openid profile email` (default is fine). * **Initial Secret** (optional): paste the Entra client secret here so it's set in one step. You can also skip this and rotate via the Connection tab after Save — same audit-event shape either way. 5. **User Update Script** tab: default for Entra is ```js (claims) => ({ firstname: claims.given_name?.trim(), lastname: claims.family_name?.trim(), email: claims.email ?? claims.preferred_username, acronym: (claims.given_name?.[0] ?? '') + (claims.family_name?.[0] ?? ''), }) ``` The **Run** button at the top of the test panel runs the script against a sample claims object — instant feedback on what comes out. After at least one successful login, **Last Login** loads the actual claims that came through last. 6. **Create.** The provider is created **disabled** (security default; enable explicitly after the smoke-test). The modal stays open and transitions into Edit mode — the URL fragment updates to the new provider id and the **Redirect URI** field now appears in the General tab with a copy button next to it. **Copy the Redirect URI** from the General tab — you'll paste it into Entra next. Because the URI is built from your chosen slug (not a generated GUID), deleting and recreating the provider with the same slug keeps the **same** Redirect URI — no need to re-edit the Entra app. #### 3. Back in Entra: paste the redirect URI 1. Azure Portal → your App Registration → **Authentication** → **+ Add a platform** → **Web** 2. Paste the redirect URI you copied from Modgud 3. **Configure** #### 4. Enable + test 1. Back in Modgud's provider modal, click the **Disabled** badge to flip it to **Enabled**. Modgud verifies Client ID + Client Secret are set before enabling. 2. Open Modgud's login page in incognito. 3. The new SSO button should appear. 4. Click → redirect to Microsoft → sign in → redirect back. 5. You're signed in. From the Users list, right-click the user and choose **Show IdP Claims** to verify the mapped fields. ### Generic OIDC If your IdP isn't Entra, pick **Generic OIDC** instead: 1. Provide the **Authority** URL (the `iss` from the discovery document) 2. **Client ID** + **Client Secret** as registered with the IdP 3. Adjust **Scopes** if the provider expects something other than `openid profile email` 4. Author the **User Update Script** to map whichever claims the provider sends — every IdP delivers a slightly different shape ### Just-in-Time provisioning By default Modgud provisions a new local user the first time someone signs in via the external IdP — no admin action needed. The user-update script populates the master data from claims. If you want to **disable JIT** (only pre-existing users may sign in via SSO), toggle the **Auto-create unknown users** flag in the **Linking & Policies** tab. Unknown users get a 403 with a message explaining how to request access. ### Linking OIDC to existing users When a user is already signed in and visits **Profile → Linked accounts**, they can attach additional OIDC identities to their existing Modgud account. The link is stored on `ExternalIdentityLink` (issuer + subject → user id) and survives email changes on either side. To deny self-service linking for a particular provider, untick **Allow linking** in the Linking & Policies tab. ### Multiple linked providers & profile precedence A user may hold links to several IdPs at once (e.g. EntraID *and* Google). Identity matching is always by the IdP's stable **`(issuer, subject)`** — never by email (email is only a fallback for the opt-in auto-link / JIT paths). So a returning login resolves to the right account regardless of how many providers are linked. On every external login the provider's user-update script *can* patch the four profile fields (first name / last name / email / acronym). To stop two providers fighting over them on alternating logins, only **one provider is authoritative for the profile** at a time: * The provider whose login **JIT-created** the user is authoritative by default. * Any provider can be made authoritative explicitly via the **Authoritative for profile** toggle in the **Linking & Policies** tab. * A non-authoritative provider still authenticates the user (and may confer session membership), but does **not** overwrite their profile fields. The net effect: a user's display name / email stays stable no matter which linked IdP they signed in with, and there is no per-login flapping. > **Unlinking forgets the binding.** Disconnecting a provider in **Profile → Linked accounts** (or via admin force-unlink) frees the `(issuer, subject)` slot, so the same external identity can later be re-linked — to the same user, or, once released, to a different one. The last remaining authentication method (the only password / passkey / link) cannot be removed. ## Disabling without deleting For OIDC providers, toggle the **Enabled** flag in the detail dialog. The button disappears from the login page; existing user-account links are preserved. Re-enabling brings the button back. The Internal provider has no enable/disable button — by design. ## Configuration secrets Configuration values flagged as secret (client secret) are stored encrypted in the IdP secret store and **shown only once** at creation. Forgot one? Regenerate it on the upstream provider and rotate the value via the secret panel on the **Connection** tab. ## Common pitfalls * **Wrong redirect URI** in Entra → "AADSTS50011" error. Copy it exactly from Modgud. * **Client secret expired** → users get redirected, then 500 in Modgud's external auth callback. Rotate in Entra and update. * **User update script returns wrong field names** → master data is empty after login. Use the test panel before saving. * **Mismatch between Entra group and Modgud role** → user is "Admin" in Entra but has no admin permission. By design — assign manually or via membership script. ::: warning Test the new provider before disabling Internal If a misconfigured external provider is the only login path and an admin can't sign in, the [Recovery CLI](../operate/recovery-cli) is your only way back. ::: --- --- url: /admin/applications.md --- # Applications An **Application** in Modgud is the organisational clamp around a SaaS app — it owns its own permission catalog, its own roles, and its own OAuth bindings. When a realm is created the system app `modgud` (= Modgud itself) is provisioned automatically; every other app you register here. An Application is **not** an isolation boundary — that is the realm (tenant): own database, signing keys, OIDC issuer, user pool. An Application is a **soft facet** *within* a realm: it shares the realm's user pool (one account, one `sub`, across all of a realm's apps — no shadow users), and on top of the permission clamp it can carry its own **login-experience** — an optional subdomain, branding, email branding, and per-app overrides of self-registration / native-grant / DCR / CIMD policy. Those live in [Application settings](#application-settings) below. See [Concepts → Apps & resource access](../concepts/apps-and-resource-access) for the model. ::: tip First time? If this is your first integration, the [SaaS App Integration Walkthrough](../integrate/saas-walkthrough) is the better entry point — it walks through all five stations (App, Client, Resource Server, Roles, backend code). ::: ## What is an Application for? Modgud manages permissions as **two-segment** `:` strings inside an App's catalog — the app slug is the implicit context, not part of the string. The same string `invoice:write` in the `billing` app's catalog and in the `shipping` app's catalog are different permissions, distinguished by the audience the gate is running for. An app therefore bundles: * **Permission catalog** — the `:` entries that the app's resources understand * **Roles** with `AppId` — bundles of `PermissionIds` from this app's catalog * **Groups** via `BoundTo` — which organisational unit is active in which app * **OAuth Clients** via their `AppIds` list — which token requesters serve the app * **OAuth APIs (Resource Servers)** via their `AppId` — which backend identities belong to it * **OAuth Scopes** via their `AppId` — which scopes a client of the app may request ## Application fields | Field | Meaning | | --- | --- | | Slug | URL- and permission-safe identifier. Lowercase, 3-63 characters, letters/digits/hyphens. **Immutable after creation.** | | Display Name | What appears in lists and consent screens | | Description | Optional, one-liner | | Permission catalog | `:` entries this app's resources can be gated by | | IsSystem | True only for `modgud` and `control-plane`; cannot be deleted | ## Reserved slugs These slugs are forbidden — they collide with the permission grammar or with system invariants: * `realm` — would clash with `realm:admin` (realm-wide bypass) * `*` — wildcard in `Group.BoundTo` * `modgud` — system app, seeded automatically into every realm * `control-plane` — control-plane system app, seeded only on the Control-Plane realm ## Creating an app Click **Create** in the list view. 1. Pick a slug — kebab-case, memorable: `acme`, `billing`, `inventory`. Not changeable. 2. Fill in display name and description. 3. Add catalog entries: `:`, kebab-case both sides (`invoice:read`, `invoice:write`, `invoice:admin`). 4. **Create**. The app appears in the list. **It still has no effect** on its own — you also need to: * link at least one OAuth client to it ([OAuth Clients](./oauth-clients)) * (for an authenticated server-to-server callback into Modgud) provision a resource server * create at least one role + group that connects users to the app ## Cloning an app The slug is immutable, so the way to stand up a near-identical app — or to effectively **rename** one — is to clone it. In the list, right-click a row → **Clone**. The Create modal opens pre-filled from the source: * **Slug** is left blank — give the copy its own (the source's can't be reused). * **Display name, description and the whole permission catalog** are copied. The catalog entries are copied as *new* entries (fresh ids), so the source app's role grants and resource-server subsets are left untouched. * **Settings** are copied too — branding, registration, native-grant / DCR / CIMD overrides — **except the Origin subdomain**, which is globally unique and would collide. Set a new subdomain on the copy if it needs one. To rename: clone the app, give the copy the new slug, re-point the dependent clients / scopes / APIs / roles / groups, then delete the original. ## Provisioning the resource server Under [OAuth → APIs](./oauth-apis), create an OAuth API named after the app's slug and link it to the app. Its `PermissionIds` declare which subset of the catalog this resource server gates on (full catalog is the typical default; tighten for microservices that only need a slice). This is the identity Modgud uses to compute the per-Audience `resource_access` block in UserInfo. ## Extending or changing the catalog Catalog entries can be edited any time, but: * **Adding** is harmless. Existing role assignments remain valid; new permissions become assignable. * **Removing** is dangerous. Roles that reference the removed entry silently lose the grant. The admin UI shows a "rename" indicator and a delete-block prompt when something downstream is still referencing a catalog entry. Audit roles before dropping. ## Application settings Beyond the permission catalog, an Application can override a slice of the realm's configuration and carry its own login experience. Open the app from the list and switch to the **Settings** tab (disabled for the system apps). Everything here is **optional and sparse** — an App overrides only what you switch on; anything left off **inherits the realm** value, field by field. Clearing an override re-inherits the realm. | Section | What it does | | --- | --- | | **Origin** | The App's own subdomain (e.g. `acme.cocoar.app`), which must be a child of the realm's primary domain. Setting it routes that host to this App and serves the branded login there; clearing it falls back to the tenant URL. The OIDC issuer stays the tenant's (anchored to the realm primary domain) — a subdomain is not its own issuer. | | **Branding** | Product name, primary colour, logo/favicon — the look of the login + consent UI when reached via this App. | | **Email branding** | The product name used in this App's outbound emails (OTP, magic link, ...) instead of the realm default. | | **Self-registration** | Per-app override of the realm self-registration policy (allowed email domains, admin approval, default groups, ToS/privacy URLs) plus the **posture** (see below). Captcha stays realm-level. | | **Registration fields** | Per-app override of which identity fields (username / first / last name) are required when an account is created — each one inheriting the realm by default. See [Registration fields](#registration-fields) below. | | **Native grants** | Per-app toggle + token lifetimes for the cookieless [native passwordless grants](../integrate/native-apps). | | **DCR** | Per-app override of [Dynamic Client Registration](./dynamic-client-registration) (enable, token lifetimes, rate limits, reserved-name blocklist). | | **CIMD** | Per-app override of [Client-ID Metadata Documents](./client-id-metadata-documents) (enable, token lifetimes). | ### Self-registration posture The self-registration section carries a **posture** that decides how a passwordless sign-up is triggered for the App: | Posture | Behaviour | | --- | --- | | `JitOnOtp` *(default)* | Sign-in-or-sign-up: an unknown email at the native OTP endpoint creates a passwordless user and emails a one-time code; redeeming it both verifies the mailbox and signs in. The low-friction consumer default. | | `Off` | No self-registration — an unknown email gets the uniform anti-enumeration response, no user is created. | | `ExplicitEndpoint` | Registration is a deliberate, separate step via `POST /api/account/native/register` (room for an app's own ToS / profile UI); sign-in stays strict — the OTP-request endpoint serves only known users. An unknown email at the register endpoint creates the passwordless user and emails the same registration code. | | `InviteCode` | Invite-only: an unknown email becomes a passwordless user **only** when the native sign-up request carries a valid, unused, unexpired [invite code](#invite-codes-the-invitecode-posture). Existing confirmed users still sign in normally (no code needed). Code failures are indistinguishable from `Off` (anti-enumeration). | See [Integrate → Native apps](../integrate/native-apps#native-passwordless-registration-jit-on-otp) for the end-to-end flow. ### Invite codes (the `InviteCode` posture) Set the posture to `InviteCode` to run an app **invite-only** — a new person gets an account only by presenting a single-use code. The code is app-bound, optionally email-bound (bearer by default), hashed at rest, and expires (default **14 days**). Two ways to mint, and you only need to set up the second one if a backend should mint automatically: * **Admin UI (works immediately, no setup).** Open **Invite Codes** in the admin sidebar (OAuth & Federation), pick the app, and bulk-mint. The plaintext codes are shown **once** — copy them then; only their hashes are stored. Gated by the `invite-code:read` / `invite-code:write` permissions. * **Machine-to-machine (the consuming app's backend).** For a backend that mints codes itself (e.g. when a user invites someone), there is a one-time setup: 1. Create an OAuth scope named **`invite:write`** that is **bound to this app** (set its App-ID) — see [OAuth scopes](../reference/admin-api). Scope names are unique per realm, so in a multi-app realm name the scope per app. 2. Give a **ServiceAccount** a credential (a confidential `client_credentials` client) carrying that scope. 3. The backend then calls `POST /api/app/{appId}/invite-codes` `{ "Count": N, "BoundEmail": null, "ExpiresInDays": 7 }` with its `client_credentials` access token. The response carries the plaintext codes once. The `{appId}` must match the scope's app — a cross-app or cross-tenant caller is rejected. The public mobile client never mints — it only **redeems** a code it was handed, by passing it on the native sign-up request (`InviteCode` field on `POST /api/account/native/otp/request`). Redemption confirms the mailbox via the usual OTP step. See [Integrate → Native apps](../integrate/native-apps) for the redemption flow. > **Identity vs. authorization.** Modgud's invite code only governs *who may > exist*. What the invite is *for* (join list L, beta access, …) stays in the > consuming app — Modgud only ever learns `(email, code, appId)`. ### Registration fields Overrides the realm's [Registration Fields](./realm-settings#registration-fields) policy for this App — which identity fields (username / first / last name) are required when an account is created here. Each field is a tri-state (`Off` / `Optional` / `Required`) that **inherits the realm** when left unset, so a Consumer App can stay email-only inside the same tenant where an Enterprise App requires a real name. The resolved (App ⊕ realm) policy is published at `GET /api/app-info`, so the App's clients render exactly the inputs it requires. When an App requires a field, **its native clients must collect and send it** (`FirstName` / `LastName` on the native OTP / register calls) — otherwise registration fails. Email is always required and is never configurable. ### What stays realm-only Captcha (needs a per-app secret store), account deletion / audit / custom pages (operational / GDPR), and the DCR garbage-collection interval (the GC job iterates per realm) are **not** per-app overridable — set them in [Realm settings](./realm-settings). An App is one resource, so these overrides ride **inline** on the app itself — `GET /api/app/{id}` returns them, and `POST`/`PUT /api/app` write them in the same call that creates or updates the app (`app:read` / `app:write`), in one tenant transaction. There is no separate settings endpoint. See the [Admin API reference](../reference/admin-api#application-settings). ## Relationships to other areas | Linked with | Where | How | | --- | --- | --- | | OAuth Clients | [OAuth Clients](./oauth-clients) | n:m via the client's `AppIds` list | | OAuth Scopes | [OAuth Scopes](./oauth-scopes) | 1:n via the scope's `AppId` (or null = global) | | OAuth APIs (Resource Servers) | [OAuth APIs](./oauth-apis) | 1:n via the API's `AppId` | | Roles | [Roles](./roles) | n:1 via the role's `AppId` | | Groups | [Groups](./groups) | n:m via the group's `BoundTo` list | ## The system apps ### `modgud` The app `modgud` represents the Modgud admin surface itself. Permissions like `user:read` or `oauth-client:write` (in this app's catalog) gate the admin UI sidebar. * **Auto-seeded** on first realm setup * **Not deletable** (`IsSystem = true`) * **Slug not renameable** * Catalog matches the built-in admin surface — edit cautiously ### `control-plane` Seeded **only** into the realm flagged `IsControlPlane = true`. Owns the `realm:read` / `realm:write` permissions that gate `/api/admin/realms/*`. See [Concepts: Control Plane / Data Plane](../concepts/control-plane). If you change a system app's catalog, the admin sidebar may hide items because the corresponding permission is no longer registered. When in doubt, restore the default catalog (see `AppRealmSeeder` in source). ## Deleting an app System apps cannot be deleted. Regular apps can — but: * OAuth clients with the app in their `AppIds` list keep the entry (UI shows it as "unknown app") * OAuth scopes with this AppId become orphaned * Roles with this AppId stay — but their `PermissionIds` no longer resolve to anything * Groups with the app in BoundTo keep the entry, but it no longer has effect So before deleting: re-link or delete the dependent clients, scopes, and roles first. --- --- url: /admin/realms.md --- # Realms A **Realm** in Modgud is a tenant — a fully isolated namespace with its own database, users, groups, OAuth clients, and apps. Realms are how multi-tenant Modgud deployments separate customers / environments / staging. ::: info When do I need multiple realms? * **Multiple customers** sharing one Modgud instance (each gets their own realm) * **Stage separation** (production, staging, development) on shared infrastructure * **Compliance isolation** (some customer data must not coexist in the same DB) Single-tenant deployments only need the system realm — provisioned automatically on first start. ::: ![Realm list](/screenshots/admin-realms-liste.png) ## The Control-Plane realm Exactly **one** realm in a deployment is the **Control Plane** — the realm flagged `IsControlPlane = true`. The Control Plane is the only host where realm CRUD is exposed; tenant realms get a 404 even from a user that somehow holds `realm:read`/`realm:write` (those catalog entries don't exist in their tenant DB because the `control-plane` app isn't seeded there). See [Concepts: Control Plane / Data Plane](../concepts/control-plane) for the full three-layer defence. The Control-Plane flag is a **stored, transferable** field. The `system` realm is stamped as the CP at first boot, but the role can be moved to any active realm (see [Transferring the control plane](#transferring-the-control-plane)). There is always exactly one CP per deployment. The system realm's default domains are `system.localhost`, `localhost`, `127.0.0.1` — anything resolving to those lands on the system realm. ## Realm fields | Field | Meaning | | --- | --- | | Slug | URL-safe identifier, 3-63 chars, immutable. Determines the tenant DB name (`_`). | | Display Name | UI label | | Description | Optional | | Domains | List of hostnames that route to this realm | | Primary Domain | The realm's canonical public host — one of `Domains`. Used for every outbound link (magic-links, bootstrap-invites) and as the WebAuthn relying-party ID for passkeys. Changing it invalidates existing passkeys. | | IsControlPlane | Stored flag — exactly one realm holds it. Moved via the transfer action, not edited inline. | | IsActive | Disabled realms reject login attempts | ## Permissions Realm-CRUD endpoints under `/api/admin/realms/*` are gated by permissions in the `control-plane` app's catalog: | Permission | Effect | |---|---| | `realm:read` (control-plane) | List + read realms | | `realm:write` (control-plane) | Create / edit / deactivate realms | These permissions only exist on the Control-Plane realm because the `control-plane` App catalog is only seeded there. The realm-wide bypass `realm:admin` grants all of them. ## Creating a realm ::: warning Only available on the Control-Plane realm The "Create" button only appears when you're signed in on the 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 | | --- | --- | | Slug | `acme` | | Display Name | `Acme Corp` | | Description | `Production tenant for Acme` | | Domains | `acme.auth.example.com` | | Primary Domain | `acme.auth.example.com` — defaults to the first domain; pick which one is canonical when a realm has several | | **Initial admin** | **required** — UserName + Email of the recipient who'll bootstrap the realm | The Initial-Admin block is mandatory. A realm with no admin path would be unreachable; the UI rejects the form if either UserName or Email is empty. On save, Modgud: 1. Validates the slug format (3-63 chars, lowercase, alphanumeric + hyphen). 2. Creates a PostgreSQL database `_acme`. 3. Registers the realm with Marten's master-table tenancy and applies the schema. 4. Stores the Realm document in the master DB. 5. Seeds the 6 default OAuth scopes + the Internal login provider in the new tenant DB. 6. Seeds the `modgud` app (the realm-internal admin surface). The `control-plane` app is **not** seeded into a tenant realm — it only exists in the Control-Plane realm. 7. Issues a **bootstrap-invite** for the Initial-Admin: writes a single-use, 7-day token into the new tenant DB and sends a magic-link email. The magic-link URL is also returned in the API response so you can copy it manually if SMTP isn't reachable. The recipient clicks the magic link, lands on `/bootstrap?token=…` in the new realm's SPA, sets their own password, and is auto-signed-in. The token is revoked on first use. If the link gets lost (expired, deleted, never delivered), open the realm in the admin UI and click **Resend invite** — a fresh token is issued for the same recipient and the previous one is revoked. ## Editing a realm Most fields are live-editable; the **slug is immutable** (it's baked into the database name). The Control-Plane flag isn't a checkbox — it moves via the dedicated transfer action (below). ::: warning Changing the Primary Domain invalidates passkeys The Primary Domain is the WebAuthn relying-party ID. Re-pointing it (in the domain picker, or via the [Recovery CLI](../operate/recovery-cli) `realm-set-primary-domain`) **invalidates every passkey registered in the realm** — affected users must re-register theirs on next sign-in. Password, TOTP, Email OTP, and magic-link logins are unaffected. ::: ## Transferring the control plane To hand cross-realm administration to another realm, open the **target** realm (the one that should become the CP) in the admin UI and click **Make this realm the control plane** (a danger action shown in edit mode for active, non-CP realms). After you confirm: * the target realm's `realm:admin` users gain the realm-management surface; * **this** host stops being the control plane — `/api/admin/realms` 404s here and the realm grid disappears. Continue administration on the target realm's domain. Make sure the target realm already has a `realm:admin` user before transferring, or recover one afterwards via the [Recovery CLI](../operate/recovery-cli) (`control-plane transfer` / `bootstrap-admin`). ## Deactivating vs. deleting * **Deactivate** (clear "Is Active") — the realm rejects logins but stays in the DB. Reactivatable any time. Cannot deactivate the Control-Plane realm (`Realm.CannotDeactivateControlPlane`). * **Delete** — soft delete in the master DB by default. The tenant database is **not** dropped automatically (data preservation by default), so a plain delete is reversible at the database level. To wipe a realm for real, hard delete it: `DELETE /api/admin/realms/{slug}?hard=true` drops the tenant database and removes the realm record (Control-Plane only, irreversible) — see [Declarative Realm Provisioning](./realm-provisioning) for the API surface. ## First-time setup of a fresh realm The realm is provisioned together with a bootstrap-invite for the Initial Admin (above). The invite recipient clicks the magic-link and sets their password — that's the standard path for nearly every realm. If something goes wrong: * **Token lost or expired** — reopen the realm in the admin UI and click **Resend invite**. Same recipient, fresh token. * **No prior invite, no admin yet** (e.g. provisioned via a tool that didn't issue one) — drop into the container and run `dotnet Modgud.Api.dll recover bootstrap-admin --email --realm `. See [Recovery CLI](../operate/recovery-cli). * **Locked-out admin** — same recovery CLI, again with `bootstrap-admin --email `. The CLI adds the new user to the realm's existing admin group rather than creating a duplicate. ## Routing Modgud's `RealmMiddleware` resolves the realm from `HttpContext.Request.Host`. Each request finds its realm by matching the host against any realm's `Domains` list. If a host doesn't match any realm: 404 (the request is for an unrecognised tenant). For dev work without hosts-file edits, the system realm's default `Domains` list includes `localhost` and `127.0.0.1` — the single-realm fallback in `RealmCache` also catches localhost variants when only one realm is active. ## Tips ::: tip Naming conventions Realm slugs are baked into the tenant DB name and the default Domains list (`.localhost`). Pick stable, customer-friendly slugs and stick with them. Slug changes are not supported. ::: ::: tip Data residency Each realm's data lives in its own PostgreSQL database. For data-residency compliance, you can configure separate database servers per realm via the `RealmProvisioningService` extension hooks (advanced setup, not exposed in the UI today). ::: --- --- url: /admin/realm-provisioning.md --- # 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. --- --- url: /admin/realm-settings.md --- # Realm Settings **Realm Settings** are realm-wide configuration owned by the **realm admin** (not the Control-Plane admin). They live in the tenant database as a singleton document and are managed via Administration → **Realm Settings**. ::: info Realm structure vs. Realm settings * **[Realms](./realms)** (structural metadata: slug, domains, Control-Plane flag, active state) are managed by the **Control-Plane admin** only. * **Realm Settings** (self-registration, DCR policy, branding, …) are managed by the **realm admin** inside their own realm. The Control-Plane admin reaches their own realm's settings the same way as any other realm admin would — through this page. ::: ::: tip These are the realm defaults — Applications can override them The Self-Registration, Registration-Fields, DCR, CIMD and Native Passwordless Grants policies (and branding / email branding) set here are the **realm defaults**. An individual [Application](./applications#application-settings) can override a slice of them per-app (sparse, field by field — anything it doesn't set inherits the realm value here). Captcha, account deletion and the DCR garbage-collection interval stay realm-only. ::: ## Tabs The page currently has these tabs: * [Self-Registration](#self-registration) — public sign-up policy * [Registration Fields](#registration-fields) — which identity fields are required when an account is created * [Dynamic Client Registration](#dynamic-client-registration) — anonymous OAuth-client registration policy (linked detail page: [Dynamic Client Registration](./dynamic-client-registration)) * **Client ID Metadata Documents** — fetch-on-demand OAuth-client identification by HTTPS-URL `client_id` (linked detail page: [Client ID Metadata Documents](./client-id-metadata-documents)). Off by default. * **Native Passwordless Grants** — the per-realm master toggle for the cookieless `urn:cocoar:*` grants (see [Native app integration](../integrate/native-apps)) * [Rate Limits](#rate-limits) — per-IP request ceilings for the realm's auth endpoints * [Account Deletion](#account-deletion) — grace period and recycle-bin retention policy * [Signing Keys](#signing-keys) — rotate the realm's OAuth/OIDC token-signing key Per-realm **branding** is configured on a separate page under Platform — see [Customization → Branding](../platform/branding). Permissions: `realm-settings:read` / `realm-settings:write`. The realm-admin role grants both via the `realm:admin` bypass. ## Self-Registration Public sign-up: visitors can create an account themselves at `/register`. Opt-in per realm, **disabled by default** — anonymous probes to `/api/account/self-registration-info` return the all-defaults shape so disabled realms can't be enumerated. ### Enabling self-registration 1. Open Administration → **Realm Settings** → tab **Self-Registration**. 2. Check **Enable self-registration**. 3. Configure the additional fields that appear (see below). 4. **Save**. Once enabled, the login page picks up a **"No account yet? Register →"** link and `/register` becomes reachable. ### Fields | Field | Default | Meaning | | --- | --- | --- | | **Enable self-registration** | off | Master toggle. When off, the `/register` route returns the same anti-enumeration response as a never-registered email. | | **Require email verification** | on | New accounts are created with `EmailConfirmed=false` and can't sign in until they click the magic-link in the verification mail. Turn off only for trusted-internal scenarios. | | **Require admin approval** | off | Layered on top of email verification — after the user confirms the link, the account stays `IsActive=false` until an admin flips the flag manually. Useful for moderated communities. | | **Allowed email domains** | empty (all) | Whitelist. Empty = accept any domain. Case-insensitive match on the part after the last `@`. | | **Default groups** | empty | Groups the new user is auto-attached to once the account is fully active (post-verification + post-approval). Role memberships flow through groups, so this is the lever for "what can self-registered users do?". | | **Terms-of-Service URL** | empty | When set: the registration form shows a required "I accept" checkbox linking here. The endpoint rejects submissions without the checkbox ticked. | | **Privacy Policy URL** | empty | When set: rendered as a discreet footer link on the registration form. No checkbox. | | **Enable Cloudflare Turnstile captcha** | off | Independent of the master toggle. See [Captcha](#captcha) below. | | **Captcha site key** | empty | Per-realm Turnstile site key. Empty + captcha enabled = falls back to the Cocoar-default keys. | | **Captcha secret** | not set | Per-realm Turnstile secret, encrypted at rest. Write-only — never returned, only an "is configured" flag. Empty + save = clear (revert to Cocoar default). | ### Captcha Cloudflare Turnstile is the only supported captcha provider. It is **independent of the master toggle** so two scenarios both work: * **Public-internet deployment** → enable captcha, configure either per-realm keys or rely on the Cocoar-default keys configured via the `Turnstile__SiteKey` / `Turnstile__SecretKey` environment variables. * **Air-gapped / intern deployment** → leave captcha disabled. Modgud then never calls out to `challenges.cloudflare.com`. Honeypot field + per-email rate-limit (1/min, 3/hour) cover the bot-spam surface. Resolution order when the captcha is enabled: 1. Per-realm site key + per-realm secret if both set 2. Cocoar-default site key + Cocoar-default secret 3. None configured → the verifier rejects every registration and logs a `WARN` so the admin notices the misconfiguration ::: tip One captcha secret per realm The captcha secret is encrypted with ASP.NET Data Protection (purpose `Modgud.SelfRegistration.CaptchaSecret.v1`). Migrating data-protection keys between deployments invalidates all per-realm captcha secrets — they need to be re-entered. The same warning applies to login-provider client secrets. ::: ### Anti-enumeration The public endpoints are explicitly engineered against enumeration: * `POST /api/account/register` always responds with the same generic success message regardless of outcome: existing email, existing username, captcha failure, honeypot trigger, rate-limit, domain-whitelist rejection — all look identical to a real success from the client's perspective. No mail is sent in the rejected cases. * `GET /api/account/self-registration-info` returns the same all-defaults shape (`Enabled=false`) whether the realm has the feature off, doesn't exist at all, or is currently being configured. The SPA reads `Enabled` to decide between rendering the form vs. redirecting to `/login`. The email-verification endpoint (`POST /api/account/register/verify-email`) is the exception — it returns real error codes for expired / used / unknown tokens, because by the time someone is consuming a token they already have it, and there is nothing left to enumerate. ### What's stored where | Data | Location | | --- | --- | | Realm-settings document (the toggles above) | tenant DB, singleton document `RealmSettings` | | Pending self-registration (token-hash, user ID, expiry) | tenant DB, `mt_doc_pendingselfregistration` | | User record | tenant DB, `mt_doc_applicationuser` (created at register time, activated on verify) | | Captcha secret (encrypted) | inside the `RealmSettings` document, encrypted with Data Protection | ### Known limitations (current MVP) * **No dedicated "pending approvals" UI.** When admin-approval is required, the user record is created with `IsActive=false` and an admin has to flip the flag from the regular user-edit modal. A filter chip on the user list for "pending approval" is a sensible follow-up. * **In-memory rate limiter.** The per-email cap (1/min, 3/h) resets on restart and isn't shared across multiple instances. Single-instance deployments are fine; multi-instance setups can bypass the cap by hopping between instances. A Redis-backed implementation behind the same interface is a follow-up. * **No pre-submit username availability check.** The form surfaces username collisions through the generic 200-OK like every other rejection. An anonymous `GET /api/account/check-username/{name}` (rate-limited) would improve the UX without touching the anti-enumeration guarantees on email. * **Email template is shared with the email-change flow.** Both reuse `EmailTemplate.EmailVerification`. A dedicated `EmailTemplate.SelfRegistrationVerify` with welcome wording is a quality-of-life improvement. ## Registration Fields Controls **which identity fields are required when a user account is created** — across every creation path (admin create/edit, public self-registration, native passwordless registration). **Email is always required** and is the anchor every other field is resolved against; it is not configurable. Each of the other three fields is a uniform tri-state. The **default is `Optional` for all three**, which is exactly the historical behaviour — a realm that never touches this tab behaves as before (zero change). | Field | `Off` | `Optional` | `Required` | | --- | --- | --- | --- | | **Username** | hidden; the username **is always the email** | shown; blank → the email | a non-empty username must be supplied | | **First name** | not collected | shown, may be blank | must be supplied | | **Last name** | not collected | shown, may be blank | must be supplied | ### Why configure it * **Consumer apps** (e.g. a task-management app) stay email-only — leave everything `Optional` (or set `Username = Off`) for the lowest-friction passwordless sign-up. * **Enterprise apps** typically want a real first/last name on every account — set them `Required`. This is coherent with enterprise tenants that disable self-registration, but it is enforced on *all* paths regardless. ### How it is enforced * A missing **required** field is rejected on every creation path. On the admin and native endpoints it surfaces as a hard `400`; on the anti-enumeration self-registration endpoint it is folded into the uniform generic response. * The check is **independent of whether the email already exists**, so it never leaks account existence. * The resolved policy is published anonymously at `GET /api/app-info` (`RegistrationFields`), so native apps and the web register form render exactly the inputs the realm (or App) requires. ::: info Per-Application override + native clients A single [Application](./applications#application-settings) can override this policy (e.g. an Enterprise App requiring names inside a tenant whose realm default is lenient). When an App requires a field, its **native clients must collect and send it** — see [Integrate → Native apps](../integrate/native-apps#required-identity-fields). ::: ::: warning Federation (SSO) is lenient Just-in-time accounts created from an external IdP (OIDC/SAML) are **not** held to the required-field policy today — they take whatever `given_name` / `family_name` claims the IdP provides. Tightening this is a possible follow-up. ::: ## Dynamic Client Registration Anonymous OAuth-client registration policy: master toggle, token lifetimes, GC TTL, rate limits, reserved-name blocklist. The companion per-API and per-Scope toggles live on [OAuth APIs](./oauth-apis) and [OAuth Scopes](./oauth-scopes) respectively — DCR is a **triple opt-in** by design. Off by default. See the full feature page for when to enable it, what gets accepted, and the consent-screen `[unverified]` marker: → **[Dynamic Client Registration](./dynamic-client-registration)** (full feature page) ## Rate Limits Per-IP request ceilings for this realm's auth endpoints. Each policy is a **max requests / window (minutes)** pair, partitioned by source IP and applied **per realm**. The shipped defaults are the secure production posture — the knob exists so a test realm, dev, or a legitimately bursty consumer can raise a ceiling **without a modgud code change + redeploy**, and so a hardened realm can tighten one. | Policy | Endpoint | Default | | --- | --- | --- | | Native OTP request | `POST /api/account/native/otp/request` (+ native register) | 5 / 60 min | | Magic-link request | `POST /api/account/magic-link/request` | 5 / 60 min | | Password-reset request | `POST /api/account/forgot-password` | 5 / 60 min | | Email-OTP login verify | `POST /api/account/email-otp/login` | 30 / 1 min | | Email verification resend | `POST /api/account/email/send-verification` | 5 / 60 min | | Passkey ceremony begin / enroll | `POST /connect/passkey/begin` (+ enroll) | 60 / 5 min | | First-admin bootstrap | `POST /api/account/bootstrap-admin` | 10 / 15 min | Notes: * A realm that never touches this tab behaves exactly as before — the defaults are the previously-hardcoded values. * Limits are **per realm**: raising a ceiling on one realm does not affect another on the same shared IdP. * The limiter is in-memory and resets on app restart; over-limit requests get `429 Too Many Requests`. * A config change takes effect within a few seconds (a short cache smooths the per-request lookup). * This governs the **request rate**; it is independent of per-challenge attempt counters (e.g. the email-OTP `MaxAttempts` brute-force lock) and of the realm's anti-enumeration uniform responses. ## Account Deletion Controls the account-deletion lifecycle for this realm — the self-service grace period and the admin recycle-bin retention. These replace the old hardcoded 7-day confirm-token window. The mechanics of both flows are documented under [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) and [Profile → Privacy](../end-user/profile#privacy). ### Fields | Field | Default | Meaning | | --- | --- | --- | | **Grace period (days)** | 30 | Self-service window: after a user requests deletion they stay able to sign in and cancel for this many days, then the account is auto-erased. | | **Reminder lead (days)** | 2 | How many days before the grace deadline the "your account is about to be deleted" reminder email is sent. Must be **less than** the grace period to ever fire. | | **Admin retention (days)** | 30 | Recycle-bin window: how long an admin-deleted (deactivated) account is kept before it becomes eligible for auto-purge. An admin can restore or permanently delete it at any point during this window. | | **Auto-purge** | on | When on, a scheduled job permanently erases recycle-bin accounts once their retention window elapses. When off, the bin is only emptied by explicit admin action (*Delete permanently*). | ::: info One job drives all three A single scheduled sweep (`account-lifecycle-sweep`) does the work for the whole realm: it sends reminders, auto-erases self-service accounts past their grace deadline, and (when auto-purge is on) empties the admin recycle bin past retention. Changing these values takes effect on its next run. ::: ## Signing Keys Every realm signs its OpenIddict **access tokens and id tokens** with its own RSA-2048 key — a token signed for realm A cannot validate against realm B's JWKS, so the keys are the cryptographic core of realm isolation. The key is generated lazily on first token issuance; this tab lets a realm admin **rotate** it on demand. ### Rotating the key 1. Open Administration → **Realm Settings** → tab **Signing Keys**. 2. Click **Rotate signing key** and confirm. On rotation: * A fresh RSA keypair is generated and becomes the **active** signing key — all *new* tokens are signed with it immediately. * The previous key is **retired** but kept in the realm's JWKS and verification set for a **30-day overlap window**, so tokens issued just before the rotation (and resource servers that cache the JWKS) keep validating until they naturally expire / refresh. * Once the overlap window elapses, the retired key is **hard-deleted** by the `signing-key-janitor` scheduled job (see [Scheduled Jobs](./scheduled-jobs)). The in-memory verification set drops it on its own as soon as the window passes, even before the janitor runs. ::: warning When to rotate Rotate on **suspected key exposure** or as scheduled hygiene — not casually. Resource servers that cache the JWKS very aggressively (longer than typical) may briefly reject freshly-signed tokens until they refresh their key set. The 30-day overlap is sized so a normally-behaving RS (which refreshes its JWKS far more often) never sees an interruption. ::: ::: info Operator alternative Rotation can also be triggered from the recovery CLI without UI access: `recover rotate-signing-key --realm `. Both paths write an entry to the [Auth Log](./auth-log). ::: Permission: `realm-settings:write` (the `realm:admin` bypass grants it). ## Branding (separate page) Branding lives on its own page under Platform — go to **Platform → Customization → Branding**. It writes a sub-document on the same `RealmSettings` doc but isn't surfaced as a tab here today. → **[Customization — Branding](../platform/branding)** --- --- url: /admin/auth-log.md --- # Auth Log The **Security log** is the audit trail of security-relevant events in this realm that don't belong to a specific user record: failed and rejected logins, lockouts, rate limits, and a handful of operational actions. It's one of two tabs on the admin **Logs** page — the other, **Audit**, is the GDPR-relevant history of changes to users and realm configuration. Each tab is gated by its own permission (`auth-log:read` for Security, `audit-log:read` for Audit), so an admin holding only one of the two only sees that tab. This page describes the **Security** tab. Administration → **Logs** → **Security** tab. ![Auth log list](/screenshots/admin-auth-log.png) ## What gets logged Each row in the grid surfaces: | Column | What | | --- | --- | | **Time** | UTC instant the event was logged | | **Category** | The event's broad bucket (e.g. security, operations) — also offered as filter chips above the grid | | **Event** | The stable event code, e.g. `security.login_failed_unknown_user`, `security.rate_limit_triggered` | | **Detail** | Human-readable message for the row | | **Actor** | The acting principal's username, or the attempted identifier for an unknown-user attempt | | **IP** | Client IP, taken from `X-Forwarded-For` if a known proxy chain is configured, else the direct `RemoteIpAddress` | | **Level** | `Info` for ordinary events, `Warning` for failed-login bursts and lockouts, `Error` for unhandled exceptions on the auth path | | **Realm** | The realm the event was emitted in. Constant (your own realm) for a tenant admin; varies for the control-plane admin who sees the full cross-realm log — see [Per-realm scoping](#per-realm-scoping) | ## Filters The list view supports: * **Category chips** — narrow to one bucket at a time (only categories present in the current rows are offered) * **Free-text search** across user, message * **Refresh** the grid manually (it also refreshes itself periodically) * **Clear** the entire log (realm-admin only — destructive) ## Retention Security log entries are kept for **7 days**, then hard-deleted by the `security-audit-prune` [scheduled job](./scheduled-jobs). The window isn't currently realm-configurable — it's the same across every realm in a deployment. Reads (`GET /api/admin/auth-log`) require `auth-log:read`; clearing the entire log (`DELETE /api/admin/auth-log`) requires `realm:admin`. ## Per-realm scoping All auth-log entries are persisted to a single cross-realm store (the system database) but tagged with the realm they were emitted in. The read and clear are scoped by the **caller's** realm: * A **tenant realm-admin** sees — and can clear — only their own realm's entries. * The **control-plane realm** (the cross-realm operator, by `Realm.IsControlPlane`) sees the full cross-realm log and can clear everything; this is the deployment-wide audit view. This follows the control-plane **role**, not a fixed slug — if the role is transferred to another realm, the global view moves with it. Background / no-tenant work (scheduled jobs, bootstrap) is attributed to the `system` realm, so those operational events show up in the system realm's view and in the control-plane view. ## GDPR Security log rows aren't tied to a user record the way the Audit tab's rows are, so there's no per-user erasure step here — the short, fixed 7-day retention is itself the safeguard for the personal data (attempted usernames, IPs) these rows can carry. The **Audit** tab works differently: it's projected from the user event stream, so a GDPR-erased user's audit rows are de-identified rather than deleted, keeping the change history traceable without the personal data. ## Tips ::: tip Watch for failed-login clusters A burst of `security.login_failed_unknown_user` or `security.rate_limit_triggered` rows for the same IP in a short window points at credential-stuffing or account enumeration. Modgud's account lockout (5 attempts → 1 minute lock) already mitigates brute force against a known account, but the pattern is worth a periodic eyeball. ::: ::: tip Reviewing admin/config changes For realm settings, OAuth client edits, and other admin actions, check the **Audit** tab instead — this Security tab focuses on threat signals (unknown-actor attempts, rejected external logins, rate limits) plus a handful of operational events. ::: --- --- url: /admin/scheduled-jobs.md description: >- Tenant-admin surface for the realm's background scheduled jobs — review schedules, tune retention, trigger manually, inspect run history. --- # Scheduled Jobs **Scheduled Jobs** are the realm's recurring background tasks — garbage collection, retention sweeps, periodic housekeeping. Each job ships with a sensible default schedule baked into the build; admins can override the cron expression, tweak per-job parameters, disable runs, trigger an out-of-band run, or read the last 50 executions per job — all from one page. ## Surface | Surface | Path | Required permission | | --- | --- | --- | | List + grid | `/admin/scheduled-jobs` | `scheduled-job:read` | | Detail modal (Schedule / Configuration / History) | `/admin/scheduled-jobs#` | `scheduled-job:read` to view, `scheduled-job:write` to save / trigger | The `realm:admin` role bypasses both; granular delegation works by handing out `scheduled-job:read` and/or `scheduled-job:write` from the modgud App catalog. ::: info Per-tenant Run history (`JobRunHistoryEntry`) and per-job overrides (`JobConfig`) live in the **calling tenant's** Marten DB. Each realm sees only its own runs and configures its own retention. ::: ## Registered jobs Six jobs ship with Modgud today. Most of them iterate every active realm internally — you see one row per job, not one row per (job, realm). The exception is `security-audit-prune`, which operates on a single cross-realm store rather than per realm. ### `inbox-retention` — Inbox Retention Applies the per-kind inbox retention policy across every active realm. * **Default cron:** `0 0 3 * * ?` (03:00 UTC daily) * **Parameters:** none — retention rules are configured separately under [Inbox Settings](/platform/inbox). * **What it does:** loads each realm's `InboxRetentionSettings` doc, dismisses or hard-deletes items per the configured policy, reports per-reason counts in the run summary. * **On failure:** an `inbox-retention failed for realm ` entry is logged and an inbox notification fires (see [Failure notification](#failure-notification)). ### `job-run-history-retention` — Job-Run-History Retention Trims the per-tenant `JobRunHistoryEntry` document table so it doesn't grow unbounded. * **Default cron:** `0 30 3 * * ?` (03:30 UTC daily) * **Parameters:** * **Max. age in days** — runs older than this are deleted. Default `30`. Leave blank to disable the age sweep. * **Max. entries per job** — keep only the N newest entries per job key. Default unlimited. * **What it does:** two independent passes per realm (age cutoff + per-key count cap), summed and reported. * **On failure:** logged + inbox-notified. ::: tip Two independent caps The age sweep and the per-job count cap run independently. Use one, the other, or both. Both blank = the job runs and deletes nothing. ::: ### `dcr-gc` — DCR Garbage Collector Soft-deletes [Dynamic Client Registration](./dynamic-client-registration) clients whose `modgud:dcr:last_used_at` has aged past the realm's configured TTL. * **Default cron:** `0 0 4 * * ?` (04:00 UTC daily — after the two retention jobs) * **Parameters:** none — TTL lives on [Realm Settings → Dynamic Client Registration](./realm-settings#dynamic-client-registration) (`GcTtlDays`, default 90). * **What it does:** for every realm with DCR enabled, finds DCR-registered clients whose last-used timestamp is older than `now − GcTtlDays` and soft-deletes them via the OAuth application aggregate. Realms with DCR disabled are skipped after a single indexed lookup. * **On failure:** logged + inbox-notified. Soft delete means client\_id history stays intact for forensics. ### `signing-key-janitor` — Signing Key Janitor Hard-deletes per-realm OAuth/OIDC signing keys whose rotation overlap window has elapsed. * **Default cron:** `0 0 5 * * ?` (05:00 UTC daily — after the GC + retention jobs) * **Parameters:** none — the overlap window is a fixed 30 days. * **What it does:** for every realm (including deactivated ones, whose retired keys still hold private signing material), deletes signing keys where `RetiredAt + 30 days < now`. Active keys and keys still inside their overlap window are left untouched. Realms with nothing expired finish after a single indexed lookup. See [Realm Settings → Signing Keys](./realm-settings#signing-keys) for the rotation that produces these retired keys. * **On failure:** logged + inbox-notified. ### `account-lifecycle-sweep` — Account Lifecycle Sweep Drives the account-deletion deadlines across every active realm: sends "about to be deleted" reminders, erases self-service deletion requests whose grace period has passed, and auto-purges admin recycle-bin users past their retention deadline (when auto-purge is enabled for the realm). Also prunes used/expired registration invite codes as a hygiene side effect. * **Default cron:** `0 30 3 * * ?` (03:30 UTC daily) * **Parameters:** none — deadlines and lead times come from [Realm Settings → Account Deletion](./realm-settings#account-deletion). * **What it does:** for each realm, runs the self-service reminder/erasure sweep, the admin recycle-bin auto-purge sweep, and the invite-code prune, then reports counts for each. See [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) for the lifecycle this job enforces. * **On failure:** logged per realm; the sweep continues with the remaining realms. ### `security-audit-prune` — Security Audit Prune Hard-deletes security/ops audit entries older than a fixed 7-day retention window. * **Default cron:** `0 0 2 * * ?` (02:00 UTC daily) * **Parameters:** none — the 7-day retention is fixed and not configurable per realm. * **What it does:** deletes entries older than the retention window from the single cross-realm audit store in one indexed delete — there's no per-realm iteration for this job. * **On failure:** logged + inbox-notified. ## Job-detail modal Double-click any row (or open `/admin/scheduled-jobs#`) to get a three-tab modal. | Tab | What it shows | | --- | --- | | **Schedule** | Cron expression input (placeholder shows the registration default), enabled toggle, **Run now** button, and the computed **Next run** timestamp. | | **Configuration** | One field per `JobParameterField` declared by the job, grouped by `Section` when set. Empty value = fall back to the schema's `Default`. Tab is hidden for jobs with no tunable parameters — currently every job except `job-run-history-retention`. | | **History** | Last 50 runs, newest first. Success runs show duration + optional one-line summary. Failed runs show the first-line error message and an expandable stack trace. Manual triggers carry a `manual` tag. | The modal's footer **Save** button persists Schedule + Configuration in one shot; the trigger button on the Schedule tab is independent. ## Manual trigger ("Run now") The **Run now** button on the Schedule tab fires the job off-schedule, immediately. Two things happen as a result: * A new history entry appears with `ManualTrigger = true`, surfaced in the History tab with a `manual` tag. * The triggering admin gets a `ManualJobCompleted` inbox item with the run summary or error message — handy when the job is slow and you don't want to babysit the modal. The scheduled cron is unaffected — the job's next regular run still fires per its schedule. ## Cron overrides The cron field on the Schedule tab is a **Quartz 7-field expression** (sec min hour day-of-month month day-of-week year). When the field is **empty** the job uses the registration default; when set, the override is persisted in a per-tenant `JobConfig` Marten document and applied to the live scheduler immediately. The endpoint validates the expression server-side (`CronExpression.IsValidExpression`) and returns `400` with a clear error if it parses wrong — you won't see a runtime scheduler failure later. ## Failure notification When any run completes with an exception, a `ScheduledJobFailed` item drops into the inbox of every admin (the same recipient set as other admin notifications). The dedup key is derived from the job key, so **repeated failures of the same job collapse onto one bell entry per admin** — fix the root cause once, dismiss once, done. The notification links straight to `/admin/scheduled-jobs#` so the History tab is one click away. See [Inbox](/platform/inbox) for the notification slice in general. ## Permissions | Permission | What it grants | | --- | --- | | `scheduled-job:read` | List all jobs, view a single job, fetch run history. | | `scheduled-job:write` | Save schedule / parameter overrides, trigger a job manually. Implies `:read` is also needed to see anything. | Both are seeded in the modgud App permission catalog. `realm:admin` bypasses both per Modgud's standard 3-tier model. --- --- url: /admin/change-requests.md --- # Change Requests When the **profile-change approval flow** is enabled (see [Settings](../platform/settings)), users can't change certain profile fields (typically email, name, phone) directly. Instead they submit a **change request** that an admin must approve before it takes effect. Administration → **Change Requests**. ::: info Why approve profile changes? Some compliance regimes require that user profile changes are reviewed — particularly email-address changes, which are an account-takeover vector. The approval flow inserts a human gate between "user wants to change" and "change is live". ::: ![Change request list](/screenshots/admin-change-requests-inbox.png) ## The list Columns: *Last changed*, *User*, *Type*, *Fields* (which fields the request touches), *Status*. By default only open requests show; tick **Also show completed** to include approved/rejected ones too. There's free-text search, but no separate Status/User/date-range filters — open a row to see the proposed old → new values for each field. Status values: *Waiting for email confirmation* (`EmailVerificationPending`), *Waiting for approval* (`AdminApprovalPending`), *Approved*, *Rejected*. There's no "Cancelled" status — a self-cancelled request is removed from the queue outright (see [Cancelling](#cancelling)). ## Approving a request Open a request → review the proposed change → **Approve**. For most fields the change takes effect immediately on admin approval. **Email changes are a two-stage flow**, and — unlike other fields — the admin is deliberately the *second* gate, not the first, because the new address is untrusted until the recipient proves they own it: 1. **Recipient confirms first.** As soon as the request is submitted, a confirmation link is emailed to the **new** address; the request sits in *Waiting for email confirmation* and doesn't even reach the admin queue yet. The user's effective email is still the old one. 2. **Admin approves second.** Only once the recipient has clicked the link does the request move to *Waiting for approval* — that's when you're notified and can approve or reject. Approving then makes the new address the user's effective email. So for email specifically there are **two consents in sequence**: the recipient's click on the verification email, then the admin's approval (this UI). If the user can't access the new mailbox, the request never reaches you at all — which is the point. Requests that never get confirmed simply stay in *Waiting for email confirmation*; the user can re-trigger the verification email from their profile. For other fields (name, …) there's no email-ownership step, so the request goes straight to *Waiting for approval*. ## Rejecting **Reject** with an optional reason. The user sees the rejection in their profile UI; the original value remains. Rejection is only available once a request has reached *Waiting for approval* — a request still waiting on the recipient's email confirmation can't be rejected (or approved) yet. ## Cancelling The user can cancel their own pending request from their profile page — this removes it from the queue outright rather than moving it to a status. There's no admin-side cancel; approve or reject are the two admin actions on an open request. ## Audit Approvals and rejections are recorded in the server logs with the deciding admin, the request, and any reason. ## Tips ::: tip Email changes need extra scrutiny By the time an email change reaches your queue, the new address has already been verified — the recipient had to click the confirmation link before it got here. Your approval is the second gate: it's there to catch a coerced or socially-engineered change, not to (re-)prove mailbox ownership. ::: ::: tip Disable the flow for trusted realms For internal staff realms where users are well-known and the workflow's friction outweighs the security gain, disable the approval flow in [Settings](../platform/settings). Users then change profile fields directly with double-opt-in for email. ::: --- --- url: /platform.md description: Operator-facing configuration of the Modgud instance. --- # Platform The sidebar has **two** top-level admin areas, and the difference matters. **Administration** is realm-admin work — users, groups, OAuth clients, realms; the "who can do what" of the system. **Platform** is operator-facing IdP config — branding, observability, notification retention, app-level settings; the "how this IdP-instance is configured". ## Why the split Different audience, different cadence. * **Administration** is touched daily by realm admins, user managers, and OAuth managers. The data inside is the live tenant content. * **Platform** is touched mostly during setup, on the occasional theme refresh, and when an operator needs to look at runtime telemetry or trim inbox retention. The data inside describes the instance, not the tenants in it. Keeping them apart keeps the daily admin sidebar short, and gives operators a predictable home for "where did I configure that scrape token / branding asset / retention window" without scrolling past two dozen tenant grids. ## Sub-nav groups The Platform area is itself split into two thematic groups inside its own sub-nav (see `PlatformView.vue`): ### Customization | Item | Path | What it does | | --- | --- | --- | | [Branding](./branding) | `/platform/customization/branding` | Per-realm SPA theming (optionally overridden per Application) — product name, primary color, logo, favicon | | [Pages](./pages) | `/platform/customization/pages` | Page-builder editor (Beta) for login / logout / forgot-password — gated by the `PageBuilder` feature flag | | [Asset Library](./assets) | `/platform/customization/assets` | BYTEA store for logos, favicons, login illustrations; SVG sanitisation, 2 MB cap | ### Operations | Item | Path | What it does | | --- | --- | --- | | [Observability](../operate/observability) | `/operate/observability` | Live IdP metrics + traces, with the OpenTelemetry pipeline behind it | | Inbox settings | `/platform/inbox-settings` | Per-tenant notification retention windows | | [Settings](./settings) | `/platform/settings` | Projection rebuild, 2FA enforcement, grace period, SMTP, …; the catch-all operator surface | ## Permission gating The sidebar entry hides when the user holds **none** of these grants: ```ts const PLATFORM_RESOURCE_PERMISSIONS = [ 'realm-settings:read', 'asset:read', 'observability:read', 'inbox-settings:read', 'realm:admin', ] as const ``` Source: `src/frontend-vue/src/layouts/MainLayout.vue` (`hasAnyPlatformPermission`). Per-item gating lives inside `PlatformView.vue` on each `SubNavItem.visible` — items the user can't read disappear from the sub-nav even when the wrapper is shown. `realm:admin` is a realm-wide bypass and is honoured implicitly by `authStore.hasPermission`. The Pages item adds a second gate on `appConfig.config.Features.PageBuilder` so the editor stays hidden when the operator hasn't switched the beta flag on. ## URL convention Every Platform route sits under `/platform/*`. The wrapper redirects an empty `/platform` to `/platform/customization/branding` (the always-on starting point), so the area is link-safe even when the user has no other platform permission. ## Header pattern Every Platform view sets the same header shape via `useUI()`: ```ts ui.header.title = 'Platform' ui.header.subTitle = 'Branding' // or 'Observability', 'Asset Library', … ``` So the breadcrumb the user sees is always `Platform › ` — consistent across the area regardless of which sub-page they landed on. ## Quick links * [Branding](./branding) — per-realm logo, colors, product name * [Pages](./pages) — page-builder editor (Beta) * [Asset Library](./assets) — image upload + SVG sanitisation * [Observability](../operate/observability) — metrics, traces, live activity feed * [Inbox](./inbox) — operator notification stream * [Inbox settings](./inbox-settings) — per-tenant notification retention * [Settings](./settings) — projections, SMTP, 2FA, grace period ::: tip Looking for tenant-admin work? Users, groups, OAuth clients, realms — those live under [Administration](../admin/), not here. ::: --- --- url: /platform/branding.md --- # Customization — Branding Per-realm SPA-shell branding so every tenant can present its own product name, colour, logo, and favicon at the login page and across the admin UI — without the operator rebuilding the SPA bundle. ::: info Default is "no branding" Every realm starts unbranded. The Cocoar defaults (product name "Modgud", primary color, logo, favicon) apply until at least one branding field is set. Partial branding is supported — set just the logo and leave the colour at default, etc. ::: ::: info Reaching this surface [Realm Settings](../admin/realm-settings#branding-separate-page) links out to this page for branding, but the editable form lives only here — under **Platform → Customization → Branding**. ::: Permissions: `realm-settings:read` / `realm-settings:write`. The `realm:admin` bypass grants both. ## Fields | Field | Default | Effect when set | | --- | --- | --- | | **Product name** | "Modgud" | Header title in the admin UI + login page; `document.title` prefix on every page. ≤ 100 characters. | | **Primary color** | design-system blue | Drives the `--coar-color-primary` CSS variable. Accepts hex (`#rgb`, `#rrggbb`, `#rrggbbaa`), `rgb()` / `rgba()`, `hsl()` / `hsla()`, or a CSS named colour. **No** `calc()` / `var()` / arbitrary CSS — that's blocked at the API to prevent injection into the property value. | | **Logo** | Modgud logo (`/idp-logo.svg`, white variant `/idp-logo-white.svg` for the dark header) | Header logo in the admin UI + login page. Pick from the [Asset Library](./assets) via the asset picker. | | **Favicon** | `/idp-logo.svg` | Browser-tab icon. Same asset picker; the SPA rewrites the `` element at boot. | ::: tip Tri-state save semantics Each field has three save states: **leave the value** (don't touch the input), **clear back to default** (empty the input and save), or **replace** (type / pick a new value). Clearing is how you revert to a Cocoar default without leaving a stale custom value behind. ::: ## Setting it up 1. Upload the images you want to use to the [Asset Library](./assets). At minimum a logo (any of the allowlisted formats — usually SVG or PNG with transparency). Favicon is typically a square `.ico` or small PNG. 2. Open **Administration → Customization → Branding**. 3. Fill in the form: type the product name, pick the primary colour, click the Logo / Favicon picker tiles and select from the uploaded assets. 4. **Save**. The branding sub-document is rewritten in the tenant DB. ## Where the values surface | Surface | What it picks up | | --- | --- | | `/api/app-info` (anonymous, public) | All four fields, resolved as the *effective* branding for the request (realm branding merged with any per-Application override — see below). The endpoint resolves `LogoAssetId` / `FaviconAssetId` to public URLs (`/api/assets/{shortGuid}`) — anonymous callers never see the raw asset id. | | Login page (`/login`) | Product name + logo + primary color; favicon set at boot. | | Admin shell | Same. The shell picks the values up from the same `appConfig` Pinia store. | | `document.title` | Product name as prefix. | | Browser tab icon | `` is rewritten in JS at boot. | ## Per-Application override A realm can host more than one Application (each with its own origin, login behaviour, and OAuth clients — see **Administration → Apps**). Each Application can optionally override the realm's product name and primary color on top of the realm-wide branding described above. The override lives on the Application record itself: open **Administration → Apps**, pick an Application, and switch to the **Origin & Branding** tab. A "custom branding" checkbox turns the override on; leaving it off means the Application simply inherits the realm branding. The logo and favicon are not currently overridable per Application — only product name and primary color. For a realm with a single Application (the common case), the effective branding and the realm branding are identical, so this distinction doesn't come up. ## Asset-reference safety The Branding sub-document stores `LogoAssetId` and `FaviconAssetId` — **not** URLs. That has two consequences: * **Cross-domain risk**: zero. A realm admin can only reference assets uploaded into their own realm's asset library. Pasting an `https://evil.example.com/cookie-stealer.svg` into branding is not possible — the picker only shows local assets. * **Delete-block**: the asset-library endpoint refuses to delete an asset that's referenced by the realm's branding. You get an HTTP 409 with the referencing field name; clear the branding field first, then delete the asset. ## What's stored where | Data | Location | | --- | --- | | Branding sub-document (the four fields) | tenant DB, inside the singleton `RealmSettings` document | | Logo and favicon binary | tenant DB, `mt_doc_asset` (see [Asset Library](./assets)) | | Default values served when a field is null | hardcoded in the SPA (`appconfig.store.ts`) | ## Public exposure The `/api/app-info` endpoint is anonymous — branding is metadata, not secrets. It surfaces the same shape as the existing public realm settings and is required for the login page to render branded **before** the user authenticates. No tokens, no secrets, no cross-realm leakage (each realm's `RealmMiddleware`-resolved tenant scope only sees its own settings). --- --- url: /platform/assets.md --- # Customization — Asset Library Per-realm image library for branding (and, later, page-builder schemas). Upload once, reference by ID from any branding field. Each realm's library is fully isolated — assets live in the tenant DB and can never be referenced from another realm. ::: warning Trust boundary: SVG and image MIME The asset library accepts SVG, but every upload is sanitised on the server before the bytes touch storage. The MIME type is sniffed from the leading magic bytes — **the client's `Content-Type` header is never trusted**. Anything that doesn't match the allowlist is rejected outright. ::: Permissions: `asset:read` to list / view, `asset:write` to upload / delete. The `realm:admin` bypass grants both. ## Limits and allowlist | Limit | Value | Notes | | --- | --- | --- | | Max file size | **2 MiB** | Hard cap per upload. Hits return HTTP 400 with the explicit limit. | | Allowed MIME types | PNG, JPEG, GIF, WebP, SVG, ICO | Sniffed from magic bytes. Anything else → reject. | | Filename | Free text | Used only for display; the storage key is the asset id (a UUIDv7). | | Per-realm count | unlimited (no quota in v1) | Storage-quota enforcement is a future feature — see roadmap. | ::: tip Why magic-byte sniffing A malicious uploader could send `image/png` as their `Content-Type` while the bytes are actually an executable. The MIME-type allowlist alone doesn't help — only the magic-byte sniff does. The allowlist is a separate guard on top of the sniff, not a replacement for it. ::: ## SVG sanitisation SVG goes through a dedicated cleanup pass before it lands in storage. The sanitiser: 1. Parses the SVG with `DtdProcessing.Ignore` + `XmlResolver = null` — no external entity resolution, no DTD-based attacks (XXE). If parsing fails, the upload is rejected (`Asset.SvgNotWellFormed`); we never persist SVG we can't fully parse. 2. **Removes `