# Modgud > Generated: 2026-09-10 · Source commit: 18b92a1 · Product: Modgud · Canonical: https://docs.cocoar.dev/modgud/ · Version: v0.13 --- 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`, issue a short-lived installation link, and create the first realm and administrator in the browser. * **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. When a token targets a registered OAuth API and includes the `roles` and/or `permissions` scope, it can carry a Keycloak-shaped `resource_access` block keyed by that API's exact Audience, with bypass-pre-expansion and per-RS subset narrowing. Resource servers do straight exact-match against projected claims. ## 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`, complete first installation, 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 and PostgreSQL data 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 about 15 seconds while Marten provisions the master database, tenant registry and Global Store. No realm or user exists yet. ::: 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. Complete first installation A fresh deployment has zero realms and zero users. Normal routes remain closed until an operator with shell access issues a short-lived, single-use installation link: ```bash docker exec modgud \ dotnet Modgud.Api.dll recover install-link \ --base-url http://localhost ``` Open the printed `/install?token=...` URL. Enter a realm slug and display name, use `localhost` as the primary domain, then choose the first administrator's username, email and password. Completion creates the first ordinary realm and its tenant database, assigns `IsControlPlane`, creates the administrator with `realm:admin`, and redirects to the login page. ::: tip Password rules The installation API enforces the same Identity password policy as the regular admin UI (length, mixed case, digit). A weak password is rejected — see [Settings](../platform/settings) for how to adjust the policy if needed. ::: ::: details Automated installation for CI/test `recover install-link --json` returns the plaintext bearer token in a machine-readable final line. A trusted runner can submit it together with the realm and administrator payload to `POST /api/install/complete`. The browser uses the same API. See [First-time setup](./first-time-setup#automated-installation-citest) for a complete `curl` example. ::: ## 3. Sign in Open and sign in with the credentials chosen during installation. 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** — Applications, Realms, Realm Settings, Logs, 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. Because `localhost` was registered during installation, it resolves to your first 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` and `aud`; once the token targets a registered OAuth API, requesting `roles` and/or `permissions` adds the corresponding arrays under `resource_access[]`. ## 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 Check that installation completed successfully and use the username, not the email address, unless both are identical. `docker logs modgud` shows migration or provisioning failures. If the container is still starting, wait for `/health/ready` 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. Realm-admin invitation endpoints return the one-time URL, so local setup is still possible. To 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. Make sure the requested host is listed in the realm's Domains and that one of them is the Primary Domain. 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 For this disposable quickstart, remove the Compose volume and start again. This deletes the master database and every realm database: ```bash docker compose down -v docker compose up -d ``` Then repeat step 2. Do not use `down -v` on an environment whose data you need; it is intentionally destructive. ::: ## 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 registered OAuth API Audience. The linked App may have a different 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). * Device-aware login throttling instead of a global account lockout: failure buckets per trusted browser and per untrusted pool, an unlock e-mail for the owner, a password-spray signal per source (all realm settings) * 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.) * **SAML 2.0 Service Provider federation** (Microsoft Entra Enterprise Apps, ADFS, Okta and standards-compatible SAML IdPs) * 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) * SAML v1 is SP-initiated and SP-only; IdP-initiated SSO, SAML Single Logout and Artifact Binding are not supported ### 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 * **Application roles** bound to one app, holding permissions on its resources; pure `realm:admin` roles are the explicit realm-local exception * **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), keyed by the exact registered OAuth API Audience when that audience and the `roles` and/or `permissions` scope are present * **Bypass-pre-expanded + per-RS narrowed** — consumers do straight exact-match without porting the evaluator * **`Modgud.AspNetCore.ResourceServer`** supports local JWT validation and reference-token introspection; each authentication scheme projects its own audience block into native role and permission claims ### 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.AspNetCore.ResourceServer`** — explicit JWT and introspection handlers that validate tokens and project the configured audience block onto the principal * JWT validation is local; reference-token validation uses RFC 7662 introspection for immediate revocation ### UserInfo as the permission delivery channel * JWT access tokens, UserInfo and authorized introspection responses can expose the same audience-keyed `resource_access` claim * Bypass-pre-expanded server-side + narrowed to each RS's declared `OAuthApi.PermissionIds` subset * Delivered via standard JWT claims, UserInfo, and token-introspection responses — any OIDC-aware consumer can parse it. `Modgud.AspNetCore.ResourceServer` adds audience selection and scheme-local claims projection for ASP.NET Core; 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 A fresh Modgud deployment starts with **zero realms and zero users**. Startup creates only the master database, the tenant registry and the Global Store. The first installation then creates: * the first ordinary realm; * that realm's tenant database and standard seed data; * the first user and its `realm:admin` membership; and * the `Realm.IsControlPlane` flag on that first realm. There is no special runtime `system` realm. Every realm has the same data shape. Cross-realm authority belongs to `realm:admin` users in whichever realm currently carries `IsControlPlane`. ## Trust boundary The installation form is not anonymously claimable. An operator with shell access first issues a short-lived, single-use installation token through the recovery CLI. Only its SHA-256 hash is stored in the Global Store. Both the browser installation form and CI call the same HTTP API with that token. The API never issues installation tokens itself. ## Interactive installation Start the container, then issue an installation link from inside it: ```bash docker exec modgud \ dotnet Modgud.Api.dll recover install-link \ --base-url https://auth.example.com ``` The command prints a URL like: ```text https://auth.example.com/install?token=... ``` Open the URL and enter: * realm slug and display name; * primary domain (normally the host used in `--base-url`); * first administrator username, email and password. The API provisions the realm inactive, creates the administrator, activates the realm and marks installation complete. Normal API and browser routes return `503 not_initialized` or redirect to `/install` until that sequence succeeds. Issuing another link revokes any previous unconsumed link. The default lifetime is 30 minutes; `--minutes` accepts values from 1 to 1440. **The `--base-url` you pass is the deployment's public origin.** It decides two things, both of them from that single declaration: 1. Installation sends you back to it verbatim — scheme, host and port. You are standing at that origin, so that is where the sign-in page has to be. 2. It is recorded as the new realm's **public origin**, and from then on every outbound link is built against it: magic links, password resets, email verification, invites, and the login-provider callback URLs you paste into an upstream IdP. Nothing is inferred from the environment, so a deployment on a non-default port works without special cases. Change it later with `recover realm-set-public-url --slug --url `. It is separate from the realm's **primary domain**, which stays a bare host name because it is also the passkey relying-party ID — see [Deployment](../operate/deployment#where-public-urls-come-from). ::: warning Production boot guards The published image runs as **Production** and refuses dev-shaped security configuration. In particular, OpenIddict development mode must be disabled and an enabled Prometheus endpoint needs a strong bearer token. See [Deployment](../operate/deployment). ::: ## Automated installation (CI/test) Use `--json` to make the recovery command's final output line machine-readable: ```bash install_json="$( docker exec modgud \ dotnet Modgud.Api.dll recover install-link \ --base-url https://auth.test.localhost \ --minutes 10 \ --json | tail -n 1 )" token="$(printf '%s' "$install_json" | jq -r .token)" ``` Wait until `GET /health/live` succeeds, then call the completion API: ```bash curl --fail-with-body \ --request POST \ --header 'Content-Type: application/json' \ --data @- \ https://auth.test.localhost/api/install/complete <` explicitly. For example: ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --realm acme \ --email recovery-admin@example.com \ --username recovery-admin \ --password 'StrongPass1!' ``` `bootstrap-admin` adds the user to the realm's existing Administrators group and therefore restores a `realm:admin` path. See [Recovery CLI](../operate/recovery-cli). ## Recommended next steps 1. Enable TOTP or a passkey on the first administrator. 2. Configure SMTP and test outbound mail. 3. Register the first OAuth/OIDC application. 4. Configure external SSO if required. 5. Plan and test Control-Plane transfer before relying on it operationally. The guard that prevents removal of the final realm or final effective `realm:admin` path is a separate hardening concern. The recovery CLI remains the break-glass path if an administrator is locked out. --- --- 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 you do not have to operate multiple tenants: for a "one app, one company" deployment, install one realm and keep it as the Control Plane. ## 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 first realm is an ordinary, **fully-featured realm**. First installation assigns it the Control-Plane flag, so it also owns the cross-realm functions. | Feature | Available in the single realm | |---|---| | Users, Groups, Roles, Permissions | ✅ | | OAuth clients for your apps | ✅ | | OAuth scopes, resource APIs | ✅ | | Login providers (Internal, OIDC and SAML 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 evaluation, use the [Quickstart](quickstart) and create one realm during installation. For a real single-tenant **production** deployment, run the published image with every environment variable 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 # Issue the shell-authorized installation URL docker exec modgud dotnet Modgud.Api.dll \ recover install-link --base-url https://auth.example.com ``` Open the printed URL. Use `auth.example.com` as the realm's primary domain and create the first administrator in the installation form. 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.** Both operations are blocked because the deployment would lose its cross-realm admin surface. The 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 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 first realm is created explicitly during first installation. It starts as the **Control-Plane** realm — flagged `IsControlPlane = true` — meaning only its `realm:admin` users may create further realms. The flag can later move to another active realm; no realm is special by slug. The realm boundary is the **domain** (Host header), not the URL path. Realm `acme` might live under `acme.example.com`; a local realm commonly uses `auth.localhost` 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 and SAML providers automatically show "Login with {Provider}" buttons in the login UI. Internal never produces an SSO button — it backs the local username/password form. SAML is SP-only and SP-initiated in v1. *** ## 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 audience-keyed authorization claim — shaped like Keycloak's nested `resource_access` format for familiarity — works at the token boundary, 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` assigns a group to one or more Apps: its effective members belong to those Apps' Principal scopes, and its roles are active there. This also supports pure assignment groups. A group with `BoundTo = ["acme"]` and no roles grants no permission, but its effective members still belong to Acme's Principal scope. Modgud therefore needs no second per-user or per-position App-assignment list. The `"*"` binding assigns the group to every App in the realm. ## 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. That link makes the Apps' scopes requestable; when the request targets registered OAuth APIs in those Apps and includes `roles` and/or `permissions`, the issued token can carry one `resource_access` block per targeted API Audience. 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** at the token boundary bypass-pre-expands those same markers into concrete catalog strings before emission (see below), so token consumers never have to special-case them. ## The token shape When a user logs in via an OAuth Client entitled to the `billing` and `shipping` Apps, requests scopes targeting the registered audiences `billing-api` and `shipping-api`, and receives the appropriate claim scopes, the access-token principal contains a nested claim shaped like Keycloak's `resource_access` format: ```json { "sub": "abc123…", "email": "alice@example.com", "name": "Alice", "resource_access": { "billing-api": { "roles": ["Editor"], "permissions": ["invoice:read", "invoice:write"] }, "shipping-api": { "roles": ["Viewer"], "permissions": ["shipment:read"] } } } ``` Each resource server reads its own exact Audience block. The Billing API sees `resource_access["billing-api"]`; the Shipping API sees `resource_access["shipping-api"]`. Both blocks may be present side-by-side in a multi-audience claim, but each authentication scheme projects only its configured Audience. The `Modgud.AspNetCore.ResourceServer` authentication handlers take the matching audience block and project its roles onto `ClaimTypes.Role`, so `[Authorize(Roles="Editor")]` works out of the box without global claims state or per-endpoint plumbing. ### What gets emitted is opt-in by scope * A block is considered only when an `aud` value resolves to a registered OAuth API linked to an App. * `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 either claim scope, or without a matching registered API audience, the entire `resource_access` claim is omitted. Clients ask for exactly what they need; tokens stay lean. JWT access tokens carry the claim directly, reference tokens retain it in their server-side payload for authorized introspection, and UserInfo returns the same eligible block. ### 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. * **Blocks for audiences the token does not target.** An App link controls which App-scoped scopes the client may request; only the resulting registered OAuth API audiences become `resource_access` keys. * **`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 entitled to scopes from many apps).** **`OAuthApi.AppId` is `1:1` (a resource server belongs to one app).** The client link does not itself create claim blocks; requested scopes/resources create token audiences, and each registered audience resolves through its API to exactly one App. The asymmetry supports one frontend calling many resource servers without muddling each server's catalog and audit context. ## 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 token-bound authorization claim, shaped like Keycloak's nested format and keyed by exact OAuth API Audience, with scope-gated roles and bypass-pre-expanded permissions narrowed per resource server. --- --- 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/SAML IdPs) * its own **cookie domain** * its own **auth rate-limit policies** (per source, target, client, app and device on login/register/etc., overridable per App) 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" auth.localhost → Realm "local-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 Local development `*.localhost` resolves to loopback on modern desktop systems. Modgud still requires the exact hostname in the realm's Domains list; an unknown host fails closed with 404 instead of guessing a tenant. ::: ## Database-per-tenant via Marten Modgud uses Marten's `MasterTableTenancy`: ```mermaid graph TD Master[(
= Master DB)] Master -->|realms.mt_tenant_databases| Acme[(_acme)] Master -->|realms.mt_tenant_databases| Finance[(_finance)] Master -->|global Schema| GlobalRealm["Realm-Documents
(IGlobalStore)"] 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) | | `_` | A separate physical DB for every realm, including the first | ::: info Master DB vs. realms The master DB is deployment-wide infrastructure — tenant registry, `IGlobalStore`, installation state, global jobs and platform audit. It is **not** a tenant. Every realm has the same data shape and lives in its own `_` database. Cross-realm authority follows the transferable [Control-Plane flag](./control-plane.md), not a special database or slug. ::: ### 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(forWrite: true)); private string ResolveTenantId(bool forWrite) { var tenant = TenantContext.CurrentOrNull ?? httpContextAccessor.HttpContext?.Items["TenantId"] as string; return tenant ?? throw new InvalidOperationException("No realm resolved"); } ``` Every `IDocumentSession`/`IQuerySession` injection is realm-scoped and fails closed without an explicit realm. Deployment-wide code uses `IGlobalStore`; background realm work enters `TenantContext.Enter(slug)` explicitly. ### 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 installation Startup creates the master database, tenant registry and Global Store, but no realm. A shell-authorized [installation link](../getting-started/first-time-setup) then drives one browser/API transaction: 1. Create and register `_`. 2. Apply the tenant schema and seed standard OAuth scopes, the Internal login provider and the `modgud`/`control-plane` apps. 3. Store the first ordinary Realm document in `IGlobalStore` with `IsControlPlane = true`. 4. Create its first user and `realm:admin` membership. 5. Activate the realm and mark installation complete. ### 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"] } ``` 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`** → 6 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. The realm is complete and active without requiring an administrator. A Control-Plane admin can later issue a single-use, 24-hour invitation through `POST /api/admin/realms/{slug}/admin-invites`. Issuing a new link revokes the previous open one. The recipient sets a password and is auto-signed-in with `realm:admin`; `RealmAdminBootstrapper` seeds the 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 Control-Plane realm The realm currently holding `IsControlPlane` cannot be deactivated. Transfer the flag to another active realm first. ::: ### 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 **deployment-wide installation and cross-realm administration** (first installation, realm CRUD) 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 first-installation API stamps the first ordinary realm with the flag only after its first `realm:admin` has been created. No realm is special by slug. The flag is **transferable** to any active realm, so a deployment that starts single-tenant can later hand cross-realm administration to another realm. ### 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. * The initial realm receives the flag only while the global realm registry is empty. Normal realm creation never sets it, and startup never assigns or moves it, so a transfer remains durable across reboots. `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 first-installation form requires the first realm's domain and primary domain. Additional hostnames are managed on the realm or with `recover realm-add-domain`; there is no seeded hostname or special slug. `IRealmCache` is invalidated when realm metadata changes. From the next request onward, a matching Host header resolves to that realm. If it currently holds `IsControlPlane`, `ControlPlaneGateMiddleware` exposes `/api/admin/realms/*`; otherwise that surface remains 404. There's no separate environment variable mirroring the hostname list. The realm's own `Domains` field in `IGlobalStore` is the single source of truth. ## 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 acme ``` 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). Realm creation and administrator onboarding are separate operations: 1. Creates the realm (DB, OAuth scopes, login providers, app seeding) 2. A CP admin may later call `POST /api/admin/realms/{slug}/admin-invites` 3. The API issues a `PendingAdminInvite`, sends the email, and returns its one-time `MagicLinkUrl` The SPA reveals the `MagicLinkUrl` once after invitation — useful in SMTP-less dev and air-gapped scenarios where the email won't arrive. ### Token lifecycle * 32-byte URL-safe random plaintext, SHA-256-hashed in the DB * 24-hour TTL (`PendingAdminInvite.DefaultExpirationHours`) * Single-use: `UsedAt` is set on success; reuse → 400 `BootstrapInvite.TokenUsed` * A new invite revokes every prior open invite — there is at most one consumable admin invitation 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 realm browser-session policy (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) | Realm browser-session policy | | **Magic Link** | Email with single-use token; can also be sent by an admin | Realm browser-session policy | | **OIDC/SAML External** | Federated login via an upstream IdP | Realm browser-session policy | 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 realm browser-session policy | | `Modgud.2FA` | UserId between password step and 2FA step | Strict | 5 min | | `Modgud.External` | OIDC callback holder | Lax | 10 min | | `Modgud.Passkey.Challenge` / `Modgud.Passkey.Enroll` | Id of the server-side passkey login / registration ceremony | Strict | 5 min | `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 and SAML) Users can sign in through Microsoft Entra ID and standards-compatible OIDC or SAML providers. Providers are configured independently per realm. 1. Admin creates an OIDC or SAML `LoginProvider`. 2. The login page shows a button for every enabled external provider. 3. OIDC uses Authorization Code + PKCE. SAML uses an SP-initiated AuthnRequest and a correlated ACS response. 4. After protocol validation, `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. The realm's browser-session policy determines the login-cookie lifetime. Modgud consumes SAML only as a Service Provider and accepts only SP-initiated, correlated responses. IdP-initiated SSO, SAML Single Logout and Artifact Binding are outside the v1 surface. See [Login providers](/integrate/login-providers) and [SAML federation](/admin/saml-federation). ## Account lifecycle | How does a user enter the system? | Mechanism | |---|---| | Self-registration | Registration form (when enabled for the realm) | | External login | OIDC/SAML 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 an administrator. Wrong-password floods no longer lock the account itself: failures are throttled per device and per untrusted pool so the owner's own browsers keep working (see [Rate limits → Password login](../platform/rate-limits#password-login-device-aware-throttling)) * **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 a token-bound `resource_access` block is built, the OAuth API identified by the audience determines which App 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 token claims, 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`, …). Application PermissionRoles bind to one app (via `AppId`); a pure `realm:admin` role is the explicit exception. 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. The IdP evaluates and pre-expands grants; resource servers perform exact claim checks through `Modgud.AspNetCore.ResourceServer`. ## 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 token audience resolves an OAuth API; that API's `AppId` selects the permission catalog. The OAuth API Audience and App slug are separate identifiers and need not match. 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` and is used inside Modgud. At the token boundary Modgud pre-expands bypasses; the resource-server package performs exact checks against the projected concrete claims and does not run the evaluator. ## 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 For each token audience (`aud`) that resolves to a registered OAuth API linked to an App, Modgud can build a **`resource_access[]`** block shaped like Keycloak's nested format. `roles` must be granted to emit its role array; `permissions` must be granted to emit its permission array. If neither scope is present, or no audience resolves to an OAuth API with an App, the whole claim is absent. For JWT access tokens the claim is carried on the wire. For reference tokens it remains in the server-side payload and is exposed only through authorized introspection. `/connect/userinfo` returns the same block for an eligible bearer token: ```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 in the audience's linked App; a `:admin` bypass is expanded into every `:*` string in that 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 array and `scope=roles` to see the role array. Requesting only one never implicitly adds the other. The `Modgud.AspNetCore.ResourceServer` authentication handlers project the matching audience block onto the principal so standard ASP.NET Core `[Authorize(Roles="…")]` and `RequireModgudPermission(…)` 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 through an audience-keyed `resource_access` block when the corresponding OAuth API audience and claim scopes are present. 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. ### The `sid` claim Every token issued for a user session — ID token, access token, and the introspection response of a reference token — carries `sid`, the session identifier: the browser session for browser flows, the native client session for native grants. It is opaque and realm-local. Relying parties and resource servers use it to match a logout notification to a session (see [logout propagation](../integrate/login-flows#logout-propagation-to-relying-parties)). Client-credentials tokens have no user, no session and no `sid`. ### 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; every relying party of the session receives a back-channel logout token and the change feed deletes the `session` entity | 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 logout | Supported | [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html) | Server-initiated: a signed logout token is POSTed to every relying party of an ended session, and the same fact is published as a `session` entity on the [Application change feed](../integrate/application-change-feed) for relying parties Modgud cannot reach. Every user token carries `sid`. See [Logout propagation](../integrate/login-flows#logout-propagation-to-relying-parties). | | Front-channel logout | Not supported | — | Deliberately: unreliable under third-party-cookie blocking and blind to admin- and lifecycle-triggered session ends. Use back-channel logout. | | [PAR (RFC 9126)](/reference/oauth-api#pushed-authorization-requests-par) | Supported | Available, not required | Pushed authorization requests at `/connect/par`. Any client may push its authorization request and hand `/connect/authorize` only a one-time `request_uri`; no client is forced to, so direct browser and device flows keep working. | | DPoP (RFC 9449) | Supported | Offered; enforcement opt-in per client | Sender-constrained tokens. A proof at `/connect/token` binds the access token to the proof key (`cnf.jkt`); resource servers enforce the binding on both the JWT and the introspection path. Refresh tokens for a DPoP client are bound to the same key. Two per-client opt-ins harden it further: *Require DPoP* (reject tokenless requests) and *Require DPoP nonce* (`use_dpop_nonce` + `DPoP-Nonce` handshake). | | 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, realm-owned structured security events with configurable 1–365 day retention (7-day default), and a separate event-sourced audit history. | [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. * **Release supply chain** — every release image passes a Trivy vulnerability gate, is signed with cosign (keyless), and ships with build-provenance attestations and per-arch SPDX SBOMs; the NuGet package carries a provenance attestation too. Verification commands: [Supply-chain verification](/operate/supply-chain). 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.*` | | `ClusterSettings` | `Cluster:` — `DrainDelaySeconds`, `NodeName` (see [Running two instances](#running-two-instances)) | | `OutboundHttpSettings` | `OutboundHttp:` — `AllowedPrivateHosts` (see [Identity providers on private networks](#identity-providers-on-private-networks)) | 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. ### Where public URLs come from Two mechanisms build public URLs, and neither guesses: * **Per-request** — the OIDC issuer and every discovery endpoint come from the request as the client made it: scheme, host **and port**, taken from the forwarded headers. Correct on any port, no configuration. * **Per-realm** — every *outbound* link (magic link, password reset, email verification, invites, the login-provider callback URLs shown in the admin UI) is built against the realm's **public origin**, and that origin is also an accepted WebAuthn origin. The public origin is a property of the realm: an absolute URL such as `https://auth.example.com` or `http://localhost:4300`, port included. **First installation records the exact origin its installation link was issued for** — so a deployment reached on a non-default port says so from the start, and nothing has to be inferred from the environment. Change it later with: ```bash docker exec modgud dotnet Modgud.Api.dll recover realm-set-public-url --slug acme --url https://auth.example.com ``` It is deliberately separate from `PrimaryDomain`, which stays a bare **host name** because it doubles as the WebAuthn RP ID and the cookie domain — neither may carry a scheme or a port. The primary domain says *which host this realm is*; the public origin says *where users reach it*. Changing the origin does not invalidate passkeys; changing the primary domain does. A realm that declares no origin — every realm created before this field existed — falls back to `https://{PrimaryDomain}`, i.e. the reverse-proxy-on-443 topology this page describes. If such a realm is served anywhere else, give it an explicit origin with the command above. ### Identity providers on private networks Every URL a realm admin types into Modgud and that Modgud then fetches server-side — an OIDC provider's discovery and token endpoints, SAML IdP metadata, a client-id metadata document, the back-channel logout endpoint of a resource server — goes through an SSRF guard: the name is resolved, any address that is not publicly routable (private ranges, loopback, link-local, CGNAT, ULA …) is refused, and the connection goes to exactly the validated address. A realm admin is a lower trust tier than the platform operator, so "an admin configured it" does not switch this off. An identity provider or an application on your **internal network** is a legitimate case the guard would otherwise block. The platform operator lists those hosts explicitly, deployment-wide: ```yaml OutboundHttp__AllowedPrivateHosts: "keycloak.corp.internal, *.apps.corp.internal" ``` Exact host names, or `*.suffix` for a whole zone (the suffix alone does not match). Separate entries with commas, semicolons or whitespace. A listed host is exempt from the address check only; TLS still validates the certificate against the name, redirects stay off and the timeouts stay tight. A refused fetch says so in the log, naming this setting. ### 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 deployment-wide infrastructure (the tenant registry, Global Store and Wolverine durability); it is **not** a tenant. Every realm lives in its own `_` DB. For a master DB called `modgud`, realms `acme` and `finance` use `modgud_acme` and `modgud_finance`. Back up the master DB **and** every realm DB. ::: ## 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. Your **own reverse proxy only** — never a backend-for-frontend; a BFF identifies itself as a confidential client with the trusted-forwarder capability instead (see [Rate limits](../platform/rate-limits#trusted-forwarders)). 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 plain-HTTP scheme, and OpenIddict **refuses** the request (`invalid_request: This server only accepts HTTPS requests`) rather than publishing an `http` issuer. So a missing proxy range is a loud failure on the OAuth endpoints, not a subtly wrong token. 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 An empty deployment has no realm or user. Issue a short-lived installation URL from inside the container: ```bash docker exec modgud dotnet Modgud.Api.dll \ recover install-link --base-url https://auth.example.com ``` Open the printed `/install?token=...` URL. Create the first ordinary realm, register `auth.example.com` as its primary domain and create its first administrator. The same token can be submitted to `/api/install/complete` by CI; see [First-time setup](../getting-started/first-time-setup). ### 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 database is created if missing. 2. Marten applies the tenant registry and Global Store schema idempotently. 3. Deployment-wide installation, audit and scheduled-job documents become available. No tenant is inferred or created. First installation then creates `_`, registers the tenant, applies its schema, seeds the default scopes, login provider and apps, and stores the first realm in the Global Store. That realm receives the Control-Plane flag only as part of successful installation. 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. Several instances may boot in parallel: Marten's schema apply is idempotent and serialised by Postgres locks, and the one-off Quartz schema step runs under a cluster lock. Realm provisioning applies the schema of a new realm database at runtime, so a separate migration phase is neither needed nor possible. See [Running two instances](#running-two-instances) for the update rules. ## 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 and the node is **not draining** after SIGTERM. Use it as the readiness probe (gate traffic on it). The JSON body names the failing check (`postgres`, `marten-schema`, `openiddict-cert`, `cluster`). The image also declares a Docker `HEALTHCHECK` on `/health/ready` (every 15 s, 120 s start period), so `docker ps` shows `healthy` / `unhealthy` and other services can wait with `depends_on: condition: service_healthy`. If you change `AppUrl`, point the probe at the new address with `HEALTHCHECK_URL`. ## Startup and process supervision **Waiting for PostgreSQL.** In a container stack Modgud regularly comes up before Postgres does. Boot therefore retries the first database contact for a bounded window — connection refused, a hostname that does not resolve yet, or Postgres' own "the database system is starting up" — with growing delays (1 s, 2 s, 4 s, 8 s, then every 10 s) and a warning per attempt: ``` [WRN] PostgreSQL at postgres:5432 not reachable yet (attempt 3: Failed to connect to ...) - retrying in 4s, giving up after 90s ``` The window is `DbSettings__StartupTimeoutSeconds` (default `90`; `0` = a single attempt). Configuration errors — wrong password, missing role, malformed connection string — are **not** retried: they fail on the first attempt so the real cause is at the top of the log. Nothing is served while waiting; Kestrel only starts after the bootstrap succeeded. **Fail fast, never half-alive.** When the window runs out, or any unhandled exception occurs, the process logs `Host terminated unexpectedly` and **exits with code 1**. That is deliberate: the alternative — a process that is up but cannot serve — is invisible to `restart:` policies and readiness probes alike. Pair it with a restart policy (`restart: unless-stopped` in Compose, the default restart in Kubernetes) and the container comes back as soon as Postgres does. `depends_on: condition: service_healthy` on the Postgres service (see the Compose example above) avoids the retries altogether. **PID 1.** The image runs `dotnet` under [tini](https://github.com/krallin/tini) so the process is never PID 1 itself. Without an init process the kernel does not deliver the abort signal the .NET runtime raises on a crash, and a crashed container stays "Up" at 100 % CPU forever. If you build your own image from the published binaries, keep an init process (`tini`, or `init: true` in Compose / `docker run --init`). ## Running two instances Modgud can run as **two (or more) containers against one PostgreSQL**, both serving traffic all the time. That is what makes an image update a non-event: replace one container while the other keeps serving, then the other. It also means a single crashed container no longer takes the login page down. It is *not* a claim of failover across machines or regions. There is no "cluster mode" to switch on. A Production container always runs the cluster-capable code path, with one instance as well as with two: | Concern | How it is coordinated | |---|---| | Outbox, scheduled messages, event forwarding | Wolverine in `Balanced` mode — leader election over its node table in the master DB, work reassigned when a node's heartbeat goes stale | | Async projections and event subscriptions (audit view, application change feed, back-channel logout) | Wolverine-managed distribution: every realm database's projections run on exactly one live node; a dead node's databases are picked up by a survivor | | Scheduled jobs | Quartz.NET clustered on a Postgres job store (schema `quartz` in the master DB): a trigger fires on one node, `RequestRecovery` jobs interrupted by a crash re-run on a survivor | | Live updates over SignalR | The SignalARRR Postgres backplane on the master database (`LISTEN`/`NOTIFY`) carrying a cluster subject with Modgud's data events: an event raised on one node is replayed into the hub streams of the other, so every browser sees it exactly once, from the node it is pinned to. A listener drop is caught up from the backplane's message table, not lost | | Login providers (OIDC/SAML), passkey ceremonies, sessions, rate limits, DataProtection keys | Resolved from the database by whichever node serves the request — nothing lives only in one process | How many nodes are alive is read from Wolverine's node table at runtime; nothing is configured twice. ### What you need 1. **Nothing beyond PostgreSQL.** In Production every node runs the SignalARRR backplane on the master database: one long-lived `LISTEN` connection per node, a `signalarrr` schema created on first start (the database role needs `CREATE` once), `NOTIFY` for delivery and an unlogged message table that every envelope passes through. Modgud's live updates travel as a cluster subject on it. A node whose listener connection drops reconnects and replays what it missed from that table, in order, for up to five minutes; a longer outage is logged as a gap and the grids catch up on their next fetch. Two things to know: the connection string must point at the **primary** (`NOTIFY` is not replicated), and the listener needs a **direct or session-pooled** connection — a transaction-pooling PgBouncer cannot hold a `LISTEN`; startup fails with a clear message if it cannot subscribe. The ceiling is in the low thousands of cross-node messages per second, two orders of magnitude above what an identity provider produces. 2. **Sticky sessions at the reverse proxy.** SignalR requires that every request of one connection reaches the same process; the backplane carries events between nodes, it does not replace affinity. Cookie affinity is the right kind: it survives NAT and keeps a browser's connection and its WebAuthn ceremony on one node. 3. **Active health checks on `/health/ready`** so the proxy removes a draining or failed node within seconds instead of after a client saw an error. 4. **Synchronised clocks** (NTP) on the hosts — Quartz clustering and Wolverine's stale-node detection compare timestamps between nodes. 5. `stop_grace_period` **≥ 45 s** on the container, so a graceful stop can drain (5 s), finish running jobs and hand its agents over (see [Graceful stop](#graceful-stop)). ### Settings | Env var | Default | Meaning | |---|---|---| | `Cluster__DrainDelaySeconds` | `5` | How long the node keeps serving after SIGTERM with readiness already at 503. `0` disables the drain. | | `Cluster__NodeName` | container hostname | Name of this node in logs and health output. | ### Caddy ``` auth.example.com { reverse_proxy modgud-a:8081 modgud-b:8081 { lb_policy cookie health_uri /health/ready health_interval 5s fail_duration 30s } } ``` Caddy sets `X-Forwarded-*` and handles the WebSocket upgrade for `/signalr` by itself. ### Nginx ```nginx upstream modgud { hash $cookie_modgud_affinity consistent; # or: ip_hash; if all clients have distinct addresses server modgud-a:8081 max_fails=2 fail_timeout=10s; server modgud-b:8081 max_fails=2 fail_timeout=10s; } ``` Nginx open source has no active health checks; keep the passive `max_fails`/`fail_timeout` short and rely on the drain window. The `location` blocks are the same as in [Reverse proxy (Nginx)](#reverse-proxy-nginx) with `proxy_pass http://modgud;`. ### Docker Compose Two named services sharing one environment: ```yaml services: modgud-a: &modgud image: ghcr.io/cocoar-dev/modgud:1.0.0 stop_grace_period: 45s environment: &modgud-env DbSettings__ConnectionString: "Host=postgres;Database=modgud;Username=postgres;Password=postgres" ProxyAllowedNetworks: "10.0.0.0/24" Observability__Prometheus__BearerToken: "${PROMETHEUS_TOKEN}" volumes: - cocoar-keys:/app/data/keys # both nodes must see the same OpenIddict certificates depends_on: postgres: { condition: service_healthy } modgud-b: <<: *modgud ``` Both nodes must load the **same OpenIddict signing and encryption certificates** — mount the same keys volume (or the same files) into every container; a token signed by one node is verified by the other. DataProtection keys already live in the database. **Rolling update with Compose.** Compose itself has no rolling strategy; the sequence is a short script: ```bash docker compose pull docker compose up -d --no-deps modgud-b # replaces b; a keeps serving until curl -fsS http://localhost:8082/health/ready >/dev/null; do sleep 2; done docker compose up -d --no-deps modgud-a # replaces a; b keeps serving ``` Expose each node's port on localhost (`127.0.0.1:8081` / `127.0.0.1:8082`) for the readiness wait, or watch `docker compose ps` for `healthy`. **Docker Swarm** does the same natively, on a single host as well: `deploy.replicas: 2` with `update_config: { parallelism: 1, order: start-first }` and the image `HEALTHCHECK` gating each step. Use it if you would rather not script the order. ### Graceful stop On SIGTERM a node 1. reports `/health/ready` = 503 immediately (`"Draining — this node is shutting down."`), 2. keeps serving for `Cluster__DrainDelaySeconds` so the proxy's active check takes it out of rotation while in-flight requests complete, 3. stops accepting connections, waits for running Quartz jobs, stops its Wolverine agents and deregisters its node so the peer takes over its projections and outbox work without waiting for the stale-node timeout. A killed node (`docker kill`, OOM, host crash) skips all of that; the survivor takes over its work after Wolverine's stale-node timeout and Quartz's cluster check-in, both well under a minute (measured on the reference rig: all projection shards, outbox agents and jobs on the survivor within 60 s). Expect one `StopRemoteAgent … Timed out` error on the survivor at that moment — that is Wolverine clearing the dead node's leader record and getting no answer, not a fault in the survivor. Browsers reconnect to the other node and their admin grids resubscribe. ### Which release is safe to roll Both instances run against the same databases for the duration of an update, so a release must be able to run **next to its predecessor**. Marten applies additive schema changes idempotently at boot — new tables, columns and indexes are fine while the old version is still running. Every GitHub release states it in its notes: * **`Rolling update: safe`** — replace one container after the other as above. * **`Rolling update: stop required (reason)`** — scale to one instance, update it, scale back. This is the case for a Marten upgrade that replaces its `mt_*` functions, a projection rebuild, an inline projection whose new shape the previous version cannot read, or a new event type the previous version has no upcaster for. A **projection rebuild** (Admin → Projections) is always a single-instance operation; the endpoint refuses with `409` while more than one node is live. ### What still lives per node Two things are deliberately process-local and bounded rather than shared: * **Caches with a short revalidation window** — realm lookups, signing keys, CORS origins, login-provider schemes: a change made on one node is visible on the other within seconds (15–60 s depending on the cache), never "after a restart". * **The live observability view** (activity feed, error feed) shows the node the browser is connected to. Persisting these events is the next increment. ## 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. With two instances the SignalARRR backplane on the master database carries events between nodes and the proxy keeps each connection on one node — see [Running two instances](#running-two-instances). ## 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 installation** — issue the shell-authorized `install-link`; the browser or CI then creates the first realm and administrator. 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 | |---|---| | `install-link --base-url [--minutes] [--json]` | Issue the single-use token for browser or automated first installation. | | `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.AspNetCore.ResourceServer/ ← Published NuGet package: JWT and introspection 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. No external message broker is required: Production runs `DurabilityMode.Balanced` with Wolverine's node table in the master DB as the coordination point (leader election, outbox agents and Marten projection shards assigned across live nodes — see [Running two instances](./deployment#running-two-instances)); Development and Testing run `Solo`. The Marten outbox is active in both 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** (raw SQL, because Marten cannot create its own target database) 2. **Apply the primary-store schema** so the tenant registry exists 3. **Apply the Global Store schema**, including installation state 4. **Load every existing active realm** and idempotently apply its realm seeders 5. **Warm RealmCache** On a fresh database this intentionally leaves the registry empty. The shell-authorized installation API creates the first tenant database, its first `realm:admin`, and the initial `IsControlPlane` assignment. See [First-time setup](../getting-started/first-time-setup). 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 deployment-wide infrastructure; each realm gets its own physical database named `_`. A first realm named `acme` therefore uses `modgud_acme`; no special `system` database is created. 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` | | `RealmSecurityAuditEvent` | Structured security/ops events owned by this realm. Explicit forensic fields; unknown identifiers are realm-HMACed; configurable 1–365 day hard retention | `Timestamp`, `EventType` | | `RealmAuditFingerprintKey` | Per-realm random HMAC key for unresolved identifiers | Singleton | | `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 | | `PersonProjection` → `Person` (Principal subtype) | User stream | Authentication slice | | `GroupProjection` → `Group` (Principal subtype) | Group stream | 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 } ... ``` `PersonProjection` consumes these events and writes them into the `mt_doc_principal` table as the concrete `Person` subclass. `GroupProjection` does the same independently for group streams. Keeping the projections concrete avoids source-generator inheritance across assemblies while preserving one polymorphic table for email routing and membership predicates. Because `Person`, `Group`, and the directly stored `ServiceAccount` subtype share that physical table, principal projections must be rebuilt as one coordinated operation. The admin endpoint and `recover rebuild-projections` replay both event-sourced subtypes in place, then prune only stale Person and Group discriminator rows; Service Accounts are left intact. Do not invoke Marten's individual generic rebuild API for these projection types from custom maintenance code. ## 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` | Authoritative browser/SSO sessions | | `mt_doc_clientsession` | Authoritative native/OAuth client sessions and refresh-token-family binding | In the master DB additionally: | Table | Contents | |---|---| | `realms.mt_tenant_databases` | Marten tenant registry | | `global.mt_doc_realm` | Realm documents | | `global.mt_doc_platform_audit_event` | PII-free deployment-wide operations | | `global.mt_doc_job_config` | Deployment-wide job configuration | | `global.mt_doc_job_run_history_entry` | Deployment-wide job history | Every realm DB, including `_system`, contains its own `mt_doc_realm_security_audit_event`. No realm DB contains another realm's security events. ## 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`); * **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_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 | |---|---| | `auth.example.com` | Control-Plane realm | | `acme.example.com` | Acme realm | | `auth.acme.example.com` | Acme realm (second domain) | | `auth.localhost` (dev) | Local development realm | `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 Acme["_acme"] AcmeData["Acme tenant data"] end subgraph Finance["_finance"] FinanceData["Finance tenant data"] end 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 for every realm, including the first | The master DB holds **no** tenant data. Every realm has the same shape and lives in its own `_` database. The [Control-Plane flag](../concepts/control-plane) can move between active realms; no database or slug is privileged by itself. ## 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 realm-scoped. When neither signal resolves a tenant, reads and writes both fail closed. Deployment-wide work uses `IGlobalStore`; background realm work explicitly enters `TenantContext.Enter(slug)`. Moving the Control Plane can therefore never redirect unrelated data into another realm. ## 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 and transferable. The first installed realm receives the // flag; it can later 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** (raw SQL) 2. **Apply primary and Global Store schemas** 3. **Load and idempotently seed every existing active realm** 4. **Warm RealmCache** 5. **Check the recovery-CLI path** or start Kestrel A fresh boot stops here with zero realms. The first-installation flow creates the first tenant database and assigns its realm the Control-Plane flag only after the first `realm:admin` exists. Existing deployments keep their registered realms and persisted Control-Plane assignment. ## 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` creates a complete realm independently from administrator onboarding. `InitialAdmin` is optional for API clients that want creation and invitation in one atomic request; the admin UI uses the separate invitation action. ```http POST /api/admin/realms { "Slug": "acme", "DisplayName": "Acme Corp", "Domains": ["acme.example.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. Realm creation completes independently from administrator onboarding. `POST /api/admin/realms/{slug}/admin-invites` issues a single-use, 24-hour realm-admin invitation. Issuing a new invitation revokes every previous open admin invitation in that realm. The recipient consumes the invite at `POST /api/account/bootstrap-admin` on the realm's host, sets a password and gets auto-signed-in. Atomic with that consume, `RealmAdminBootstrapper` creates the user, seeds the default roles and adds the user to the Administrators group with `realm:admin`. ### 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 Control-Plane realm The realm currently holding `IsControlPlane` cannot be deactivated. Transfer the flag first. ::: ### 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 event-sourced tenant audit (`/admin/audit`) is a separate pipeline. The structured Security and Platform feeds are also independent of observability export and use their own per-event durability classes: transactional/synchronous for required changes and incidents, bounded aggregation for abuse signals, and best-effort only for reconstructable operations telemetry. | 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 ] ``` Tenant-scoped commands infer the realm only when exactly one active realm exists. With multiple realms, `--realm ` is required. With zero realms, only deployment-wide commands such as `install-link` can run. 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 command fails and asks for an explicit target. With a single realm the target 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 ### `install-link` Issue the short-lived, single-use authorization for the initial installation. This command works while the deployment has zero realms. The browser installation form and CI both submit the resulting token to `/api/install/complete`. ```bash dotnet Modgud.Api.dll recover install-link \ --base-url https://auth.example.com \ --minutes 30 # Machine-readable final output line for CI dotnet Modgud.Api.dll recover install-link \ --base-url https://auth.test.localhost \ --minutes 10 \ --json ``` Issuing a new link revokes older unconsumed links. The plaintext token is shown only in CLI output; the Global Store contains its SHA-256 hash. See [First-time setup](../getting-started/first-time-setup). ### `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. Stop the normal application container and take a database backup before running this command. Do not run it with Modgud v0.9.1: that version can remove Principal subtypes from their shared table during replay. Upgrade to a newer patch release first. The fixed command rebuilds Person and Group together while preserving directly stored Service Accounts. ```bash dotnet Modgud.Api.dll recover rebuild-projections ``` ### `bootstrap-admin` Create or recover an admin in an existing realm. 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 ` | when multiple realms exist | Inferred when exactly one active realm exists. | ### `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 acme ``` ### `realm-list` List every active realm with its slug, display name, primary domain, and configured domains (the control-plane realm is marked `[CP]`). A fresh, uninitialized deployment returns an empty list. ```bash dotnet Modgud.Api.dll recover realm-list ``` ### `realm-add-domain` Add a domain to an active realm's `Domains` list. This is useful when adding a hostname after installation or preparing a reverse-proxy change. ```bash dotnet Modgud.Api.dll recover realm-add-domain \ --slug acme \ --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 name. It is the **WebAuthn relying-party ID** and the cookie domain, which is why it is a bare host: neither may carry a scheme or port. (Where users actually *reach* the realm is a separate value — see [`realm-set-public-url`](#realm-set-public-url).) 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. ::: ### `realm-set-public-url` Set a realm's **public origin** — the absolute base URL users actually reach it at, port included. Every outbound link (magic link, password reset, email verification, invites, the login-provider callback URLs shown in the admin UI) is built against it, and it is an accepted WebAuthn origin. First installation records the origin its installation link was issued for, so a fresh deployment already has the right value. Use this command for a realm created before the field existed, or when the deployment moves. ```bash dotnet Modgud.Api.dll recover realm-set-public-url --slug acme --url https://auth.example.com ``` Flags: * `--slug ` — required. * `--url ` — required. Absolute `http(s)` URL with no path, query or fragment (`https://auth.example.com`, `http://localhost:4300`). Pass an empty value to clear it back to `https://{PrimaryDomain}`. Passkeys are **not** invalidated: the relying-party ID is the PrimaryDomain, which this command does not touch. ### `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** deployment-wide storage is ready — 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/supply-chain.md --- # Supply-chain verification Every Modgud release ships with verifiable supply-chain artifacts: the container image is vulnerability-scanned as a release gate, signed with cosign, and covered by GitHub build-provenance attestations, and per-arch SBOMs are attached to the GitHub release. This page lists what exists and the exact commands to verify it before you deploy. ::: info Applies to releases after v0.6.0 Images and packages from v0.6.0 and earlier predate the signing pipeline — they have no signatures, attestations, or SBOMs. The first release published after this page went live carries the full set. ::: ## What a release ships | Artifact | What it proves | Where it lives | |---|---|---| | Trivy scan gate | The image had no known fixable CRITICAL/HIGH vulnerabilities at publish time — each architecture is scanned separately, and a finding blocks the whole release | Enforced in CI (`cd-release.yml`), not a downloadable artifact | | cosign signature (keyless) | The image digest was signed by the release workflow itself, via its short-lived OIDC identity — there is no long-lived signing key that could leak | GHCR, next to the image; log entry in the public Rekor transparency log | | Build-provenance attestation (image) | Which repository, workflow, commit, and run built the image | GitHub attestation store + GHCR | | Build-provenance attestation (NuGet) | Same, for the `Modgud.AspNetCore.ResourceServer` package | GitHub attestation store | | SPDX SBOMs (per arch) | The full component inventory of the image, one file per platform | GitHub release assets (`modgud--linux-.spdx.json`) | | BuildKit inline SBOM + provenance | Machine-readable equivalents embedded in the image manifest | GHCR, part of the multi-arch manifest list | ## Verify the container image **cosign** — verifies the signature and pins it to the release workflow's identity, so a signature from any other repository or workflow fails: ```bash cosign verify ghcr.io/cocoar-dev/modgud: \ --certificate-identity-regexp '^https://github\.com/cocoar-dev/modgud/\.github/workflows/cd-release\.yml@refs/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` **GitHub attestation** — verifies build provenance (repository, commit, workflow run) via the `gh` CLI: ```bash gh attestation verify oci://ghcr.io/cocoar-dev/modgud: -R cocoar-dev/modgud ``` Both checks operate on the image digest, and every release tag (`:`, `:latest`, `:production`, …) points at the same multi-arch manifest — verifying one tag covers them all. For deployments, pin the digest the verification printed rather than a mutable tag. ## Verify the NuGet package ```bash gh attestation verify Modgud.AspNetCore.ResourceServer..nupkg -R cocoar-dev/modgud ``` This proves the exact `.nupkg` you downloaded from nuget.org was produced by the release workflow in this repository, at the commit the attestation names. ## Read the SBOM Download the SPDX JSON for your platform from the GitHub release assets, then feed it to your inventory or scanning tooling, for example: ```bash trivy sbom modgud--linux-amd64.spdx.json ``` The same inventory is also embedded in the image itself as a BuildKit SBOM attestation: ```bash docker buildx imagetools inspect ghcr.io/cocoar-dev/modgud: --format '{{ json .SBOM }}' ``` ## How the release gate works The release pipeline builds each architecture on its own native runner and scans each one with Trivy before anything publishes. A fixable CRITICAL or HIGH finding fails the build job, which fails the release gate, which blocks the NuGet push, the Docker tag promotion, and the docs deploy alike — a release is all-or-nothing. Unfixable findings (no patched package available upstream) are reported in the job log but don't block, since there is nothing to ship for them; deliberate waivers live in `.trivyignore` at the repository root, each with a justification comment. --- --- 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 # enable Positions and shared terminals (default is false): AppSettings__Features__PositionTerminals=true ``` Flags are read at startup; no hot-reload. A flip requires a restart. ## Current flags | Flag | Default | Effect | | --- | --- | --- | | `PageBuilder` | `false` | End-to-end [custom authentication pages](../platform/pages). While off: editor routes are hidden/redirected, Realm/Application page endpoints return 404, DTO/app-info schemas are masked, and auth routes use fixed screens. While on: Realm defaults and Application overrides are editable and rendered on login, forgot-password, and signed-out routes. | | `PositionTerminals` | `false` | [Positions and shared terminals](../admin/positions) — the device-bound identity for terminals that changing people staff by passkey tap. While off: the sidebar entry is hidden, `/api/position*` and the terminal-facing `/connect/staffing/*` endpoints return 404, and the staffing grant is refused. While on: positions, grants, terminal slots, enrollment and staffing sessions are live. See the [consumer contract](../integrate/position-terminals) for the wire formats. | ## 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** — aggregate and anonymous surfaces (`GET /api/admin/realm-settings`, `GET /api/app-info`) emit the gated section as empty so the SPA can't fingerprint stored data. 5. **Runtime fallback** — auth routes render the fixed screen whenever the flag is off, a schema is absent/invalid, or `?safemode=1` is present. 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 Microsoft Entra ID and standards-compatible OIDC or SAML providers; setup walkthroughs included * [Realms](./realms) — multi-tenant setup; each tenant gets its own database * [Configuration Drafts](./configuration-drafts) — realm config is staged like code: every save commits onto a draft, review the exact change plan, apply in one transaction (git model: draft = branch, apply = push + merge) * [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 page schemas; SVG sanitisation built in * [Pages (Beta)](../platform/pages) — Realm defaults plus per-Application overrides for login / signed-out / forgot-password; end-to-end gated by a [feature flag](../operate/feature-flags) ### Operations * [Observability](../operate/observability) — OpenTelemetry metrics + tracing + in-app live activity feed * [Logs](./auth-log) — realm-owned **Audit** and **Security** tabs; the Control Plane additionally gets a separate PII-free **Platform** tab * [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**. ![Create user dialog](/screenshots/admin-benutzer-modal.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. ## Failed sign-ins Repeated wrong passwords no longer lock the account for everyone. Failures are counted per browser: a browser that completed a sign-in for the user before has its own allowance (default 10 per 15 minutes), every other client shares the user's untrusted pool (default 5 per 15 minutes). An exhausted pool refuses further attempts from unfamiliar clients only — the user's own devices keep working — and the user receives one e-mail per window with a sign-in link that also trusts the device. Nothing clears itself except time, and no admin action is needed; the limits are realm settings (see [Rate limits](../platform/rate-limits#password-login-device-aware-throttling)). An explicit admin lock on the account (deactivation) is a separate action and still blocks every sign-in. ## 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 ![Create service account dialog](/screenshots/admin-service-account-modal.png) 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 Both are real cut-offs: rotating or deleting a credential revokes that credential's outstanding tokens, scoped to exactly that OAuth client so the SA's *other* credentials keep working. * **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. A bearer token does not re-check the secret, so rotation also revokes the tokens already minted with the old one. * **Delete** removes the OAuth client and revokes its outstanding tokens; no new tokens can be minted. ::: warning One residual window Revocation flips the stored token to revoked, which cuts off reference tokens — the default — at the next validation. A credential that opts into `AccessTokenType.Jwt` receives self-validating access tokens with no stored document, so an already-issued JWT stays acceptable at the resource server until its (short) lifetime runs out. No *new* token can be minted either way. Keep the access-token lifetime short if you choose JWT. ::: ### 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 when the request targets registered OAuth APIs and includes the corresponding claim scopes. A JWT carries the claim directly; a reference token keeps it in the server-side payload for authorized introspection. The `client_credentials` flow has no UserInfo round-trip in practice. 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. To let a Service Account administer selected Modgud resources itself, issue a credential carrying the protected `modgud.management` scope and grant the SA the corresponding Modgud permissions through its groups and roles. The first supported operations are Position reads. See the [Management API integration guide](/integrate/management-api) for the fixed audience, token request, and complete authorization contract. ## 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. ## Event history and existing accounts Service Account create, update, and delete operations are event-sourced. A legacy document-only account is upgraded lazily on its first mutation by seeding its current snapshot as the stream's creation event; operators do not need a one-time data migration. ## 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. * [Management API](/integrate/management-api) — use an SA credential to call selected Modgud administration endpoints. * [Auth Log](./auth-log) — filter for the SA's account name to see every action it has taken. --- --- url: /admin/positions.md --- # Positions & shared terminals > **Status:** behind the `PositionTerminals` feature flag (default off). > While off, the sidebar entry is hidden and the APIs return 404. A **position** is a business identity that changing people staff in shifts — "gate porter for customer XY", "reception HQ". Unlike a user or a service account, a position never signs in directly: its tokens are minted only after an allowed activation proof succeeds on an **enrolled shared terminal**. Downstream systems then see the POSITION as the actor (`sub` = the position), never the person — who tapped stays visible only to you, in the staffing-session audit view. This page is the admin workflow. New to the model? Start with [Positions — the concepts](/admin/positions-concepts) — the building blocks, the three links, and why a position is not a group, with diagrams. The developer-facing contract (token classes, wire formats, integration events) lives under [Integrate → Position terminals](/integrate/position-terminals). ## 1. Create the position **Admin → Positions → Create.** You need `position:write`. * **Account name** — lowercase, 2–64 chars (`a-z 0-9 . _ -`). Becomes the position's token subject handle and audit identity; it shares one namespace with user and service-account names. * **Terminal use** — off by default. Terminal slots can only be created and enrolled while this is on. * **Activation proofs** — one or more of personal passkey, personal password, personal e-mail OTP, or a position-owned activation token. Team secret is a reserved wire ID and is not selectable yet. * **Device bindings** — one or more of DPoP, client secret, or no binding. DPoP is the recommended default; the weaker choices are explicit policy decisions and may be forbidden by the realm security floor. * **Staffing session (minutes)** — how long one shift lives (default 960 = 16 h). **Absolute maximum** — the hard ceiling no refresh can extend past (default 1440 = 24 h). Access tokens stay short-lived (10 min) independently of these. * **Authorized users** and **terminal slots** can be staged right in the create dialog, on their own tabs — the position, its grants and its slots are created in one atomic save. Nothing forces you to create the position first and come back for the rest. (Enrolling a device stays a later step: that is a ceremony on the device, not a setting.) Like every principal, the position receives roles/permissions through the normal groups & roles machinery — that is what ends up in its staffing tokens' `resource_access`. ## 2. Authorize users (grants) **Position detail → Authorized users.** A grant says "this person may staff this position". One live grant per (position, user); grants are suspend-/resume-/revocable, revoke is final (re-authorizing later creates a fresh grant with its own audit trail). The **"No passkey" badge** matters when `personal-passkey` is enabled. A user may still activate with password or e-mail OTP when the position permits that method. Password and OTP failures are locked per grant as well as rate-limited per source IP; changing/resetting the password or disabling e-mail OTP ends sessions established with that proof. Suspending or revoking a grant **immediately ends** that person's running staffing sessions and revokes the session tokens. ## 3. Create terminal slots **Position detail → Terminals** (or the same tab while creating the position). One slot per physical device. Each slot atomically creates its own managed OAuth client. Binding, grants, lifecycle and reference-token profile stay locked to the terminal contract; Display Name, target Apps and business scopes remain configurable in the OAuth client editor. The selected binding fixes the security profile: | Binding | Client | Device identity | |---|---|---| | `dpop` | public, no secret | enrolled P-256 key; DPoP required | | `client-secret` | confidential | one-time-displayed secret | | `none` | public, no secret | no cryptographic device identity | * **WebAuthn RP ID** — the domain staff passkeys verify against. Use ONE RP-ID for all terminals of the consuming app, so a staff passkey works on every terminal. Once a position has a slot, further slots inherit its RP-ID and the field locks — staff passkeys hang off the RP-ID, so only a matching RP-ID lets the already-enrolled tokens unlock a new terminal. * The slot view shows the **`client_id`** and the slot id — hand both to whoever installs the terminal device. For `client-secret`, copy the secret immediately; it is never returned again. * A new slot can be assigned to several compatible positions before enrollment. One terminal may then staff any of them, but still runs only one staffing session at a time. Removing an assignment is immediate. Adding an assignment after enrollment is intentionally rejected: create a replacement multi-position slot and run Device Flow again. ### How terminal clients appear elsewhere There are two equivalent UI entry points for creating a terminal slot: * **Position detail → Terminals** starts with the business position and adds one or more slots. * **OAuth Clients → Create → staffing** starts with the technical client. As with `client_credentials` and Service Accounts, you then choose an existing Position or draft a new one in the same dialog. Selecting `staffing` is a **terminal profile**, not a freely combinable grant. The dialog replaces the grant selection with the fixed package `device_code + refresh_token + staffing`; browser login, native-login and `client_credentials` grants cannot be added. Position (if new), slot, and client land in one atomic save. The server derives the remaining OAuth profile from the chosen binding (reference tokens; public + DPoP, confidential + client secret, or public + no binding) and generates the `client_id` (`terminal.{suffix}`). After creation, terminal clients stay visible in the **OAuth Clients grid**. Opening one allows exactly the resource-facing settings to change: **Display Name**, **Apps**, and **Scopes**. Select the consumer App and an enabled business scope whose Resources contain the consumer OAuth API. Those resources become possible staffing-token audiences; the Position's groups/roles provide the corresponding `resource_access` content. Lifecycle, grants, binding, RP-ID, URLs, token lifetimes and security flags remain locked and are managed from the Position/terminal contract. Saving an App/scope change immediately ends a running staffing session so old token rights cannot survive the change. For automation, the same contract is available through `POST /api/admin/oauth/clients`: reference an existing position (`LinkedPositionPrincipalId`) or inline-create one (`NewPosition`) — never both. The call needs `position:write` in addition to `oauth-client:write`. ## 4. Approve the enrollment Every binding uses the complete RFC 8628 Device Flow and explicit admin approval. The device starts enrollment and shows a **user code**. Open the verification link (or enter the code at `/device`) to see position(s), terminal, location, client, and binding. For DPoP, also compare the **device-key fingerprint** (`XXXX-XXXX`) with the device display before approving; the enrollment pins that key permanently. For client-secret, the device authenticates with its one-time secret. With no binding, approval is the sole issuance barrier and the consent highlights that risk. Approving requires the `position-terminal:enroll` permission (deliberately separate from `position:write` — registering a physical device is a higher-trust act). Enrollment is one-shot for every binding. Device replaced, key/secret lost, or positions added? Revoke the slot and create a fresh one. ## 5. Position-owned activation tokens **Position detail → Activation tokens.** A logical token can be assigned to one or more positions, disabled/reactivated, or permanently revoked. Its WebAuthn credential is registered from an enrolled terminal so browser origin and terminal RP-ID match. The credential is therefore RP-bound; register the same logical token separately for each consuming RP where it must work. The staffing audit records the logical token and credential, not a person. Unassigning or revoking it immediately ends every session established with it. ## 6. Monitor & intervene **Position detail → Staffing sessions** (requires `staffing-session:read`): every shift with terminal, **who activated it** (admin-only audit metadata — never part of tokens or events), start, absolute end, and the end reason. * **Force-lock** (requires `staffing-session:force-lock`) ends a running shift remotely: the terminal's tokens are revoked on the spot and its next request answers `staffing_required` — the device locks and demands a fresh tap. * **Disable a slot** for maintenance (reversible — reactivating restores Pending or Active depending on enrollment); **revoke** is final and also deletes the slot's OAuth client. * Everything cascades automatically: deactivating the position, binning the user, deleting the used passkey, or revoking the grant all end the affected sessions immediately. The same applies to password/OTP changes, activation-token invalidation, policy tightening, or removing a terminal's position assignment. Expired sessions are swept by the `staffing-sweep` system job (every 5 minutes). ## Permissions reference | Permission | Gates | |---|---| | `position:read` / `position:write` | position CRUD, grants, slots | | `position-terminal:enroll` | approving a terminal enrollment | | `staffing-session:read` | the staffing-sessions view | | `staffing-session:force-lock` | remote force-lock | --- --- url: /admin/positions-concepts.md --- # Positions & terminals — the concepts > The [Positions & shared terminals](/admin/positions) page is the admin > workflow (click here, enable that). This page explains the **model behind > it** — what the building blocks are, how they connect, and how it feels in > daily use. Protocol details live under > [Integrate → Position terminals](/integrate/position-terminals). ## The four building blocks Two of them live in Modgud, two in the real world: The **position** is the star of the model: a business role staffed by *changing* people. It receives rights through the ordinary groups & roles machinery, but it never signs in — it gets **activated** (more on that below). For downstream systems, *the gate* acts — never Anna or Ben. ## Everything is a link The whole system is three links between those blocks. Each has its own moment, its own flow — and answers a different question. | Link | Question it answers | When & how | |---|---|---| | ① Person ↔ Position | **Who** may staff this post? | A simple list on the position ("authorized users"). Grant, suspend, revoke — takes effect immediately. | | ② Terminal ↔ Position | **Where** may this post be staffed? | An authorization assignment. One terminal may carry several positions, selected for each shift. | | ③ Device ↔ Terminal | **Which hardware** actually stands there? | At installation, exactly once. DPoP pins a device key, client-secret identifies its holder, while `none` deliberately leaves this link unproven. | ::: tip Mnemonic Link ① says *who*, ② says *where*, ③ says *with what*. The daily unlock is not a fourth link — it is the moment all three are checked at once. ::: The realm security floor decides how strong links ① and ③ must be. A weaker position policy cannot silently undercut that floor. > For engineers: ① is the *grant*, ② is the *terminal slot* with its > auto-created OAuth client, ③ is the *enrollment* (device key binding). The > client appears in the OAuth grid as inventory only — everything is managed > in the position. ## A position is not a group The most tempting confusion — and the most important distinction in the model: ::: info The one-liner **A group distributes rights. A position acts.** ::: * **Group "porters" with Anna as member:** rights flow *to the person*. **Anna** acts, under her own name, with the group's rights. The group itself never appears at runtime — no tokens, no sessions. It is a distribution mechanism. * **Position "gate" with Anna authorized:** rights never flow to Anna! The grant gives her **no right of the gate** — only the ability to **switch the gate on**. Then *the gate* acts, with *its* rights. Anna's own permissions are irrelevant during the shift. The authorized-users list looks like membership but is a **key cabinet**: "these people may start the engine", not "these people are the engine". And the two concepts stack instead of competing — the position receives its own rights *through groups*, like any other principal. ### Same person, two devices, two actors ## A position never authenticates — it gets activated A position has no login credential of its own (that is the difference to a [service account](/admin/service-accounts), which identifies *itself*, from anywhere). Every position token starts with an allowed activation proof — a person proving themselves or a position-owned hardware token — **at an enrolled terminal**. The chain is strict: ``` Position → terminal assignment → enrolled device → allowed activation proof → session ``` No slot → no device → no unlock → never a token. A position without terminals is valid, but dormant: configuration waiting for hardware. ## A shift at the gate 1. **06:02 — Anna taps.** Modgud checks all three links at once: is this the real device (③)? may the gate run here (②)? may Anna staff the gate (①)? → unlocked. From now on the terminal acts as *the gate*. 2. **Handover:** Ben taps → Anna's shift ends automatically, his begins. Exactly **one** shift runs per terminal at any time. 3. **Locking:** at the device, or remotely by an admin (**force-lock**, effective immediately — terminal tokens are revoked on the spot). 4. **Time limits:** every shift ends at the configured ceiling at the latest (default 16 h, absolute maximum 24 h), even if nobody locks. 5. **Cascades:** deactivating Anna, revoking her grant, disabling the slot or the position — each ends the affected running shift automatically. ## What the audit attests — and what it doesn't The staffing audit attests **the unlock, not each action**: ``` 06:02 gate / left terminal unlocked by Anna 07:15 alarm #4711 acknowledged by "the gate" 14:01 handover: Anna's shift ended, unlocked by Ben 17:40 force-lock by admin — terminal locked ``` Who *actually clicked* the alarm at 07:15 is not recorded — if Anna was on a break and a colleague clicked, the log still shows Anna's shift. That is not a gap; it is the nature of every shared device. What the model guarantees: **only authorized people can unlock, and who unlocked is cleanly recorded.** Accountability is **session-level, not action-level**. For a critical action, the consumer can request a fresh step-up proof. Modgud then returns a separate access token valid for at most 60 seconds; it may be bound to an action and consumer nonce and is intended to be consumed once by `jti`. ## Which principal for which job? | If … | … then | |---|---| | a **person** acts and must appear in the business data (receipt, ticket, signature) | ordinary **user login** — also on a shared device, with fast switching | | a **post** acts that has to be activated (gate, control room, reception) | **position** + terminals — this model | | a **machine** acts, with no human activation at all (sealed appliance, server job) | **service account** | The test question in one sentence: *"Who owns what the system does — the person, the post, or the machine?"* One concept per answer, and no fourth is needed. (A **group** is none of the three — it distributes rights, it never acts.) ## Policy choices and guard rails How people and devices prove themselves is a per-position policy. Multiple activation classes can be enabled together; DPoP + personal passkey remains the recommended default. * **Activation proof:** personal passkey, personal password, personal e-mail OTP, or a **position-owned activation token**. The token is a logical, individually revocable object with an RP-bound WebAuthn credential; the audit names the token rather than a person. `team-secret` is reserved for a future feature and is deliberately unavailable today. * **Device binding:** DPoP key, client secret, or none. Client-secret and none still run the complete admin-approved Device Flow; `none` only removes a cryptographic device identity and is appropriate only where the physical and network controls justify it. * **Realm guard rails:** the realm declares required proof and binding capabilities. Tightening a floor first previews affected positions and, when confirmed, immediately ends sessions that no longer comply. * **Multi-position terminals:** one device may serve several positions ("reception" by day, "night gate" after hours). New assignments are fixed before enrollment; adding one later requires a replacement slot and fresh approval. Exactly one active shift still exists per terminal. --- --- url: /admin/roles.md --- # Roles An **application role** bundles permissions for exactly one app. A pure `realm:admin` role is the explicit exception: it has no Application link or catalog permissions and grants the bypass across every app in its own realm. Users receive roles only through their [groups](./groups) — never directly. ## 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`** — current-realm-wide. The holder may do anything in any app in this realm, but gains nothing in another realm. It is represented by a pure realm-admin role, 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. ![Create role dialog](/screenshots/admin-rolle-modal.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)" only for a pure bypass role; otherwise a role belongs to exactly one Application. * **Privileged role** — switches the role into the pure realm-admin mode. Enabling it clears and disables the Application link and catalog permissions. It grants `realm:admin` in this realm only and is 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: for an application role, the linked Application and selected permission subset are copied; for a realm-admin role, only the pure realm-admin mode is copied. The **Name** is blank. Give the copy a new name, adjust the 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 | | --- | --- | | Pure **Privileged role** | current-realm-wide bypass (`realm:admin`) — works in every app in this realm and has no 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`. If its backend is registered with Audience `acme-tasks-api`, then `[Authorize(Roles = "...")]` finds them through `resource_access["acme-tasks-api"].roles` when the token targets that API and the `roles` scope was granted. ::: --- --- 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). ![Create group dialog](/screenshots/admin-gruppe-modal.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 ![Create OAuth client dialog](/screenshots/admin-oauth-client-modal.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. **Scope entitlement** — 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`, `permissions`, `offline_access`). 2. **App context for targeted APIs** — a requested resource-bearing scope produces one or more token audiences. Each audience must resolve to an OAuth API, whose `AppId` selects the catalog used for its `resource_access[]` block. 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. Selecting an App does **not** automatically add a claim block. A block exists only when the token actually targets a registered OAuth API in that App and the request includes `roles` and/or `permissions`. ::: 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 in one expert editor: **General**, **Login & Consent**, **Apps**, **Flows**, **Scopes**, **Redirects & CORS**, **Tokens & Sessions**, and **Security**. Every tab edits the same draft and the footer action persists the complete client in one request. Nothing has to be created first and completed in a second pass. ::: 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 | ### Client authentication: secret or private key A confidential client proves itself at `/connect/token` (and the introspection, revocation and PAR endpoints) in one of two ways: * **Client secret** (`client_secret_basic` / `client_secret_post`) — generated at creation, shown once, rotatable with *Regenerate Client Secret*. * **`private_key_jwt`** (RFC 7523 / OpenID Connect Core §9) — register the client's public keys as a **JSON Web Key Set** on the *Security* tab (`JsonWebKeySet` in the admin API and the realm manifest): RSA or EC keys, public parts only, each with a `kid`. The client then sends a JWT it signed with the matching private key (header `typ: client-authentication+jwt`, `iss` = `sub` = its `client_id`, `aud` = the token endpoint, `jti`, short `exp`) as `client_assertion` with `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. No shared secret leaves the client. Create a confidential client with a key set and **no** secret to get a client that authenticates with assertions only; the Security tab shows which credentials exist. Rotate by replacing the key set (old keys stop working at once); removing the last credential is refused. Both may coexist. Service-account credentials (M2M clients) keep their own secret lifecycle and do not take a key set; dynamic client registration does not accept `private_key_jwt` either (see the OAuth API reference). ::: tip Machine-to-machine? Link a Service Account There is no separate "service" client type. For server-to-server flows with no user involved, use a [Service Account](./service-accounts). Selecting `client_credentials` in the **Flows** tab reveals the required Service Account field. You can select an existing account or create a new one directly in the client editor. The optional new Service Account, client, grant and ownership link are then persisted atomically by the single Create action. ::: ### 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/unassigned for App-scope entitlement; it does not mean that tokens automatically receive every App's permissions. Picking multiple apps means the client may request resource-bearing scopes from each of them. If a request targets `orders-api` and `billing-api` and includes the `roles` scope, the resulting principal can contain `resource_access["orders-api"].roles` and `resource_access["billing-api"].roles`. The keys are API Audiences, never App slugs inferred from the multi-select. ### 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 **Reference**. Two options: | Type | What it is | Validation | | --- | --- | --- | | **Reference** (default) | Opaque random string — carries no claims on the wire | The resource server must call `/connect/introspect` on every request to resolve it | | **JWT** | 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 | A resource server configured for local JWT validation expects a **JWT**. Keep the default **Reference** format when you want every token resolved and immediately revocable at the introspection endpoint. The [.NET resource-server library](../integrate/resource-server) uses one `AddModgudResourceServer` method; its `TokenMode` accepts JWTs, reference tokens, or both. ### Require Pushed Authorization Requests Toggle the **Require Pushed Authorization Requests (PAR)** checkbox in the client editor. When set, this client **must** use [Pushed Authorization Requests](../reference/oauth-api#pushed-authorization-requests-par) (RFC 9126): a direct `/connect/authorize` request from it is rejected, and it has to push the request to `/connect/par` first and authorize with the returned `request_uri`. Off by default, and PAR stays available to every client regardless — this only *forces* it for an individual high-security client (e.g. a confidential back-channel client where you never want request parameters on the front channel). Also settable via the admin API (`requirePushedAuthorizationRequests: true`) or a declarative provisioning manifest. ### Sender-constrained tokens (DPoP) DPoP ([RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449)) binds an access token to a key the client proves it holds, so a stolen token is useless without the private key. Two checkboxes in the client editor harden a client that supports DPoP; both are **off by default** and independent of each other. See the [DPoP reference](../reference/oauth-api#dpop-sender-constrained-tokens) for the protocol detail. * **Require DPoP** — reject this client's token requests that carry no DPoP proof (`invalid_dpop_proof`). Without it, DPoP is still *offered*: a client that sends a proof gets a bound token, one that doesn't gets an ordinary bearer token. Turn it on to forbid the unbound fallback for a high-security client. * **Require DPoP nonce** — additionally require a server-issued nonce in the client's proofs. The first proof (which has none) is answered with a `use_dpop_nonce` error plus a fresh `DPoP-Nonce` header, and the client retries with the nonce embedded. This stops a client from pre-computing proofs and gives the server a freshness lever. Only meaningful alongside DPoP use — pair it with **Require DPoP** to force the whole handshake. Both are also settable via the admin API (`requireDpop`, `requireDpopNonce`) or a provisioning manifest. Refresh tokens issued to a DPoP client are bound to the same key automatically — no toggle needed. ### Back-channel logout **Logout URI** (*Login & Consent* tab): the absolute `https` endpoint Modgud POSTs a signed logout token to whenever a session that holds this client's tokens ends — user sign-out, RP-initiated logout by another client, revocation from the sessions list, admin force sign-out, deactivation, deletion, expiry. `http` is accepted on `localhost` only; private and link-local addresses are refused. Leave it empty for no POST notifications; the [Application change feed](../integrate/application-change-feed#session-entity-version-1) carries session ends regardless. **Send session id (sid)** (default on) controls whether logout tokens name the session. The page shows the outcome of the last delivery attempt. Also settable via the admin API (`backChannelLogoutUri`, `backChannelLogoutSessionRequired`) and the realm manifest. Contract: [Logout propagation](../integrate/login-flows#logout-propagation-to-relying-parties). ### 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`, `/connect/par`) **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 **Flows** tab lets you select an existing account or create one inline before the first save and blocks Create while the link is missing. * A Service-Account-linked client may carry **only** `client_credentials` — adding any user-flow grant alongside it is rejected. The reverse workflow remains available too: issuing a credential from a Service Account provisions its confidential client and `client_credentials` grant through the same client-creation validation path. ::: ### Capabilities Capabilities are explicit, per-client grants a realm admin gives on the **Flows** tab. They are stored next to the grant-type permissions (as `cap:` entries) and are exported with the realm manifest. | Capability | Meaning | | --- | --- | | `cap:trusted-forwarder` | The client is a backend-for-frontend that calls the auth endpoints on behalf of browsers. When it authenticates a request with its client secret or a `private_key_jwt` assertion and sends the end user's address in the `Modgud-Forwarded-For` header, rate limits apply per user instead of per egress address. It shifts **only** the source dimension; target, client and App limits still bound the forwarder. Confidential clients only. See [Rate limits → Trusted forwarders](../platform/rate-limits#trusted-forwarders). | Trust never depends on who owns the client: any realm admin can grant the capability to any confidential client, and a capability can never lift a limit. ### Lifetimes The **Lifetimes** tab is available during create and edit. Each field is **entered in seconds**. Empty token fields use the IdP default; empty client-session fields inherit from the linked Application and then the Realm. | 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) | — | | **Client Session Idle Lifetime** | App/Realm policy | — | | **Client Session Absolute Lifetime** | App/Realm policy | — | 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. Client-session lifetimes control how long refresh-token-backed user sessions may continue. Idle lifetime slides on successful refresh; absolute lifetime never slides. Both accept 1–3650 days (`86400`–`315360000` seconds), and the absolute value must not be shorter than idle. These do not lengthen access tokens. ## 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. ![Create OAuth scope dialog](/screenshots/admin-oauth-scope-modal.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` | Adds the user's App roles to each matching registered `resource_access[]` block | | `permissions` | Adds bypass-pre-expanded permissions, narrowed to each matching OAuth API's declared 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 & Federation → OAuth-Scopes** → **Create**. ### Fields The editor groups the settings into three tabs: * **General** — immutable technical name, display name, description and optional [App](./applications) binding. No application means realm-wide (cross-app, like the standard OIDC scopes). * **Token content** — API audiences and OIDC user-claim names included for the scope. * **Behavior** — active state, consent presentation, discovery visibility and Dynamic Client Registration eligibility. The scope name is the exact value clients send in `scope=…` requests (for example `acme.read`). It cannot be changed after creation; clone the scope when you need a new name. The six seeded standard scopes can be opened for inspection, but are shown read-only because they are managed by the IdP. ### 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`. ### API audiences An audience identifies the resource server (API) that accepts tokens. It can be a stable identifier or an absolute URI. Example: * Scope: `acme.read` * Audience: `acme-api` When a client requests `scope=acme.read` and gets back an access token, the token's `aud` claim contains `acme-api` — the Acme API checks exactly that during token validation and rejects everything else. ::: warning Audience mismatch If the audience here is spelled differently from how the API checks during validation, every API request fails with `401 Unauthorized — invalid audience`. For URI audiences, scheme, host, trailing slash and port are significant. 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). Standard scopes cannot be deleted; their menu action is disabled. ::: 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). ::: ![Create OAuth API dialog](/screenshots/admin-oauth-api-modal.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 resource server in JWT access tokens, UserInfo and authorized introspection responses. 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 needs **explicit scope lists** for discovery ## Relationship to Applications An OAuth API normally belongs to **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 the separate `resource_access["acme-api"]`, `resource_access["acme-search"]` and `resource_access["acme-files"]` blocks accordingly. An API can temporarily remain unassigned for legacy or standalone setups. Without an Application link, Modgud has no permission catalog to resolve and does not emit a `resource_access` block for that audience. ## Creating an API Administration → **OAuth & Federation → 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? Recommended and required for per-Audience permission emission. * **Description** — optional ### PermissionIds The subset of the linked App's catalog this RS gates on. Used by the IdP to narrow `resource_access[].permissions` — sibling resource servers under the same App get their own Audience keys and do not project each other's permissions. The selection starts empty. Pick only the catalog entries this resource server actually exposes. ### 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's own APIs directly (e.g. an admin or distribution endpoint), 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. ### Token introspection is a special case Validating an opaque **reference** access token via `/connect/introspect` is different, because the IdP only reveals a token — its `active` status and its `resource_access` block — to a caller that is one of the token's **audiences** or its presenter. A generic Service-Account client is neither, and gets `active: false`. So an introspecting resource server registers a confidential OAuth Client whose **Client ID equals its own audience** (this API's name — the RFC 8707 `resource=` value already carried in the token's `aud`), and authenticates the introspection call with that client's own credentials (sent as form-body parameters, so a URL-shaped audience id works). The [.NET resource-server library](/integrate/resource-server#reference-token-mode) does this through `AddModgudResourceServer` with `TokenMode = ModgudTokenMode.OnlyReferenceToken`. ## 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 create dialog opens pre-filled — display name, description, scopes, user claims, the linked Application and its catalog subset are copied; only **Audience (aud)** is blank. ## 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 means each block contains only its API's permission subset. A multi-audience token may carry multiple blocks side-by-side, but each resource-server scheme projects only its configured Audience. ### 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 ![Mint invite codes dialog](/screenshots/admin-einladungscode-modal.png) 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. For the full end-to-end walkthrough — registering the MCP server, wiring discovery, connecting a real agent, and revoking access — see [Secure an MCP server with Modgud](/integrate/mcp-server). ## 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. For the full end-to-end walkthrough — registering the MCP server, wiring discovery, connecting a real agent, and revoking access — see [Secure an MCP server with Modgud](/integrate/mcp-server). ## 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. ![Create login provider dialog](/screenshots/admin-login-provider-modal.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, Protocol & Security, User Update Script, Users & Trust) 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. As soon as the slug is valid, the Redirect URI appears in the Connection tab — before save. 4. **Connection** tab: * **Tenant ID** (Entra-specific): paste from Entra. * **Client ID**: from Entra. * **Scopes**: `openid profile email` (default is fine). * **Client Secret**: paste the Entra client secret here so the complete provider can be created in one step. You can omit it while the provider is disabled and add/rotate it later. 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. Choose **Active** on the General tab only if Client ID, Client Secret and the provider-specific connection fields are already complete. **Create** saves the full provider atomically; otherwise leave it disabled and enable it after the smoke-test. **Copy the Redirect URI** from the Connection 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 external identities 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. ::: warning SAML self-service linking is limited in v1 SAML assertions return through a cross-site POST to the ACS endpoint. The Modgud application cookie is `SameSite=Lax`, so it is not sent with that POST and Modgud cannot reliably bind the assertion to the already signed-in user who started the link flow. Use normal SAML sign-in with trusted-email linking or JIT resolution instead. See [SAML federation](./saml-federation#linking-a-saml-identity-to-an-existing-account). ::: ### 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 and SAML 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. ::: * **The provider lives on your internal network and Modgud refuses to talk to it.** The log says `OIDC metadata/backchannel fetch refused: '' did not resolve to a routable public address`. Modgud's SSRF guard blocks private addresses for every admin-supplied URL by design. The platform operator lists the internal IdP in `OutboundHttp__AllowedPrivateHosts` (see [Deployment](/operate/deployment#identity-providers-on-private-networks)); a realm admin cannot grant this. --- --- 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 ![Create application dialog](/screenshots/admin-anwendung-modal.png) 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, client-session, 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 audience-keyed `resource_access` block at the token boundary. The key is the OAuth API Audience, not this App's slug. ## 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** | Product name, sender display name, sender address, validated reply-to address, optional subject prefix, preheader and footer used in this App's outbound emails (OTP, magic link, reset, verification, ...). The sender address falls back to the realm's, then the deployment's; a custom address must be deliverable from your mail provider (SPF/DKIM). Logo and button colour follow effective App branding. See [Transactional email](../platform/email-customization). | | **Login methods** | Enable/disable internal password/passkey and magic-link entry points, and select/order the external OIDC/SAML providers exposed to this App. The allow-list is enforced by the public list and protocol start endpoints, not only hidden in the SPA. An explicit empty provider list disables all external providers. | | **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. | | **Client sessions** | Idle and absolute lifetime defaults for refresh-token-backed native/OAuth sessions belonging to this App. Each field inherits the realm unless overridden; an individual OAuth client can override the App again. | | **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). | | **Rate limits** | Sparse override of the realm's [auth rate limits](../platform/rate-limits): only the overridden policy/dimension cells win, plus an optional own source allowlist and enforcement mode. | ### 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 gets a pending registration and a one-time code — no user yet; redeeming the code proves the mailbox, creates the confirmed passwordless account 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 gets a pending registration and the same registration code; the account is created when the code is redeemed. | | `InviteCode` | Invite-only: an unknown email enters the registration pipeline **only** when the native sign-up request carries a valid, unused, unexpired [invite code](#invite-codes-the-invitecode-posture) (the account is created when the code is redeemed). 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. ### Cleanup and reset semantics Turning off the Origin override sends an explicit clear and removes the global host→Application route. Deleting an unreferenced Application also removes its settings document and every hostname pointing at it before the App is tombstoned. Existing permission-reference delete blocks still apply. ### 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 first realm created during [first installation](../getting-started/first-time-setup). ::: ![Create realm dialog](/screenshots/admin-realm-modal.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. First installation stamps the first ordinary realm as the CP; the role can later move to any active realm (see [Transferring the control plane](#transferring-the-control-plane)). There is exactly one CP after installation, and no slug has special meaning. ## 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 | 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. Finishes the realm creation. Creating a realm and inviting an administrator are deliberately separate actions. To add an administrator, open the realm's context menu and choose **Realm-Admin einladen**. The recipient clicks the magic link, lands on `/bootstrap?token=…` in the realm's SPA, sets their own password, and is auto-signed-in. Only one admin invitation can be open in a realm. A new invitation revokes the previous link, is valid for 24 hours, and can be used once. ## 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. ## Inviting a realm administrator A realm is complete and active as soon as realm creation finishes; it does not need an administrator to be valid. When someone should manage it, use **Invite realm admin** in the realm's context menu. The recipient clicks the magic link and sets their password. If something goes wrong: * **Token lost or expired** — issue a new invitation. It automatically revokes the previous open link. * **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, the request returns 404. Register every hostname explicitly in the realm's Domains list. `*.localhost` resolves to loopback on modern browsers and operating systems, but Modgud still needs the exact host-to-realm mapping. ## Tips ::: tip Naming conventions Realm slugs are baked into the tenant DB name. 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/configuration-drafts.md --- # Configuration Drafts Realm configuration in Modgud is **staged like code**. For a realm admin, every save in the admin UI is a *commit* onto a **draft**, and nothing touches the live realm until the draft is **applied** — in one transaction, all or nothing. If you know git, you already know the model: | git | Modgud | |---|---| | `main` | The live realm configuration | | A branch | A **draft** (server-side, stored in the realm) | | A commit | Saving any admin modal — or deleting an entity from a list | | Push + merge | **Apply draft** | | The merge base | The draft's **baseline** — a snapshot of the realm taken when the draft was created | | A merge conflict | A three-way **conflict**: the live realm changed while your draft was open | | Rebase | *Confirm remaining differences* — the baseline moves to the current live state | | Switching branches | **Park** a draft / switch to another one | There is nothing to set up and no mode to enter: staging engages automatically for admins holding `realm:admin`. Admins with only resource-scoped permissions (e.g. `user:write`) keep the classic behavior — their saves apply immediately. ## Day to day Open any entity in its normal modal — a user, an OAuth client, a role, the realm settings — change something and save. The footer button reads **Stage to draft** instead of Save, and the first staged change implicitly creates an **auto-named draft** (your name + timestamp). You never create a draft up front. While a draft is checked out, a **staging bar** sits at the bottom of the admin area: the draft's name, how many changes are staged, plus **Review**, **Park** and **Apply**. Lists show the *merged* state — staged edits overlay their live rows, entities created in the draft appear as `Staged (new)` rows, and staged deletions mark their row in red. * **Apply now or keep going** — apply after one change (two clicks), or stage ten changes across five entity types and apply them together. Apply runs in a **single database transaction**: either the whole draft lands, or nothing does. Consequence actions (token revocations triggered by a change) run only after the transaction commits. * **Quick fix while a draft is open** — park the current draft, make the urgent change (this starts a fresh draft), apply it, then switch back to the parked draft. Exactly like stashing on one branch to hotfix on another. * **Multiple drafts** — you can have any number of parked drafts; the [Configuration Drafts page](#the-configuration-drafts-page) is the branch overview for switching between them. ::: tip Generated client secrets A confidential OAuth client created through a draft gets its generated secret **returned once, at apply**. Copy it from the apply result — it cannot be read back later. ::: ## Deletes are staged too Deleting an entity from a list is a commit like any other: the row turns red (`Staged (delete)`), the apply removes the entity through the same delete operation the live admin API uses, in dependency order, inside the same transaction. Deleting the row again (or the **Undo delete** context action) takes the deletion back out of the draft. Two special cases worth knowing: * **Users** — applying a staged user deletion moves the user into the **recycle bin** exactly as a live delete would: deactivated, scheduled for deletion, restorable during the grace period. The bin's restore and permanent-erase operations stay live actions. * **Protected targets** — the lockout and infrastructure protections that guard [prune](realm-provisioning#apply-merge-vs-prune) apply here too: the system app, auto-seeded standard scopes, service-account-linked and terminal-managed clients, the built-in Internal login provider, and anything conferring `realm:admin` (a realm-admin role, any current admin user, an admin-conferring group). Staging the deletion of a protected target flags a **plan error** and blocks the apply until you unstage it. ## What stays immediate Drafts stage **configuration**. Operational **actions** — anything with its own lifecycle, audit identity or urgency — act immediately, as they always did: | Immediate | Why | |---|---| | Deactivating a user, the client **Disable (immediate)** grid action, the login-provider grid toggle | Emergency levers — "this must stop working *now*" should never wait for an apply | | Session revocation, force-locking staffing sessions, 2FA resets, admin password set, magic links | Security actions, not state | | Secret rotation (client secrets, provider secrets) | Credential material with its own audit trail | | Recycle-bin restore and permanent erase | Lifecycle operations on the bin | | Service-account credentials, service-account deletion, terminal slots, position grants/activation tokens | Credential material the manifest deliberately does not model | The same distinction shows up inside modals: for example the client modal's *Enabled* checkbox stages with the rest of the form, while the grid's *Disable (immediate)* action is the live kill switch. ## Conflicts — when live moves under your draft Every draft remembers the realm state it started from (its baseline). When the plan detects that the **live realm changed while your draft was open**, it raises a conflict instead of silently overwriting: * **Stale overwrite** — someone changed a field live; your draft still carries the old value and applying would revert their change without you noticing. This is the case the baseline exists for. * **Both changed** — the field (or, for a staged deletion, the entity) changed live *and* in your draft — git's edit/edit and modify/delete conflicts. * **Created / deleted live** — an entity your draft touches was created or removed live in the meantime. The apply is **refused while conflicts are open**. Resolve them per field with *Take live value* in the entity's review card, or use *Confirm remaining differences* (rebase) to declare everything still differing as intentional. Drafts are **private by default**; share one to let every realm admin see, edit and apply it (one admin scaffolds the structure, another adds their client). Concurrent edits are protected by optimistic versioning — a save against a stale draft version is rejected and reloaded rather than lost. ## Secrets in drafts Secret-bearing fields — user passwords, client secrets, login-provider secrets, the captcha secret — are **write-only** in a draft: the value is encrypted at rest, the UI only ever shows *that* a secret is staged, and exports never contain it. At apply the staged secret is merged back in memory and set through the normal operation. ## The Configuration Drafts page *System → Configuration Drafts* is the branch overview and review surface: * **Your drafts** (and drafts shared with you): open, park, switch between, or discard them. * **Review** — the exact change plan per entity: creates, updates with per-field before/after, deletions, notes and conflicts. By default only actual changes are shown; unchanged entries can be revealed. * **Edit in place** — add entities directly to a draft, or open any entry as JSON for surgical edits. * **Start a draft from a manifest** — upload a JSON manifest (hand-written or machine-generated) as a new draft, review its plan, then apply. This is the interactive import path. * **Export** the current realm configuration and download the **manifest JSON Schema**. * **Selective export** — the cart: pick individual entities (an app, its clients, a group, …) and download a *partial* manifest. Whatever the selection references — transitively — is pulled in automatically and marked *Required*, so the file always applies cleanly; a "Select related" shortcut on an app grabs its clients, APIs, scopes and roles in one click. The target realm slug is written into the file, user references (group members, position grants) are excluded by default, and references that aren't part of the export (standard scopes, the system app) are assumed to exist on the target. See [moving config between realms](#moving-config-between-realms-and-instances). On a large realm you don't pick entities in a dialog — you collect them where the search and filters are: every admin grid's context menu offers **"Add to export selection"**. The collected entities appear in a footer bar (it survives navigation and reloads, per browser), and **Export…** on that bar opens the selective-export review pre-filled with your collection — showing only the selection plus its required references, with the full list one checkbox away. * **Prune** — opt-in full sync: the apply additionally deletes entities absent from the draft, with the same protections as [declarative provisioning](realm-provisioning#apply-merge-vs-prune). ## Drafts are manifests A draft's content *is* a [declarative provisioning manifest](realm-provisioning) — the same schema, the same apply engine, the same guarantees. That makes the draft workspace the **human review gate** in front of automation: an agent (or a colleague) authors a manifest against the published schema, you load it as a draft, read the plan, resolve anything unexpected, and apply. Conversely, everything you stage through the UI can be exported as a manifest and re-applied elsewhere. Manifests follow the platform-wide [merge-patch write semantics](/reference/#write-semantics): a field absent from the JSON stays unchanged, an explicit `null` clears the stored value, and `[]` clears a list — see [apply: merge-patch](realm-provisioning#apply-merge-vs-prune). The admin modals stage cleared fields as explicit `null`s automatically. ## Moving config between realms and instances The dev → stage → prod workflow is: configure and test on dev, **Selective export** the app bundle you care about, then on the target open *Configuration Drafts* → **Start a draft from a manifest**, upload the file, review the plan, apply. Because manifests are merge-patches, the partial file only touches what it contains — everything else on the target stays untouched. Four rules for the transfer: * **Ids travel with the export**: every exported entity carries its `Id`, and the transferred apps, clients, roles, groups, service accounts etc. land on the target with the *same* ids, so a consuming application that persists them as foreign keys never has to re-link (see [stable ids](realm-provisioning#stable-ids-across-environments)). The id is what identifies an entity: it updates the one it names, revives it if it was deleted, and — for roles, groups, users, service accounts and positions — renames it when the entry's name differs. Ids that can't be honoured (an immutable key like an app slug, or an id belonging to a different kind of entity) surface as an error entry in the plan before you apply. * **Never apply a partial manifest with prune** — prune deletes everything absent from the manifest. * **Secrets don't travel**: confidential clients get a freshly generated secret on the target (shown once at apply); provider secrets and user passwords are added to the manifest by hand if needed. * **User references are per-realm**: group members and position grants are user keys that usually don't exist on the target — the selective export strips them by default (absent = unchanged over there). Service accounts transfer as **hulls** (AccountName, Purpose, IsActive, Id): credentials are issued per environment via the service-account admin. They are upsert-only in a manifest — never pruned or staged-deleted (deleting one kills live credentials; that stays a deliberate live action). ## Current limits * **Renaming** works for roles, groups, users, service accounts and positions — the staged entity carries its id, so the apply renames the entity it names. App slugs, client ids, scope/API names and login-provider slugs cannot be renamed at all (other systems address the entity by them); the admin UI keeps those fields read-only on an existing entity. * **App permission-catalog renames** keep their id-stable semantics only through a live save — the app modal automatically falls back to an immediate save when it detects a catalog rename. * Entities the manifest does not model (service-account **credentials**, terminal slots, SA-linked and terminal-managed clients) are managed live in their own admin surfaces; service-account hulls export/import (upsert-only), but their delete stays live. * **Deleted users don't come back through an import.** Every other entity revives under its pinned id; a user's deletion runs the account lifecycle (recycle bin, grace, purge), so re-importing a binned user's id fails on purpose — restore the user from the bin first, then apply. --- --- 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. ::: tip Prefer clicking over JSON? The interactive counterpart is [Configuration Drafts](configuration-drafts): every save in the admin UI stages onto a draft — which *is* one of these manifests — and you review the exact change plan before applying. Uploading a manifest as a draft is the reviewed import path. ::: 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; group and position references may add the entity id:** * APIs / scopes / clients / roles reference an app by its **`Slug`**. * Permissions are addressed as **`resource:action`** (e.g. `invoice:read`). * Roles are keyed **`/`** (a realm-admin role by its bare `Name`): role names are unique *per app*, so two apps may each have an `Author`. * Groups list **`Members`** (users) and **`Roles`**; positions list **`Grants`** (users). Each entry is a *reference* in one of two forms: a plain string is **always a key** (`"acme/Author"`, an explicit role `Key`, a bare role name while exactly one role carries it; for users the username or email), or an object **`{ "Key": "acme/Author", "Id": "..." }`** where the `Id` names the entity (rename-proof) and the `Key` is the readable fallback used when no entity carries that id. Exports write the object form. Group membership is the *only* way users get roles. * Login providers are keyed by their **`Slug`** (the one in the provider's callback URLs). ```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, sessions, native grants, …) */ }, "Apps": [ { "Slug": "acme", "DisplayName": "Acme", "Permissions": [ { "Resource": "invoice", "Action": "read" } ], "Settings": { /* optional per-App override: Origin (host routing), branding, … */ } } ], "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"] } ], "LoginProviders": [ { "Slug": "corp-idp", "Flavor": "GenericOidc", "DisplayName": "Corp IdP", "ClientId": "modgud", "ClientSecret": "", "FlavorData": { "MetadataUri": "https://idp.example.com/.well-known/openid-configuration" } } ], "Positions": [ { "AccountName": "gate.porter", "Grants": ["alice"], "TerminalPolicy": { "Enabled": true, "AllowedActivationProofs": ["personal-passkey"], "AllowedDeviceBindings": ["dpop"], "StaffingSessionLifetimeMinutes": 60, "MaximumStaffingSessionLifetimeMinutes": 480 } } ] } ``` Positions require the `PositionTerminals` feature flag; terminal **slots** (device enrollments and their one-time-secret clients) are credential material, not config — provision them through the position/terminal admin APIs after import. 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 **merge-patch** (in the spirit of [RFC 7386](https://www.rfc-editor.org/rfc/rfc7386)) — the same [write semantics](/reference/#write-semantics) as the admin API: a field **absent** from the manifest is left unchanged (it takes the shipped default only on create), while every **present** field is applied — an explicit `null` **clears** the stored value, and `[]` clears a list. Concretely: * Boolean flags have no clear — omit or `null` both mean "unchanged"; `true`/`false` sets. * Optional scalars (display names, descriptions, token lifetimes, branding fields, …): omitted = unchanged, `null` = clear back to the default, value = set. * App links (`App` on an API or scope): omitted = unchanged, `null` = detach. * Lists (redirect URIs, scopes, group members, position grants, …): omitted = unchanged, `[]` = clear, non-empty = replace the full list. * 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 and terminal-managed clients, the built-in Internal login provider, 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, login-provider secrets, or password hashes (those are one-way or encrypted), and it omits auto-seeded standard scopes / system apps / the built-in Internal login provider / SA-linked and terminal-managed clients / terminal slots. 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 a provider secret, 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. ### Stable ids across environments Every exported entity carries its **`Id`** (ShortGuid), and the apply **pins that id at create** — importing an export into another instance recreates every app, API, scope, client, role, user, group, service account, login provider and position with the *exact same id*. A stage → prod transfer therefore keeps every id consuming applications persist as their foreign key — nothing has to be re-linked. The rules: **The `Id` names the entity**, and the natural key (slug, name, client id, account name) is ordinary data. So an entry carrying an `Id` is matched by it first, and only entries without one fall back to matching by key: * The id names a **live** entity → that entity is **updated** to the entry's values. If its natural key differs and the type can be renamed (roles, groups, users, service accounts, positions), the apply **renames** it — an export → edit-the-name → import round trip is a rename, not a duplicate. The plan shows it as `Name: old → new` plus a note, so you always see *which* entity is being renamed before you apply. * Some natural keys can't change, because other systems address the entity by them: an **app slug**, a **client id**, a **scope or API name** (the API name is the `aud` claim), a **login-provider slug** (it owns the callback URLs). If the id names one of those and the entry's key differs, the entry fails with both ways out spelled out — fix the key to match, or drop the `Id` to create a separate entity. * The id names a **deleted** entity → the apply **revives** it, under the entry's values (name included). Deleting is a soft delete, so the id and its history are still there. This is what makes "transfer to prod → something broke → delete → fix → re-import the same config" end where it started. * The id names an entity of a **different kind** → conflict (`*.PinnedIdTaken`), and the plan flags that entry as an error. Appending one kind's events onto another's stream would corrupt it, so this is the one collision with no automatic resolution. * **Users are the exception to reviving**: deleting a user runs the account lifecycle (recycle bin, grace period, GDPR purge), so a manifest never revives one. Re-importing a binned user's id fails with a message naming the way out — restore the user from the bin, then re-apply (the apply then updates it, id intact). * Omit `Id` (hand-written manifests) and matching falls back to the natural key, exactly as before; a create then generates a fresh id. Service accounts export as **hulls** (AccountName, Purpose, IsActive, Id): their credentials never travel — issue them per environment. Service accounts are upsert-only: prune never deletes them. ## 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. For a bearer caller, Modgud evaluates `realm:admin` live from the principal's current groups and roles; the permission is not copied into the token. 3. For machine access, allow the protected `modgud.management` scope on the linked Service Account credential and request a token for resource `urn:modgud:management-api`. 4. Call `/api/admin/realm-config/*` against the realm's host with that cookie or bearer. 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) ``` For unattended provisioning, use the fixed [Management API contract](../integrate/management-api) instead of an admin cookie: ```bash TOKEN=$(curl -sS -X POST "$REALM/connect/token" \ -d 'grant_type=client_credentials' \ -d 'client_id=' \ -d 'client_secret=' \ -d 'scope=modgud.management' \ -d 'resource=urn:modgud:management-api' | jq -r '.access_token') curl -H "Authorization: Bearer $TOKEN" \ "$REALM/api/admin/realm-config/export" curl -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -d @manifest.json \ "$REALM/api/admin/realm-config/apply" ``` ## 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 uses the current Control-Plane host. 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, Client Sessions, 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 * [Sessions](#sessions) — browser/SSO policy and the native/OAuth client-session default * [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) — request ceilings per source, target, client, app and device 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. ::: tip Registration before proof Submitting the form does **not** create a user. Modgud keeps a *pending registration* for the address — hashed password, names, the snapshotted default groups and approval flag — and creates the account only when the verification link is clicked. One pending record per address, overwritten by the latest request, hard-deleted on verification or after 24 hours. A stranger can therefore never block someone's address, and an abandoned sign-up leaves nothing behind. ::: ### 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 | The sign-up is held as a pending registration (24 h) and the account is created only when the verification link is clicked. Turn off only for trusted-internal scenarios — then the account is created immediately, confirmed. | | **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 registration (address, hashed password, token hash, expiry, snapshotted groups / approval flag) | tenant DB, `mt_doc_pendingregistration` — a plain document, hard-deleted on verification or expiry, never event-sourced | | User record | tenant DB, `mt_doc_applicationuser` + the user's event stream — created **at verification**, never before | | 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 account is created `IsActive=false` at verification 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. * **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) ## Sessions Browser and native clients deliberately use different policies: | Policy | Default | Meaning | | --- | --- | --- | | Browser idle lifetime | 30 days | Sliding inactivity window for the shared realm SSO cookie | | Browser absolute lifetime | 180 days | Hard limit from interactive sign-in; activity never extends it | | Allow remember me | on | Whether a caller may request a browser-persistent cookie | | Client-session idle lifetime | 30 days | Sliding window renewed when a native/OAuth app uses its refresh token | | Client-session absolute lifetime | 365 days | Hard limit before the app must perform a new user sign-in | Client-session values support 1–3650 days. Ten years is therefore valid for low-risk consumer apps where forced periodic login would be disruptive. Access tokens stay short-lived and independent of this setting. Resolution order is **OAuth client → Application → Realm**. Empty App/client fields inherit the next level. A client linked to several Applications uses the strictest participating App policy unless the client has an explicit override. ## Rate Limits Multi-dimensional ceilings for this realm's public auth endpoints — per **target** (the mailbox: the defence), per **App** (the mail-cost brake), per **client**, per **source** (a coarse, NAT-sized brake) and a silent **sign-ups-per-source** ceiling against address spraying. Each policy row shows the effective value per dimension; an empty cell inherits the shipped default, an overridden cell stores only that override. The page also carries the **source allowlist** (addresses / CIDR ranges exempt from the source dimensions only) and the **enforcement mode** (enforce, or log-only for rollout). See [Rate limits](../platform/rate-limits) for the dimensions, the defaults per policy, the trusted-forwarder capability for backends-for-frontend, and the `429` contract. Notes: * Limits are **per realm** and can be overridden per [Application](./applications#application-settings). * Counters are shared through Postgres, so several Modgud instances agree on every count; over-limit requests get `429 Too Many Requests` with `Retry-After` and a machine-readable body. * A realm that still carries per-IP rules from before this model runs **log-only** until a mode is chosen; the old values are shown and can be removed on save. ## 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 --- # Security and platform logs Administration → **Logs** has two realm-owned tabs and, in the Control-Plane realm, one additional deployment-wide tab: * **Audit** is the event-sourced history of user and configuration changes. * **Security** contains structured threat and operations events owned by the current realm. * **Platform** exists only in the Control Plane and contains PII-free, deployment-wide operations. `auth-log:read` gates Security, `audit-log:read` gates Audit, and `control-plane:platform-audit:read` gates Platform. ## Security events are realm-owned A Security event is stored in the physical database of the realm where it occurred. There is no `Realm` column and no central cross-realm table. The Control Plane is a normal realm for this purpose: its Security tab shows only Control-Plane-realm events. The structured record can retain forensic context during its short retention: * actor and target subject IDs (separate fields); * source IP and User-Agent/device context; * OAuth client, application, session and login-provider IDs; * authentication method, outcome/reason codes and correlation ID. Display text is rendered from the stable event code and structured fields at read time. Free-form `Actor`, `Reason` and persisted `Message` fields do not exist. For a known account, only its subject ID is stored and resolved for display. After account erasure the row remains useful and displays **Deleted user**. For an unknown login/reset identifier, Modgud stores only a realm-specific HMAC fingerprint. The raw or merely masked identifier is never persisted, and fingerprints cannot be correlated across realms. For a Control-Plane operation against another realm, the acting subject, IP and User-Agent remain in the Control-Plane realm. The target realm receives only a non-identifying `ControlPlane` counterpart with the same correlation ID. ## Retention and deletion Realm admins configure Security retention under **Realm settings → Logs**. The default is **7 days** and the allowed range is **1–365 days**. `security-audit-prune` is a realm job: its configuration and run history live in that realm DB and it deletes only expired events from that realm DB. There is no “Clear log” action or `DELETE /api/admin/auth-log` endpoint. Manually triggering the prune job still respects the configured cutoff; fresh events cannot be arbitrarily deleted. Hard-deleting a realm removes its whole database and therefore all of its Security events immediately. ## Platform log True deployment events—realm provisioning/adoption, Control-Plane transfer and deployment-wide maintenance—go to a separate `PlatformAuditEvent` type in the non-tenanted Global Store. That type has no subject, identifier, IP, User-Agent, OAuth client, application or session fields. The Platform log is read at `GET /api/admin/platform-audit` and never mixes realm Security events through a hidden cross-database union. Its single `platform-audit-prune` system job defaults to **365 days** and is configurable deployment-wide from the Control Plane. It has no clear action. ## API | Method | Path | Permission | |---|---|---| | `GET` | `/api/admin/auth-log?category=...&eventType=...&limit=...` | `auth-log:read` | | `GET` | `/api/admin/platform-audit?category=...&eventType=...&limit=...` | `control-plane:platform-audit:read` + Control-Plane realm | ## Delivery guarantees Every streamless event type has one fixed durability class. A call site cannot choose a weaker path; attempting to record an event through the wrong class fails immediately. | Class | Used for | Guarantee | |---|---|---| | **Required** | Privileged or irreversible changes, trust-material changes and refresh-token reuse teardown | Stored in the same Marten transaction as the realm/global state change where both share a database. Cross-database DDL operations write a durable `initiated` record before the external step and a `completed` record with the Global Store mutation. Other callers wait for persistence before reporting success. | | **Incident** | Individual takeover, tamper, signature and protocol-correlation failures | The rejecting request waits for the individual event to persist. A storage failure is not silently downgraded. | | **Abuse** | Attacker-amplifiable login, magic-link, policy, DCR and rate-limit signals | Raw occurrences enter a bounded in-memory buffer and may be shed under pressure. Accepted bursts are coalesced by structured identity into rows carrying `Count`, `FirstObservedAt` and `LastObservedAt`; persistence retries while the process remains alive. This is deliberately bounded, not a lossless request journal. | | **Telemetry** | Reconstructable cleanup and refresh summaries | Explicitly best-effort. A failed write is logged and does not make the operation fail. | The event-sourced Audit tab has its own transactional semantics. None of these surfaces is a cryptographic or tamper-proof audit chain. --- --- 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 scheduled runs (manual runs remain available), 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 Every realm job has its own Quartz job + trigger. Run history (`JobRunHistoryEntry`) and per-job overrides (`JobConfig`) live in the **owning realm's** Marten DB. Changing or manually starting a job affects that realm only. ::: ## Registered jobs Eleven job definitions ship with Modgud today: * Nine are **realm jobs**. Each active realm gets an independent Quartz job and trigger, so one customer can run at 18:00, another at 21:00, and another can disable its cron and run manually. * Two are **system jobs**: `system-job-run-history-retention` and `platform-audit-prune`. Each exists exactly once because it operates on a deployment-wide store, and is visible/configurable only in the realm that currently holds the Control-Plane role. The Control-Plane realm is still a realm, so it also owns its own copies of all nine realm jobs. System-job configuration and history live in the non-tenanted global store, not in the Control-Plane realm's database. Transferring the Control-Plane role therefore moves visibility and authority, but not the system job's data or schedule. ### `inbox-retention` — Inbox Retention Applies this realm's per-kind inbox retention policy. * **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 the owning realm's `InboxRetentionSettings` doc, dismisses or hard-deletes items per the configured policy, and reports per-reason counts in the run summary. * **On failure:** the failure is written to that realm's history and an inbox notification fires there (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 in this 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:** when DCR is enabled in this realm, finds DCR-registered clients whose last-used timestamp is older than `now − GcTtlDays` and soft-deletes them via the OAuth application aggregate. A realm with DCR disabled is skipped after a single indexed lookup. * **On failure:** logged + inbox-notified. Soft delete means client\_id history stays intact for forensics. ### `pending-registration-sweep` — Pending registration sweep Hard-deletes expired pending registrations — sign-ups (web, native OTP, invite code) whose verification link or code was never redeemed — and prunes [rate-limit](../platform/rate-limits) counters idle for two days. * **Default cron:** `0 10 * * * ?` (ten past every hour) * **Parameters:** none — lifetimes are fixed (10 minutes for codes, 24 hours for links). * **What it does:** deletes every pending registration past its expiry (and any consumed record a crash left behind), drops rate-limit counters nobody touched for two days, and forgets trusted-device records nobody logged in from for 90 days. These records are plain documents, not users: after the sweep nothing identifying the person remains. See [Realm Settings → Self-Registration](./realm-settings#self-registration). * **On failure:** logged + inbox-notified; the next hourly run catches up. ### `unconfirmed-registration-reaper` — Unconfirmed registration reaper Erases the "ghost" accounts the pre-ADR-0018 sign-up paths created before the proof: passwordless users whose registration code was never redeemed. * **Default cron:** `0 30 4 * * ?` (04:30 UTC daily) * **Parameters:** `dryRun` (default **true** — only logs the candidates), `olderThanDays` (default 7). * **What it does:** matches accounts that are unconfirmed, have no password, no passkey, no external login, no redeemed code, and whose stream is older than `olderThanDays`; erases them through the normal permanent-erase path (masking + archiving), never a raw delete. Anything an admin created with a password, or that ever signed in, is outside the signature. Leave it in dry-run until the logged list looks right, then set `dryRun=false`. * **On failure:** logged + inbox-notified. ### `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:** in its owning realm, deletes signing keys where `RetiredAt + 30 days < now`. Active keys and keys still inside their overlap window are left untouched. This is the one realm job whose trigger remains scheduled while a realm is deactivated, because soft-delete retains that realm's database and private key material. 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 this realm's account-deletion deadlines: 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:** runs the self-service reminder/erasure sweep, the admin recycle-bin auto-purge sweep, and the invite-code prune in the owning realm, then reports counts for each. See [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) for the lifecycle this job enforces. * **On failure:** that realm's run fails and is written to its own history; no other realm's run is affected. ### `backchannel-logout-retry` — Back-channel logout retry Retries logout-token deliveries to relying parties whose logout URI did not accept the first, immediate attempt (see [logout propagation](../integrate/login-flows#logout-propagation-to-relying-parties)). * **Default cron:** `0 * * * * ?` (every minute) * **Parameters:** none — the schedule is fixed: about 1, 5 and 30 minutes after the previous failure, then the delivery is given up. * **What it does:** sweeps the realm's pending deliveries whose next attempt is due, mints a fresh logout token for each and POSTs it. A delivered or given-up row is removed; every attempt is a security-log entry and updates the client's "last delivery" status. * **On failure:** that realm's run fails and is written to its own history; no other realm's run is affected. ### `session-prune` — Session Prune Removes expired browser/SSO and native OAuth client-session documents from this realm. * **Default cron:** `0 15 4 * * ?` (04:15 UTC daily) * **Parameters:** none — expiry is determined from each session's idle and absolute lifetime. * **What it does:** deletes `UserSession` and `ClientSession` rows whose idle or absolute expiry has passed. Runtime cookie and refresh-token validation already rejects an expired row, so pruning is storage hygiene rather than the enforcement boundary. An expired session still ends its relying-party sessions: the sweep emits the end marker that drives [back-channel logout](../integrate/login-flows#logout-propagation-to-relying-parties) with reason `expired`, and removes any session-grant row left without a session. * **On failure:** that realm's run fails and is written to its own history; no other realm's run is affected. ### `security-audit-prune` — Security Audit Prune Hard-deletes this realm's structured Security events after its configured retention window. This is a **realm job**: every realm has its own trigger, configuration and run history. * **Default cron:** `0 0 2 * * ?` (02:00 UTC daily) * **Parameters:** none on the job. Retention is configured under **Realm settings → Logs** (default 7 days, range 1–365). * **What it does:** deletes only expired `RealmSecurityAuditEvent` documents from the owning physical realm DB. * **On failure:** only that realm's run fails. ### `platform-audit-prune` — Platform Audit Prune Hard-deletes PII-free deployment events from the Global Store. This is a deployment-wide **system job**, visible only in the Control Plane. * **Default cron:** `0 15 2 * * ?` (02:15 UTC daily) * **Parameter:** `retentionDays` (default 365, range 1–3650) * **What it does:** deletes expired `PlatformAuditEvent` documents only. ### `system-job-run-history-retention` — System Job-Run-History Retention Trims only the execution history of deployment-wide system jobs in the non-tenanted global store. This is itself a deployment-wide **system job**: it appears only in the current Control-Plane realm and has only one Quartz trigger. It is deliberately separate from `job-run-history-retention`, because a realm-owned job must never read or mutate platform metadata. * **Default cron:** `0 45 3 * * ?` (03:45 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 system-job key. Default unlimited. * **What it does:** applies the same two independent retention caps as the realm job, but exclusively inside the global store. * **On failure:** logged + inbox-notified through the current Control-Plane realm. ## 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 the realm and system job-history-retention jobs. | | **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 and applied to the live scheduler immediately. Realm-job overrides live in that realm's Marten DB; system-job overrides live only in the non-tenanted global store. 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". ::: ## 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. | | Fixed authentication surfaces | Product name + logo + primary color on login, registration, forgot/reset password, magic-login, consent, device verification, logged-out and bootstrap screens; 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 all four branding fields 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. Product name, primary colour, logo and favicon are independently nullable inside the override and fall back to the realm value. The editor shows a live fixed-layout login and email preview. This preview is intentionally independent from the experimental PageBuilder: most tenants can complete their branding without replacing page structure. 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 realm or Application branding. You get an HTTP 409 with the exact referencing field; clear it 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/email-customization.md --- # Customization — Transactional email Modgud's built-in OTP, magic-link, password-reset, email-verification, change-request and bootstrap messages use one responsive fixed layout. The PageBuilder is not involved. ## Effective branding Email branding follows the same request context as the login experience: 1. Application context from the host, or the single Application bound to the OAuth client 2. realm branding 3. built-in Modgud defaults An Application may override the product name, sender display name, sender address, validated reply-to address, subject prefix, hidden preheader and footer text. The sender address resolves App → realm → the deployment's configured sender (`Email:Smtp:FromAddress` / Postmark), so a realm or App can send from its own domain; making that address deliverable (SPF/DKIM/DMARC, or the Postmark sender signature) is the configuring admin's responsibility. Its effective logo and primary colour are reused for the email header and action buttons. Background-capable code can pass an Application or client context explicitly; it does not have to guess from an ambient hostname. ## Languages and message format Every built-in template has German and English copy. Request language selects the set, with German as the safe fallback. SMTP and the built-in Postmark fallback carry both `text/html` and `text/plain` alternatives. When Postmark template IDs are configured, the corresponding Postmark template owns its HTML/plain-text parts. ## Security * Model values are HTML-escaped before substitution, including text rendered inside links and tables. * CR/LF is removed from dynamic subject values to prevent mail-header injection. * Logo URLs are emitted only for absolute HTTP(S) URLs resolved by Modgud. * Primary colours accept only a six-digit hex token in email markup; other CSS forms safely use the built-in button colour. * Unknown placeholders remain visible in development instead of silently disappearing. * Plain text is generated from the final rendered message and contains the same user-visible information. * The sender address accepts a bare `local@domain` only; a display-name form (`Name `) is rejected so nothing can carry extra header material into the envelope. Whether a custom address is *deliverable* is deliberately not checked here: that depends on the admin's mail provider (SPF/DKIM/DMARC, Postmark sender signature) and is theirs to configure. ## Preview The realm branding page and the Application settings show a live **email preview**: one tab per built-in template (email code, sign-in link, password reset, email verification, admin invite, change-request notifications), with a German/English switch. It is not a mock-up — the backend renders the real template through the same template store and brand layout a real send uses, with the effective branding (realm, or realm + Application override) and the form's *unsaved* values overlaid, so it tracks what you type. The header shows the resolved sender and reply-to as they will appear in the mailbox; the body is rendered in a sandboxed frame with fictional sample data and inert links. The same endpoint backs it (`POST /api/admin/realm-settings/email-preview`, `realm-settings:read`), which is the seat for editable templates later on. For delivery QA, the development stack exposes Mailpit on port 8025 and SMTP port 1025. --- --- url: /platform/assets.md --- # Customization — Asset Library Per-realm image library for branding and page-builder schemas. Upload once, reference by ID from any branding field or custom page. 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 `