--- 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 "downloaded the repo" to "first SaaS app integrated" in a small number of pages. ## Three angles to start from Pick the one that matches what you're trying to do right now: * **Run it locally** — [Quickstart (Docker)](./quickstart). Spins up Postgres + Modgud, creates the first admin via the recovery CLI, leaves you with a logged-in admin SPA. * **Integrate a SaaS app you already have** — go straight to the [SaaS Integration Walkthrough](../integrate/saas-walkthrough). It links into the relevant admin docs as you go. * **Embed Modgud into your own deployment** — [Requirements](./requirements) and [Features](./features) explain what you're getting and what infrastructure you'll need. ## What Modgud is — in one paragraph A self-hostable IdP. OAuth 2.0 + OpenID Connect server, runs on .NET 10, persists in PostgreSQL via Marten (event-sourced where it matters). Each customer / environment lives in an isolated realm with its own database. Apps within a realm declare their own permission catalogs and OAuth bindings. Tokens carry Keycloak-style `resource_access` keyed per Audience, with bypass-pre-expansion and per-RS subset narrowing — resource servers do straight exact-match against a flat permission list, no custom claim format and no separate IdP roundtrip. ## 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 provider. OIDC and OAuth 2.0 only. ## Sections * [**Quickstart (Docker)**](./quickstart) — `docker compose up`, bootstrap the first admin, sign in — in 10 minutes * [**Requirements**](./requirements) — runtime and infra checklist * [**Features**](./features) — point-by-point list of what the box delivers * [**First-time setup**](./first-time-setup) — the three bootstrap paths and when to use which --- --- url: /getting-started/quickstart.md --- # Quickstart (Docker) Get a local Modgud running, sign in for the first time, and verify the OAuth/OIDC endpoints respond — in under 10 minutes. ## Prerequisites * Docker Desktop (or Docker Engine + Compose) * A free port 9099 (Modgud API) and 4300 (Vue admin SPA, if you run the dev frontend separately) * About 200 MB of disk for the container + the tenant DB * Node 20+ on your machine if you want to run the optional demo-data seed script For requirements beyond a quick local run, see [Requirements](./requirements). ## 1. Bring up the stack ```bash git clone https://github.com/cocoar-dev/modgud.git cd modgud docker compose up -d ``` This starts PostgreSQL + Modgud in the background. First boot takes ~15 seconds while Marten provisions the master DB and seeds the system realm. ## 2. Create your first admin A fresh deployment has zero users. There is no anonymous "first-run wizard" — the very first admin is created explicitly by someone with shell access to the container, and from then on every admin is provisioned through the regular admin UI / API. For local development the simplest path is the recovery CLI in **direct mode** (sets a password right away): ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin \ --password 'StrongPass1!' ``` You should see: ``` ✓ Admin created in realm 'system': UserName: admin Email: admin@example.com Mode: Direct (password set on creation) ``` The CLI atomically creates the user, seeds the three default roles (System Admin / User Manager / Viewer) into the system realm, and adds the user to the **Administratoren** group with `realm:admin`. ::: tip Password rules The CLI enforces the same Identity password policy the SPA uses (length, mixed case, digit). A weak password is rejected — see [Settings](../plattform/settings) for how to relax the policy if needed. ::: ::: details Other ways to create the first admin Two more paths are available — they trade off CLI convenience against email verification: **Invite mode** (CLI, no `--password`) — the CLI writes a magic-link invite and prints the URL on stdout. You click the link, set the password yourself in the SPA. Useful when you want the recipient to own their credentials end-to-end. ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com # → magic-link printed; open it in your browser ``` **HTTP path** — once you already have one admin, additional realms (and their initial admins) are created through `POST /api/admin/realms` with an `InitialAdmin` payload. See [First-time setup](./first-time-setup) for the full decision tree. ::: ## 3. Sign in Open and sign in with `admin` + your password. You land in the admin SPA's dashboard. The sidebar shows everything because you hold `realm:admin`: * **Identity & Access** — Users, Roles, Groups * **Apps** — Applications * **OAuth & OIDC** — Clients, Scopes, APIs * **Federation** — Login Providers, Realms * **Operations** — Auth Log, Change Requests, Settings ## 4. Verify OIDC endpoints In a separate terminal: ```bash # Discovery document curl http://localhost:9099/.well-known/openid-configuration | jq ``` You should see `issuer`, `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, etc. The endpoints are rooted at `http://localhost:9099/` — Modgud resolves the realm from the **Host header**, not from a URL path segment. For `localhost` requests that's the system realm. ```bash # JWKS (signing keys) curl http://localhost:9099/.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 tokens. ## 5. Seed demo data (optional) The repo 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. ::: tip Why a script and not a backend service The seed runs as a regular API client. There's no second write path that could drift from the admin endpoints, no production-disabled DI registration, and the script itself doubles as an end-to-end smoke test. See `scripts/README.md` for details. ::: ## 6. Try a real OAuth flow If you ran the seed in step 5, an OAuth client `demo-spa` is pre-configured. Open it in the admin SPA → copy the test redirect URI → paste it into [oidcdebugger.com](https://oidcdebugger.com) along with the discovery URL from step 4. Click **Send Request** in oidcdebugger → log in as `admin` → consent → you'll see an access token. Decode it at [jwt.io](https://jwt.io) — `sub`, `email`, `aud`, plus a `resource_access` block once you request the `roles` scope. ## 7. 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). ## Troubleshooting ::: details I get 401 "Invalid credentials" on the login page The bootstrap-admin command writes the user immediately. If login still fails, check `docker logs modgud` for the boot output — the admin creation also prints there. Most common cause: trying to sign in before the container finished its first migration. Wait ~15 seconds and retry. ::: ::: details Magic-link emails don't arrive Default `configuration.json` ships with an in-memory mail service for dev. Magic-link emails appear in the API logs (`docker logs modgud -f`) and in `data/dev-emails/` — they aren't actually sent. To use real SMTP, edit `configuration.local.json` (gitignored) and set the SMTP block — see [Settings](../plattform/settings). ::: ::: details OIDC discovery returns 404 Modgud resolves the realm from the host header. For `localhost`, that's the system realm if its `Domains` list contains `localhost`. The single-realm dev fallback also kicks in: if there's only one active realm, localhost variants resolve to it. Check `docker logs modgud` for `RealmMiddleware` warnings if you suspect a host-resolution problem. ::: ::: details I want to start over Drop the master DB and any tenant DBs, then bring the stack back up: ```bash docker exec cocoar-postgres \ psql -U postgres -c "DROP DATABASE modgud;" docker compose restart modgud # then re-run step 2 ``` ::: ## Next steps * [First-time setup](./first-time-setup) — the three bootstrap paths explained, when to use which * [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 * **9099** — Modgud API * **4300** — Vue admin SPA (only when running the dev frontend separately; in production it's served from the API container) * **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. Configure via [Settings](../plattform/settings) per realm or instance-wide via `configuration.json`. ### 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 in `configuration.local.json` * **CORS** — for SPA-style clients consuming the OAuth flow, add their origin to the OAuth client's allowed CORS origins ### Server-to-server endpoints Resource servers reaching out to Modgud (e.g. for `/connect/userinfo` or `/connect/introspect`) need network reachability to the Modgud API endpoint, with the bearer token's audience matching their App slug. No special CORS — server-to-server. ## Browser support Admin SPA targets evergreen browsers — last 2 versions of Chrome, Firefox, Safari, Edge. WebAuthn/Passkey support requires: * Chrome 67+, Firefox 60+, Safari 13+, Edge 18+ * HTTPS context (or `localhost` for dev) * Platform authenticator (TPM, Touch ID, Face ID) or roaming authenticator (YubiKey) ## What's not (yet) supported * **SAML** — OIDC / OAuth 2.0 only * **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 with bcrypt-hashed credentials * Configurable account lockout (default: 5 failed attempts → 5 minute lock) * Password reset via emailed magic link * Email confirmation with double-opt-in for self-service email changes ### Two-factor authentication * **TOTP** (Google Authenticator, 1Password, Authy, …) * **Email OTP** (six-digit code sent to verified address) * **WebAuthn / FIDO2 Passkeys** (Touch ID, Windows Hello, YubiKey, etc.) * **Recovery codes** (one-time backup codes for self-service recovery) * **Configurable enforcement** — Off / Optional / Required, with per-user override and a grace period ### External Identity Providers (SSO) * **Microsoft Entra ID** (Azure AD) * **Generic OIDC** (anything Discovery-compliant — Keycloak, Okta, Auth0, Cognito, etc.) * Per-IdP user-update scripts for claim → profile mapping * Just-in-time user provisioning (toggle-able) * Mixed-mode realms (Internal + External providers side by side) ### Magic-link sign-in * One-time token via email, no password required * Configurable lifetime * Single-use enforcement ## Authorization ### Multi-app permission model * **Apps** as first-class organisational containers within a realm * **Resources** declared per app * **Roles** bound to one app, holding permissions on its resources * **Groups** with `BoundTo` activation switch — wildcard `*`, specific apps, or dormant * Permission strings shaped `:` (two segments; app context implicit from the catalog container) with two bypass tiers (`realm:admin`, `:admin`) ### Permission distribution to resource servers * **Keycloak-style `resource_access`** claim emitted in `/connect/userinfo`, keyed by app slug, per-Audience * **Bypass-pre-expanded + per-RS narrowed** — consumers do straight exact-match without porting the evaluator * **`Modgud.Client.AspNetCore`** library ships an `IClaimsTransformation` that flattens `resource_access[].roles` into `ClaimTypes.Role` so `[Authorize(Roles="...")]` works on resource servers without per-endpoint code ### ABAC Modgud is a pure RBAC + grouping IAM. Row-level access policies (ABAC) live in the consuming app where the row schema lives — see [Concepts → ABAC](../concepts/abac) for the boundary and the three deployment profiles (IAM-only, code-static ABAC, admin-pluggable via local groups). ### Auto membership * Groups can compute their members from a JsEval predicate over the principal directory * Recomputes incrementally on principal changes (script-dependency tracking) * Hybrid mode: static members + automatic additions ## OAuth 2.0 / OpenID Connect ### Flows * **Authorization Code + PKCE** (web, SPA, mobile) * **Refresh Token** * **Client Credentials** (server-to-server) * **Device Code** (CLI tools, set-top boxes — implementation present, lightly tested) ### Endpoints (per realm) * `/connect/authorize`, `/connect/token`, `/connect/userinfo`, `/connect/logout`, `/connect/introspect` * `/.well-known/openid-configuration`, `/.well-known/jwks` * Realm-aware issuer URLs — every realm is its own OIDC provider ### Token formats * **JWT** (default) — self-validating with JWKS rotation * **Reference tokens** — server-side opaque, validated via introspection. Useful when you need short-circuit revocation across many resource servers. ### Standard scopes * `openid`, `profile`, `email`, `offline_access`, `roles`, `permissions` (seeded into every realm) * Plus the Keycloak-style `resource_access` claim shape (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 * Cross-realm leakage is impossible at the database level ### Realm management * Realm-management UI on the Control-Plane realm (the realm with slug `system` — Control Plane is determined by the reserved slug, not by a separate persisted flag) * Per-realm bootstrap via Control-Plane-issued magic-link invite or recovery CLI * Exactly one Control Plane per deployment, enforced on create / promote / demote ### Per-realm configuration * Domains, display name, description * 2FA enforcement, grace period * Sign-in cookie lifetime * SMTP settings * Profile-change approval flow ## 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`, `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 * One-click sample data on first setup * Roles, groups, OAuth client, sample external provider — realistic playground without setup tax ## Developer integration ### Resource server libraries * **`Modgud.Client.AspNetCore`** — drop-in `IClaimsTransformation` that flattens the per-Audience `resource_access` block onto the principal * Standard `JwtBearerHandler` for token validation; nothing custom required on the framework side ### UserInfo as the permission delivery channel * `/connect/userinfo` emits `resource_access` keyed by app slug, per Audience * Bypass-pre-expanded server-side + narrowed to each RS's declared `OAuthApi.PermissionIds` subset * Standard OIDC tooling consumes it; no Modgud-specific endpoint required ## 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) * 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) * **Audience-restricted tokens** (RFC 8707 `resource` parameter) for hard cross-RS isolation --- --- url: /getting-started/first-time-setup.md --- # First-time setup How to bootstrap the very first admin account in a fresh deployment, and how to onboard the admin of every additional realm you create later. ## The mental model Modgud has **no anonymous setup wizard**. A freshly-deployed instance with zero users does not expose a "click here to claim the instance" form — that would be a race window where the first stranger to reach the URL becomes the global admin. Instead, the first admin is created by someone with a **proven trust boundary**: * **Container shell** (recovery CLI). Whoever can run commands inside the container is at the same trust level as someone who has the database password. That's an acceptable identity for "I'm the operator". * **An existing admin** (HTTP API). Once at least one admin exists in the deployment's Control-Plane realm, every new realm's first admin is bootstrapped by that existing admin via the regular admin API. There are three concrete paths. Pick by your scenario: | Scenario | Use | | --- | --- | | Local dev / first install on a self-hosted box | **Recovery CLI — direct mode** | | Operator delegates the first sign-in to someone else (e.g. handing the system to a customer admin) | **Recovery CLI — invite mode** | | Provisioning a new tenant realm in an already-running deployment (SaaS or multi-environment self-hosted) | **HTTP API — `POST /api/admin/realms`** | ::: tip Running a single-tenant deployment? If your deployment hosts one app for one company (no SaaS, no per-customer isolation), you don't need to provision additional realms — the system realm is fully featured and works on its own. See [Single-tenant mode](single-tenant-mode) for the recipe. ::: All three paths end up with the same shape inside the realm: an `ApplicationUser`, the three default roles (System Admin / User Manager / Viewer), and an `Administratoren` group containing the new user with `realm:admin` — exactly what every other admin in the system has. ## Prerequisite — add your public hostname to the system realm ::: warning Production deployments must do this BEFORE the first admin bootstrap The system realm is auto-created on first boot with a hardcoded dev-friendly domain list — `system.localhost`, `localhost`, `127.0.0.1`. `RealmMiddleware` matches incoming requests against that list to resolve which realm a request belongs to. A request to `https://auth.example.com/...` against an unmodified system realm gets rejected as "no realm" and you can't reach the SPA, the login page, or even the bootstrap-magic-link. Add your real public hostname first: ```bash docker exec modgud \ dotnet Modgud.Api.dll recover realm-add-domain \ --slug system \ --domain auth.example.com ``` The command is idempotent — re-running with the same domain is a no-op. List the current domains with `recover realm-list`. Remove with `recover realm-remove-domain --slug system --domain auth.example.com`. Skip this section if you're on the default `localhost:4300` dev setup — the seeded domains already cover that. ::: ## Path A — Recovery CLI, direct mode The simplest path for local development and self-hosted first-installs. Sets the password right away — no email roundtrip needed. ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin \ --password 'StrongPass1!' \ [--realm system] ``` The `--realm` flag defaults to `system`. You only need it for non-system tenants (rare from the CLI — usually you'd use Path C for those). Output: ``` ✓ Admin created in realm 'system': UserName: admin Email: admin@example.com Mode: Direct (password set on creation) ``` Sign in immediately at the realm's host — `http://localhost:4300/` for a default dev setup. ::: tip Password rules apply The CLI enforces the same Identity password policy the SPA uses (length ≥ 8, mixed case, at least one digit). A weak password is rejected with a clear error — no privileged bypass. See [Settings](../plattform/settings) to relax the policy if your operational needs require it. ::: ## Path B — Recovery CLI, invite mode Same CLI, but **without** `--password`. Useful when the operator (you) shouldn't know the admin's password — e.g. when handing off a customer's instance. ```bash docker exec modgud \ dotnet Modgud.Api.dll recover bootstrap-admin \ --email max@acme.com \ --username max \ [--realm system] ``` Output: ``` ✓ Bootstrap-invite issued for realm 'system': UserName: max Email: max@acme.com Expires: 2026-05-12 10:26:51 +00:00 Link: http://localhost:4300/bootstrap?token=… ``` The CLI: 1. Writes a **`PendingAdminInvite`** record into the realm's tenant DB (single-use, 7-day expiry, hashed token). 2. Sends a **magic-link email** to the recipient (if SMTP is configured). 3. Prints the magic-link URL on stdout regardless — useful for SMTP-less dev setups or air-gapped operations. The recipient opens the link, lands on `/bootstrap?token=…` in the SPA, sets their own password, and is auto-signed-in. The token is revoked on first successful use. If the link expires or gets lost, run the same CLI command again — it revokes any open invite for that email and issues a fresh one. ## Path C — HTTP API (Control-Plane admin issues an invite) Used for **every realm beyond the first**. Once you have at least one admin in the Control-Plane realm, you create new tenant realms (and their first admins) through `POST /api/admin/realms` from the Control-Plane host. This is the SaaS-friendly path: a customer registers, you provision their realm by calling one endpoint, the customer gets the magic link in their inbox and never shares a password with you. The Control-Plane admin sends: ```http POST /api/admin/realms HTTP/1.1 Host: auth.example.com # the Control-Plane host Content-Type: application/json Cookie: Modgud.Auth=… # the CP-admin's session cookie { "Slug": "acme", "DisplayName": "Acme Corp", "Domains": ["auth.acme.com"], "InitialAdmin": { "UserName": "max", "Email": "max@acme.com", "Firstname": "Max", "Lastname": "Mustermann" } } ``` Response (201 Created): ```json { "Realm": { "Slug": "acme", "DisplayName": "Acme Corp", "Domains": ["auth.acme.com"], "IsControlPlane": false, "IsActive": true, "CreatedAt": "..." }, "InitialAdminInvite": { "UserName": "max", "Email": "max@acme.com", "ExpiresAt": "...", "MagicLinkUrl": "https://auth.acme.com/bootstrap?token=…" } } ``` Behind the scenes, atomically with the realm creation: 1. The tenant DB is provisioned and the realm-internal `modgud` app is seeded. 2. A `PendingAdminInvite` is written into the new tenant DB. 3. The magic-link email is sent to `InitialAdmin.Email`. The magic-link URL is also returned in the response — useful when SMTP isn't reachable in the calling environment. Treat the URL as secret-equivalent until it's been clicked. To re-issue a fresh token (e.g. operator pressing the button after an expired link is reported): ```http POST /api/admin/realms/acme/resend-bootstrap-invite ``` The previous invite is revoked, a fresh `MagicLinkUrl` is returned. The recipient identity (UserName + Email + Firstname + Lastname) is reused from the original invite — no `body` needed. ::: warning Email is mandatory The HTTP API requires `InitialAdmin.UserName` and `InitialAdmin.Email`. There is no way to create a realm without a recipient — a realm with no admin path would be an orphaned shell. If the recipient's email turns out to be wrong, delete the realm and provision a fresh one (the soft-delete leaves data for forensics; see [Realms admin](../admin/realms)). ::: ## After the first admin is in You're now signed in. The admin SPA dashboard shows: * Sidebar with every section visible — you hold `realm:admin`, the wildcard bypass. * The `modgud` system app already registered (seeded by `AppRealmSeeder` on realm creation). Recommended next steps: 1. **Enable 2FA on your admin account** — Profile → Security → TOTP or Passkey. 2. **Configure SMTP** — Settings → SMTP, then send a test email. Without real SMTP, magic-link / password-reset emails only land in the API logs and `data/dev-emails/`. 3. **Seed demo data** (optional, dev/test only) — run `node scripts/seed-demo.mjs` to fill the realm with users, groups, OAuth clients and a sample external IdP. 4. **Bind your first SaaS app** — [SaaS Integration Walkthrough](../integrate/saas-walkthrough). 5. **Configure external SSO** (optional) — [Login Providers](../admin/login-providers). 6. **Plan additional realms** — [Realms admin](../admin/realms). ## Lost the admin account? If the only admin in a realm loses their access, no UI flow can restore them — but the recovery CLI can. Run the same `bootstrap-admin` command again with a fresh email/username; it adds you to the existing `Administratoren` group rather than duplicating it. See [Recovery CLI](../operate/recovery-cli) for related commands (`reset-2fa`, `magic-link`, `set-email`). ## Tips ::: tip Always set an email Without an email address you have no recovery channel — no magic link, no password reset. Always set one on the first admin and verify SMTP works before you need it. ::: ::: tip One Control-Plane realm per deployment Exactly one realm in a deployment is the Control Plane: the realm with the reserved slug `system`. The slug is in `RealmSlugRules.ReservedSlugs` (no tenant can claim it) and immutable after creation, so the "exactly one" invariant is structural — there's no separately persisted flag to flip. You cannot transfer Control-Plane status to another realm; you can only deactivate or delete the system realm (both are blocked by service-level guards because they'd lock the deployment out of cross-realm administration). See [Concepts: Control Plane / Data Plane](../concepts/control-plane). ::: --- --- url: /getting-started/single-tenant-mode.md --- # Single-tenant mode Modgud is multi-tenant by design — every realm gets its own PostgreSQL database, hostname routing, OAuth-client + user store. But the multi-tenant architecture is **opt-in**: for a "one app, one company" deployment the system realm is enough on its own. ## When this fits * You're running modgud as the IdP for **your own** apps and users, not hosting tenants for third parties. * You don't need per-customer data isolation. * The deployment has one public hostname (e.g. `auth.acme.com`) and every user signs in there. If any of those don't fit, you're a SaaS or multi-customer scenario and want one realm per tenant. See [Multi-realm deployment](../operate/realms) for that pattern. ## What you get The system realm is a **fully-featured realm** that behaves identically to any tenant realm for everyday IdP work — plus the control-plane functions on top. | Feature | Available in system realm | |---|---| | Users, Groups, Roles, Permissions | ✅ | | OAuth clients for your apps | ✅ | | OAuth scopes, resource APIs | ✅ | | Login providers (Internal, OIDC federation) | ✅ | | Custom permissions, auto-membership scripts | ✅ | | Magic-link, 2FA, Passkeys, email-OTP | ✅ | | `/api/admin/realms` (cross-realm management) | ✅ control-plane only | | `control-plane:*` permission namespace | ✅ control-plane only | The two control-plane-only items don't get in the way for a single-tenant deployment — they just sit there unused. ## Setup Same recipe as the [Quickstart](quickstart) without any extra steps: ```bash docker run -d \ --name modgud \ -p 80:8081 \ -v cocoar-keys:/app/data/keys \ -e DbSettings__ConnectionString="Host=...;Database=modgud;..." \ -e OpenIddict__Issuer="https://auth.example.com" \ -e ProxyAllowedNetworks="" \ ghcr.io/cocoar-dev/modgud:latest # Add the public hostname to the system realm and restart docker exec modgud dotnet Modgud.Api.dll \ recover realm-add-domain --slug system --domain auth.example.com docker compose restart auth # Bootstrap the first admin into the system realm (default) docker exec modgud dotnet Modgud.Api.dll \ recover bootstrap-admin \ --email admin@example.com --username admin --password 'StrongPass1!' ``` That's it. From the browser: 1. `https://auth.example.com/login` → sign in as `admin` 2. Set up 2FA (the grace-period dialog appears on first login) 3. Admin → Users → invite your team 4. Admin → OAuth clients → register the apps that will sign in against this IdP 5. Admin → Roles + Groups → wire up app-specific permissions ## What to avoid * **Don't grant `control-plane:*` permissions to regular users.** The default seeding doesn't — `realm:admin` (in the seeded Administratoren 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 system realm.** Both are blocked by service-level guards (the deployment would lose its admin surface), but they're a configuration footgun if you're scripting realm CRUD. ## Growing into multi-tenant later If a single-tenant deployment later needs to host a second tenant (merger, white-label rollout, …), nothing has to change in the existing system realm. You just create a new realm via `POST /api/admin/realms` (or the Admin UI), give it its own hostname, and the existing users / clients / scopes in the system realm stay where they are. The new realm gets its own PostgreSQL database (`_`), its own hostname-routing entry, its own everything. Cross-realm isolation is enforced at the database level — there's no path for tenant data to leak from one realm to another, even if a bug in Modgud opened a query without the tenant scope. --- --- url: /concepts/glossary.md --- # Glossary Terms in modgud and their counterparts in other identity systems. ## Core terms ### Realm An isolated identity boundary. Each realm has **its own PostgreSQL database** (`_`), its own users, roles, OAuth clients, and login providers. Mapping to other systems: | modgud | Keycloak | Auth0 | Azure AD | |---|---|---|---| | Realm | Realm | Tenant | Tenant (Directory) | The **system realm** is the first realm, created automatically on first boot. It starts as the **Control-Plane** realm — flagged `IsControlPlane = true` — meaning only its users may create further realms. Exactly one realm per deployment is the Control Plane. The realm boundary is the **domain** (Host header), not the URL path. Realm `acme` lives under `acme.example.com`, the system realm under `system.example.com` or `localhost`. ### 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. UAParser parses the 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** / **Ldap** / **Kerberos** | Reserved | Enum values exist; create endpoint rejects with `LoginProvider.TypeNotSupported`. The shape ships now so the FE doesn't have to add a "not supported yet" UI per type later. | Configured Oidc providers automatically show "Login with {Provider}" buttons in the login UI. Internal never produces an SSO button — it backs the local username/password form. *** ## Terms in code | Term in code | Term in docs/UI | Where | |---|---|---| | `TenantId` | Realm slug | Marten/Wolverine, infrastructure layer | | `Principal` | User or group or service account | Authorization slice (polymorphic) | | `Person` | User read model in the authorization slice | A subclass of Principal | | `Aggregate` | Event-sourced entity | Domain layer | | `*State` | Inline projection for sync consistency | Infrastructure layer | | `*ListReadModel` / `*DetailsReadModel` | Async projection for read optimization | Infrastructure layer | | `LoginProvider` | Login provider configuration (Internal / Oidc / ...) | Authentication slice | ::: info "Realm" vs. "Tenant" User-facing it is **Realm** everywhere. The code uses **Tenant** in the infrastructure layer (`TenantId`, `ITenantSessionFactory`, `MasterTableTenancy`), because that is what Marten and Wolverine call it. Same thing, two names. ::: --- --- url: /concepts/apps-and-resource-access.md --- # Apps and resource\_access This page explains the mental model behind Modgud's permission system: what an "App" is, how it relates to OAuth concepts, how the Keycloak-style `resource_access` claim is shaped, and how the permission resolver gets from a logged-in user to a concrete answer. ## The four-axis model OAuth/OIDC officially knows four roles: Resource Owner (the user), Client, Authorization Server, Resource Server. Modgud adds a fifth concept that the OAuth spec doesn't model — the **App**. ``` Realm │ ┌───────────┴───────────────┐ │ │ Identity Apps │ (the IAM axis) ┌────────────┼─────────────┐ │ │ │ │ ┌────┼─────────────┐ Users Groups PermRoles │ │ │ App Resources Roles (per app) │ ┌───────┼───────┐ │ │ │ OAuth OAuth Scopes Clients APIs (per app) (n:m) (1:n) ``` Why an App layer? Because in OAuth a **Resource Server** is just a Resource Server — `acme-api` is one thing. But organisationally, "Acme as a product" might be many resource servers (api, search, files), share resources/roles across them, and need a coherent permission story regardless of which microservice the user is hitting. **The App is the organisational clamp.** | Concept | Purpose | OAuth analog | | --- | --- | --- | | **App** | Organisational identity for a SaaS product, owns Resources + Roles | none (IAM-specific) | | **OAuth Client** (`OAuthApplication`) | Identity that requests tokens (frontend, CLI, mobile) | OAuth Client | | **OAuth API** (`OAuthApi`) | Identity that authenticates as a resource server | OAuth Resource Server | | **OAuth Scope** | What a token may do (gross-grained) | OAuth Scope | | **Group** | Org-level user collection (mailing-list semantics) | none | | **PermissionRole** | Bundle of permissions for one app | Role | | **Permission** | Smallest unit, shape `:` within an app catalog | Permission | Every artefact below the realm sits on one of these axes. App-scoped artefacts (`PermissionRole.AppId`, `OAuthScope.AppId`, `OAuthApi.AppId`) reach back up to the App; `Group.BoundTo` is the activation switch ("is this group active in app X?"). ## Why apps and resource servers aren't 1:1 Two real-world deviations from "one App = one Resource Server": **Microservice apps.** Acme's backend might be split into `acme-api`, `acme-search`, `acme-files`. All three are different OAuth API identities (each with its own secret, its own audit identity), but they share the same App `acme` — so a user is `Editor in Acme` and that role works regardless of which microservice handles a given HTTP request. **Multi-app frontends.** A unified webshop frontend might call into a `shop` app, a `payments` app, and an `inventory` app. The frontend has *one* OAuth Client (one user-facing identity), but the client is linked to all three Apps via its `AppIds` list. The issued token then carries `resource_access` blocks for all three; each backend reads its own block. The two flexibilities together let Modgud represent any reasonable architecture without forcing you into "everything is one app" or "split everything into separate clients". ## Permission resolution: step by step Given a `(userId, appSlug)` pair (e.g. `(alice, "acme")`), what permissions does the user effectively hold? ``` 1. BFS user → groups (transitive: User in A; A in B; A and B both count) 2. Filter groups (g.BoundTo contains "*" OR appSlug) 3. Collect role IDs (g.RoleIds for each surviving group) 4. Load roles (drop deleted) 5. Filter to this app (r.AppId == app.Id OR r.IsRealmAdmin) 6. For each role: resolve PermissionIds → catalog strings ("invoice:read", "invoice:write", …) 7. Bypass-pre-expand: - realm:admin → all catalog strings of every reachable App - :admin → all : strings in this app's catalog 8. 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 both for in-process gates and for the per-Audience `resource_access` block on `/connect/userinfo`. ## The token shape When a user logs in via an OAuth Client linked to apps `[billing, shipping]`, the access token's `/connect/userinfo` response (with appropriate scopes granted) contains a Keycloak-style nested claim: ```json { "sub": "abc123…", "email": "alice@example.com", "name": "Alice", "resource_access": { "billing": { "roles": ["Editor"], "permissions": ["invoice:read", "invoice:write"] }, "shipping": { "roles": ["Viewer"], "permissions": ["shipment:read"] } } } ``` Each resource server reads its own block. The Billing-API sees `resource_access["billing"]`; the Shipping-API sees `resource_access["shipping"]`. Neither sees the other's data magnified — they each have it side-by-side, but consume just their own. The `Modgud.Client.AspNetCore` helper lib's `IClaimsTransformation` takes the matching audience block and flattens its roles onto `ClaimTypes.Role`, so `[Authorize(Roles="Editor")]` works out of the box without per-endpoint plumbing. ### What gets emitted is opt-in by scope * `scope=roles` → emit the `roles` array per Audience block. * `scope=permissions` → emit the `permissions` array per Audience block (bypass-pre-expanded and narrowed to that RS's `OAuthApi.PermissionIds` subset). Without those scopes, the block is omitted (or empty). Clients ask for exactly what they need; tokens stay lean. ### Per-RS subset narrowing Each `resource_access` block is narrowed to the OAuthApi's declared `PermissionIds` subset of the App's catalog. A microservice within a multi-resource-server App only sees the permissions it declared as its gating surface — strings from a sibling microservice within the same App are excluded. No cross-RS leaks. ## What's *not* in the token A few things are deliberately absent from UserInfo: * **Group memberships.** Organisational signal, not authorisation. Also app-scoped via BoundTo, which UserInfo's flat shape can't express cleanly. Groups stay IAM-side. * **Cross-app roles for apps the calling client isn't linked to.** The token only carries `resource_access` blocks for the apps the issuing client knows about. * **`realm:admin` as a literal string.** It's bypass-pre-expanded into concrete catalog strings before emission, so consumers do straight exact-match without needing to mirror the evaluator's bypass logic. Anything that's "what may this user do" and stable enough to ride along with the identity → goes in the token. Group memberships and other organisational signal → IAM admin endpoints. ## Design decisions worth knowing These are non-obvious choices the resolver makes. Knowing them avoids "why doesn't this work" moments: **`Group.BoundTo = []` ≠ `BoundTo = ["*"]`.** Empty means *dormant for permission purposes* — the group exists for org/mailing-list reasons but contributes zero to authorisation. Wildcard means *active in every app* (rare, mostly the realm-admin group). **Permissions are not cascaded when BoundTo changes.** Removing an app from `BoundTo` *deactivates* the group in that app — it does NOT strip the group's roles. You can re-add the app and the group is immediately active again. Reduces accidental data loss in admin operations. **`Role.AppId` is fixed.** Once a role is created, its app affiliation cannot change — moving permissions across apps means cloning the role under a new AppId. Rare operation, easy to spot in audit logs. **Bypass tiers are pre-expanded server-side.** Token consumers never see `realm:admin` or `:admin` as literal strings — Modgud expands them into the concrete catalog entries before emission. The client just checks `permissions.includes("invoice:write")` and is done. **`OAuthApplication.AppIds` is `n:m` (a client can be linked to many apps).** **`OAuthApi.AppId` is `1:1` (a resource server belongs to one app).** Asymmetric on purpose: client-side aggregation (one frontend, many resource servers) is normal; server-side aggregation would muddle the audit trail. ## Glossary * **Realm** — top-level tenant. Own database, own users, own apps. * **App** — organisational identity for a SaaS product within a realm. * **OAuth Client** — token requester. Has `AppIds: List` (n:m). * **OAuth API** — token-validating server identity. Has `AppId: Guid` (1:1). * **OAuth Scope** — gross-grained capability claim. Has `AppId: Guid?` (null = global, e.g. `openid`). * **Group** — user collection. Has `BoundTo: string[]` (which apps it's active in). * **PermissionRole** — bundle of permissions. Has `AppId: Guid?` (null when `IsRealmAdmin = true`). * **Permission** — `:` string within one App's catalog. App context is implicit from the catalog container. * **`resource_access`** — Keycloak-style nested UserInfo claim, keyed by app slug, with bypass-pre-expanded permissions narrowed per-RS. --- --- url: /concepts/realms.md --- # Realms ## What is a realm? A realm is a **fully autonomous identity provider**. It is the fundamental isolation boundary in modgud. Per realm: * its own **PostgreSQL database** (`_`) * its own **users and groups** * its own **roles and permissions** * its own **OAuth clients, scopes, APIs** * its own **OIDC discovery endpoint** * its own **login providers** (Internal + OIDC IdPs) * its own **cookie domain** Each realm looks like a standalone modgud installation — because that is essentially what it is. ## Domain-based routing Modgud identifies the realm via the **HTTP Host header** — not via URL paths. Each realm has one or more configured domains. ``` acme.example.com → Realm "acme" auth.acme.example.com → Realm "acme" (second domain for the same realm) finance.example.com → Realm "finance" system.example.com → System realm localhost → System realm (single-tenant fallback in dev) ``` `RealmMiddleware` (in `Modgud.Api.Middleware`) runs before all other middlewares and: 1. Reads `request.Host.Host` 2. Looks up a match in `IRealmCache` 3. Sets `HttpContext.Items["TenantId"] = realm.Slug` 4. If no match → `404` The cache is warmed at boot and invalidated on realm CUD. ::: tip Single-tenant fallback in dev If only one realm is active AND the host is a localhost variant (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`), the cache returns that single realm — even if it does not have the localhost domain in its list. This makes a single-realm dev boot work without a hosts-file entry. ::: ## Database-per-tenant via Marten Modgud uses Marten's `MasterTableTenancy`: ```mermaid graph TD Master[(
= Master DB)] Master -->|realms.mt_tenant_databases| System[(_system)] Master -->|realms.mt_tenant_databases| Acme[(_acme)] Master -->|realms.mt_tenant_databases| Finance[(_finance)] Master -->|global Schema| GlobalRealm["Realm-Documents
(IGlobalStore)"] System -->|tenant data| SystemUsers[Users, Groups, OAuth, ...] Acme -->|tenant data| AcmeUsers[Users, Groups, OAuth, ...] Finance -->|tenant data| FinanceUsers[Users, Groups, OAuth, ...] ``` | Database | Contents | |---|---| | `` (Master) | Schema `realms.mt_tenant_databases` (tenant registry) + schema `global` (Realm documents) | | `_system` | System realm data (users, groups, ...) — physically the same DB as the master | | `_` | A separate physical DB per additional realm | ::: info System realm and master DB The system realm intentionally points at the master DB. That way a single-realm installation needs only one DB. Multi-realm installations add separate tenant DBs for the other realms without the system realm needing to move away from the master. ::: ### Tenant resolution in code `TenantedSessionFactory` (Marten `ISessionFactory`) reads the `TenantId` from `HttpContext.Items` and opens a tenant-scoped session: ```csharp public IDocumentSession OpenSession() => _store.LightweightSession(ResolveTenantId()); private string ResolveTenantId() => _httpContextAccessor.HttpContext?.Items[TenantConstants.HttpContextTenantIdKey] as string ?? TenantConstants.SystemTenantId; ``` Every `IDocumentSession`/`IQuerySession` injection is therefore automatically realm-scoped. Background services (without HttpContext) fall back to the system tenant. ### GlobalStore for realm documents The `Realm` document itself cannot live in the tenant store — otherwise there would be a chicken-and-egg problem. It lives in a separate Marten store (`IGlobalStore`) that writes to schema `global` of the master DB. `RealmCache` loads the realm list from there. ## Realm lifecycle ### 1. First-time bootstrap On first start: 1. **Create the master DB** (raw SQL, because Marten cannot `CREATE DATABASE` on an active connection) 2. **Apply the Marten schema** → `realms.mt_tenant_databases` is created 3. **Register the system tenant** → `tenancy.AddDatabaseRecordAsync("system", masterCs)` 4. **Apply the Marten schema again** → per-tenant tables for system 5. **Seed the system realm document** in `IGlobalStore`, flagged `IsControlPlane = true` 6. **Seed default OAuth scopes + the Internal login provider** 7. **Seed the `modgud` and `control-plane` apps** into the system tenant DB 8. **Warm `RealmCache`** 9. The instance is ready, but has zero users — the first admin is created via the [recovery CLI](../operate/recovery-cli) or, for additional realms, by an existing CP-admin via `POST /api/admin/realms`. See [First-time setup](../getting-started/first-time-setup). ### 2. Create additional realms Only users with `control-plane:realm:write` **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"], "IsControlPlane": false, "InitialAdmin": { "UserName": "max", "Email": "max@acme.com" } } ``` Backend: 1. Validates `slug` (regex, no reserved word) and the exactly-one-CP invariant. 2. `CREATE DATABASE _acme` (raw SQL). 3. `tenancy.AddDatabaseRecordAsync("acme", connStringForAcme)`. 4. `Storage.ApplyAllConfiguredChangesToDatabaseAsync()`. 5. **`OAuthRealmSeeder`** → 5 default scopes + Internal login provider. 6. **`AppRealmSeeder`** → registers the `modgud` app in the new tenant DB. The `control-plane` app is **not** seeded — it only exists in the Control-Plane realm. 7. Save the `Realm` document in `IGlobalStore`. 8. `RealmCache.Invalidate()`. 9. **Bootstrap-invite** issued atomically: writes a `PendingAdminInvite` into the new tenant DB, sends a magic-link email to `InitialAdmin.Email`, returns the URL in the API response. The recipient clicks the magic-link, lands at `/bootstrap?token=…`, sets a password, and is auto-signed-in with `realm:admin`. The first user creation runs through `RealmAdminBootstrapper`, which atomically seeds the three default roles and adds the user to the `Administratoren` group. ### 3. Deactivate a realm ```http PATCH /api/admin/realms/{slug} { "isActive": false } ``` `RealmCache` filters on `IsActive = true` — inactive realms are no longer resolved, all requests to the domain land at `404`. The data stays in the DB. ::: danger Do not deactivate the system realm The system realm must not be deactivated — otherwise you have no way back into the system. ::: ### 4. Hard-delete a realm ::: warning Work in progress Currently only soft-delete (deactivation) is implemented. Hard-delete would have to drop the tenant DB cleanly, shut down Wolverine's durability agent, invalidate sessions — complex. Roadmap item. ::: ## 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 guarantees | Guarantee | Mechanism | |---|---| | No user-data leaks | Database-per-tenant, physical DB boundary | | No permission leaks | Per-tenant Marten sessions, no cross-tenant joins | | No token leaks | Issuer-claim check + per-realm OpenIddict stores | | No cookie leaks | Cookie domain per realm | | No SignalR leaks | Hub connection is auth-gated, runs in the realm context | --- --- url: /concepts/control-plane.md --- # Control Plane / Data Plane Modgud separates **cross-realm administration** (realm CRUD, the first-run setup wizard) from **tenant self-service** (everything else) on three independent layers. A request that hits a Control-Plane endpoint from a tenant host has to defeat all three to succeed — and they're deliberately decoupled so a regression in one doesn't open the others. ## Why bother Every realm in modgud is a fully autonomous IdP — its own DB, users, OAuth clients, login providers (see [Realms](./realms.md)). But one operation is 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). 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 — **structurally**, not as a separately-stored flag. `Realm.IsControlPlane` is computed: ```csharp public bool IsControlPlane => Slug == RealmSlugRules.SystemSlug; // SystemSlug = "system" ``` Three structural facts make this an "exactly one" guarantee without any runtime validation: 1. The slug `"system"` is in `RealmSlugRules.ReservedSlugs` — no `CreateRealm` call can claim it. 2. The system realm is seeded once at first boot (`EnsureSystemRealmExistsAsync`). 3. `Slug` is immutable after creation — the realm document carries the slug for life. So no `Update` can promote a tenant realm to Control Plane, no `Create` can spawn a second one, no flag can be flipped off. The Control Plane is wherever the slug is — and that's always exactly the system realm. `RealmProvisioningService` does still block deactivating or deleting the system realm — losing the Control Plane would lock the deployment out of cross-realm administration — but those are the only two remaining guards. ::: 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. ## Hostname routing — DB is source of truth The system realm is seeded with the localhost-style domains `["system.localhost", "localhost", "127.0.0.1"]` so a fresh checkout boots without any ENV setup. For a deployed installation, the operator adds the public hostname via the Recovery CLI: ```bash docker exec modgud dotnet Modgud.Api.dll \ recover realm-add-domain --slug system --domain auth.example.com ``` The `IRealmCache` is invalidated immediately — no container restart needed. From the next request onwards, `Host: auth.example.com` resolves to the system realm and `ControlPlaneGateMiddleware` lets `/api/admin/realms/*` through. There's no separate ENV variable mirroring the hostname list. The realm's own `Domains` field is the single source of truth — kept in the DB next to the rest of the realm metadata. ## First-admin onboarding A freshly provisioned realm has no users. There is **no anonymous "first-run" wizard** — that would be a "first-come-takes-the-instance" race window. Three explicit-trust paths replace it: ### Path 1 — Recovery CLI, direct password (operator-local) Filesystem trust. The operator runs: ```bash docker exec dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin \ --password 'StrongPass1!' \ --realm system ``` Atomic seed of `ApplicationUser` (Identity-Password-Rules enforced — the CLI does NOT bypass policy), the three default roles (System Admin / User Manager / Viewer) and the Administratoren group. Idempotent: re-running for a second admin appends them to the existing group instead of duplicating. ### Path 2 — Recovery CLI, invite mode (delegated trust) Same CLI without `--password`. The CLI writes a `PendingAdminInvite` into the tenant DB and prints the magic-link URL on stdout (also sent by email when SMTP is configured). The recipient clicks, sets a password via `/bootstrap?token=...`, gets auto-signed in. ```bash dotnet Modgud.Api.dll recover bootstrap-admin \ --email max@acme.com \ --realm acme ``` ### Path 3 — HTTP, control-plane admin issues an invite `POST /api/admin/realms` is the only HTTP path that creates a realm. It is CP-only (gated by all three layers above) and now requires `InitialAdmin: { UserName, Email, Firstname?, Lastname? }`. The backend atomically: 1. Creates the realm (DB, OAuth scopes, login providers, app seeding) 2. Switches into the new tenant via `TenantContext.Enter(slug)` 3. Issues a `PendingAdminInvite` and sends the email 4. Returns `{Realm, InitialAdminInvite { UserName, Email, ExpiresAt, MagicLinkUrl }}` The SPA reveals the `MagicLinkUrl` once after creation — useful in SMTP-less dev and air-gapped scenarios where the email won't arrive. A `POST /api/admin/realms/{slug}/resend-bootstrap-invite` endpoint issues a fresh token (and revokes any open ones) for the same recipient identity if the original is lost. ### Token lifecycle * 32-byte URL-safe random plaintext, SHA-256-hashed in the DB * 7-day TTL (`PendingAdminInvite.DefaultExpirationDays`) * Single-use: `UsedAt` is set on success; reuse → 400 `BootstrapInvite.TokenUsed` * Reissue revokes prior open invites for the same email — there is at most one consumable invite per recipient per realm ### Anti-race-window The "elimination" of SETUP-01 is not just an upgrade of the gate — the gate itself is gone. None of the three paths is anonymous and unauthenticated: * Path 1 + 2: filesystem trust (whoever can `docker exec` already owns the host) * Path 3: authenticated CP-admin trust (already proved their identity via the regular login) * The bootstrap endpoint that sets the password (`POST /api/account/bootstrap-admin`) IS anonymous, but only consumes a token that one of the trusted paths already issued. Without a valid token the endpoint can't elevate anyone — same posture as a password-reset link. ## What a tenant sees The SPA reads `IsControlPlane: bool` from the anonymous `/api/app-info` endpoint: | Host | Sidebar shows "Realms" | `/api/admin/realms` | |---|---|---| | auth.example.com (CP) | ✅ if user has `control-plane:realm:read` | 200 OK | | acme.example.com (tenant)| Never | 404 Not Found | ## Layer-by-layer test pinning | Layer | Tests | Where | |---|---|---| | Routing gate | `ControlPlaneGateMiddlewareTests` | `Modgud.Tests.Unit/Api/Middleware/` | | Endpoint filter | `RealmsEndpointsTests.RequireControlPlaneFilterTests` | `Modgud.Tests.Unit/Api/Features/Admin/` | | End-to-end | `ControlPlaneSeparationTests` (tenant→404, CP→OK, exactly-one-CP invariant on create + promote + demote, app-info IsControlPlane) | `Modgud.Api.Tests/Security/` | | Realm-cache resolution | `RealmCacheLookupTests` | `Modgud.Tests.Unit/Realms/` | A regression in any one layer is caught by the layer's tests; a regression in middleware ordering or wiring is caught by the end-to-end suite. --- --- url: /concepts/authentication.md --- # Authentication Modgud has two orthogonal authentication axes: 1. **First-party login** — the user signs in to modgud itself (admin UI, profile, setup). Cookie-based, no token in the browser. 2. **OAuth/OIDC server** — external apps let users sign in via modgud. Authorization Code + PKCE, classic. Both share the same login methods under the hood. ## First-party login Implemented in the **Authentication slice** (`Modgud.Authentication`). Endpoints mounted under `/api/account/...`. ### Login methods | Method | When | Cookie lifetime | |---|---|---| | **Password** | Default, allowed at AuthLevel 0/1 | Session or 30 days (RememberMe) | | **TOTP** | Second factor after password | Inherits from the password step | | **Email OTP** | Second factor — or as an alternative login | Inherits from the password step | | **Passkey (FIDO2)** | Second factor — or as a sole login (passwordless) | Always 30 days (persistent) | | **Magic Link** | Email with single-use token; can also be sent by an admin | Always 30 days | | **OIDC External** | Federated login via Entra ID, Google, ... | 30 days | See [Login flows](/integrate/login-flows) for details. ### Authentication level Configured globally via `IAuthSettings.AuthenticationMinimumLevel`: | Level | Effect | |---|---| | 0 = None | Password-only allowed — no enforcement | | 1 = SecureLogin (default) | User must have 2FA or a passwordless method | | 2 = Passwordless | Password login disabled — only Magic Link + Passkey | At level >= 1 the `TwoFactorEnforcementMiddleware` runs and blocks authenticated requests from users without 2FA (with a grace period). ### Cookies | Cookie | Purpose | SameSite | Lifetime | |---|---|---|---| | `Modgud.Auth` | Main session (HttpOnly) | Lax | Session or 30 days | | `Modgud.2FA` | UserId between password step and 2FA step | Strict | 5 min | | `Modgud.External` | OIDC callback holder | Lax | 10 min | | `Modgud.Session` | Only for passkey attestation options | Strict | 5 min idle | `SameSite=Lax` on the main session cookie is required so that OIDC redirect-back navigations carry the cookie (top-level GET → cookie sent). Cross-site POSTs are still blocked by `SameSite=Lax`, plus the `CsrfDefenseMiddleware` rejects state-changing requests whose `Sec-Fetch-Site` indicates cross-origin. In production all cookies are `Secure`. In dev `Secure=None` so the Vite dev server (`http://localhost:4300`) can write them. ## OAuth 2.0 / OIDC server Modgud is at the same time a full-fledged OpenID Connect provider for external apps. Implemented via **OpenIddict 7** with its own Marten-based stores (no Entity Framework). ### Flows ```mermaid sequenceDiagram participant App as External App participant Auth as modgud participant User App->>Auth: GET /connect/authorize?...&code_challenge=... Auth->>User: Login page (if needed) User->>Auth: User signs in (password + 2FA) Auth->>Auth: Consent (implicit or explicit) Auth->>App: Redirect with ?code=... App->>Auth: POST /connect/token (code + verifier) Auth->>App: access_token + id_token + refresh_token ``` Supported: **Authorization Code + PKCE**, **Client Credentials**, **Refresh Token**. Not supported: Implicit Flow, ROPC. See [OAuth & OIDC](/concepts/oauth) and [OAuth implementation](/integrate/oauth) for details. ### 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. ## External login (OIDC IdPs) Users can sign in via external OIDC providers (Entra ID, Google, Auth0, ...). Configurable per realm. 1. Admin creates a `LoginProvider` of `Type = Oidc`: authority, client ID, client secret, `UserUpdateScript` 2. Login page automatically shows buttons for enabled OIDC providers 3. Click → OIDC Authorization Code + PKCE → IdP login 4. On callback: `ExternalLoginProcessor` runs * Looks up `ExternalIdentityLink` (issuer + subject) → existing user or JIT-create * `UserUpdateScript` (Jint) maps claims to user fields 5. If the user has 2FA enabled, the normal 2FA flow runs afterwards 6. Login cookie is set (always 30 days) See [Login providers (OIDC)](/integrate/login-providers) for details. ## Account lifecycle | How does a user enter the system? | Mechanism | |---|---| | Self-registration | Registration form (when enabled for the realm) | | External login | OIDC IdP → JIT-create on first login | | Admin-created | Admin creates the user via the UI | | Setup | First-time setup — the first user becomes system admin | Lifecycle states: * **Active** — normal state * **Locked** — by account lockout (5 failed logins → 1 min) * **Soft-deleted** — `IsDeleted = true`, all data preserved, reactivatable * **GDPR-erased** — stream archived, PII masked, irreversible (Article 17) Detailed slice-internal walkthrough lives in the repo-only [Authentication slice blueprint](https://github.com/cocoar-dev/modgud/blob/develop/dev-docs/architecture/authentication.md) notes. --- --- url: /concepts/groups-and-authorization.md --- # Authorization (RBAC) Modgud is a pure **RBAC + grouping** Identity & Access Management system. It answers `(user, app, permission)` — nothing more. Row-level access policies (ABAC) deliberately stay **outside** the IAM and live in the consuming app where the row schema is. See [ABAC and the IAM boundary](./abac) for the rationale and the three deployment profiles. ## RBAC: User → Group → Role → Permission Permissions flow exclusively through groups: ``` User ──► Group ──► PermissionRole ──► ":" within an App catalog ``` There are no direct `User → Role` assignments and no `User → Permission` overrides. The resolution path: 1. Find every group the user is in (transitively, including nested groups). 2. Filter to groups whose `BoundTo` includes the requested app (or the `*` wildcard). 3. Collect the role ids on those groups. 4. Filter to roles whose `AppId` matches the requested app — plus any role with `IsRealmAdmin = true`. 5. Resolve each role's `PermissionIds` → catalog strings. 6. Bypass-pre-expand and run the evaluator below. ## Permission format Permission strings inside an App's catalog are **two segments**: ``` : ``` The App context is implicit from the catalog container — the string itself never carries an app slug. When the resolver sweeps a user's effective permissions for a given app, it works against that App's catalog; when UserInfo emits a per-Audience block, the audience determines which app's catalog applies. | Example | Meaning | | --- | --- | | `user:read` | Read users (in whichever App's catalog defines it) | | `oauth-client:write` | Manage OAuth clients | | `invoice:read` | Read invoices (e.g. in a `billing` app's catalog) | | `realm:admin` | Realm-wide bypass — everything in every app | | `:admin` | Resource-wide bypass for that resource | ### Bypass tiers — exactly two `PermissionEvaluator.Evaluate(grants, needed)` returns true when: 1. the user holds `realm:admin`, **or** 2. the user holds `needed` directly, **or** 3. the user holds `:admin` for the same resource. There is **no app-wide bypass tier** (`:admin`). Bypass is either realm-wide or resource-wide; nothing in between. `realm:admin` is intentionally narrow — only the System Admin default role carries it. For the full evaluator + emission story (per-Audience UserInfo, bypass-pre-expansion, per-RS subset narrowing) see the canonical [Permissions reference](./permissions). ## Apps and BoundTo The IAM hosts an arbitrary number of consuming apps in one realm; each is identified by a slug (`modgud`, `acme`, `billing`, …). PermissionRoles bind to one app (via `AppId`); groups carry an activation list (via `BoundTo`). A group's `BoundTo` field is the **activation switch**: it lists the app slugs in which the group's roles take effect. | BoundTo | Effect | | --- | --- | | `["*"]` | Wildcard — active in every app. Typical for the realm-admin group. | | `["acme"]` | Roles only contribute when an `acme`-scoped permission is being resolved. | | `["acme", "billing"]` | Active in both apps; same role assignments contribute in either resolution. | | `[]` | Dormant — the group exists for organisational/mailing purposes only and contributes no permissions. | Removing an app from a group's BoundTo is a non-destructive deactivation: role assignments stay; re-adding the app reactivates them immediately. ## Default roles per realm | Role | Permissions | | --- | --- | | **System Admin** | `IsRealmAdmin = true` (the realm-wide bypass) | | **User Manager** | `user:read`, `user:write`, `permission-role:read`, `authorization-group:read`, `authorization-group:write` (in the `modgud` app) | | **Viewer** | Read-only on Users, Roles, Groups, OAuth-Clients, OAuth-Scopes (in the `modgud` app) | The first-time-setup admin lands in the System Admin group with `BoundTo: ["*"]`, so they immediately see every app. ## Groups `Group` is the carrier of permissions. A group has: * `Name`, `Description` * `MembershipMode` — `Manual` or `Auto` * `MemberIds` — users or other groups (nested) * `RoleIds` — references to `PermissionRole`s * `BoundTo` — app slugs in which the group is active (see above) * Optional: `MembershipScript` (when membership is Auto) * Optional: `Email` + `EmailMode` for distribution-list semantics ### Manual vs Auto * **Manual** — the admin maintains `MemberIds` directly. * **Auto** — a JsEval predicate (`MembershipScript`) decides which principals match. Re-evaluated on every relevant principal mutation; dependency-tracking skips re-runs when the changed property doesn't appear in the script. The membership script only sees IAM-owned fields (`DisplayName`, `Email`, `IsActive`, `ExternalIdentities`, `AccountName`). It must not — and cannot — read app-specific schema; that would re-couple the IAM to every consumer's schema. See [ABAC and the IAM boundary](./abac). ### Nested groups A group can contain other groups. The permission-resolution BFS treats them polymorphically (`IPrincipalWithMembers`), with cycle-detection via a visited set. ``` "All Staff" (Manual) ├── "Engineering" (Auto: matches engineers) ├── "Sales" (Auto: matches sales) └── "Support" (Auto: matches support) ``` ## What this architecture is *not* * **No deny rules.** Only positive grants; effective access is the union over all the user's groups. * **No implicit grants.** Group membership grants nothing on its own; roles must be explicitly assigned. * **No direct user-to-role.** Everything routes through groups. * **No row-level rules.** ABAC stays in the app; the IAM keeps `(user, app, permission)` as its sole answer surface. * **No app-wide bypass tier.** Just realm-wide and resource-wide. ## Sidebar mirror The Vue admin shell mirrors the same logic 1:1: each sidebar item declares the permission it requires, the backend evaluates the same string. The single source of truth is the permission constant — frontend gating cannot drift from backend gating because both consult the identical literal. ```ts { section: 'authorization', label: 'nav.users', icon: 'users', path: '/admin/users', requirePermissions: ['user:read'] } ``` A user with only `user:read` (in the `modgud` app context) sees just "Users" in the sidebar — no OAuth, no System. --- --- url: /concepts/permissions.md --- # Permissions & gating Modgud uses **granular per-resource gating**: every endpoint and every sidebar item checks a single permission string, and the same evaluator runs IdP-side (Authorization slice) and resource-server-side (Modgud.Client.AspNetCore). ## Permission format **Two segments:** `:`. The string carries no app slug. The app context is **implicit from the caller**: * For in-process gates inside Modgud, the gate's audience is the Modgud app itself. * For resource-server gates, the audience is the RS's own App slug (resolved from the access token's `aud` claim by the time the request reaches the gate). This is enforced at write-time: catalog entries are validated against the regex `^[a-z0-9-]+:[a-z0-9-]+$` — exactly two lowercase segments, hyphens allowed inside a segment, no colon-prefix. Modgud's own admin surface uses two App slugs internally: * **`modgud`** — the realm-internal admin surface (users, groups, roles, OAuth clients, login providers, etc.). Seeded into every realm. * **`control-plane`** — the cross-realm admin surface (realm CRUD). Seeded **only** into the realm flagged `IsControlPlane = true`. A consuming SaaS app gets its own App slug and registers its own catalog of `:` permissions. The slug is the *implicit context* — it never appears in the permission string itself. | Permission (catalog string) | Meaning | |---|---| | `user:read` | Read user list/detail | | `user:write` | Create/edit users | | `user:admin` | Resource-wide bypass for all user actions | | `oauth-client:read` | Read OAuth clients | | `oauth-client:write` | Create/edit OAuth clients | | `permission-role:read` | Read roles | | `authorization-group:write` | Create/edit groups | | `login-provider:read` / `:write` | Login-provider management | | `auth-log:read` | Read the auth log | | `gdpr:admin` | Permanent-erase GDPR operations | | `realm:read` / `realm:write` | Realm CRUD (control-plane app) | | `realm:admin` | **Realm-wide bypass** (every app, every resource, every action) | `realm:admin` is the one exception: it's a *realm-constant* — a PermissionRole carries `IsRealmAdmin = true` and the resolver injects the literal grant. It bypasses every check anywhere in the realm. ## Bypass tiers Only **two** tiers, both checked by `PermissionEvaluator`: | Grant | Effect | |---|---| | `realm:admin` | Everything in every app — the realm-wide emergency exit | | `:admin` | All actions on that resource in the calling app | `Evaluate(grants, "user:read")` returns true when: 1. the user holds `realm:admin`, **or** 2. the user holds `user:read` directly, **or** 3. the user holds `user:admin` (resource-wide bypass). There is **no app-wide bypass tier** (`:admin` was discussed but never shipped — bypass is either realm-wide or resource-wide, nothing in between). Per-area owners typically get per-resource `:admin` (e.g. an OAuth owner gets `oauth-client:admin` + `oauth-scope:admin` * `oauth-api:admin`, but not `user:admin`). The canonical evaluator implementation lives in `Modgud.Permissions.Abstractions/PermissionEvaluator.cs` — pure, no I/O, reused on both ends of the wire. ## Resources ### `modgud` app catalog (realm-internal — every realm) | Resource | What for | |---|---| | `app` | App registration management | | `user` | User management (`ApplicationUser`) | | `permission-role` | Role management | | `authorization-group` | Group management | | `session` | Per-user session management | | `service-account` | Service-account identity layer | | `auth-log` | Read AuthLog | | `gdpr` | Permanent-erase GDPR operations | | `oauth` | OAuth admin surface umbrella | | `oauth-client` | OAuth client management | | `oauth-scope` | OAuth scope management | | `oauth-api` | OAuth API resource management | | `login-provider` | Internal/external login providers | | `realm-settings` | Per-realm settings (self-reg, DCR, branding) | | `asset` | Asset library | | `observability` | Read-only observability view | | `scheduled-job` | Quartz scheduled job admin | | `inbox-settings` | Inbox retention configuration | ### `control-plane` app catalog (only on the Control-Plane realm) | Resource | What for | |---|---| | `realm` | Realm CRUD (`/api/admin/realms/*`) | The `control-plane` slug is intentionally decoupled from the product name. If the IdP is ever rebranded, cross-realm permissions don't need a migration. ## How resource servers receive permissions Resource servers read permissions from a standard OIDC UserInfo call. For each audience (`aud`) the access token names, Modgud emits a **`resource_access`** block on `/connect/userinfo`, Keycloak-style: ```json { "sub": "…", "resource_access": { "billing-api": { "permissions": ["invoice:read", "invoice:write"], "roles": ["billing-owner"] }, "shipping-api": { "permissions": ["shipment:read"], "roles": [] } } } ``` What's in the block: * **Bypass-pre-expansion** — `realm:admin` is expanded server-side into every concrete catalog string of every reachable App; a `:admin` bypass is expanded into every `:*` string in the App's catalog. Consumers do straight exact-match — no PermissionEvaluator port required client-side. * **Per-RS-subset narrowing** — each audience block is narrowed to `OAuthApi.PermissionIds` (the catalog subset the resource server declared as its gating surface). Anything outside that subset is excluded — no permission strings leak from one microservice into a sibling's block. * **Roles vs Permissions** are gated by separate scopes (`roles`, `permissions`) — request `scope=permissions` to see the permissions block; without it you get just the roles list. The `Modgud.Client.AspNetCore` helper lib's `IClaimsTransformation` flattens the matching audience block onto the principal so standard ASP.NET Core `[Authorize(Roles="…")]` and `RequiresPermission(…)` work out of the box. ## Backend gating: `RequiresPermission` Endpoints gate via a `RouteHandlerBuilder` extension: ```csharp app.MapGet("/api/user", async (...) => { ... }) .RequiresPermission("user:read"); app.MapPost("/api/user", async (...) => { ... }) .RequiresPermission("user:write"); app.MapPost("/api/admin/realms", async (...) => { ... }) .RequiresPermission("realm:write"); // control-plane app context ``` The filter (`PermissionEndpointFilter`): 1. Reads `ClaimTypes.NameIdentifier` from `HttpContext.User` 2. Resolves the user's effective permissions via `IPermissionService.GetUserPermissionsAsync(userId, appSlug)` (BFS through groups, BoundTo-filtered, role-filtered by AppSlug, already bypass-pre-expanded) 3. Calls `PermissionEvaluator.Evaluate(grants, "user:read")` ## Frontend gating: sidebar + buttons The `auth.store.ts` (Pinia) loads the effective permissions of the current user at login and uses the same evaluator logic: ```typescript // grants: string[] e.g. ["user:read", "user:write"] function hasPermission(needed: string): boolean { const grants = permissions.value if (grants.includes('realm:admin')) return true // tier 1 if (grants.includes(needed)) return true // exact match const parts = needed.split(':') if (parts.length === 2) { // tier 2 if (grants.includes(`${parts[0]}:admin`)) return true } return false } ``` Sidebar items in `views/admin/AdminView.vue` declare which permissions make them visible: ```typescript const allNavItems: NavItem[] = [ { section: 'authorization', label: 'nav.users', icon: 'users', path: '/admin/users', requirePermissions: ['user:read'] }, { section: 'oauth', label: 'admin.oauthClients.title', icon: 'app-window', path: '/admin/oauth/clients', requirePermissions: ['oauth-client:read'] }, { section: 'system', label: 'admin.realms.title', icon: 'globe', path: '/admin/realms', requirePermissions: ['realm:read'] }, { section: 'system', label: 'nav.settings', icon: 'settings', path: '/plattform/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 **Administratoren** 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] ``` Maintains users + groups + sessions, reads roles + auth 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` (admin maintains `MemberIds` directly) or `Auto` (a membership script determines the members dynamically). ## Manual mode ``` Group "Backend Team" MembershipMode: Manual MemberIds: [, , ] ``` Admins add and remove users via the UI. Nothing happens automatically. ## Auto mode ``` Group "Sales Department" MembershipMode: Auto MembershipScript: (p) => p.OrganizationalUnit === "sales" && 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`: ```typescript // Predicate form (p) => p.OrganizationalUnit === "engineering" && p.AccountName !== "service-account-bot" && p.IsActive // With externalClaims (from the most recent OIDC login): (p) => p.externalClaims.department === "Finance" ``` Translated by Cocoar.JsEval.Linq into an `Expression>` → SQL against `mt_doc_principal WHERE mt_doc_type = 'person'`. A single query returns the new `MemberIds`. ## Recompute triggers `AutoMembershipSyncHandlers` (Wolverine handlers) listen for person mutation events: | Event | Action | |---|---| | `UserCreated` | Check auto-groups whose script predicate matches → on match: add user | | `UserUpdated` | Check auto-groups → add or remove user based on the new state | | `UserDeleted` | Remove user from all auto-groups | | `GroupMembershipScriptChanged` | Full recompute pass for that one group | ## Dependency tracking (selective recompute) Auto-membership recompute is expensive if you do it on every heartbeat update of the user. Solution: per script, when saving, a **dependency set** of the read properties is calculated: ```typescript // Script (p) => p.OrganizationalUnit === "sales" && p.IsActive // Dependencies ["OrganizationalUnit", "IsActive"] ``` On `UserUpdated`, we check whether any field in the dependency set changed. If not → skip the recompute for that group. Example: a user updates `LastLoginAt`. `IsActive` and `OrganizationalUnit` are unchanged → the Sales group isn't checked at all, even though the `UserUpdated` event fired. ## Failure handling If the script throws (translator error or runtime error during compile), a `GroupMembershipRecomputeFailedEvent` with the error message is fired. The Group projection sets `MembershipLastError` and keeps the previous `MemberIds`. The admin sees the error in the group detail view. A successful recompute → `GroupMembershipRecomputedEvent` with the new `MemberIds`. `MembershipLastError` is set to `null`. ## 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) => p.OrganizationalUnit === "engineering" "Sales" (Auto) Script: (p) => p.OrganizationalUnit === "sales" ``` The permission BFS expands this without special-casing — `IPrincipalWithMembers` is polymorphic. Cycle detection via a visited set. ## Initial recompute When an admin creates a new auto-group (or changes the script), an initial full pass runs: ```csharp // IAutoMembershipRecalculator await recalculator.RecomputeAllMembersAsync(group, ct); ``` → a single SQL query against all person documents, with the script as a WHERE clause. Result → set `MemberIds` + fire event. At a million persons this could be slow — but modgud is currently sized for an order of magnitude well below that (mid-sized SaaS org charts, a few thousand users per realm). ## Example setup ``` Group "OU Sales" (Auto) Script: (p) => p.OrganizationalUnit === "sales" && p.IsActive Roles: ["Sales Read", "Customer Manager"] Group "Active Engineers" (Auto) Script: (p) => p.Department === "engineering" && p.IsActive && !p.AccountName.startsWith("svc-") Roles: ["Code Repo Reader", "CI Trigger"] ``` When a new sales user is provisioned via OIDC login: 1. `UserCreated` with `OrganizationalUnit=sales` fires 2. `AutoMembershipSyncHandlers` evaluates both auto-scripts: * "OU Sales" matches → user is added to membership * "Active Engineers" doesn't match → no effect 3. `GroupMembershipRecomputedEvent` fires for "OU Sales" 4. SignalR notification to all admin browsers → the group list in the frontend updates automatically (via `useEntityService` subscriptions) 5. The user automatically inherits all permissions of "OU Sales" → can see customer data immediately, without anyone clicking anything --- --- url: /concepts/abac.md --- # ABAC and the IAM boundary **Modgud is a pure RBAC + grouping IAM.** It does **not** evaluate row-level access policies on behalf of consuming apps. ABAC ("can user X read row Y?") is the responsibility of the app that owns the data. This page explains where the line is, why it sits there, and how an app can layer ABAC on top of what Modgud provides. ## What Modgud gives you * **Identity** — who the user is, with their stable id and verified contact info. * **Groups** — organisational membership, including transitive sub-groups, manual or auto-managed. * **Roles** — bundles of `:` permissions inside one App's catalog (the App context is implicit). * **Resolution** — a single decision per `(user, app, permission)`, propagated via the per-Audience `resource_access` block on `/connect/userinfo`. That's the whole authorisation surface from the IAM. Every grant the IAM emits is **schema-free**: there is no `tenantId`, no `ownerId`, no row-level filter. Only "user X holds permission `app:resource:action` in app A". ## What ABAC needs that the IAM cannot give ABAC questions are about *attributes of the protected row*: "is the user the owner?", "is the row in the same tenant?", "is the project not archived?". The fields those questions read live in the **consuming app's schema** — the IAM has never seen them and shouldn't, because: 1. **Schema drift.** If the IAM held an access script that reads `row.tenant`, every change to the app's data shape becomes an IAM change. The IAM stops being a stable contract and turns into a satellite of every app it serves. 2. **Operational coupling.** An app team can't ship a model change without coordinating with whoever maintains the IAM scripts. 3. **Boundary erosion.** "What is row-level access?" is a domain question. Once the IAM starts answering it for one app, every other app expects the same — the IAM owns more and more domain logic until it stops being an IAM. So the rule is simple: **anything that names a field of an app row stays in that app**. ## Three profiles for app teams How an app actually does ABAC depends on what its admins need to configure. ### Profile 1 — IAM-only (no ABAC) The app's permission model is fully expressible as `(role × resource × action)`. The IAM token is enough; the app does plain `[Authorize(Roles = "Editor")]` checks. This is the right default. Most apps live here. **When this stops being enough:** the moment your endpoint logic asks "*which* todos may this user see?" — that question reads `todo.responsibleId == user.Id`, which is row data, so it leaves the IAM. ### Profile 2a — Code-static ABAC The row-level rules are domain logic, not configuration. They live in the app's code as ordinary `WHERE` clauses or specifications: ```csharp var visible = db.Todos.Where(t => t.OrgId == user.OrgId && (t.OwnerId == user.Id || t.IsPublic)); ``` No JsEval, no scripts, no admin-editable predicates. If the rule changes, you ship code. This covers the vast majority of "I need ABAC" cases. Reach for Profile 2b only when admins genuinely need to author predicates without a developer in the loop. ### Profile 2b — Admin-editable ABAC (local groups + IAM mapping) Common in enterprise IAMs: the IAM hands out *coarse* group membership, and the app keeps its own *narrow* groups whose membership is wired to the IAM groups. Active Directory has done this for decades. ``` IAM (Modgud) App ───────────────── ─── Group "All Editors" ─maps→ LocalGroup "Editors of Vienna Office" + ABAC: row.officeId == "vienna" ``` The app stores its own JsEval (or any script-engine) policies on its own group entities, evaluates them at query time, and treats the IAM group as a membership condition. The IAM stays out of the row-data conversation entirely; the app's admin UI is what surfaces the predicate authoring. This is essentially Profile 2a with admin-pluggable predicates. The infrastructure (script engine, dependency tracker, recompute pipeline) is the same shape as `MembershipScript` in this repo — copy that pattern when you need it. ## What about membership scripts in Modgud? Group **membership scripts** (`MembershipMode = Auto`) stay. They're not ABAC: they decide *who is in this IAM group*, using only fields the IAM itself owns (display name, email, IsActive, external identities). No app schema is involved, no schema drift, no boundary violation. The distinction is: | Question | Where it lives | | --- | --- | | "Is this user in the *Vienna* IAM group?" | IAM (membership script — uses IAM-owned fields only) | | "Can this user see *this todo row*?" | App (Profile 1, 2a, or 2b) | ## Practical guidance * Start every app at Profile 1. Don't reach for ABAC until the IAM token genuinely cannot answer the access question. * When you need ABAC, default to Profile 2a (code). It's faster to build, faster to test, and there is no second policy store to back up. * Only adopt Profile 2b when admins must author predicates without a developer round-trip. That's a real but narrow need. * Whatever profile you're in, the IAM token is still the source of identity and coarse roles. The app composes its row-level decisions on top. ## Why this matters for Modgud Keeping ABAC out of the IAM is what lets Modgud serve many apps with stable contracts. Adding row-level scripts back in would re-couple the IAM to every consumer's schema and turn it into a brittle, app-specific service. The boundary is intentional, not a missing feature. --- --- url: /concepts/oauth.md --- # OAuth 2.0 & OpenID Connect ## Overview Modgud is a full-fledged OAuth 2.0 authorization server and OpenID Connect provider. Implemented via **OpenIddict 7** with its own Marten-based stores (`MartenApplicationStore`, `MartenScopeStore`, `MartenAuthorizationStore`, `MartenTokenStore`) — no Entity Framework. Terminology (Client, Scope, API, Grant Type, token types) in the [Glossary](/concepts/glossary#oauth-oidc-begriffe). ## The three actors | Actor | Role | Example | |---|---|---| | **User** | The person signing in | Someone using your app | | **Client** | The application requesting access | SPA, mobile app, backend service | | **API** | The protected service | A billing API, an order API | Modgud sits in the middle — it authenticates the user, issues tokens to the client, and the API verifies the tokens. ## Supported flows ### Authorization Code + PKCE (for user apps) Standard for web apps, SPAs, mobile. PKCE (Proof Key for Code Exchange) is **enforced** (`RequireProofKeyForCodeExchange`). ```mermaid sequenceDiagram participant App as Client App participant Auth as modgud participant User App->>Auth: GET /connect/authorize
(client_id, code_challenge, scopes) Auth->>User: Login + 2FA (if not yet) User->>Auth: Sign-in Auth->>Auth: Consent (implicit or explicit) Auth->>App: Redirect with ?code=... App->>Auth: POST /connect/token
(code + code_verifier) Auth->>App: access_token + id_token + refresh_token ``` ### Client Credentials (for services) Machine-to-machine. The service authenticates directly with client ID + secret, no user involved: ```http POST /connect/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials client_id=my-service client_secret=... scope=billing.read ``` ### Refresh Token Enabled for clients that request `offline_access`. Refresh tokens are reference tokens, stored server-side in `OpenIddictTokenDocument`. ### Device Code (RFC 8628) For devices with no browser or limited input — CLIs, TVs, input-constrained appliances. The client polls `/connect/token` while the user completes the flow on a separate device. Endpoint shape + verification UI documented in [Reference → OAuth API](/reference/oauth-api). ## 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. ::: 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. `AuthorizationController` checks for existing permanent authorizations 2. If none → redirect to `/consent?returnUrl=...` 3. `ConsentController` shows scope details and processes the decision 4. Approved scopes are 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**. Standard OIDC scopes default to public; custom App and API scopes default to private. 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: `modgud:oauth-client:read/write/delete`, `modgud:oauth-scope:read/write/delete`, `modgud:oauth-api:read/write/delete`. Per-resource admin bypass via `modgud:oauth-client:admin` etc. --- --- 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 mandates DCR for the "agent attaches to a tool server" handshake, because the universe of agents is open-ended and growing weekly. 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. ## 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. * [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 that mandates DCR for agent attachment. --- --- 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/account/sessions DELETE /api/account/sessions/{id} DELETE /api/account/sessions ``` ### Admin variant ```http GET /api/admin/users/{id}/sessions DELETE /api/admin/users/{id}/sessions # Force logout ``` Admin needs `modgud:user:read` or `modgud:user:write` (or the `:admin` bypass). ## OAuth tokens When an external app authenticates a user via OAuth, it receives tokens. Three kinds: ### Access Token What the app sends to the API to prove access. Configured per client as one of two formats: | Format | Looks like | API validation | |---|---|---| | **Reference** (default) | Opaque string — not decodable | API calls modgud's introspection endpoint | | **JWT** | Signed JSON token — decodable | API verifies the signature locally | * **Short-lived** — typically 60 min (configurable per client) * **Reference tokens are revocable instantly** — JWTs only via expiry ### Identity Token A signed JWT that tells the client **who is signed in**. Contains user info per the granted scopes (name, email, roles). Read by the client, not sent to APIs. ### Refresh Token Lets the app fetch new access tokens without signing the user in again. Only issued when `offline_access` is granted. * Long-lived (days to weeks, configurable) * **Single-use with rotation** — every use returns a new refresh token and invalidates the old one * Revocable at any time ## 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 | ## 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: /operate/deployment.md --- # Docker & deployment ## Prerequisites | Dependency | Version | Purpose | |---|---|---| | .NET | 10.0+ | Backend runtime | | PostgreSQL | 17+ | DB (document + event store + per-tenant DBs) | | Node.js | 22+ | Frontend build | | Docker | 20+ | Container runtime | ## Configuration Modgud uses **Cocoar.Configuration v5** 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`, `PublicUrl`, `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:` — `Issuer`, `*LifetimeMinutes`, `DevelopmentMode`, `SigningCertificatePath` | ### Example `configuration.json` ```json { "AppUrl": "http://0.0.0.0:80", "PublicUrl": "https://auth.example.com", "DbSettings": { "ConnectionString": "Host=postgres;Port=5432;Database=;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": { "Issuer": "https://auth.example.com", "AccessTokenLifetimeMinutes": 60, "RefreshTokenLifetimeDays": 14, "AuthorizationCodeLifetimeMinutes": 5, "DevelopmentMode": false } } ``` ::: 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. When additional realms are created, modgud appends `_` to that name for each tenant DB (e.g. for a master DB called `auth`: `auth_acme`, `auth_finance`). ::: ## Docker image The official Docker image bundles backend (.NET) + the built Vue SPA (as static `wwwroot/` content). ``` ghcr.io/cocoar-dev/modgud:latest # Latest production release ghcr.io/cocoar-dev/modgud:1.0.0 # Specific version ``` Multi-arch: **linux/amd64** + **linux/arm64**. ### Quick start The minimum production-shape config is **three environment variables** plus a persistent volume for auto-generated certs: ```bash docker run -d \ --name modgud \ -p 80:8081 \ -v cocoar-keys:/app/data/keys \ -e DbSettings__ConnectionString="Host=your-postgres;Database=;Username=postgres;Password=..." \ -e OpenIddict__Issuer="https://auth.example.com" \ -e ProxyAllowedNetworks="10.0.0.0/24" \ ghcr.io/cocoar-dev/modgud:latest ``` What each one does: * **`DbSettings__ConnectionString`** — Postgres master DB. Realms get per-tenant DBs auto-provisioned with the slug appended. * **`OpenIddict__Issuer`** — public HTTPS URL of the IdP. C2 boot validation rejects `http://` or `localhost` here in Production. * **`ProxyAllowedNetworks`** — comma-separated CIDR list of reverse- proxy IPs. Required so `X-Forwarded-Proto` is honoured for cookie-Secure decisions; everything else is rejected. Everything else has sensible defaults: * `ASPNETCORE_ENVIRONMENT` defaults to `Production` (set in the image). * `AppUrl` defaults to `http://0.0.0.0: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. The `cocoar-keys` volume mount above persists them across container restarts so issued tokens stay valid. * `OpenIddict__DevelopmentMode` defaults to `false` (production shape — real signing keys, transport-security required). ::: warning ENV variable casing Cocoar.Configuration's environment-variable provider serializes ENV keys 1:1 into the JSON map that's deserialized into the config types, and the deserializer is **case-sensitive** on the property side. ENV keys must match the C# property casing exactly: * ✅ `DbSettings__ConnectionString`, `OpenIddict__Issuer`, `Email__Smtp__Host` * ❌ `DBSETTINGS__CONNECTIONSTRING` — silently fails to bind, the property stays at its class default Two underscores (`__`) are the section separator. Single underscore is literal. The full list of bindable settings is in the Settings classes table above. ::: ### First-time bootstrap The system realm is seeded automatically with the localhost-style domains `["system.localhost", "localhost", "127.0.0.1"]`. To make the public hostname route to the system realm, add it via the Recovery CLI, then restart so the in-process realm cache picks up the change: ```bash docker exec modgud dotnet Modgud.Api.dll \ recover realm-add-domain --slug system --domain auth.example.com # The CLI runs as a separate process; the running server's realm # cache doesn't see the change until restart: docker compose restart auth ``` Then create the first admin user (the `system` slug is the default, so `--realm system` is implicit): ```bash docker exec modgud dotnet Modgud.Api.dll \ recover bootstrap-admin \ --email admin@example.com --username admin --password 'StrongPass1!' ``` Open `https://auth.example.com/` in the browser and sign in. ### Docker Compose (full stack) ```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:latest ports: - "80:8081" # Kestrel listens on 8081 in the image; map to 80 environment: DbSettings__ConnectionString: "Host=postgres;Database=;Username=postgres;Password=postgres" OpenIddict__Issuer: "https://auth.example.com" ProxyAllowedNetworks: "10.0.0.0/24" # adjust to your reverse proxy CIDR # Email is optional but recommended — magic-link, forgot-password, # invite, email-OTP all need a working SMTP relay. mailpit is fine # for Beta; 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 auto-generated certs 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 OpenIddict__Issuer: "https://auth.example.com" 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). ::: ### 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:80; 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:80; 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 + AuthLog * **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. Master DB is created if missing (`CREATE DATABASE`) 2. Marten schema is applied (idempotent) 3. System tenant is registered in `realms.mt_tenant_databases` 4. Marten schema is applied again (per-tenant tables for the system tenant) 5. System realm document is seeded 6. Default scopes + internal LoginProvider are seeded 7. RealmCache is warmed up Additional realms are only created at runtime via `POST /api/admin/realms`. ::: warning Multi-pod deployments When several modgud instances boot in parallel, schema apply can race. In practice this is not an issue today (Marten is idempotent + Postgres locks help), but for very large setups a separate migration phase is preferable: `AutoCreate.None` in the pods + a `migrate` sidecar/job that applies the schema once before the pod rollout. ::: ## Health check ```bash curl http://localhost/health ``` Returns `200` if the master DB connection is OK. Skip path — no realm routing required. ## SignalR Modgud pushes live updates over `/signalr/ui` (typed RPC via SignalARRR). Reverse proxies need upgrade headers (see above). The connection is auth-gated — the user must be logged in before it's established. ## Security headers Modgud doesn't set its own security headers — that's the job of the reverse proxy or a fronting WAF. Recommendations: ``` X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() Strict-Transport-Security: max-age=31536000; includeSubDomains ``` ## Email provider Modgud ships two outbound providers — pick whichever your infrastructure already gives you. Switch between them by flipping `Email__Provider`; the unused section is ignored. ### SMTP ```yaml environment: Email__Provider: "Smtp" Email__Smtp__Host: "smtp.example.com" Email__Smtp__Port: "587" Email__Smtp__UseSsl: "true" Email__Smtp__UserName: "noreply@example.com" Email__Smtp__Password: "${SMTP_PASSWORD}" Email__Smtp__FromAddress: "noreply@example.com" Email__Smtp__FromName: "Modgud" ``` ### Postmark ```yaml environment: Email__Provider: "Postmark" Email__Postmark__ServerToken: "${POSTMARK_TOKEN}" Email__Postmark__FromAddress: "noreply@example.com" Email__Postmark__FromName: "Modgud" Email__Postmark__MessageStream: "outbound" # default; e.g. "broadcast" for bulk-streams ``` ### Dev In Development env, an `InMemoryEmailService` is registered in addition that keeps mails in memory — the `/api/dev/emails` endpoint shows them. Useful for E2E tests in Docker without an SMTP relay. ### No email configured The container keeps running (magic-link / forgot-password / invite simply fail to send), but the logger warns at boot. Email is **optional** in the sense of "the host won't crash without it" — but every user-facing recovery flow needs it, so configure something before you go live. ## Recovery CLI in the container The Recovery CLI runs the same binary in command mode instead of starting Kestrel — pass `recover ` to `dotnet Modgud.Api.dll`. The CLI is for two situations: 1. **First-time bootstrap** — set up the system realm's public domain and create the first admin (covered in [Quick start](#quick-start) above). 2. **Break-glass recovery** — all admins locked out, 2FA reset, projection rebuild. Reference (`docker exec modgud dotnet Modgud.Api.dll recover help` prints the same): | Verb | Purpose | |---|---| | `list` | List all users (UserName · Email · Active · Admin · 2FA · Passkeys) | | `reset-2fa ` | Disable TOTP + Email-OTP + delete all Passkeys | | `set-email ` | Update the user's email address | | `magic-link ` | Generate a one-time login URL and print it | | `bootstrap-admin --email --username [--password]` | Create the first admin in a realm. With `--password` direct mode; without, invite mode (prints magic-link URL). | | `realm-list` | Show every active realm with its slug and domains. | | `realm-add-domain --slug --domain` | Add a domain to a realm's `Domains` list. After running, restart the container so the in-process realm cache picks up the change. | | `realm-remove-domain --slug --domain` | Remove a domain. Same restart requirement. | | `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 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.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 (acme copies)"] Authn[Modgud.Authentication
Login, 2FA, OIDC, GDPR] Authz[Modgud.Authorization
Groups, Roles, Permissions] end subgraph Infra ["Modgud.Infrastructure"] Tenancy[TenantedSessionFactory
+ MasterTableTenancy] OpenIddictStores[Marten OpenIddict stores
Application/Scope/Auth/Token] Realms[RealmCache + RealmProvisioning] IGlobalStore[IGlobalStore - Realm documents] end subgraph DataLayer ["Marten + PostgreSQL"] Master[(Master DB
+ realms.mt_tenant_databases
+ global schema)] TenantA[(_acme)] TenantB[(_finance)] end SPA <-->|Cookie + SignalR| MW MW --> Endpoints MW --> Hub Endpoints --> Authn Endpoints --> Authz Endpoints --> OpenIddictStores Authn --> Tenancy Authz --> Tenancy OpenIddictStores --> Tenancy Realms --> IGlobalStore Tenancy --> Master Tenancy --> TenantA Tenancy --> TenantB IGlobalStore --> Master Setup --> Master Setup --> Realms ``` ## Request lifecycle ``` Browser → ASP.NET Core ↓ UseRouting ↓ UseMiddleware ← sets HttpContext.Items["TenantId"] ↓ UseSession ↓ UseAuthentication ← cookie auth ↓ UseAuthorization ↓ UseMiddleware ← blocks users without 2FA at level ≥ 1 ↓ Endpoint routing ↓ Endpoint with RequiresPermission(...) ← per-resource gating ↓ Handler ↓ IDocumentSession ← TenantedSessionFactory reads TenantId ↓ Marten query against tenant DB ↓ Response ``` `TenantedSessionFactory` is registered as a Marten `ISessionFactory` (`AddMarten(...).BuildSessionsWith()`), so every `IDocumentSession`/`IQuerySession` injection is automatically tenant-scoped. ## Wolverine CQRS CQRS commands and queries are dispatched via Wolverine's `IMessageBus`: ```csharp var result = await _messageBus.InvokeAsync>( new CreateUserCommand(...)); ``` Handlers are auto-discovered. Modgud runs with `DurabilityMode.Solo` (in-memory, local) — no external message broker required. The Marten outbox is still active for event side-effects: SignalR notifications fire after `SaveChangesAsync` via `ProjectionSideEffects`. Codegen runs with `TypeLoadMode.Auto` — Wolverine/Marten generated classes are pre-generated at build time to save cold-start time and avoid Roslyn compilation at runtime. ## 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`, `WebAuthnChallenge` | Ephemeral challenges | | `IdpConfig` | OIDC IdP configuration | | `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 | | `LoginProviderState` | Internal/external login provider state | ### 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, ... | | `LoginProviderAggregate` | Created, Updated, Disabled, ... | 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 master DB** (raw SQL, because Marten can't do this while the connection hangs on a missing DB) 2. **Apply Marten schema** (`Storage.ApplyAllConfiguredChangesToDatabaseAsync`) → `realms.mt_tenant_databases` is created 3. **Register system tenant** (`tenancy.AddDatabaseRecordAsync("system", masterCs)`) 4. **Apply Marten schema again** → per-tenant tables for the system tenant 5. **Seed system realm document** (`EnsureSystemRealmExistsAsync`) 6. **Seed default OAuth scopes + internal login provider** (`OAuthRealmSeeder.SeedAsync`) 7. **Warm up RealmCache** Only after this does Kestrel start listening. ## Recovery CLI The Authentication slice ships a break-glass CLI. Instead of starting Kestrel, the image can run in the container with the `recover` subcommand: ```bash dotnet Modgud.Api.dll recover list dotnet Modgud.Api.dll recover reset-2fa dotnet Modgud.Api.dll recover set-email dotnet Modgud.Api.dll recover magic-link dotnet Modgud.Api.dll recover rebuild-projections ``` 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`). See the repo-only [Vue frontend notes](https://github.com/cocoar-dev/modgud/blob/develop/dev-docs/frontend.md) for the slice-internal detail. ## 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 * **WireMock** — fake OIDC server for external login tests * **Pre-generated Wolverine/Marten code** (`TypeLoadMode.Auto`) — eliminates Roslyn compilation at runtime --- --- 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. 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` | | `ApplicationRole` | Identity role | `NormalizedName` (unique) | | `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` | | `WebAuthnChallenge` | Passkey ceremony state | TTL ~5 min | | `IdpConfig` | OIDC IdP config (without secret) | Per realm | | `IdpSecret` | OIDC client secret (separate) | Per IdpConfig | | `OpenIddictAuthorizationDocument` | OAuth consent records | `ApplicationId`, `Subject` | | `OpenIddictTokenDocument` | Reference tokens, refresh tokens | `ApplicationId`, `Subject`, `ReferenceId` | | `AuthLogDocument` | Auth events (login, logout, failures) | TTL 7 days | | `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 | | `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 | | `LoginProviderStateProjection` → `LoginProviderState` | `LoginProviderAggregate` | Login provider resolution | | `PrincipalProjectionBase` → `Principal` (polymorphic) | abstract — app extension | Authorization slice | | `PermissionRoleProjection` | Permission role aggregate | Authorization slice | | `IdpConfigProjection` → `IdpConfig` | IdpConfig aggregate | OIDC login | | `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 | ## Event-stream example User lifecycle (written by the Authentication slice): ``` Stream: v1: UserCreated { UserId, UserName, Email, ... } v2: UserPasswordChanged { UserId } v3: UserLoggedIn { UserId, IpAddress, OccurredAt } v4: UserNameChanged { UserId, NewFirstName, NewLastName } v5: UserTwoFactorEnabled { UserId } v6: UserLoggedIn { UserId, IpAddress, OccurredAt } ... ``` `PrincipalProjectionBase` (abstract) consumes these events and writes them into the `mt_doc_principal` table as the `Person` subclass. That's the bridge to the Authorization slice: the slice needs `Person` records for email routing and membership predicates, the app fills them from the events. ## Security data separation **Security-sensitive data does NOT land in the event stream.** Instead of `UserPasswordChanged(UserId, NewPasswordHash)` there's `UserPasswordChanged(UserId)` and the hash is written in parallel into `UserSecurityData` (plain document, same id). Same approach for: | Data | Where | |---|---| | Password hash | `UserSecurityData.PasswordHash` | | TOTP authenticator key | `UserSecurityData.AuthenticatorKey` | | Recovery codes | `UserSecurityData.RecoveryCodes` | | Passkey credentials (public key, sign count) | `StoredPasskeyCredential` (separate doc, per user) | | OIDC client secret | `IdpSecret` (separate doc, per IdpConfig) | 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 usernames/emails must be reusable after a soft-delete. Solution: **filtered unique indexes** with PostgreSQL partial indexes: ```csharp schema.For() .UniqueIndex(UniqueIndexType.DuplicatedField, "NormalizedUserName", u => u.NormalizedUserName) .Where(u => u.IsDeleted == false || u.IsDeleted == null); ``` In SQL: ```sql CREATE UNIQUE INDEX ... ON mt_doc_applicationuser ((data ->> 'NormalizedUserName')) WHERE (data ->> 'IsDeleted')::boolean IS NOT TRUE; ``` This way usernames/emails can be reused immediately after soft-delete without colliding with active users. ## GDPR via Marten ### Data masking ```csharp options.Events.AddMaskingRuleForProtectedInformation(x => new UserCreated(x.UserId, "[DELETED]", "[DELETED]", null, null, null)); options.Events.AddMaskingRuleForProtectedInformation(x => new UserLoggedIn(x.UserId, "[DELETED-IP]", x.OccurredAt)); ``` 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_loginproviderstate` | Login provider inline projection | | `mt_doc_openiddicttokendocument` | Reference tokens, refresh tokens | | `mt_doc_openiddictauthorizationdocument` | OAuth authorizations (consent records) | | `mt_doc_idpconfig` | OIDC IdP configurations | | `mt_doc_authlogdocument` | Auth events (7-day retention) | | `mt_doc_usersession` | Active sessions | In the master DB additionally: | Table | Contents | |---|---| | `realms.mt_tenant_databases` | Marten tenant registry | | `global.mt_doc_realm` | Realm documents | --- --- url: /operate/realms.md --- # Multi-tenancy / Realms Modgud uses a **realm model** for multi-tenancy. Each realm is a fully autonomous Identity Provider with its own database, users, roles, OAuth configuration, and login providers. ::: info "Realm" vs. "tenant" User-facing it's called **realm** everywhere (UI, docs). The code uses **tenant** in the infrastructure layer (`TenantId`, `ITenantSessionFactory`, `MasterTableTenancy`), because that's what Marten/Wolverine call it. `TenantId` = realm slug. ::: ## Domain-based routing Realms are identified by the **Host header**, not by URL path. Each realm has one or more configured domains: | Hostname | Realm | |---|---| | `system.example.com` | System realm | | `acme.example.com` | Acme realm | | `auth.acme.example.com` | Acme realm (second domain) | | `localhost` (dev, single-realm) | System realm (single-tenant fallback) | `RealmMiddleware` (`src/dotnet/Modgud.Api/Middleware/RealmMiddleware.cs`) runs as the very first middleware: ```csharp public async Task InvokeAsync(HttpContext context) { var path = context.Request.Path.Value; if (SkipPaths.Any(p => path.StartsWith(p))) { await _next(context); return; } var hostname = context.Request.Host.Host; var tenantInfo = await _realmCache.ResolveDomainAsync(hostname); if (tenantInfo is null) { context.Response.StatusCode = 404; return; } context.Items[TenantConstants.HttpContextTenantIdKey] = tenantInfo.Slug; context.Items[TenantConstants.HttpContextTenantInfoKey] = tenantInfo; await _next(context); } ``` Skip paths: `/health`, `/swagger`, `/openapi`, `/_framework`, `/signalr` — these run without realm context. ### 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. ## RealmCache `RealmCache` (`Modgud.Infrastructure/Realms/RealmCache.cs`) holds a snapshot of the domain → realm mappings in memory: ```csharp private sealed record CacheSnapshot( ConcurrentDictionary ByDomain, TenantInfo? SingleActiveRealm); ``` Loads all active realms from `IGlobalStore` (see below) at startup. Invalidated on realm CUD (Create/Update/Delete via the admin API). ## Database-per-tenant via Marten Modgud uses Marten's `MasterTableTenancy`: ```mermaid graph TD subgraph Master["Master DB ()"] Tenancy["Schema: realms
realms.mt_tenant_databases"] GlobalSchema["Schema: global
(Realm documents)"] SystemTenant["System tenant data
(physically here)"] 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) + system tenant data | | `_` | A dedicated physical DB per additional realm | The **system tenant intentionally points at the master DB**. This way a single-realm installation only needs one DB. Multi-realm setups add more tenant DBs without migrating the system tenant. ## 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()); public IQuerySession OpenQuerySession() => _store.QuerySession(ResolveTenantId()); private string ResolveTenantId() => _httpContextAccessor.HttpContext? .Items[TenantConstants.HttpContextTenantIdKey] as string ?? TenantConstants.SystemTenantId; ``` Wired up via: ```csharp builder.Services.AddMarten(...) .BuildSessionsWith(); ``` This way every `IDocumentSession`/`IQuerySession` injection is automatically realm-scoped. Background services without an `HttpContext` fall back to the system tenant. ## 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); 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", ...] // Computed: the deployment's single Control Plane is the realm with // slug "system". The slug is reserved + immutable, so the property // is too — there's no separately persisted flag. public bool IsControlPlane => Slug == RealmSlugRules.SystemSlug; 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 master DB** (raw SQL) 2. **Apply Marten storage** → `realms.mt_tenant_databases` is created 3. **Register the system tenant in the tenancy table** (`tenancy.AddDatabaseRecordAsync("system", masterCs)`) 4. **Apply Marten storage again** → the system tenant gets per-tenant tables 5. **Seed system realm document** (`EnsureSystemRealmExistsAsync`) 6. **OAuthRealmSeeder** seeds 6 default scopes (`openid`, `email`, `profile`, `roles`, `offline_access`, `permissions`) + internal LoginProvider into the system tenant 7. **Warm up RealmCache** 8. **Check the recovery-CLI path** or start Kestrel ## 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 with slug `system`). On any other host: 404 (the existence of the surface is hidden from tenant realms — see [Concepts: Control Plane](../concepts/control-plane)). ### Create `POST` requires an `InitialAdmin` payload — a realm with no admin path would be unreachable. ```http POST /api/admin/realms { "Slug": "acme", "DisplayName": "Acme Corp", "Domains": ["acme.example.com"], "InitialAdmin": { "UserName": "max", "Email": "max@acme.com" } } ``` `IsControlPlane` is **not** in the request body — it's a computed read-only property derived from `Slug == "system"`. Backend: 1. Validates `slug` (regex, reserved-words check). 2. `CREATE DATABASE _acme` (raw SQL). 3. `tenancy.AddDatabaseRecordAsync("acme", connStringForAcme)`. 4. `Storage.ApplyAllConfiguredChangesToDatabaseAsync()`. 5. **`OAuthRealmSeeder`** seeds 6 default scopes + the Internal login provider into the new tenant DB. 6. **`AppRealmSeeder`** seeds the `modgud` app. The `control-plane` app is **only** seeded when the new realm is itself the Control Plane. 7. `Realm` document persisted in `IGlobalStore`. 8. `RealmCache.Invalidate()`. 9. **Bootstrap-invite** issued atomically: a `PendingAdminInvite` is written into the new tenant DB, the magic-link email is sent, and the URL is returned in the response (`InitialAdminInvite.MagicLinkUrl`). The recipient consumes the invite at `POST /api/account/bootstrap-admin` on the new realm's host (anonymous, rate-limited under `bootstrap`), sets a password, gets auto-signed-in. Atomic with that consume, `RealmAdminBootstrapper` creates the user, seeds the three default `PermissionRole`s (System Admin / User Manager / Viewer) and adds the user to the `Administratoren` 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 System realm The system realm cannot be deactivated — the endpoint blocks that. ::: ### Hard-delete ::: warning In progress Not currently implemented. Would need to drop the tenant DB cleanly, shut down the Wolverine durability agent for the tenant, invalidate sessions — see roadmap. ::: ## 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 in any non-Development environment. ::: 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://localhost:4317`) | Whatever the collector requires. Off by default; turn on when you actually have a collector (Tempo, Honeycomb, …). | | In-app live view | `/operate/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. | | Live push (SignalR) | `ObservabilityHub.Subscribe()` | Same. Streams new events for the subscriber's realm. The in-app view uses this — no polling. | ## 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 outside Development; empty = boot fails }, "Otlp": { "Enabled": false, // default off "Endpoint": "http://localhost:4317", // gRPC by default "Protocol": "Grpc" // or "HttpProtobuf" } } ``` ::: 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}. | | `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 `/operate/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. 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). --- --- 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, a legacy client needs a Phase-2C retrofit, etc. Every invocation is written to the auth log. ## Entry point ```bash dotnet Modgud.Api.dll recover [args...] [--realm ] ``` The `--realm` flag defaults to `system`. Commands that don't need a tenant context ignore it. ## Commands ### `list` List every active user with `UserName · Email · Active · Admin · 2FA · Passkeys`. ```bash dotnet Modgud.Api.dll recover list ``` `Admin` means the user holds `realm:admin` (typically via the System Admin role inside the seeded Administratoren group). ### `reset-2fa ` Disable TOTP and Email-OTP, delete every stored passkey credential, and clear the grace-period stamp so the user gets a fresh secure-setup window on next login. ```bash dotnet Modgud.Api.dll recover reset-2fa alice ``` ### `set-email ` Update the user's email and append a `UserUpdatedEvent` so projections * SignalR-driven admin grids refresh live. ```bash dotnet Modgud.Api.dll recover set-email alice alice@example.com ``` ### `magic-link ` Issue a one-time magic-link URL and print it to stdout. Useful for nudging a locked-out user back in without resetting their password. ```bash dotnet Modgud.Api.dll recover magic-link alice ``` ### `rebuild-projections` Rebuild all Marten projections (inline + async). Bootstrap path for the first migration after a breaking schema change — runs without any admin authentication. ```bash dotnet Modgud.Api.dll recover rebuild-projections ``` ### `bootstrap-admin` Create the first admin in a realm. Default realm: `system`. Two modes — **Direct** (password set immediately) and **Invite** (a magic-link URL is printed and emailed if SMTP is configured). ```bash # Direct mode dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin \ --firstname Admin \ --lastname User \ --password 'ChangeMe1!' # Invite mode (no --password) dotnet Modgud.Api.dll recover bootstrap-admin \ --email admin@example.com \ --username admin ``` Flags: | Flag | Required | Notes | |---|---|---| | `--email` | yes | Email — required in both modes. | | `--username` | no | Defaults to the local-part of the email. | | `--firstname` | no | Optional. | | `--lastname` | no | Optional. | | `--password` | no | If present: Direct mode. Validated against the configured Identity password rules. If absent: Invite mode. | | `--realm ` | no | Defaults to `system`. | ### `migrate-cc-credentials` Phase-2C retrofit. For every OAuth client that still has the `client_credentials` grant without a `LinkedServiceAccountId` (i.e. pre-Phase-2C clients), auto-provision a Service Account named `legacy.{clientId}` and backfill the link so the standard SA-managed mutation guard applies. Idempotent — already-linked clients are skipped; existing `legacy.*` SAs are re-used. ```bash dotnet Modgud.Api.dll recover migrate-cc-credentials --realm system ``` ### `realm-list` List every active realm with its slug and configured domains. Useful first probe after a fresh deploy — shows the system realm's seeded localhost domains so you know which Host header to use. ```bash dotnet Modgud.Api.dll recover realm-list ``` ### `realm-add-domain` Add a domain to an active realm's `Domains` list. Typically used once after a fresh deploy to add the production hostname to the system realm. ```bash dotnet Modgud.Api.dll recover realm-add-domain \ --slug system \ --domain auth.example.com ``` Flags: * `--slug ` — required. * `--domain ` — required. Stored verbatim; case-insensitive match at request time. ### `realm-remove-domain` Remove a domain from an active realm's `Domains` list. No-op if not present. ```bash dotnet Modgud.Api.dll recover realm-remove-domain \ --slug system \ --domain old.example.com ``` ### `help` Show the usage summary. ```bash dotnet Modgud.Api.dll recover help ``` ## Audit trail Every recovery invocation writes a `Auth: Recovery ` entry to the auth log via Serilog. Successful operations log at `Warning` level (to make them stand out in the log stream); failures log at `Error`. ## 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. * **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. 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/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. **Casing is case-sensitive** — see [the convention note below](#env-var-casing). ```jsonc // configuration.local.json { "AppSettings": { "Features": { "PageBuilder": true } } } ``` ```bash # or via env (PascalCase, double-underscore as section separator): AppSettings__Features__PageBuilder=true ``` Flags are read at startup; no hot-reload. A flip requires a restart. ## Current flags | Flag | Default | Effect | | --- | --- | --- | | `PageBuilder` | `false` | Visibility of the [Customization → Pages](../plattform/pages) editor. While off: sidebar tile hidden, `/plattform/customization/pages` routes redirect to Branding, `/api/admin/customization/pages/*` returns 404, `RealmSettingsDto` omits the Pages section. While on: editor mounts and persists. **Runtime rendering of stored schemas on /login etc. is a separate sprint and is not gated by this flag.** | ## Defense in depth For each gated feature the flag fires at every layer the surface touches: 1. **SPA sidebar** — the navigation entry is hidden via `requireFeature` in `AdminView.vue`. 2. **Vue-router** — `beforeEnter` guards on the gated routes redirect to a visible sibling so deep-links don't dead-end on a blank screen. 3. **Backend endpoints** — return **404 Not Found** (not 403 / 401) so curl-callers see "no such endpoint", not "permission denied". 4. **DTO masking** — surfaces that aggregate multiple sub-documents (e.g. `GET /api/admin/realm-settings`) emit the gated section as empty so the SPA can't fingerprint stored data. The stored data itself persists across flips — turning a flag off doesn't delete anything from the tenant DB. Flipping it back on surfaces the existing data unchanged. ## ENV-var casing Cocoar.Configuration v5 (the binding layer Modgud uses) reads environment variables **case-sensitively** with the same shape as the JSON keys. PascalCase only — `AppSettings__Features__PageBuilder=true`, **never** `APPSETTINGS__FEATURES__PAGEBUILDER`. The latter is silently ignored. 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 ### 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 ### Federation & Realms * [Login Providers](./login-providers) — built-in Internal plus external OIDC (Google, Microsoft, Entra, any OIDC); step-by-step setup walkthroughs included * [Realms](./realms) — multi-tenant setup; each tenant gets its own database * [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](../plattform/branding) — product name, primary color, logo, favicon * [Asset Library](../plattform/assets) — upload images for branding (and, later, page schemas); SVG sanitisation built in * [Pages (Beta)](../plattform/pages) — drag-and-drop editor for login / logout / forgot-password; gated behind a [feature flag](../operate/feature-flags) while the runtime renderer is still being built ### Operations * [Observability](../operate/observability) — OpenTelemetry metrics + tracing + in-app live activity feed * [Auth Log](./auth-log) — audit trail of all login events * [Change Requests](./change-requests) — approve profile changes (when the approval flow is enabled) * [Settings](../plattform/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 three-segment model Modgud manages permissions in the form **`app:resource:action`**. Examples: | Permission | Meaning | | --- | --- | | `user:read` | Read the user list in modgud | | `oauth-client:write` | Manage OAuth clients in modgud | | `todo:write` | Write todos in the acme-tasks app | | `realm:admin` | **Realm-wide bypass** — everything in any app | | `modgud:admin` | App-wide bypass for modgud | | `modgud:user:admin` | Resource-wide bypass for "user" in modgud | Three bypass tiers keep permission lists short: * **`realm:admin`** — realm-wide. Whoever holds it may do anything in any app. * **`:admin`** — app-wide. * **`::admin`** — resource-wide. ::: 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 `Administratoren` 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** — `modgud:oauth-client:*` + `modgud:oauth-scope:*` + `modgud: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.firma.at`, `tenant2.auth.firma.at`). ### Admin locked out [Recovery CLI](../operate/recovery-cli) — a shell tool inside the container that bypasses the UI and writes directly to the database. ## Real-time updates All admin lists refresh themselves automatically when another admin (or you in a second tab) changes something. This happens via SignalR push — no manual reload needed. --- --- url: /admin/users.md --- # Users Administration → **Users**. ![User list](/screenshots/admin-benutzer-liste.png) ## User list Columns: *Username*, *First name*, *Last name*, *Email*, *Active*, *2FA*, *Last login*. Filters: * **Search** across username, email, first/last name * **Status filter** — active / disabled / soft-deleted Double-click a row to open the detail dialog. ## Creating a user **Create** button at the top right. Required: * **Username** (unique, lower-case recommended) Optional but recommended: * **First name**, **Last name** * **Email** (without it, magic links and reset emails are impossible) * **Phone number** ::: 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, profile name, email, phone, username, **active flag**. ::: 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). ::: ### Security Overview of the user's security status: * **2FA methods** (TOTP / email-OTP / passkeys) with status and counts * **Last login** and IP * **Linked external accounts** (Google, Microsoft, …) * **Recovery codes remaining** Actions: * **Set password** — assign a new password (the user can still change it themselves later) * **Reset 2FA completely** — disable all methods, fresh grace period (see [Recovery CLI](../operate/recovery-cli)) * **Send sign-in link** (magic link via email) * **Lift lockout** — when the user has locked themselves out via too many failed attempts * **2FA enforcement override** — exempt this user from the global 2FA requirement (use sparingly, audited) ### Groups Assignment to [groups](./groups). Group membership determines roles and therefore permissions. You see: * **Static memberships** — added manually, removable here * **Auto memberships** — computed by membership scripts, can't be edited manually (the script decides) ### Sessions List of the user's current sessions. Per session: device, browser, IP, last activity. Actions: * **End single session** * **End all sessions** — force-logout, the user is signed out on every device and must sign in again ### IdP claims (when an external provider is linked) Raw and mapped claims from the user's most recent external login. Useful for debugging when SSO-side fields are missing or wrong. ## Unlocking a user After too many failed attempts, Modgud temporarily locks the account. List → right-click → **Lift lockout**, or in the security tab → **Unlock**. ## Soft delete vs. permanent erase Modgud uses **soft delete** by default — deleted users are flagged as deleted, but the records stay (for audit trail, projection rebuild safety, …). ### Soft delete List → right-click → **Delete**. Effect: * Account can no longer sign in * Marked as "deleted" in every UI * Data remains in the database * The auth log keeps the username — you can still tell who did what after deletion ### Restore List → filter "Show deleted" → right-click the deleted user → **Restore**. ::: warning Username must still be free If someone registered the same username in the meantime, the restore fails — restore them under a different name, or rename the conflict first. ::: ### GDPR permanent erase ::: warning Final — no restore Permanent erase is the actual deletion under GDPR Article 17 ("right to be forgotten"). Personally identifiable data is **masked** in events, the user record itself is **archived** and hidden from every list. There is no going back. ::: List → right-click on a (preferably already soft-deleted) user → **Permanent erase (GDPR)** → confirmation dialog → confirm. 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 * The auth log keeps the user ID for correlation but no cleartext PII When to use: * A GDPR delete request (usually the user does this via self-service "Delete account"; an admin only if the user can no longer sign in) * Compliance after employee departure + retention period * Data cleanup after test or demo setups ## 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: * `bwi` (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 at validation time — see `OAuthAdminService.cs` for the invariant guards. ::: 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`. ## Creating a Service Account 1. Open `/admin/service-accounts` and click **Create**. 2. Fill in: * **Account name** — lowercase letters, digits, dots, hyphens or underscores; 2-64 chars; starts with a letter or digit. This is the audit-log handle (`ci.build-agent`, `integrations.acme-tasks`, `nightly.sync`). Unique across the whole principal table — a Person and a ServiceAccount can't share an account name, because both can act as the login handle in different contexts. * **Purpose** (optional) — free text. Pure documentation; the authorization layer never reads it. 3. **Create**. The SA exists but has no credentials yet — services can't authenticate as it. The new account appears in the grid. Open it to manage credentials, group membership, and the active toggle (deactivating an SA causes its `/connect/token` requests to be refused immediately, even with otherwise-valid credentials). ## Issuing credentials (the OAuth client behind the SA) Credentials are managed exclusively from the **SA detail modal → Credentials** section. The global `/admin/oauth-clients` grid shows the resulting clients with an **M2M** column listing the linked SA name, but double-clicking an SA-managed row navigates straight back to the SA modal — there's only one place to edit them. To issue a credential: 1. Open the SA, scroll to **Credentials**, click **Issue credential**. 2. Pick the scopes the caller needs and (optionally) the Apps the credential is bound to. Grant types are system-pinned to `["client_credentials"]` and not user-editable. 3. **Save**. The server creates a confidential OAuth client with: * `client_id` auto-generated as `{AccountName}.{8-char-suffix}` (e.g. `ci.build-agent.k7f2x9n3`) * `client_secret` hashed in the database, shown **once** in a copy-to-clipboard panel with a "won't be shown again" warning * `LinkedServiceAccountId` pointing at this SA 4. Copy the secret into the caller's secret store (GitHub Action secret, Kubernetes secret, vault entry, …). Closing or refreshing the modal loses it permanently — at that point only **Rotate secret** can mint a new one. ### Rotate and delete * **Rotate secret** generates a fresh `client_secret`, invalidates the old one immediately, and surfaces the new one in the same one-time-display panel. Use it for periodic rotation or after suspected exposure. * **Delete** removes the OAuth client. Tokens issued before the delete remain valid until their natural expiry (Modgud does not currently revoke outstanding tokens at delete time); no new tokens can be minted. ### 1:N One SA can own multiple credentials. Useful for: * **Zero-downtime rotation** — issue a second credential, switch the caller, delete the old one. * **Per-caller scope narrowing** — `ci.build-agent.read` with `builds:read` only, `ci.build-agent.write` with `builds:write`; both log as `ci.build-agent`. * **Multiple environments under one identity** — dev-CI, staging-CI, prod-CI share the audit name `ci.build-agent` but hold independent secrets. `N:1` (multiple SAs sharing one OAuth client) is forbidden by the same `sub`-ambiguity argument that bans mixed-grant clients. ## How a caller gets a token Plain OAuth `client_credentials` — no SA-specific wire protocol: ```bash curl -X POST https://idp.example.com/connect/token \ -d "grant_type=client_credentials" \ -d "client_id=ci.build-agent.k7f2x9n3" \ -d "client_secret=" \ -d "scope=builds:write" \ -d "resource=https://acme-tasks.example.com" ``` The token endpoint loads the OAuth client, follows `LinkedServiceAccountId` to the SA, verifies the SA isn't deleted or inactive, and issues an access token whose `sub` is the SA's `Id`. ## Token contents For an SA-issued token: * `sub` — `ServiceAccount.Id` * `name` — `ServiceAccount.AccountName` * `scope` — exactly what was requested (and allowed by the linked client) * `resource_access` — per-audience `roles` and `permissions` blocks built from the SA's group/role/permission chain, embedded directly in the access token. The `client_credentials` flow has no UserInfo round-trip in practice, so the resource server gets everything it needs from the JWT itself. The shape mirrors what human tokens carry via UserInfo per audience. The downstream API validates the token, reads `sub`, and gates access exactly the same way it does for a Person — the permission evaluator doesn't care whether the principal is a Person or a ServiceAccount. ## Group and role membership A Service Account can be added to any group from `/admin/groups` the same way a Person can. JsEval auto-membership scripts can target SAs too: they receive `principal.type == "service-account"` and can branch on `accountName`, `purpose`, or any group/permission predicate. Concretely, granting permissions to a Service Account is the same three-step path as for a human: put it in a group, give the group a role, give the role the permissions it needs. There is no special "service-account-only role" — humans and machines pull from the same role catalogue. ## Audit log Every token issue, group membership change, credential rotation, and delete attributes to the SA's `AccountName`, not to the raw `client_id`. The auth log shows entries like `ci.build-agent triggered build` rather than `client_id=4f7a9b…e3 triggered build`. Downstream apps that log against `sub`/`name` claims get the same readable handle. ## Cascade delete Deleting a Service Account (`DELETE /api/service-account/{id}`) cascade-deletes every credential owned by the SA in one transaction, then soft-deletes the SA itself. The response includes `DeletedCredentialCount` so the UI can confirm the blast radius. Soft-delete (rather than hard-delete) keeps audit-log references resolvable — historical entries still hydrate the SA name. There is no "unlink credential" operation. The only way to detach a credential from its SA is delete-and-reissue under a different SA. ## Migrating pre-2C clients Realms that existed before the Service-Account-credentials feature shipped may still hold standalone `client_credentials` clients with no `LinkedServiceAccountId`. The token endpoint falls back to the legacy `sub = client_id` behaviour for these so production callers keep working, but their tokens skip the SA-derived `resource_access` block and they show up in audit as raw client IDs. To migrate them in one shot, run the recovery CLI: ```bash dotnet Modgud.Api.dll recover migrate-cc-credentials [--realm ] ``` For each un-linked `client_credentials` client the command auto-provisions a Service Account named `legacy.{clientId}`, links the client to it, and leaves a re-runnable trail (already-linked clients are skipped; existing `legacy.*` SAs are re-used). Defaults to the `system` realm; pass `--realm` to scope to a specific tenant. After migration, rename the SA from the admin UI or merge it into a properly-named one. ## Related * [OAuth Clients](./oauth-clients) — the global grid that lists user-facing clients alongside SA-managed credentials with an M2M column linking back here. * [Groups](./groups) — where SAs pick up roles and permissions. * [Applications](./applications) and [OAuth Scopes](./oauth-scopes) — the resources and scopes a credential's tokens can target. * [Auth Log](./auth-log) — filter for the SA's account name to see every action it has taken. --- --- url: /admin/roles.md --- # Roles A **role** bundles permissions for one app. Users receive roles only through their [groups](./groups) — never directly. ![Roles list](/screenshots/admin-rollen-liste.png) ## The permission model ``` User ↓ membership (transitive BFS) Group(s) ↓ does BoundTo contain the requesting app? (otherwise: dormant) active group(s) ↓ roles Role(s) (with AppSlug) ↓ filter: Role.AppSlug == requesting app? (or permission is fully-qualified) Permission(s) → app: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` with `AppSlug = "acme-tasks"`, 4. the role's permissions `read`, `write` on resource `todo` expand to `acme-tasks:todo:read`, `acme-tasks:todo:write`. ## Permission format: three segments Modgud manages permissions as **`app:resource:action`** strings: | Permission | Meaning | | --- | --- | | `modgud:user:read` | Read the user list in modgud | | `modgud:oauth-client:write` | Edit OAuth clients in modgud | | `acme-tasks:todo:write` | Write todos in the Acme-Tasks app | Plus three bypass tiers: * **`realm:admin`** — realm-wide. The holder may do anything in any app. * **`:admin`** — app-wide. * **`::admin`** — resource-wide. ## 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 — all under the system app `modgud`: | Role | App | Effect | | --- | --- | --- | | **System Admin** | modgud | holds the fully-qualified permission `realm:admin` → realm-wide bypass | | **User Manager** | modgud | `modgud:user:read/write` + `:session:read/write` + `:authorization-group:read` + `:permission-role:read` + `:auth-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 | | **session** | read, write | | **permission-role** | read, write | | **authorization-group** | read, write | | **oauth-client** | read, write | | **oauth-scope** | read, write | | **oauth-api** | read, write | | **login-provider** | admin, read, write | | **idp-config** | read, write | | **realm** | read, write | | **auth-log** | read | | **gdpr** | admin | External apps (Acme-Tasks, Knowledge, …) bring their own resources, defined in their App record. ## Creating or editing a role Administration → **Roles** → **Create**, or double-click an entry. ![Role detail](/screenshots/admin-rolle-detail.png) Fields: * **Name** (unique per realm) * **Description** (optional) * **AppSlug** — which app does this role belong to? Required. A role belongs to exactly one app. * **Resource Type** — together with AppSlug determines the permission prefix * **Permissions** — actions on the resource. With Resource Type `todo` and Permissions `["read", "write"]`, the role resolves to `:todo:read` and `:todo:write`. ### Multi-resource roles If you want a role to span several resources (e.g. "User Manager" covers user, session, authorization-group), **leave Resource Type empty** and write fully-qualified permissions in the list: ``` modgud:user:read modgud:user:write modgud:session:read modgud:authorization-group:read ``` Fully-qualified strings (containing `:`) pass through the resolver unchanged. The seeded System Admin / User Manager / Viewer roles are built exactly this way. ## Cross-app roles (special case) A role can also include fully-qualified permissions from **other** apps in its permissions list — for example a "Cross-App Auditor" with `modgud:auth-log:read` AND `acme-tasks:audit:read`. This works because fully-qualified permissions pass through without further filtering. In practice though: prefer two separate roles in two separate groups (each with their own BoundTo). Cleaner to understand and audit. ## Bypass roles A role becomes a bypass role when its permissions list contains an `admin`-shaped entry: | In the permissions list | Effect | | --- | --- | | `realm:admin` (fully qualified) | realm-wide bypass | | `:admin` | app-wide bypass | | `::admin` (Resource Type empty + fully qualified) | resource-wide | | `admin` (with Resource Type set) | resource-wide, AppSlug-prefixed | On setup exactly one user is seeded as realm admin (System Admin role + Administratoren group with `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 go under `AppSlug = "acme-tasks"`, not `modgud`. They show up in the right permission lists, and `[Authorize(Roles = "...")]` in the Acme-Tasks backend finds them via the `resource_access["acme-tasks"]` claim in the token. ::: --- --- url: /admin/groups.md --- # Groups Groups are the **organisational layer** in Modgud. They serve two distinct purposes — and you decide per-group which one applies via the **Bound to apps** field. 1. **Authorisation grouping.** Members of the group inherit the group's roles in the apps the group is bound to. 2. **Mailing-list / distribution semantics.** Even a group with no roles and no app binding can carry an email address, expand to its members, and be addressed by notification flows. A user can be a member of any number of groups; a group can be a member of another group (transitive resolution). ![Groups list](/screenshots/admin-gruppen-liste.png) ## Why groups? Roles answer "what may you do"; groups answer "who is this user, organisationally". Splitting the two means you can change a person's department without touching their permissions, or change a permission set without re-onboarding everyone. The strict path from user to permission is: ``` User → Group(s) → Role(s) → Permission(s) ``` Direct user-to-role or user-to-permission assignments don't exist. Membership-via-group is the sole route. ## Creating a group Administration → **Groups** → **Create**. Tabs in the detail dialog: | Tab | Content | | --- | --- | | **General** | Name, description, **Bound to apps**, 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 Members** | 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 * **Bound to apps** — *(MultiSelect)* which apps does this group take effect in? See below. #### Bound to apps — the activation switch A group can have members and roles without taking effect for permissions. The decision is in **Bound to apps**: | 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:** `[modgud]`. When creating a group for another app, remember to update the selection. ## 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: ```javascript // Example: "all users with @cocoar.io email" return p.Type === "person" && p.Email && p.Email.endsWith("@cocoar.io"); ``` The script is recompiled and re-evaluated whenever a principal is created or changed. The membership script only sees the fields the IAM itself owns (display name, email, IsActive, external identities, …) — never any app-specific data, since that would couple the IAM to every app's schema. See [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 Members** 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. ## Deleting a group List → right-click → **Delete**. ::: warning Soft delete Groups are soft-deleted. Users who were members keep the membership entry technically — but the group contributes nothing any more. To clean up properly, also remove the group from any parent groups first. ::: ## Email & notifications Groups can carry an email address (Tab General, optional). Notification flows can address `@…` and Modgud resolves the recipient list: * **Shared** mode — mail goes to the group's own address (a shared mailbox, distribution list) * **Expand to members** mode — mail goes to each member's individual email, recursively across nested groups Cycle-safe: a group `A` containing `B` containing `A` is detected; expansion stops at the first revisit. ## What happens when a user is in multiple groups? All rights are **unioned**. If you're in two groups with different roles, you hold the combined permissions. There's no priority between groups. If two groups bring different `BoundTo` lists, both are evaluated independently — the user is "active" in any app that any of their groups covers. --- --- url: /admin/oauth-clients.md --- # OAuth Clients An **OAuth client** is an app that signs in to Modgud as the identity provider and authenticates its own users via OAuth 2.0 / OpenID Connect. Examples: * A web app using Single Sign-On * A mobile app fetching tokens for its own API * A CLI tool with the device-code flow * A server-to-server job using client-credentials ![OAuth clients list](/screenshots/admin-oauth-clients.png) ## Relationship to Applications Every OAuth client can be linked to **zero, one, or more [Applications](./applications)** (n:m, multi-select dropdown in the detail modal). The link controls two things: 1. **Token contents** — on `/connect/userinfo`, the issued token carries a `resource_access` block per linked app, with the user's app-specific roles. Resource servers read their own block (Keycloak convention). 2. **Scope restriction** — the client may only request scopes that belong to one of its apps (or are global, like the OIDC standard scopes `openid`, `email`, `profile`, `roles`, `offline_access`). The default case is **one client → one app** (`acme-web` belongs to `acme`). Multi-app clients exist for bundle frontends that talk to several resource servers at once. ::: tip First time? Use the [SaaS App Integration Walkthrough](../integrate/saas-walkthrough) for the linear path through your first integration. ::: ## Creating a client Administration → **OAuth → Clients** → **Create**. ### 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 | Type | For | Secret? | | --- | --- | --- | | **Confidential (web)** | Server-side web apps (ASP.NET, Node, Rails) — can store secrets | Yes | | **Public (SPA / mobile)** | SPAs and mobile apps — can't safely store secrets | No, PKCE only | | **Service (machine-to-machine)** | Server-to-server, no user involved | Yes, client-credentials flow | ### Consent type | Type | Behaviour | | --- | --- | | **Implicit** | First-party app — no consent screen, immediate redirect | | **Explicit** | The user must click "Allow" once per scope set | | **External** | Consent is obtained out-of-band; Modgud doesn't intervene | ### Applications The **Applications** multi-select binds the client to one or more apps. Empty means realm-wide (no app context — good for a tool that genuinely doesn't belong to any specific app). Picking multiple apps means: when this client requests a token and asks for the `roles` scope, the issued token's UserInfo carries a `resource_access` block for each picked app. That's how multi-app frontends work. ### Redirect URIs One per line. Modgud strictly checks that the redirect URI presented in the auth request is one of these. For SPAs and mobile use a deep link (`com.example.app:/oauth/callback`) or a HTTPS callback page on your domain. ### Allowed grant types Comma-separated list. Common combinations: | Combo | Use case | | --- | --- | | `authorization_code, refresh_token` | Web SPA / mobile (with PKCE on public clients) | | `client_credentials` | Pure machine-to-machine | | `authorization_code, refresh_token, client_credentials` | Hybrid: user-acting most of the time, occasional service-token needs | ### Lifetimes Optional fields override the realm defaults: * **Identity Token Lifetime** — default ~5 min * **Access Token Lifetime** — default ~1 h * **Authorization Code Lifetime** — default ~30 s * **Absolute Refresh Token Lifetime** — default ~30 d * **Sliding Refresh Token Lifetime** — default ~7 d ## 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. ## Deleting List → right-click → **Delete**. Soft-deleted entries can still be queried for audit purposes but are excluded from the OAuth flow. ## Tips ::: tip One client per integration, not per environment Use a single client `acme-web` and configure multiple redirect URIs for prod/staging/dev — instead of three separate clients. Easier to maintain, fewer secrets to rotate. ::: ::: warning Don't share secrets A client secret is the proof a confidential client is legitimate. Don't paste it into source control, email it, or include it in JS bundles. Use environment variables / secret stores. ::: --- --- url: /admin/oauth-scopes.md --- # OAuth Scopes **Scopes** define what permissions an OAuth client may request from the user — and which resources (APIs) the resulting token may target. ![OAuth scopes list](/screenshots/admin-oauth-scopes.png) ## Standard scopes (seeded per realm) Every realm is seeded with these six scopes — they're created at realm provisioning and you don't need to manage them: | Scope | Contents | | --- | --- | | `openid` | Subject (user ID) — required for any OIDC request | | `profile` | First/last name, preferred username | | `email` | Email address + `email_verified` flag | | `offline_access` | Allows issuing refresh tokens | | `roles` | Triggers the `resource_access` block with the user's roles per Audience | | `permissions` | Triggers the `resource_access` block with the user's bypass-pre-expanded permissions narrowed to the calling RS's subset | The OIDC-standard `phone` and `address` scopes are recognised by OpenIddict but **not** auto-seeded — add them manually per realm if you need to expose those claims. ## Defining your own scopes For your own APIs/resources you define custom scopes — e.g. `acme.read`, `acme.write`, `crm.api`. Administration → **OAuth → Scopes** → **Create**. ### Fields * **Name** — the technical scope string, exactly as it appears in `scope=…` requests (e.g. `acme.read`) * **Display Name** — appears on the consent screen ("Read Acme") * **Description** — plain-language explanation on the consent screen ("Allows the Acme app to read your tasks") * **Application** — the [App](./applications) this scope belongs to. Empty = global (cross-app, like the standard OIDC scopes) * **Resources** — list of resource URIs (audience) for which tokens with this scope are issued ### Application binding App-scoped scopes can only be requested by OAuth clients whose `AppIds` list contains the same App. The standard OIDC scopes are global (`AppId = null`), so any client may request them. If a client requests an app-scoped scope it isn't entitled to, `/connect/authorize` rejects with `invalid_scope`. ### Resources (audience) A **resource URI** identifies the resource server (API) that accepts tokens. Example: * Scope: `acme.read` * Resource: `https://api.acme.example.com` When a client requests `scope=acme.read` and gets back an access token, the token's `aud` claim contains `https://api.acme.example.com` — the Acme API checks exactly that during token validation and rejects everything else. ::: warning Audience mismatch If the resource URI here is spelled differently from how the API checks during validation (e.g. `http` vs. `https`, trailing slash, port differences), every API request fails with `401 Unauthorized — invalid audience`. Keep both sides in sync. ::: ### Discovery visibility Every scope has a **`Show in discovery document`** flag. When `true`, the scope's name is listed in the realm's `/.well-known/openid-configuration` under `scopes_supported`. When `false`, the scope still works for normal client requests, but is not advertised publicly. * **OIDC standard scopes** (`openid`, `profile`, `email`, `offline_access`, `roles`, `permissions`) default to `true` — clients commonly read these from discovery. * **App / API scopes** (and implicit scopes auto-created from an [OAuth API](./oauth-apis)) default to `false` — clients learn these from the resource server's integration docs, not from discovery. Hiding them is a privacy-by-default measure that prevents drive-by enumeration of which APIs a tenant operates. ::: 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. ## Deleting a scope List → right-click → **Delete** (soft delete). ::: warning Active tokens stay valid Already-issued tokens carrying the deleted scope remain valid until their lifetime expires — deletion only affects newly issued tokens. For compromised scopes, also revoke active tokens or set the shortest practical token lifetime. ::: ## Tips ::: tip Scope granularity A rule of thumb: one scope per semantic operation, not per endpoint. Example: * good: `acme.read`, `acme.write`, `acme.admin` * bad: `acme.task.list`, `acme.task.detail`, `acme.task.create`, `acme.task.update`, … Too granular = the consent screen becomes unreadable. Too coarse = apps need more power than they should. ::: ::: tip Dot namespacing Convention: name scopes `.` (`acme.read`, `crm.write`). Makes it obvious in consent screens and token inspectors which scope belongs to which API. ::: --- --- url: /admin/oauth-apis.md --- # OAuth APIs (Resource Servers) An **OAuth API** in Modgud is the registration of a **resource server** — an API that wants to validate access tokens issued by Modgud and use them to authorise requests. ::: info OAuth API vs OAuth Client * **OAuth Client** = the app that performs the user login and **gets** tokens * **OAuth API** = the API that **validates** tokens and authorises requests against them An app can be both (e.g. a BFF pattern: user-login as a client, its own API as an API). ::: ![OAuth APIs list](/screenshots/admin-oauth-apis.png) ## When do I need an OAuth API registration? For most cases — a SaaS app that validates Modgud tokens — yes, you register an OAuth API for it. The registration is what lets Modgud emit a tailored `resource_access` block for this RS on `/connect/userinfo`. Specifically, it's required when: * You want **per-Audience permission narrowing** in `resource_access` blocks. The RS declares its `PermissionIds` subset of the App's catalog, and the IdP narrows each user's emission to that subset. * The API wants to **authenticate against the OAuth server itself** (e.g. for token introspection) * You want **multi-secret support** (several parallel valid secrets, e.g. for seamless rotation) * The API needs **explicit scope lists** for discovery ## Relationship to Applications Every OAuth API belongs to **exactly one [Application](./applications)**. A microservice architecture under one app — e.g. `acme-api`, `acme-search`, `acme-files` all linked to the App `acme` — works because permissions stay app-centric: each microservice gets its own `PermissionIds` subset of the same App catalog, and the IdP narrows its `resource_access[acme]` emission accordingly. ## Creating an API Administration → **OAuth → APIs** → **Create**. ### Required fields * **Name** — technical identifier (e.g. `acme-api`). Used in `aud` claims when the token is issued. * **Display Name** — UI label * **Application** — which App does this RS belong to? Required for per-Audience subset narrowing. * **Description** — optional ### PermissionIds The subset of the linked App's catalog this RS gates on. Used by the IdP to narrow the `resource_access` block in UserInfo for this audience — sibling RSs under the same App don't see each other's permissions in the user's claims. Default at creation: full catalog. Tighten to a strict subset for microservices that only need a slice. ### Scopes A list of scope names this API understands. Any token whose `scope` claim contains one of these is considered "for this API". Used for OIDC discovery and resource indication. #### One-click implicit scope In the API detail modal there is a **Create implicit scope** button when the API has no scope with the same name yet. 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 This 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, can be requested by clients via `scope=`. ::: tip When to keep things separate Two situations warrant a manually-created additional scope on top of the implicit one: * **Granularity** — `.read` / `.write` / `.admin` against the same audience. Differentiates capabilities via `scp`, not `aud`. * **Multi-RS scope** — one scope name pointing to multiple APIs (`scope=admin` → `aud: [policy-api, audit-api]`). Edge case but valid. ::: ### User claims Optional list of claim types this API expects in tokens. Used by some IdP-side filtering mechanisms; for most setups, leave empty. ## How a resource server authenticates against Modgud An OAuth API has **no credential surface of its own**. When the resource server needs to call Modgud directly (e.g. token introspection), it does so via OAuth using a confidential [OAuth Client](./oauth-clients) linked to a [Service Account](./service-accounts): the client requests an access token via Client-Credentials and uses it as a bearer like any other token. There is no per-API shared secret to rotate. ## Editing Most fields can be edited live; **Name** 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. ## Deleting List → right-click → **Delete**. Soft-deleted; the OAuth API is no longer usable but the aggregate stream is retained for audit. ## Common patterns ### One app, one resource server Default for most SaaS apps: create one OAuth API named after the app's slug, link it to the App, and pick the catalog subset it gates on. ### One app, multiple resource servers (microservices) Each microservice gets its own OAuth API entry with its own narrower `PermissionIds` subset of the App's catalog. All link to the same App. Per-Audience narrowing in UserInfo means a token used against microservice A only carries A's permission subset, not B's — even when both are under the same App. ### Multi-tenant API If the same API logic serves multiple realms, each realm gets its own OAuth API entry. Modgud's tenancy already enforces realm separation at the database level, so cross-realm token leakage is impossible. ## 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/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". ::: ## 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, step 4 isn't possible and every agent has to be onboarded manually. With one pre-registered client an admin can pilot the integration, but "anyone with an agent attaches" needs DCR. ## 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) → **Allow DCR Clients** checkbox 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 asks for `tenant:admin:*` and a non-opted-in API 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** → tick **Allow DCR Clients**. 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 consent prompt appears on every authorize hit until the user actively trusts the app at the client end (typical pattern: the agent caches its own consent decision). ## Audit log Every DCR-related event lands in the auth log with a `DCR ` prefix; the [Auth Log](./auth-log) grid has a **"DCR events only"** filter 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/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 two 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) Future types — **SAML**, **LDAP**, **Kerberos** — are reserved at the API level and will surface in the picker once the backend handlers ship. ![Login providers list](/screenshots/admin-login-provider.png) ## The Internal provider Each realm is provisioned with a single Internal login provider. It lives in the same admin list as every external provider but is locked from edits: * It cannot be deleted, disabled, or duplicated. The list shows it with a **System** badge. * Trying to create a second Internal entry fails with `LoginProvider.InternalAlreadyExists`. * Trying to edit it fails with `LoginProvider.InternalNotEditable`. The Internal provider is what backs the username + password form on `/login`, the recovery CLI's break-glass admin, and the password-reset flow. ::: tip Keep Internal alive until SSO is fully proven For most corporate setups: keep Internal enabled (for break-glass admin access), add OIDC for everyone else. Once external SSO is fully rolled out and tested across all roles, the Internal provider can stay as a guarded fallback — there is no UI option to delete it on purpose. ::: ## OIDC providers External IdPs let users sign in via SSO instead of maintaining a local password — Modgud retains control over groups, roles, and sessions. ### What the external IdP handles — what Modgud keeps **The IdP handles:** * Authentication (who are you? — password, MFA, biometric) * User-property updates on every login (first/last name, email) **Modgud retains control over:** * Group and role assignment (manual admin management or automatic via membership scripts) * Permissions * Account lifecycle (admins can disable any user even without the IdP) * Audit trail of every login ::: warning IdP claims ≠ automatic roles A user who's in the Entra "Administrators" group does **not** automatically get the `Admin` role in Modgud. You either add them manually to a Modgud group with the right role, or write a membership script that classifies them. This is deliberate — it protects against staleness (IdP group revoked while user offline → unclear when it takes effect) and gives you the final word on access. ::: ### Wiring up Microsoft Entra ID — step by step #### 1. In Entra (Azure portal) **Create an App Registration** 1. Azure Portal → **Microsoft Entra ID** → **App registrations** → **+ New registration** 2. Name: e.g. "Modgud" 3. **Supported account types**: "Accounts in this organizational directory only" (single-tenant) 4. **Redirect URI**: leave empty — we'll fill this in later 5. **Register** **Write down** * **Application (client) ID** — you'll need it as the *Client ID* in Modgud * **Directory (tenant) ID** — you'll need it as the *Tenant ID* **Create a client secret** 1. **Certificates & secrets** → **Client secrets** → **+ New client secret** 2. Name + expiry (24 months recommended; note the rotation date) 3. **Add** 4. **Copy the Value column immediately** — Entra shows the secret only once #### 2. In Modgud **Add the login provider** 1. Admin → **Login Providers** → **Add provider** 2. **Flavor**: *Microsoft Entra ID* 3. **Display Name**: e.g. "Company SSO" 4. **Tenant ID**: paste from Entra 5. **Create** The detail dialog opens. **General tab** * **Redirect URI** — auto-generated, e.g. `https://auth.firma.at/signin-oidc/`. **Copy this URI** (button next to it). **Connection tab** * **Client ID**: from Entra * **Client Secret**: from Entra * **Scopes**: `openid profile email` (default is fine) **User Update Script tab** Default for Entra: ```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, **Letzter Login** loads the actual claims that came through last. #### 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. Test 1. Open Modgud's login page in incognito 2. The new SSO button should appear 3. Click → redirect to Microsoft → sign in → redirect back 4. You're signed in. Check the user's IdP-Claims tab 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 **Verknüpfung & Richtlinien** tab. Unknown users get a 403 with a message explaining how to request access. ### Linking OIDC to existing users When a user is already signed in and visits **Profile → Linked accounts**, they can attach additional OIDC identities to their existing Modgud account. The link is stored on `ExternalIdentityLink` (issuer + subject → user id) and survives email changes on either side. To deny self-service linking for a particular provider, untick **User dürfen diesen Provider im Profil verknüpfen** in the linking tab. ## Disabling without deleting For OIDC providers, toggle the **Aktiviert** 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 **Verbindung** tab. ## Common pitfalls * **Wrong redirect URI** in Entra → "AADSTS50011" error. Copy it exactly from Modgud. * **Client secret expired** → users get redirected, then 500 in Modgud's external auth callback. Rotate in Entra and update. * **User update script returns wrong field names** → master data is empty after login. Use the test panel before saving. * **Mismatch between Entra group and Modgud role** → user is "Admin" in Entra but has no admin permission. By design — assign manually or via membership script. ::: warning Test the new provider before disabling Internal If a misconfigured external provider is the only login path and an admin can't sign in, the [Recovery CLI](../operate/recovery-cli) is your only way back. ::: --- --- url: /admin/applications.md --- # Applications An **Application** in Modgud is the organisational clamp around a SaaS app — it owns its own permission catalog, its own roles, and its own OAuth bindings. When a realm is created the system app `modgud` (= Modgud itself) is provisioned automatically; every other app you register here. ::: tip First time? If this is your first integration, the [SaaS App Integration Walkthrough](../integrate/saas-walkthrough) is the better entry point — it walks through all five stations (App, Client, Resource Server, Roles, backend code). ::: ## What is an Application for? Modgud manages permissions as **two-segment** `:` strings inside an App's catalog — the app slug is the implicit context, not part of the string. The same string `invoice:write` in the `billing` app's catalog and in the `shipping` app's catalog are different permissions, distinguished by the audience the gate is running for. An app therefore bundles: * **Permission catalog** — the `:` entries that the app's resources understand * **Roles** with `AppId` — bundles of `PermissionIds` from this app's catalog * **Groups** via `BoundTo` — which organisational unit is active in which app * **OAuth Clients** via their `AppIds` list — which token requesters serve the app * **OAuth APIs (Resource Servers)** via their `AppId` — which backend identities belong to it * **OAuth Scopes** via their `AppId` — which scopes a client of the app may request ## Application fields | Field | Meaning | | --- | --- | | Slug | URL- and permission-safe identifier. Lowercase, 3-63 characters, letters/digits/hyphens. **Immutable after creation.** | | Display Name | What appears in lists and consent screens | | Description | Optional, one-liner | | Permission catalog | `:` entries this app's resources can be gated by | | IsSystem | True only for `modgud` and `control-plane`; cannot be deleted | ## Reserved slugs These slugs are forbidden — they collide with the permission grammar or with system invariants: * `realm` — would clash with `realm:admin` (realm-wide bypass) * `*` — wildcard in `Group.BoundTo` * `modgud` — system app, seeded automatically into every realm * `control-plane` — control-plane system app, seeded only on the Control-Plane realm ## Creating an app Click **Create** in the list view. 1. Pick a slug — kebab-case, memorable: `acme`, `billing`, `inventory`. Not changeable. 2. Fill in display name and description. 3. Add catalog entries: `:`, kebab-case both sides (`invoice:read`, `invoice:write`, `invoice:admin`). 4. **Create**. The app appears in the list. **It still has no effect** on its own — you also need to: * link at least one OAuth client to it ([OAuth Clients](./oauth-clients)) * (for an authenticated server-to-server callback into Modgud) provision a resource server * create at least one role + group that connects users to the app ## Provisioning the resource server Under [OAuth → APIs](./oauth-apis), create an OAuth API named after the app's slug and link it to the app. Its `PermissionIds` declare which subset of the catalog this resource server gates on (full catalog is the typical default; tighten for microservices that only need a slice). This is the identity Modgud uses to compute the per-Audience `resource_access` block in UserInfo. ## Extending or changing the catalog Catalog entries can be edited any time, but: * **Adding** is harmless. Existing role assignments remain valid; new permissions become assignable. * **Removing** is dangerous. Roles that reference the removed entry silently lose the grant. The admin UI shows a "rename" indicator and a delete-block prompt when something downstream is still referencing a catalog entry. Audit roles before dropping. ## Relationships to other areas | Linked with | Where | How | | --- | --- | --- | | OAuth Clients | [OAuth Clients](./oauth-clients) | n:m via the client's `AppIds` list | | OAuth Scopes | [OAuth Scopes](./oauth-scopes) | 1:n via the scope's `AppId` (or null = global) | | OAuth APIs (Resource Servers) | [OAuth APIs](./oauth-apis) | 1:n via the API's `AppId` | | Roles | [Roles](./roles) | n:1 via the role's `AppId` | | Groups | [Groups](./groups) | n:m via the group's `BoundTo` list | ## The system apps ### `modgud` The app `modgud` represents the Modgud admin surface itself. Permissions like `user:read` or `oauth-client:write` (in this app's catalog) gate the admin UI sidebar. * **Auto-seeded** on first realm setup * **Not deletable** (`IsSystem = true`) * **Slug not renameable** * Catalog matches the built-in admin surface — edit cautiously ### `control-plane` Seeded **only** into the realm flagged `IsControlPlane = true`. Owns the `realm:read` / `realm:write` permissions that gate `/api/admin/realms/*`. See [Concepts: Control Plane / Data Plane](../concepts/control-plane). If you change a system app's catalog, the admin sidebar may hide items because the corresponding permission is no longer registered. When in doubt, restore the default catalog (see `AppRealmSeeder` in source). ## Deleting an app System apps cannot be deleted. Regular apps can — but: * OAuth clients with the app in their `AppIds` list keep the entry (UI shows it as "unknown app") * OAuth scopes with this AppId become orphaned * Roles with this AppId stay — but their `PermissionIds` no longer resolve to anything * Groups with the app in BoundTo keep the entry, but it no longer has effect So before deleting: re-link or delete the dependent clients, scopes, and roles first. --- --- url: /admin/realms.md --- # Realms A **Realm** in Modgud is a tenant — a fully isolated namespace with its own database, users, groups, OAuth clients, and apps. Realms are how multi-tenant Modgud deployments separate customers / environments / staging. ::: info When do I need multiple realms? * **Multiple customers** sharing one Modgud instance (each gets their own realm) * **Stage separation** (production, staging, development) on shared infrastructure * **Compliance isolation** (some customer data must not coexist in the same DB) Single-tenant deployments only need the system realm — provisioned automatically on first start. ::: ![Realm list](/screenshots/admin-realms-liste.png) ## The Control-Plane realm Exactly **one** realm in a deployment is the **Control Plane** — the realm flagged `IsControlPlane = true`. The Control Plane is the only host where realm CRUD is exposed; tenant realms get a 404 even from a user that somehow holds `realm:read`/`realm:write` (those catalog entries don't exist in their tenant DB because the `control-plane` app isn't seeded there). See [Concepts: Control Plane / Data Plane](../concepts/control-plane) for the full three-layer defence. The Control-Plane flag is **computed from the slug**: the realm whose slug equals `system` is the CP. It's not a togglable field — there's exactly one CP per deployment, fixed at deployment time. The system realm's default domains are `system.localhost`, `localhost`, `127.0.0.1` — anything resolving to those lands on the system realm. ## Realm fields | Field | Meaning | | --- | --- | | Slug | URL-safe identifier, 3-63 chars, immutable. Determines the tenant DB name (`_`). | | Display Name | UI label | | Description | Optional | | Domains | List of hostnames that route to this realm | | IsControlPlane | Read-only flag, derived from `Slug == "system"`. | | 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. ::: Admin → **Realms** → **Create**. | Field | Example | | --- | --- | | Slug | `acme` | | Display Name | `Acme Corp` | | Description | `Production tenant for Acme` | | Domains | `acme.auth.example.com` | | **Initial admin** | **required** — UserName + Email of the recipient who'll bootstrap the realm | The Initial-Admin block is mandatory. A realm with no admin path would be unreachable; the UI rejects the form if either UserName or Email is empty. On save, Modgud: 1. Validates the slug format (3-63 chars, lowercase, alphanumeric + hyphen). 2. Creates a PostgreSQL database `_acme`. 3. Registers the realm with Marten's master-table tenancy and applies the schema. 4. Stores the Realm document in the master DB. 5. Seeds the 6 default OAuth scopes + the Internal login provider in the new tenant DB. 6. Seeds the `modgud` app (the realm-internal admin surface). The `control-plane` app is **not** seeded into a tenant realm — it only exists in the Control-Plane realm. 7. Issues a **bootstrap-invite** for the Initial-Admin: writes a single-use, 7-day token into the new tenant DB and sends a magic-link email. The magic-link URL is also returned in the API response so you can copy it manually if SMTP isn't reachable. The recipient clicks the magic link, lands on `/bootstrap?token=…` in the new realm's SPA, sets their own password, and is auto-signed-in. The token is revoked on first use. If the link gets lost (expired, deleted, never delivered), open the realm in the admin UI and click **Resend invite** — a fresh token is issued for the same recipient and the previous one is revoked. ## Editing a realm Most fields are live-editable; the **slug is immutable** (it's baked into the database name). The Control-Plane flag isn't editable — it's computed from the slug. ## 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. The tenant database is **not** dropped automatically (data preservation by default). Drop the DB manually if you really mean to wipe it. Hard-delete with automatic DB drop is a roadmap item. ## First-time setup of a fresh realm The realm is provisioned together with a bootstrap-invite for the Initial Admin (above). The invite recipient clicks the magic-link and sets their password — that's the standard path for nearly every realm. If something goes wrong: * **Token lost or expired** — reopen the realm in the admin UI and click **Resend invite**. Same recipient, fresh token. * **No prior invite, no admin yet** (e.g. provisioned via a tool that didn't issue one) — drop into the container and run `dotnet Modgud.Api.dll recover bootstrap-admin --email --realm `. See [Recovery CLI](../operate/recovery-cli). * **Locked-out admin** — same recovery CLI, again with `bootstrap-admin --email `. The CLI adds the new user to the existing Administratoren group rather than creating a duplicate. ## Routing Modgud's `RealmMiddleware` resolves the realm from `HttpContext.Request.Host`. Each request finds its realm by matching the host against any realm's `Domains` list. If a host doesn't match any realm: 404 (the request is for an unrecognised tenant). For dev work without hosts-file edits, the system realm's default `Domains` list includes `localhost` and `127.0.0.1` — the single-realm fallback in `RealmCache` also catches localhost variants when only one realm is active. ## Tips ::: tip Naming conventions Realm slugs are baked into the tenant DB name and the default Domains list (`.localhost`). Pick stable, customer-friendly slugs and stick with them. Slug changes are not supported. ::: ::: tip Data residency Each realm's data lives in its own PostgreSQL database. For data-residency compliance, you can configure separate database servers per realm via the `RealmProvisioningService` extension hooks (advanced setup, not exposed in the UI today). ::: --- --- url: /admin/realm-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. ::: ## Tabs The page currently has two tabs: * [Self-Registration](#self-registration) — public sign-up policy * [Dynamic Client Registration](#dynamic-client-registration) — anonymous OAuth-client registration policy (linked detail page: [Dynamic Client Registration](./dynamic-client-registration)) Per-realm **branding** is configured on a separate page under Plattform — see [Customization → Branding](../plattform/branding). Permissions: `realm-settings:read` / `realm-settings:write`. The realm-admin role grants both via the `realm:admin` bypass. ## Self-Registration Public sign-up: visitors can create an account themselves at `/register`. Opt-in per realm, **disabled by default** — anonymous probes to `/api/account/self-registration-info` return the all-defaults shape so disabled realms can't be enumerated. ### Enabling self-registration 1. Open Administration → **Realm Settings** → tab **Self-Registration**. 2. Check **Enable self-registration**. 3. Configure the additional fields that appear (see below). 4. **Save**. Once enabled, the login page picks up a **"No account yet? Register →"** link and `/register` becomes reachable. ### Fields | Field | Default | Meaning | | --- | --- | --- | | **Enable self-registration** | off | Master toggle. When off, the `/register` route returns the same anti-enumeration response as a never-registered email. | | **Require email verification** | on | New accounts are created with `EmailConfirmed=false` and can't sign in until they click the magic-link in the verification mail. Turn off only for trusted-internal scenarios. | | **Require admin approval** | off | Layered on top of email verification — after the user confirms the link, the account stays `IsActive=false` until an admin flips the flag manually. Useful for moderated communities. | | **Allowed email domains** | empty (all) | Whitelist. Empty = accept any domain. Case-insensitive match on the part after the last `@`. | | **Default groups** | empty | Groups the new user is auto-attached to once the account is fully active (post-verification + post-approval). Role memberships flow through groups, so this is the lever for "what can self-registered users do?". | | **Terms-of-Service URL** | empty | When set: the registration form shows a required "I accept" checkbox linking here. The endpoint rejects submissions without the checkbox ticked. | | **Privacy Policy URL** | empty | When set: rendered as a discreet footer link on the registration form. No checkbox. | | **Enable Cloudflare Turnstile captcha** | off | Independent of the master toggle. See [Captcha](#captcha) below. | | **Captcha site key** | empty | Per-realm Turnstile site key. Empty + captcha enabled = falls back to the Cocoar-default keys. | | **Captcha secret** | not set | Per-realm Turnstile secret, encrypted at rest. Write-only — never returned, only an "is configured" flag. Empty + save = clear (revert to Cocoar default). | ### Captcha Cloudflare Turnstile is the only supported captcha provider. It is **independent of the master toggle** so two scenarios both work: * **Public-internet deployment** → enable captcha, configure either per-realm keys or rely on the Cocoar-default keys configured via the `Turnstile__SiteKey` / `Turnstile__SecretKey` environment variables. * **Air-gapped / intern deployment** → leave captcha disabled. Modgud then never calls out to `challenges.cloudflare.com`. Honeypot field + per-email rate-limit (1/min, 3/hour) cover the bot-spam surface. Resolution order when the captcha is enabled: 1. Per-realm site key + per-realm secret if both set 2. Cocoar-default site key + Cocoar-default secret 3. None configured → the verifier rejects every registration and logs a `WARN` so the admin notices the misconfiguration ::: tip One captcha secret per realm The captcha secret is encrypted with ASP.NET Data Protection (purpose `Modgud.SelfRegistration.CaptchaSecret.v1`). Migrating data-protection keys between deployments invalidates all per-realm captcha secrets — they need to be re-entered. The same warning applies to login-provider client secrets. ::: ### Anti-enumeration The public endpoints are explicitly engineered against enumeration: * `POST /api/account/register` always responds with the same generic success message regardless of outcome: existing email, existing username, captcha failure, honeypot trigger, rate-limit, domain-whitelist rejection — all look identical to a real success from the client's perspective. No mail is sent in the rejected cases. * `GET /api/account/self-registration-info` returns the same all-defaults shape (`Enabled=false`) whether the realm has the feature off, doesn't exist at all, or is currently being configured. The SPA reads `Enabled` to decide between rendering the form vs. redirecting to `/login`. The email-verification endpoint (`POST /api/account/register/verify-email`) is the exception — it returns real error codes for expired / used / unknown tokens, because by the time someone is consuming a token they already have it, and there is nothing left to enumerate. ### What's stored where | Data | Location | | --- | --- | | Realm-settings document (the toggles above) | tenant DB, singleton document `RealmSettings` | | Pending self-registration (token-hash, user ID, expiry) | tenant DB, `mt_doc_pendingselfregistration` | | User record | tenant DB, `mt_doc_applicationuser` (created at register time, activated on verify) | | Captcha secret (encrypted) | inside the `RealmSettings` document, encrypted with Data Protection | ### Known limitations (current MVP) * **No dedicated "pending approvals" UI.** When admin-approval is required, the user record is created with `IsActive=false` and an admin has to flip the flag from the regular user-edit modal. A filter chip on the user list for "pending approval" is a sensible follow-up. * **In-memory rate limiter.** The per-email cap (1/min, 3/h) resets on restart and isn't shared across multiple instances. Single-instance deployments are fine; multi-instance setups can bypass the cap by hopping between instances. A Redis-backed implementation behind the same interface is a follow-up. * **No pre-submit username availability check.** The form surfaces username collisions through the generic 200-OK like every other rejection. An anonymous `GET /api/account/check-username/{name}` (rate-limited) would improve the UX without touching the anti-enumeration guarantees on email. * **Email template is shared with the email-change flow.** Both reuse `EmailTemplate.EmailVerification`. A dedicated `EmailTemplate.SelfRegistrationVerify` with welcome wording is a quality-of-life improvement. ## 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) ## Branding (separate page) Branding lives on its own page under Plattform — go to **Plattform → Customization → Branding**. It writes a sub-document on the same `RealmSettings` doc but isn't surfaced as a tab here today. → **[Customization — Branding](../plattform/branding)** --- --- url: /admin/auth-log.md --- # Auth Log The **Auth Log** is the audit trail of every authentication-relevant event in this realm: logins, logouts, password changes, 2FA setups, admin actions, OAuth consents, GDPR operations. Entries are written asynchronously by the `AuthLog` Serilog sink (`Auth:`-prefixed log events) into a Marten document; the admin grid reads them back. Administration → **Auth Log**. ![Auth log list](/screenshots/admin-auth-log.png) ## What gets logged Each row in the grid surfaces: | Column | What | | --- | --- | | **Timestamp** | UTC instant the event was logged | | **Level** | Serilog level — typically `Info` for ordinary events, `Warn` for failed-login bursts, `Error` for unhandled exceptions on the auth path | | **Event** | Human-readable message — e.g. `Login successful`, `User requires secure setup`, `Initial admin created`, `OidcSchemeBootstrap registered N external auth schemes` | | **User** | The acting principal's username (or empty for system events) | | **IP** | Client IP, taken from `X-Forwarded-For` if a known proxy chain is configured, else the direct `RemoteIpAddress` | The persisted document carries more than the grid renders — Serilog's structured fields (`UserName`, `IP`, plus event-specific properties like `DueAt`, `Method`, `RealmSlug`) live alongside the message template. The grid currently surfaces only the columns above; richer filters and per-row detail are tracked as a follow-up. ## Filters The list view supports: * **Free-text search** across user, message * **Refresh** the grid manually * **Clear** the entire AuthLog (realm-admin only — destructive) ## Retention Auth log entries are kept for **7 days**, enforced by a background worker in `AuthLogPersistenceService` (`RetentionPeriod = TimeSpan.FromDays(7)`). The window isn't currently realm-configurable — it's the same across every realm in a deployment. Reads (`GET /api/admin/auth-log`) require `auth-log:read`; clearing the entire log (`DELETE /api/admin/auth-log`) requires `realm:admin`. ## GDPR When a user is permanently erased (GDPR Art. 17), their auth log entries stay in place — but PII fields (email, name, IP) are masked with `***ERASED***`. The user's stable id is kept so the audit chain remains traceable without revealing personal data. ## Tips ::: tip Watch for failed-login clusters A burst of `Login failed — wrong password` for the same username from the same IP in a short window points at a credential-stuffing attempt. Modgud's account lockout (5 attempts → 1 minute lock) already mitigates this, but the pattern is worth a periodic eyeball. ::: ::: tip Admin actions on critical resources Watch for messages like `Realm created`, `OAuth client deleted`, `Initial admin created` — they map to infrastructure-level operations that belong in a compliance review. ::: --- --- url: /admin/scheduled-jobs.md description: >- Tenant-admin surface for the realm's background scheduled jobs — review schedules, tune retention, trigger manually, inspect run history. --- # Scheduled Jobs **Scheduled Jobs** are the realm's recurring background tasks — garbage collection, retention sweeps, periodic housekeeping. Each job ships with a sensible default schedule baked into the build; admins can override the cron expression, tweak per-job parameters, disable runs, trigger an out-of-band run, or read the last 50 executions per job — all from one page. ## Surface | Surface | Path | Required permission | | --- | --- | --- | | List + grid | `/admin/scheduled-jobs` | `scheduled-job:read` | | Detail modal (Schedule / Configuration / History) | `/admin/scheduled-jobs#` | `scheduled-job:read` to view, `scheduled-job:write` to save / trigger | The `realm:admin` role bypasses both; granular delegation works by handing out `scheduled-job:read` and/or `scheduled-job:write` from the modgud App catalog. ::: info Per-tenant Run history (`JobRunHistoryEntry`) and per-job overrides (`JobConfig`) live in the **calling tenant's** Marten DB. Each realm sees only its own runs and configures its own retention. ::: ## Registered jobs Three jobs ship with Modgud today. All three iterate every active realm internally — you see one row per job, not one row per (job, realm). ### `inbox-retention` — Inbox Retention Applies the per-kind inbox retention policy across every active realm. * **Default cron:** `0 0 3 * * ?` (03:00 UTC daily) * **Parameters:** none — retention rules are configured separately under [Inbox Settings](/plattform/inbox). * **What it does:** loads each realm's `InboxRetentionSettings` doc, dismisses or hard-deletes items per the configured policy, reports per-reason counts in the run summary. * **On failure:** an `inbox-retention failed for realm ` entry is logged and an inbox notification fires (see [Failure notification](#failure-notification)). ### `job-run-history-retention` — Job-Run-History Retention Trims the per-tenant `JobRunHistoryEntry` document table so it doesn't grow unbounded. * **Default cron:** `0 30 3 * * ?` (03:30 UTC daily) * **Parameters:** * **Max. age in days** — runs older than this are deleted. Default `30`. Leave blank to disable the age sweep. * **Max. entries per job** — keep only the N newest entries per job key. Default unlimited. * **What it does:** two independent passes per realm (age cutoff + per-key count cap), summed and reported. * **On failure:** logged + inbox-notified. ::: tip Two independent caps The age sweep and the per-job count cap run independently. Use one, the other, or both. Both blank = the job runs and deletes nothing. ::: ### `dcr-gc` — DCR Garbage Collector Soft-deletes [Dynamic Client Registration](./dynamic-client-registration) clients whose `cocoar:dcr:last_used_at` has aged past the realm's configured TTL. * **Default cron:** `0 0 4 * * ?` (04:00 UTC daily — after the two retention jobs) * **Parameters:** none — TTL lives on [Realm Settings → Dynamic Client Registration](./realm-settings#dynamic-client-registration) (`GcTtlDays`, default 90). * **What it does:** for every realm with DCR enabled, finds DCR-registered clients whose last-used timestamp is older than `now − GcTtlDays` and soft-deletes them via the OAuth application aggregate. Realms with DCR disabled are skipped after a single indexed lookup. * **On failure:** logged + inbox-notified. Soft delete means client\_id history stays intact for forensics. ## 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 `inbox-retention` and `dcr-gc`). | | **History** | Last 50 runs, newest first. Success runs show duration + optional one-line summary. Failed runs show the first-line error message and an expandable stack trace. Manual triggers carry a `manual` tag. | The modal's footer **Save** button persists Schedule + Configuration in one shot; the trigger button on the Schedule tab is independent. ## Manual trigger ("Run now") The **Run now** button on the Schedule tab fires the job off-schedule, immediately. Two things happen as a result: * A new history entry appears with `ManualTrigger = true`, surfaced in the History tab with a `manual` tag. * The triggering admin gets a `ManualJobCompleted` inbox item with the run summary or error message — handy when the job is slow and you don't want to babysit the modal. The scheduled cron is unaffected — the job's next regular run still fires per its schedule. ## Cron overrides The cron field on the Schedule tab is a **Quartz 7-field expression** (sec min hour day-of-month month day-of-week year). When the field is **empty** the job uses the registration default; when set, the override is persisted in a per-tenant `JobConfig` Marten document and applied to the live scheduler immediately. The endpoint validates the expression server-side (`CronExpression.IsValidExpression`) and returns `400` with a clear error if it parses wrong — you won't see a runtime scheduler failure later. ## Failure notification When any run completes with an exception, a `ScheduledJobFailed` item drops into the inbox of every admin (the same recipient set as other admin notifications). The dedup key is derived from the job key, so **repeated failures of the same job collapse onto one bell entry per admin** — fix the root cause once, dismiss once, done. The notification links straight to `/admin/scheduled-jobs#` so the History tab is one click away. See [Inbox](/plattform/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. *** Looking to add a new job, or curious how the Quartz wiring works under the hood? See the contributor guide: [Scheduling framework](/integrate/scheduling). --- --- url: /admin/change-requests.md --- # Change Requests When the **profile-change approval flow** is enabled (see [Settings](../plattform/settings)), users can't change certain profile fields (typically email, name, phone) directly. Instead they submit a **change request** that an admin must approve before it takes effect. Administration → **Change Requests**. ::: info Why approve profile changes? Some compliance regimes require that user profile changes are reviewed — particularly email-address changes, which are an account-takeover vector. The approval flow inserts a human gate between "user wants to change" and "change is live". ::: ![Change request list](/screenshots/admin-change-requests-inbox.png) ## The list Columns: *User*, *Submitted*, *Field*, *Old value*, *New value*, *Status* (Pending / Approved / Cancelled / Rejected). Filters: * **Status** — focus on Pending most of the time * **User** — see one specific user's history * **Date range** ## Approving a request Open a pending request → review the proposed change → **Approve**. For most fields the change takes effect immediately on approval. **Email changes are a two-stage flow** because the new address is itself untrusted until the recipient proves they own it: 1. **Admin approves** the request. The new email is recorded as *pending verification*; the user's effective email is still the old one. A confirmation token is sent to the **new** address. 2. **Recipient confirms** by clicking the verification link. Only then does the new address become the user's effective email. So for email specifically there are **two consents in sequence**: the admin's approval (this UI), and the recipient's click on the verification email. If the user can't access the new mailbox the change never lands — which is the point. If the recipient never confirms, the request stays in *Approved – Awaiting Verification* state; the user can re-trigger the verification email from their profile or the admin can cancel the request. For other fields (name, phone, …) the change is applied immediately on admin approval — no second confirmation needed. ## Rejecting **Reject** with an optional reason. The user sees the rejection in their profile UI; the original value remains. ## Cancelling The user can cancel their own pending request from their profile page. As an admin, you can also cancel any request via right-click → **Cancel**. ## Audit Every approve / reject / cancel is logged in the [Auth Log](./auth-log) with the deciding admin's name, the field, the old and new values, and any reason. ## Tips ::: tip Email changes need extra scrutiny An email change is essentially "give this account to a different mailbox". Double-check that the new address is in the user's possession — typically by an out-of-band confirmation (Slack message, phone call) before approving. ::: ::: 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](../plattform/settings). Users then change profile fields directly with double-opt-in for email. ::: --- --- url: /plattform.md description: Operator-facing configuration of the Modgud instance. --- # Plattform 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. **Plattform** 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. * **Plattform** 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 Plattform area is itself split into two thematic groups inside its own sub-nav (see `PlatformView.vue`): ### Customization | Item | Path | What it does | | --- | --- | --- | | [Branding](./branding) | `/plattform/customization/branding` | Per-realm SPA theming — product name, primary color, logo, favicon | | [Pages](./pages) | `/plattform/customization/pages` | Page-builder editor (Beta) for login / logout / forgot-password — gated by the `PageBuilder` feature flag | | [Asset Library](./assets) | `/plattform/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 | `/plattform/inbox-settings` | Per-tenant notification retention windows | | [Settings](./settings) | `/plattform/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 Plattform route sits under `/plattform/*`. The wrapper redirects an empty `/plattform` to `/plattform/customization/branding` (the always-on starting point), so the area is link-safe even when the user has no other plattform permission. ## Header pattern Every Plattform view sets the same header shape via `useUI()`: ```ts ui.header.title = 'Plattform' ui.header.subTitle = 'Branding' // or 'Observability', 'Asset Library', … ``` So the breadcrumb the user sees is always `Plattform › ` — 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: /plattform/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 Two ways to reach this surface This page (**Administration → Customization → Branding**) and the **Branding** tab inside [Realm Settings](../admin/realm-settings#branding) edit the same `RealmSettings.Branding` sub-document. Both render the same form. Pick whichever fits your mental model. ::: 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. The endpoint resolves `LogoAssetId` / `FaviconAssetId` to public URLs (`/api/assets/{shortGuid}`) — anonymous callers never see the raw asset id. | | Login page (`/login`) | Product name + logo + primary color; favicon set at boot. | | Admin shell | Same. The shell picks the values up from the same `appConfig` Pinia store. | | `document.title` | Product name as prefix. | | Browser tab icon | `` is rewritten in JS at boot. | ## Asset-reference safety The Branding sub-document stores `LogoAssetId` and `FaviconAssetId` — **not** URLs. That has two consequences: * **Cross-domain risk**: zero. A realm admin can only reference assets uploaded into their own realm's asset library. Pasting an `https://evil.example.com/cookie-stealer.svg` into branding is not possible — the picker only shows local assets. * **Delete-block**: the asset-library endpoint refuses to delete an asset that's referenced by the realm's branding. You get an HTTP 409 with the referencing field name; clear the branding field first, then delete the asset. ## What's stored where | Data | Location | | --- | --- | | Branding sub-document (the four fields) | tenant DB, inside the singleton `RealmSettings` document | | Logo and favicon binary | tenant DB, `mt_doc_asset` (see [Asset Library](./assets)) | | Default values served when a field is null | hardcoded in the SPA (`appconfig.store.ts`) | ## Public exposure The `/api/app-info` endpoint is anonymous — branding is metadata, not secrets. It surfaces the same shape as the existing public realm settings and is required for the login page to render branded **before** the user authenticates. No tokens, no secrets, no cross-realm leakage (each realm's `RealmMiddleware`-resolved tenant scope only sees its own settings). --- --- url: /plattform/assets.md --- # Customization — Asset Library Per-realm image library for branding (and, later, page-builder schemas). Upload once, reference by ID from any branding field. Each realm's library is fully isolated — assets live in the tenant DB and can never be referenced from another realm. ::: warning Trust boundary: SVG and image MIME The asset library accepts SVG, but every upload is sanitised on the server before the bytes touch storage. The MIME type is sniffed from the leading magic bytes — **the client's `Content-Type` header is never trusted**. Anything that doesn't match the allowlist is rejected outright. ::: Permissions: `asset:read` to list / view, `asset:write` to upload / delete. The `realm:admin` bypass grants both. ## Limits and allowlist | Limit | Value | Notes | | --- | --- | --- | | Max file size | **2 MiB** | Hard cap per upload. Hits return HTTP 400 with the explicit limit. | | Allowed MIME types | PNG, JPEG, GIF, WebP, SVG, ICO | Sniffed from magic bytes. Anything else → reject. | | Filename | Free text | Used only for display; the storage key is the asset id (a UUIDv7). | | Per-realm count | unlimited (no quota in v1) | Storage-quota enforcement is a future feature — see roadmap. | ::: tip Why magic-byte sniffing A malicious uploader could send `image/png` as their `Content-Type` while the bytes are actually an executable. The MIME-type allowlist alone doesn't help — only the magic-byte sniff does. The allowlist is a separate guard on top of the sniff, not a replacement for it. ::: ## SVG sanitisation SVG goes through a dedicated cleanup pass before it lands in storage. The sanitiser: 1. Parses the SVG with `DtdProcessing.Ignore` + `XmlResolver = null` — no external entity resolution, no DTD-based attacks (XXE). If parsing fails, the upload is rejected (`Asset.SvgNotWellFormed`); we never persist SVG we can't fully parse. 2. **Removes `