diff --git a/.github/workflows/cd-publish-nuget-prerelease.yml b/.github/workflows/cd-publish-nuget-prerelease.yml index a29b00f7..799b7dce 100644 --- a/.github/workflows/cd-publish-nuget-prerelease.yml +++ b/.github/workflows/cd-publish-nuget-prerelease.yml @@ -1,6 +1,6 @@ name: CD - Publish NuGet Prerelease -# Builds and publishes a prerelease of Modgud.Client.AspNetCore to +# Builds and publishes a prerelease of Modgud.AspNetCore.ResourceServer to # nuget.org. Mirrors the cocoar.configuration workflow pattern so the # release plumbing is consistent across the org. # @@ -23,7 +23,7 @@ name: CD - Publish NuGet Prerelease # Trigger this when: # - You need a specific prerelease build to test in a downstream # consumer app -# - An external user asked for "the latest" of the client library +# - An external user asked for "the latest" resource-server package # - Pre-stable release-candidate testing # # Otherwise leave it alone. The version still ships via the @@ -50,7 +50,7 @@ concurrency: jobs: test: - name: Test client lib + name: Test resource-server package runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -67,24 +67,24 @@ jobs: - name: Setup .NET uses: ./.github/actions/setup-dotnet - - name: Restore client lib + unit tests + - name: Restore resource-server package + unit tests run: | - dotnet restore Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj + dotnet restore Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj dotnet restore Modgud.Tests.Unit/Modgud.Tests.Unit.csproj working-directory: ./src/dotnet - - name: Build client lib + unit tests + - name: Build resource-server package + unit tests run: | - dotnet build Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj -c Release --no-restore + dotnet build Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj -c Release --no-restore dotnet build Modgud.Tests.Unit/Modgud.Tests.Unit.csproj -c Release --no-restore working-directory: ./src/dotnet - # Filter to the Client.AspNetCore tests so we don't pull in + # Filter to the ResourceServer tests so we don't pull in # integration tests that need Docker/Postgres (those live in - # Modgud.Api.Tests and aren't relevant for a client-lib + # Modgud.Api.Tests and aren't relevant for this package # publish gate). - - name: Test client lib - run: dotnet test Modgud.Tests.Unit/Modgud.Tests.Unit.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName~Client" + - name: Test resource-server package + run: dotnet test Modgud.Tests.Unit/Modgud.Tests.Unit.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName~ResourceServer" working-directory: ./src/dotnet publish-prerelease: @@ -134,12 +134,12 @@ jobs: echo "Calculated prerelease version: $VERSION" - name: Restore - run: dotnet restore Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj + run: dotnet restore Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj working-directory: ./src/dotnet - name: Build run: | - dotnet build Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj \ + dotnet build Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj \ -c Release --no-restore \ -p:Version=$PACKAGE_VERSION \ -p:AssemblyVersion=${{ steps.dotnet.outputs.assembly-semver }} \ @@ -149,7 +149,7 @@ jobs: - name: Pack (incl. symbols) run: | - dotnet pack Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj \ + dotnet pack Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj \ -c Release --no-build \ -p:Version=$PACKAGE_VERSION \ -p:ContinuousIntegrationBuild=true \ @@ -189,10 +189,10 @@ jobs: - name: Summary run: | { - echo "### Modgud.Client.AspNetCore — $PACKAGE_VERSION (prerelease)" + echo "### Modgud.AspNetCore.ResourceServer — $PACKAGE_VERSION (prerelease)" echo "" if [ "${{ github.ref }}" = "refs/heads/develop" ]; then - echo "**Pushed to:** https://www.nuget.org/packages/Modgud.Client.AspNetCore/$PACKAGE_VERSION" + echo "**Pushed to:** https://www.nuget.org/packages/Modgud.AspNetCore.ResourceServer/$PACKAGE_VERSION" else echo "**Artifact only** (feature branch — not pushed to nuget.org)." echo "Download the \`prerelease-packages-$PACKAGE_VERSION\` artifact and add it as a local source to test." diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index 5a9f2e49..ebf4c6c2 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -40,7 +40,7 @@ name: CD - Release # Sibling workflows for staging / prerelease / editorial doc paths: # - cd-publish-staging-image.yml — pushes `:beta` / `:` moving # Docker tags (auto on develop push + manual) -# - cd-publish-nuget-prerelease.yml — prerelease NuGet for client-lib +# - cd-publish-nuget-prerelease.yml — prerelease resource-server NuGet # (manual workflow_dispatch) # - cd-deploy-docs.yml — editorial doc deploy to Shelf # between releases (manual) @@ -146,7 +146,7 @@ jobs: working-directory: ./src/dotnet pack-nuget: - name: Pack NuGet (Client.AspNetCore) + name: Pack NuGet (ResourceServer) needs: validate-version runs-on: ubuntu-latest permissions: @@ -165,12 +165,12 @@ jobs: uses: ./.github/actions/setup-dotnet - name: Restore - run: dotnet restore Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj + run: dotnet restore Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj working-directory: ./src/dotnet - name: Build (stable) run: | - dotnet build Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj \ + dotnet build Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj \ -c Release --no-restore \ -p:Version=${{ needs.validate-version.outputs.version }} \ -p:ContinuousIntegrationBuild=true @@ -178,7 +178,7 @@ jobs: - name: Pack (incl. symbols) run: | - dotnet pack Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj \ + dotnet pack Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj \ -c Release --no-build \ -p:Version=${{ needs.validate-version.outputs.version }} \ -p:ContinuousIntegrationBuild=true \ @@ -442,9 +442,9 @@ jobs: - name: Summary run: | { - echo "### NuGet — Modgud.Client.AspNetCore ${{ needs.validate-version.outputs.version }}" + echo "### NuGet — Modgud.AspNetCore.ResourceServer ${{ needs.validate-version.outputs.version }}" echo "" - echo "**URL:** https://www.nuget.org/packages/Modgud.Client.AspNetCore/${{ needs.validate-version.outputs.version }}" + echo "**URL:** https://www.nuget.org/packages/Modgud.AspNetCore.ResourceServer/${{ needs.validate-version.outputs.version }}" } >> "$GITHUB_STEP_SUMMARY" publish-docker: diff --git a/.gitignore b/.gitignore index 0436a4ca..b1c720a6 100644 --- a/.gitignore +++ b/.gitignore @@ -288,6 +288,7 @@ FakesAssemblies/ # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ +.astro/ # Visual Studio 6 build log *.plg diff --git a/README.md b/README.md index 60e1c6d3..e8dd428e 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,22 @@ emission, full 2FA spectrum, GDPR self-service. database separation prevents query-level tenant mixing. - **Multi-app permission model** — Apps are first-class. Permissions are 2-segment (`:`) inside an app's catalog. Two - bypass tiers, no more. Roles bind to one App, groups carry a - `BoundTo` activation list. -- **Keycloak-style `resource_access` on UserInfo** — per-audience - blocks with bypass pre-expansion and per-RS subset narrowing. A - drop-in `IClaimsTransformation` library flattens the right block - into `ClaimTypes.Role` so `[Authorize(Roles = "...")]` works - without per-endpoint plumbing. + bypass tiers, no more. Application roles bind to one App; a pure + `realm:admin` role is the explicit realm-local exception. Groups + carry a `BoundTo` activation list. +- **Keycloak-shaped `resource_access` authorization claims** — when a + token targets a registered OAuth API and requests `roles` and/or + `permissions`, Modgud emits a block keyed by that API's exact + audience, with bypass pre-expansion and per-RS subset narrowing. + `Modgud.AspNetCore.ResourceServer` projects only its configured + audience block into native role and permission claims. - **Full 2FA spectrum + WebAuthn** — TOTP, Email-OTP, FIDO2/Passkey, Magic Link, recovery codes. 2FA enforcement middleware with grace period and per-user override. -- **OIDC federation** — Microsoft Entra ID, Google, any OIDC IdP. - JIT user provisioning + JavaScript claim-mapping (`UserUpdateScript`). +- **OIDC and SAML 2.0 federation** — Microsoft Entra ID and + standards-compatible OIDC or SAML identity providers. Modgud consumes SAML + as an SP; it does not issue SAML assertions. JIT user provisioning + + JavaScript claim-mapping (`UserUpdateScript`). - **Dynamic Client Registration (RFC 7591)** with triple opt-in (realm master / per-API / per-scope), audience-target containment, full audit-event trail. @@ -51,6 +55,7 @@ emission, full 2FA spectrum, GDPR self-service. |---|---| | [📘 Get Started](./docs/getting-started/) | What this is, requirements, first-time setup | | [⚡ Quickstart (Docker)](./docs/getting-started/quickstart.md) | From `docker compose up` to first login in 10 minutes | +| [🧑‍💻 Developing locally](./docs/contribute/developing-locally.md) | Running from source: dev loop, `*.localhost` realms, recovery CLI, tests | | [🧠 Concepts](./docs/concepts/) | Realms, apps, permissions, OAuth, tokens — the mental model | | [🛠️ Operate](./docs/operate/) | Deployment, observability, recovery CLI, feature flags | | [👤 Administer](./docs/admin/) | Users, groups, roles, OAuth clients, login providers | @@ -86,9 +91,16 @@ pnpm install pnpm dev ``` -First-time admin bootstrap via the recovery CLI — see -[First-time setup](./docs/getting-started/first-time-setup.md) for -the walkthrough. +That is the short version. [Developing locally](./docs/contribute/developing-locally.md) +is the full one and the page that is kept in sync with the code: the +Postgres container, what the first boot actually does, reaching tenant +realms at `*.localhost`, the recovery CLI, demo seed data, tests and +Playwright. + +For the first admin you need the recovery CLI — that guide covers it, and +[First-time setup](./docs/getting-started/first-time-setup.md) has the +decision tree for the other bootstrap routes (invite mode, provisioning +further realms). ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 9d66c9aa..e53f1e9a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ thing. - The Modgud IdP itself — backend (`src/dotnet/Modgud.Api`, `Modgud.Authentication`, `Modgud.Authorization`, `Modgud.Domain`, `Modgud.Infrastructure`) and admin SPA (`src/frontend-vue/`). -- The `Modgud.Client.AspNetCore` NuGet package that downstream apps +- The `Modgud.AspNetCore.ResourceServer` NuGet package that downstream apps use to validate Modgud-issued tokens. - The official Docker image (`ghcr.io/cocoar-dev/modgud:*`). - The default configuration shipped in diff --git a/docs/admin/applications.md b/docs/admin/applications.md index 96a93e28..eb149bfa 100644 --- a/docs/admin/applications.md +++ b/docs/admin/applications.md @@ -96,7 +96,7 @@ effectively **rename** one — is to clone it. In the list, right-click a row - **Display name, description and the whole permission catalog** are copied. The catalog entries are copied as *new* entries (fresh ids), so the source app's role grants and resource-server subsets are left untouched. -- **Settings** are copied too — branding, registration, native-grant / DCR / CIMD +- **Settings** are copied too — branding, registration, client-session, native-grant / DCR / CIMD overrides — **except the Origin subdomain**, which is globally unique and would collide. Set a new subdomain on the copy if it needs one. @@ -110,7 +110,8 @@ 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. +audience-keyed `resource_access` block at the token boundary. The key +is the OAuth API Audience, not this App's slug. ## Extending or changing the catalog @@ -140,6 +141,7 @@ re-inherits the realm. | **Email branding** | The product name used in this App's outbound emails (OTP, magic link, ...) instead of the realm default. | | **Self-registration** | Per-app override of the realm self-registration policy (allowed email domains, admin approval, default groups, ToS/privacy URLs) plus the **posture** (see below). Captcha stays realm-level. | | **Registration fields** | Per-app override of which identity fields (username / first / last name) are required when an account is created — each one inheriting the realm by default. See [Registration fields](#registration-fields) below. | +| **Client sessions** | Idle and absolute lifetime defaults for refresh-token-backed native/OAuth sessions belonging to this App. Each field inherits the realm unless overridden; an individual OAuth client can override the App again. | | **Native grants** | Per-app toggle + token lifetimes for the cookieless [native passwordless grants](../integrate/native-apps). | | **DCR** | Per-app override of [Dynamic Client Registration](./dynamic-client-registration) (enable, token lifetimes, rate limits, reserved-name blocklist). | | **CIMD** | Per-app override of [Client-ID Metadata Documents](./client-id-metadata-documents) (enable, token lifetimes). | diff --git a/docs/admin/auth-log.md b/docs/admin/auth-log.md index 7937c609..c16cf909 100644 --- a/docs/admin/auth-log.md +++ b/docs/admin/auth-log.md @@ -1,70 +1,88 @@ -# Auth Log +# Security and platform logs -The **Security log** is the audit trail of security-relevant events in this realm that don't belong to a specific user record: failed and rejected logins, lockouts, rate limits, and a handful of operational actions. It's one of two tabs on the admin **Logs** page — the other, **Audit**, is the GDPR-relevant history of changes to users and realm configuration. Each tab is gated by its own permission (`auth-log:read` for Security, `audit-log:read` for Audit), so an admin holding only one of the two only sees that tab. This page describes the **Security** tab. +Administration → **Logs** has two realm-owned tabs and, in the +Control-Plane realm, one additional deployment-wide tab: -Administration → **Logs** → **Security** tab. +- **Audit** is the event-sourced history of user and configuration changes. +- **Security** contains structured threat and operations events owned by the + current realm. +- **Platform** exists only in the Control Plane and contains PII-free, + deployment-wide operations. -![Auth log list](/screenshots/admin-auth-log.png) +`auth-log:read` gates Security, `audit-log:read` gates Audit, and +`control-plane:platform-audit:read` gates Platform. -## What gets logged +## Security events are realm-owned -Each row in the grid surfaces: +A Security event is stored in the physical database of the realm where it +occurred. There is no `Realm` column and no central cross-realm table. The +Control Plane is a normal realm for this purpose: its Security tab shows only +Control-Plane-realm events. -| Column | What | -| --- | --- | -| **Time** | UTC instant the event was logged | -| **Category** | The event's broad bucket (e.g. security, operations) — also offered as filter chips above the grid | -| **Event** | The stable event code, e.g. `security.login_failed_unknown_user`, `security.rate_limit_triggered` | -| **Detail** | Human-readable message for the row | -| **Actor** | The acting principal's username, or the attempted identifier for an unknown-user attempt | -| **IP** | Client IP, taken from `X-Forwarded-For` if a known proxy chain is configured, else the direct `RemoteIpAddress` | -| **Level** | `Info` for ordinary events, `Warning` for failed-login bursts and lockouts, `Error` for unhandled exceptions on the auth path | -| **Realm** | The realm the event was emitted in. Constant (your own realm) for a tenant admin; varies for the control-plane admin who sees the full cross-realm log — see [Per-realm scoping](#per-realm-scoping) | +The structured record can retain forensic context during its short retention: -## Filters +- actor and target subject IDs (separate fields); +- source IP and User-Agent/device context; +- OAuth client, application, session and login-provider IDs; +- authentication method, outcome/reason codes and correlation ID. -The list view supports: +Display text is rendered from the stable event code and structured fields at +read time. Free-form `Actor`, `Reason` and persisted `Message` fields do not +exist. -- **Category chips** — narrow to one bucket at a time (only categories present in the current rows are offered) -- **Free-text search** across user, message -- **Refresh** the grid manually (it also refreshes itself periodically) -- **Clear** the entire log (realm-admin only — destructive) +For a known account, only its subject ID is stored and resolved for display. +After account erasure the row remains useful and displays **Deleted user**. +For an unknown login/reset identifier, Modgud stores only a realm-specific +HMAC fingerprint. The raw or merely masked identifier is never persisted, and +fingerprints cannot be correlated across realms. -## Retention +For a Control-Plane operation against another realm, the acting subject, IP and +User-Agent remain in the Control-Plane realm. The target realm receives only a +non-identifying `ControlPlane` counterpart with the same correlation ID. -Security log entries are kept for **7 days**, then hard-deleted by the `security-audit-prune` [scheduled job](./scheduled-jobs). The window isn't currently realm-configurable — it's the same across every realm in a deployment. +## Retention and deletion -Reads (`GET /api/admin/auth-log`) require `auth-log:read`; clearing -the entire log (`DELETE /api/admin/auth-log`) requires `realm:admin`. +Realm admins configure Security retention under **Realm settings → Logs**. +The default is **7 days** and the allowed range is **1–365 days**. +`security-audit-prune` is a realm job: its configuration and run history live +in that realm DB and it deletes only expired events from that realm DB. -## Per-realm scoping +There is no “Clear log” action or `DELETE /api/admin/auth-log` endpoint. +Manually triggering the prune job still respects the configured cutoff; fresh +events cannot be arbitrarily deleted. Hard-deleting a realm removes its whole +database and therefore all of its Security events immediately. -All auth-log entries are persisted to a single cross-realm store (the -system database) but tagged with the realm they were emitted in. The -read and clear are scoped by the **caller's** realm: +## Platform log -- A **tenant realm-admin** sees — and can clear — only their own - realm's entries. -- The **control-plane realm** (the cross-realm operator, by - `Realm.IsControlPlane`) sees the full cross-realm log and can clear - everything; this is the deployment-wide audit view. This follows the - control-plane **role**, not a fixed slug — if the role is transferred - to another realm, the global view moves with it. +True deployment events—realm provisioning/adoption, Control-Plane transfer +and deployment-wide maintenance—go to a separate `PlatformAuditEvent` type in +the non-tenanted Global Store. That type has no subject, identifier, IP, +User-Agent, OAuth client, application or session fields. -Background / no-tenant work (scheduled jobs, bootstrap) is attributed to -the `system` realm, so those operational events show up in the system -realm's view and in the control-plane view. +The Platform log is read at `GET /api/admin/platform-audit` and never mixes +realm Security events through a hidden cross-database union. Its single +`platform-audit-prune` system job defaults to **365 days** and is configurable +deployment-wide from the Control Plane. It has no clear action. -## GDPR +## API -Security log rows aren't tied to a user record the way the Audit tab's rows are, so there's no per-user erasure step here — the short, fixed 7-day retention is itself the safeguard for the personal data (attempted usernames, IPs) these rows can carry. The **Audit** tab works differently: it's projected from the user event stream, so a GDPR-erased user's audit rows are de-identified rather than deleted, keeping the change history traceable without the personal data. +| Method | Path | Permission | +|---|---|---| +| `GET` | `/api/admin/auth-log?category=...&eventType=...&limit=...` | `auth-log:read` | +| `GET` | `/api/admin/platform-audit?category=...&eventType=...&limit=...` | `control-plane:platform-audit:read` + Control-Plane realm | -## Tips +## Delivery guarantees -::: tip Watch for failed-login clusters -A burst of `security.login_failed_unknown_user` or `security.rate_limit_triggered` rows for the same IP in a short window points at credential-stuffing or account enumeration. Modgud's account lockout (5 attempts → 1 minute lock) already mitigates brute force against a known account, but the pattern is worth a periodic eyeball. -::: +Every streamless event type has one fixed durability class. A call site cannot +choose a weaker path; attempting to record an event through the wrong class +fails immediately. -::: tip Reviewing admin/config changes -For realm settings, OAuth client edits, and other admin actions, check the **Audit** tab instead — this Security tab focuses on threat signals (unknown-actor attempts, rejected external logins, rate limits) plus a handful of operational events. -::: +| Class | Used for | Guarantee | +|---|---|---| +| **Required** | Privileged or irreversible changes, trust-material changes and refresh-token reuse teardown | Stored in the same Marten transaction as the realm/global state change where both share a database. Cross-database DDL operations write a durable `initiated` record before the external step and a `completed` record with the Global Store mutation. Other callers wait for persistence before reporting success. | +| **Incident** | Individual takeover, tamper, signature and protocol-correlation failures | The rejecting request waits for the individual event to persist. A storage failure is not silently downgraded. | +| **Abuse** | Attacker-amplifiable login, magic-link, policy, DCR and rate-limit signals | Raw occurrences enter a bounded in-memory buffer and may be shed under pressure. Accepted bursts are coalesced by structured identity into rows carrying `Count`, `FirstObservedAt` and `LastObservedAt`; persistence retries while the process remains alive. This is deliberately bounded, not a lossless request journal. | +| **Telemetry** | Reconstructable cleanup and refresh summaries | Explicitly best-effort. A failed write is logged and does not make the operation fail. | + +The event-sourced Audit tab has its own transactional semantics. None of these +surfaces is a cryptographic or tamper-proof audit chain. diff --git a/docs/admin/index.md b/docs/admin/index.md index 9f202da0..32867b2a 100644 --- a/docs/admin/index.md +++ b/docs/admin/index.md @@ -32,7 +32,9 @@ Modgud is not just a login frontend — it's a full **OAuth 2.0 / OpenID Connect ### Federation & Realms -- [Login Providers](./login-providers) — built-in Internal plus external OIDC (Google, Microsoft, Entra, any OIDC); step-by-step setup walkthroughs included +- [Login Providers](./login-providers) — built-in Internal plus Microsoft + Entra ID and standards-compatible OIDC or SAML providers; setup + walkthroughs included - [Realms](./realms) — multi-tenant setup; each tenant gets its own database - [Declarative Realm Provisioning](./realm-provisioning) — create/update/tear down a whole realm from one JSON manifest (realm-as-code, per-test realms, agent automation); serves a fetchable schema - [Realm Settings](./realm-settings) — realm-admin-owned config (self-registration, DCR policy, branding) @@ -48,7 +50,7 @@ Per-realm look and feel. SPA-shell branding plus a beta page-builder editor. ### Operations - [Observability](../operate/observability) — OpenTelemetry metrics + tracing + in-app live activity feed -- [Logs](./auth-log) — a combined **Audit** tab (GDPR audit trail of user/config changes) and **Security** tab (login events, lockouts, rejected logins), gated separately +- [Logs](./auth-log) — realm-owned **Audit** and **Security** tabs; the Control Plane additionally gets a separate PII-free **Platform** tab - [Change Requests](./change-requests) — approve profile changes (when the approval flow is enabled) - [Settings](../platform/settings) — 2FA enforcement, grace period, SMTP, … - [Feature Flags](../operate/feature-flags) — operator-level toggles for beta / WIP surfaces diff --git a/docs/admin/login-providers.md b/docs/admin/login-providers.md index 938645d4..e5b5c42c 100644 --- a/docs/admin/login-providers.md +++ b/docs/admin/login-providers.md @@ -104,7 +104,7 @@ on access. 1. Admin → **Login Providers** → **Add provider.** A single modal opens — flavor picker in the header, all tabs (General, Connection, - User Update Script, Linking & Policies) visible. + Protocol & Security, User Update Script, Users & Trust) visible. 2. **Flavor** (header dropdown): *OIDC · Microsoft Entra ID*. Switching flavor in this modal re-seeds the flavor-derived defaults (Scopes, default User Update Script, button icon) without touching what you've @@ -114,15 +114,15 @@ on access. identifier (lowercase letters/digits/hyphens, 3-64 chars) that becomes part of the Redirect URI (`/signin-oidc/`). It is **immutable after create** — pick a stable name (e.g. `company-sso`); typing a - Display Name first lets Modgud suggest one. The Redirect URI field - appears AFTER first save. + Display Name first lets Modgud suggest one. As soon as the slug is + valid, the Redirect URI appears in the Connection tab — before save. 4. **Connection** tab: - **Tenant ID** (Entra-specific): paste from Entra. - **Client ID**: from Entra. - **Scopes**: `openid profile email` (default is fine). - - **Initial Secret** (optional): paste the Entra client secret here - so it's set in one step. You can also skip this and rotate via the - Connection tab after Save — same audit-event shape either way. + - **Client Secret**: paste the Entra client secret here so the complete + provider can be created in one step. You can omit it while the provider + is disabled and add/rotate it later. 5. **User Update Script** tab: default for Entra is ```js @@ -138,13 +138,12 @@ on access. against a sample claims object — instant feedback on what comes out. After at least one successful login, **Last Login** loads the actual claims that came through last. -6. **Create.** The provider is created **disabled** (security default; - enable explicitly after the smoke-test). The modal stays open and - transitions into Edit mode — the URL fragment updates to the new - provider id and the **Redirect URI** field now appears in the - General tab with a copy button next to it. +6. Choose **Active** on the General tab only if Client ID, Client Secret + and the provider-specific connection fields are already complete. + **Create** saves the full provider atomically; otherwise leave it + disabled and enable it after the smoke-test. -**Copy the Redirect URI** from the General tab — you'll paste it into +**Copy the Redirect URI** from the Connection tab — you'll paste it into Entra next. Because the URI is built from your chosen slug (not a generated GUID), deleting and recreating the provider with the same slug keeps the **same** Redirect URI — no need to re-edit the Entra app. @@ -188,7 +187,7 @@ toggle the **Auto-create unknown users** flag in the **Linking & Policies** tab. Unknown users get a 403 with a message explaining how to request access. -### Linking OIDC to existing users +### Linking external identities to existing users When a user is already signed in and visits **Profile → Linked accounts**, they can attach additional OIDC identities to their existing Modgud @@ -198,6 +197,15 @@ user id) and survives email changes on either side. To deny self-service linking for a particular provider, untick **Allow linking** in the Linking & Policies tab. +::: warning SAML self-service linking is limited in v1 +SAML assertions return through a cross-site POST to the ACS endpoint. The +Modgud application cookie is `SameSite=Lax`, so it is not sent with that +POST and Modgud cannot reliably bind the assertion to the already signed-in +user who started the link flow. Use normal SAML sign-in with trusted-email +linking or JIT resolution instead. See +[SAML federation](./saml-federation#linking-a-saml-identity-to-an-existing-account). +::: + ### Multiple linked providers & profile precedence A user may hold links to several IdPs at once (e.g. EntraID *and* Google). Identity matching is always by the IdP's stable **`(issuer, subject)`** — never by email (email is only a fallback for the opt-in auto-link / JIT paths). So a returning login resolves to the right account regardless of how many providers are linked. @@ -214,7 +222,7 @@ The net effect: a user's display name / email stays stable no matter which linke ## Disabling without deleting -For OIDC providers, toggle the **Enabled** flag in the detail dialog. The +For OIDC and SAML providers, toggle the **Enabled** flag in the detail dialog. The button disappears from the login page; existing user-account links are preserved. Re-enabling brings the button back. diff --git a/docs/admin/oauth-apis.md b/docs/admin/oauth-apis.md index 6c359af9..2cc51ae2 100644 --- a/docs/admin/oauth-apis.md +++ b/docs/admin/oauth-apis.md @@ -20,49 +20,52 @@ own API as an API). 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: +emit a tailored `resource_access[]` block for this resource +server in JWT access tokens, UserInfo and authorized introspection +responses. Specifically, it's required when: - You want **per-Audience permission narrowing** in `resource_access` blocks. The RS declares its `PermissionIds` subset of the App's catalog, and the IdP narrows each user's emission to that subset. -- The API 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)**. +An OAuth API normally belongs to **one [Application](./applications)**. A microservice architecture under one app — e.g. `acme-api`, `acme-search`, `acme-files` all linked to the App `acme` — works because permissions stay app-centric: each microservice gets its own `PermissionIds` subset of the same App catalog, and the IdP narrows -its `resource_access[acme]` emission accordingly. +the separate `resource_access["acme-api"]`, +`resource_access["acme-search"]` and +`resource_access["acme-files"]` blocks accordingly. + +An API can temporarily remain unassigned for legacy or standalone setups. +Without an Application link, Modgud has no permission catalog to resolve and +does not emit a `resource_access` block for that audience. ## Creating an API -Administration → **OAuth → APIs** → **Create**. +Administration → **OAuth & Federation → OAuth-APIs** → **Create**. ### Required fields - **Audience (aud)** — technical identifier (e.g. `acme-api`). Used in `aud` claims when the token is issued. - **Display Name** — UI label -- **Application** — which App does this RS belong to? Required for - per-Audience subset narrowing. +- **Application** — which App does this RS belong to? Recommended and required + for per-Audience permission emission. - **Description** — optional ### PermissionIds The subset of the linked App's catalog this RS gates on. Used by the -IdP to narrow 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. +IdP to narrow `resource_access[].permissions` — +sibling resource servers under the same App get their own Audience +keys and do not project each other's permissions. -Default at creation: full catalog. Tighten to a strict subset for -microservices that only need a slice. +The selection starts empty. Pick only the catalog entries this resource server +actually exposes. ### Scopes @@ -134,8 +137,9 @@ Client whose **Client ID equals its own audience** (this API's name — the RFC 8707 `resource=` value already carried in the token's `aud`), and authenticates the introspection call with that client's own credentials (sent as form-body parameters, so a URL-shaped audience id -works). The [.NET client library](/integrate/resource-server#reference-token-mode-opaque-tokens) -does this for you via `AddModgudReferenceTokenClient`. +works). The [.NET resource-server library](/integrate/resource-server#reference-token-mode) +does this through `AddModgudResourceServer` with +`TokenMode = ModgudTokenMode.OnlyReferenceToken`. ## Editing @@ -150,7 +154,7 @@ immediately switch to the new app context. resource server, clone it. List → right-click → **Clone**. The Create wizard opens pre-filled — display name, description, scopes, user claims, the linked Application and its catalog subset are copied; only -**Audience (aud)** is blank. API secrets are not copied; the copy starts with none. +**Audience (aud)** is blank. ## Deleting @@ -168,9 +172,10 @@ slug, link it to the App, and pick the catalog subset it gates on. 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. +App. Per-Audience narrowing means each block contains only its API's +permission subset. A multi-audience token may carry multiple blocks +side-by-side, but each resource-server scheme projects only its +configured Audience. ### Multi-tenant API diff --git a/docs/admin/oauth-clients.md b/docs/admin/oauth-clients.md index 7bb0f3d5..aa8dd59c 100644 --- a/docs/admin/oauth-clients.md +++ b/docs/admin/oauth-clients.md @@ -15,11 +15,15 @@ Examples: 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`). +1. **Scope entitlement** — the client may only request scopes that belong to one of its apps (or are global, like the OIDC standard scopes `openid`, `email`, `profile`, `roles`, `permissions`, `offline_access`). +2. **App context for targeted APIs** — a requested resource-bearing scope produces one or more token audiences. Each audience must resolve to an OAuth API, whose `AppId` selects the catalog used for its `resource_access[]` block. The default case is **one client → one app** (`acme-web` belongs to `acme`). Multi-app clients exist for bundle frontends that talk to several resource servers at once. +Selecting an App does **not** automatically add a claim block. A block +exists only when the token actually targets a registered OAuth API in +that App and the request includes `roles` and/or `permissions`. + ::: tip First time? Use the [SaaS App Integration Walkthrough](../integrate/saas-walkthrough) for the linear path through your first integration. ::: @@ -28,7 +32,11 @@ Use the [SaaS App Integration Walkthrough](../integrate/saas-walkthrough) for th Administration → **OAuth → Clients** → **Create**. -The create modal exposes the full configuration up front — identity on the left, and tabs for **Grants**, **Scopes**, **Redirect URIs** and **Apps** on the right. (These used to be edit-only, so a freshly created client was born non-functional.) Set them at create time and the client is usable immediately. +The create modal exposes the full configuration up front in one expert editor: +**General**, **Login & Consent**, **Apps**, **Flows**, **Scopes**, +**Redirects & CORS**, **Tokens & Sessions**, and **Security**. Every tab edits +the same draft and the footer action persists the complete client in one +request. Nothing has to be created first and completed in a second pass. ::: tip authorization_code clients: two create-time requirements For an `authorization_code` client the Create button stays disabled until you have both: at least one **Redirect URI** (URLs tab) and the **`authorization_code`** grant (Grants tab). This stops you from silently producing a client that can't complete a login. @@ -49,8 +57,13 @@ There are exactly two client types — `public` and `confidential`: | **Confidential** | Server-side web apps (ASP.NET, Node, Rails) — can store secrets | Yes | | **Public** | SPAs and mobile apps — can't safely store secrets | No, PKCE only | -::: tip Machine-to-machine? Use a Service Account -There is no separate "service" client type. For server-to-server flows with no user involved, create a [Service Account](./service-accounts) — it owns a confidential client wired to the `client_credentials` grant. The standard create-client form deliberately can't produce a client-credentials client on its own (see the grant-type rules below). +::: tip Machine-to-machine? Link a Service Account +There is no separate "service" client type. For server-to-server flows with no +user involved, use a [Service Account](./service-accounts). Selecting +`client_credentials` in the **Flows** tab reveals the required Service Account +field. You can select an existing account or create a new one directly in the +client editor. The optional new Service Account, client, grant and ownership +link are then persisted atomically by the single Create action. ::: ### Consent type @@ -63,9 +76,14 @@ There is no separate "service" client type. For server-to-server flows with no u ### 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). +The **Applications** multi-select binds the client to one or more apps. Empty means realm-wide/unassigned for App-scope entitlement; it does not mean that tokens automatically receive every App's permissions. -Picking multiple apps means: 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. +Picking multiple apps means the client may request resource-bearing +scopes from each of them. If a request targets `orders-api` and +`billing-api` and includes the `roles` scope, the resulting principal +can contain `resource_access["orders-api"].roles` and +`resource_access["billing-api"].roles`. The keys are API Audiences, +never App slugs inferred from the multi-select. ### Redirect URIs @@ -75,14 +93,19 @@ For SPAs and mobile use a deep link (`com.example.app:/oauth/callback`) or a HTT ### Access Token Type -New clients default to **JWT**. Two options: +New clients default to **Reference**. Two options: | Type | What it is | Validation | | --- | --- | --- | -| **JWT** (default) | Self-contained signed token — the claims are inside the token | The resource server validates it locally against the realm's signing key (JWKS); no callback to Modgud | -| **Reference** | Opaque random string — carries no claims | The resource server must call `/connect/introspect` on every request to resolve it | +| **Reference** (default) | Opaque random string — carries no claims on the wire | The resource server must call `/connect/introspect` on every request to resolve it | +| **JWT** | Self-contained signed token — the claims are inside the token | The resource server validates it locally against the realm's signing key (JWKS); no callback to Modgud | -A resource server built with ASP.NET Core's `AddJwtBearer` expects a **JWT** — that's the right pick for the common case. Use **Reference** only when you specifically want every token resolvable/revocable at the introspection endpoint and you've wired the RS to call it. The [.NET client library](../integrate/resource-server) supports both — `AddModgudClient` for JWT, `AddModgudReferenceTokenClient` for reference tokens. +A resource server configured for local JWT validation expects a +**JWT**. Keep the default **Reference** format when you want every +token resolved and immediately revocable at the introspection endpoint. +The [.NET resource-server library](../integrate/resource-server) uses +one `AddModgudResourceServer` method; its `TokenMode` accepts JWTs, +reference tokens, or both. ### Require Pushed Authorization Requests @@ -119,15 +142,19 @@ Pick the grants the client actually needs (multi-select). There are **no silent ::: warning No hybrid user-flow + client-credentials clients A client is **either** a user-flow client (`authorization_code` / `refresh_token` / `device_code` / …) **or** a machine-to-machine client (`client_credentials`) — never both. The split is structural, enforced at the create/update endpoint: -- `client_credentials` requires the client to be linked to a [Service Account](./service-accounts); the standard create-client form has no such link field, so it rejects a bare `client_credentials` selection. +- `client_credentials` requires the client to be linked to a [Service Account](./service-accounts); the **Flows** tab lets you select an existing account or create one inline before the first save and blocks Create while the link is missing. - A Service-Account-linked client may carry **only** `client_credentials` — adding any user-flow grant alongside it is rejected. -To get machine-to-machine tokens, create a Service Account; it provisions the confidential client + `client_credentials` grant for you. +The reverse workflow remains available too: issuing a credential from a Service +Account provisions its confidential client and `client_credentials` grant +through the same client-creation validation path. ::: ### Lifetimes -The **Token Lifetimes** tab is edit-only (it appears once a client exists, not on the create form). Each field is **entered in seconds**; leaving it empty falls back to the IdP default. The defaults are: +The **Lifetimes** tab is available during create and edit. Each field is +**entered in seconds**. Empty token fields use the IdP default; empty +client-session fields inherit from the linked Application and then the Realm. | Field | Default | In seconds | | --- | --- | --- | @@ -135,9 +162,17 @@ The **Token Lifetimes** tab is edit-only (it appears once a client exists, not o | **Authorization Code Lifetime** | 5 min | `300` | | **Identity Token Lifetime** | OpenIddict default (no Modgud override) | — | | **Sliding Refresh Token Lifetime** | OpenIddict default (no Modgud override) | — | +| **Client Session Idle Lifetime** | App/Realm policy | — | +| **Client Session Absolute Lifetime** | App/Realm policy | — | Access-token, authorization-code and refresh-token defaults are set globally on the IdP (`AccessTokenLifetimeMinutes`, `AuthorizationCodeLifetimeMinutes`, `RefreshTokenLifetimeDays`). The identity-token and sliding-refresh fields have no Modgud-level default — leave them blank unless you have a specific reason to override OpenIddict's built-in value. +Client-session lifetimes control how long refresh-token-backed user sessions +may continue. Idle lifetime slides on successful refresh; absolute lifetime +never slides. Both accept 1–3650 days (`86400`–`315360000` seconds), and the +absolute value must not be shorter than idle. These do not lengthen access +tokens. + ## Editing / regenerating Open a client by double-click. Most fields can be edited live; **Client ID** is immutable after creation. diff --git a/docs/admin/oauth-scopes.md b/docs/admin/oauth-scopes.md index c568e2f3..76cbe798 100644 --- a/docs/admin/oauth-scopes.md +++ b/docs/admin/oauth-scopes.md @@ -15,8 +15,8 @@ realm provisioning and you don't need to manage them: | `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 | +| `roles` | Adds the user's App roles to each matching registered `resource_access[]` block | +| `permissions` | Adds bypass-pre-expanded permissions, narrowed to each matching OAuth API's declared subset | The OIDC-standard `phone` and `address` scopes are recognised by OpenIddict but **not** auto-seeded — add them manually per realm if @@ -26,15 +26,19 @@ you need to expose those claims. For your own APIs/resources you define custom scopes — e.g. `acme.read`, `acme.write`, `crm.api`. -Administration → **OAuth → Scopes** → **Create**. +Administration → **OAuth & Federation → 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 +The editor groups the settings into three tabs: + +- **General** — immutable technical name, display name, description and optional [App](./applications) binding. No application means realm-wide (cross-app, like the standard OIDC scopes). +- **Token content** — API audiences and OIDC user-claim names included for the scope. +- **Behavior** — active state, consent presentation, discovery visibility and Dynamic Client Registration eligibility. + +The scope name is the exact value clients send in `scope=…` requests (for example `acme.read`). It cannot be changed after creation; clone the scope when you need a new name. + +The six seeded standard scopes can be opened for inspection, but are shown read-only because they are managed by the IdP. ### Application binding @@ -42,17 +46,17 @@ App-scoped scopes can only be requested by OAuth clients whose `AppIds` list con If a client requests an app-scoped scope it isn't entitled to, `/connect/authorize` rejects with `invalid_scope`. -### Resources (audience) +### API audiences -A **resource URI** identifies the resource server (API) that accepts tokens. Example: +An audience identifies the resource server (API) that accepts tokens. It can be a stable identifier or an absolute URI. Example: - Scope: `acme.read` -- Resource: `https://api.acme.example.com` +- Audience: `acme-api` -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. +When a client requests `scope=acme.read` and gets back an access token, the token's `aud` claim contains `acme-api` — the Acme API checks exactly that during token validation and rejects everything else. ::: warning Audience mismatch -If the 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. +If the audience here is spelled differently from how the API checks during validation, every API request fails with `401 Unauthorized — invalid audience`. For URI audiences, scheme, host, trailing slash and port are significant. Keep both sides in sync. ::: ### Discovery visibility @@ -81,7 +85,7 @@ Scope **Name** is immutable, so to make a variant of an existing scope, clone it ## Deleting a scope -List → right-click → **Delete** (soft delete). +List → right-click → **Delete** (soft delete). Standard scopes cannot be deleted; their menu action is disabled. ::: warning Active tokens stay valid Already-issued tokens carrying the deleted scope remain valid until their lifetime expires — deletion only affects newly issued tokens. For compromised scopes, also revoke active tokens or set the shortest practical token lifetime. diff --git a/docs/admin/realm-settings.md b/docs/admin/realm-settings.md index e9d589b4..d76c613f 100644 --- a/docs/admin/realm-settings.md +++ b/docs/admin/realm-settings.md @@ -8,7 +8,7 @@ ::: ::: tip These are the realm defaults — Applications can override them -The Self-Registration, Registration-Fields, DCR, CIMD and Native Passwordless +The Self-Registration, Registration-Fields, Client Sessions, DCR, CIMD and Native Passwordless Grants policies (and branding / email branding) set here are the **realm defaults**. An individual [Application](./applications#application-settings) can override a slice of them per-app (sparse, field by field — anything it @@ -23,6 +23,7 @@ The page currently has these tabs: - [Self-Registration](#self-registration) — public sign-up policy - [Registration Fields](#registration-fields) — which identity fields are required when an account is created +- [Sessions](#sessions) — browser/SSO policy and the native/OAuth client-session default - [Dynamic Client Registration](#dynamic-client-registration) — anonymous OAuth-client registration policy (linked detail page: [Dynamic Client Registration](./dynamic-client-registration)) @@ -171,6 +172,27 @@ Off by default. See the full feature page for when to enable it, what gets accep → **[Dynamic Client Registration](./dynamic-client-registration)** (full feature page) +## Sessions + +Browser and native clients deliberately use different policies: + +| Policy | Default | Meaning | +| --- | --- | --- | +| Browser idle lifetime | 30 days | Sliding inactivity window for the shared realm SSO cookie | +| Browser absolute lifetime | 180 days | Hard limit from interactive sign-in; activity never extends it | +| Allow remember me | on | Whether a caller may request a browser-persistent cookie | +| Client-session idle lifetime | 30 days | Sliding window renewed when a native/OAuth app uses its refresh token | +| Client-session absolute lifetime | 365 days | Hard limit before the app must perform a new user sign-in | + +Client-session values support 1–3650 days. Ten years is therefore valid for +low-risk consumer apps where forced periodic login would be disruptive. +Access tokens stay short-lived and independent of this setting. + +Resolution order is **OAuth client → Application → Realm**. Empty App/client +fields inherit the next level. A client linked to several Applications uses +the strictest participating App policy unless the client has an explicit +override. + ## Rate Limits Per-IP request ceilings for this realm's auth endpoints. Each policy is a **max requests / window (minutes)** pair, partitioned by source IP and applied **per realm**. The shipped defaults are the secure production posture — the knob exists so a test realm, dev, or a legitimately bursty consumer can raise a ceiling **without a modgud code change + redeploy**, and so a hardened realm can tighten one. diff --git a/docs/admin/realms.md b/docs/admin/realms.md index 6da0d122..7d5c2b12 100644 --- a/docs/admin/realms.md +++ b/docs/admin/realms.md @@ -90,11 +90,6 @@ Admin → **Realms** → **Create**. | Description | `Production tenant for Acme` | | Domains | `acme.auth.example.com` | | Primary Domain | `acme.auth.example.com` — defaults to the first domain; pick which one is canonical when a realm has several | -| **Initial admin** | **required** — UserName + Email of the recipient who'll bootstrap the realm | - -The Initial-Admin block is mandatory. A realm with no admin path -would be unreachable; the UI rejects the form if either UserName or -Email is empty. On save, Modgud: @@ -109,18 +104,16 @@ On save, Modgud: 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. +7. Finishes the realm creation. Creating a realm and inviting an + administrator are deliberately separate actions. + +To add an administrator, open the realm's context menu and choose +**Realm-Admin einladen**. The recipient clicks the magic link, lands +on `/bootstrap?token=…` in the realm's SPA, sets their own password, +and is auto-signed-in. + +Only one admin invitation can be open in a realm. A new invitation +revokes the previous link, is valid for 24 hours, and can be used once. ## Editing a realm diff --git a/docs/admin/roles.md b/docs/admin/roles.md index 9789ff63..bfa71862 100644 --- a/docs/admin/roles.md +++ b/docs/admin/roles.md @@ -1,6 +1,9 @@ # Roles -A **role** bundles permissions for one app. Users receive roles only through their [groups](./groups) — never directly. +An **application role** bundles permissions for exactly one app. A pure +`realm:admin` role is the explicit exception: it has no Application link or +catalog permissions and grants the bypass across every app in its own realm. +Users receive roles only through their [groups](./groups) — never directly. ![Roles list](/screenshots/admin-rollen-liste.png) @@ -42,7 +45,7 @@ The app is never part of the string — it comes from the role's Application link (or, for the built-in `modgud`/`control-plane` admin surfaces, from the endpoint being called). Plus two bypass tiers: -- **`realm:admin`** — realm-wide. The holder may do anything in any app. Set via the role's **Privileged role** flag, not a catalog entry. +- **`realm:admin`** — current-realm-wide. The holder may do anything in any app in this realm, but gains nothing in another realm. It is represented by a pure realm-admin role, not a catalog entry. - **`:admin`** — resource-wide, within the role's linked Application (e.g. `user:admin` bypasses both `user:read` and `user:write`). There is no app-wide bypass tier — bypass is either realm-wide or resource-wide, nothing in between. @@ -105,12 +108,12 @@ The modal has two tabs: - **Name** (unique per realm) - **Description** (optional) - **Application** — which app does this role belong to? Pick "— None - (realm-admin role)" for a pure bypass role (only meaningful together - with **Privileged role** below); otherwise a role belongs to exactly - one Application. -- **Privileged role** — a checkbox, independent of the Application - link. Grants `realm:admin` — the realm-wide bypass. Reserved for the - System Admin role. + (realm-admin role)" only for a pure bypass role; otherwise a role + belongs to exactly one Application. +- **Privileged role** — switches the role into the pure realm-admin mode. + Enabling it clears and disables the Application link and catalog + permissions. It grants `realm:admin` in this realm only and is reserved + for the System Admin role. **Permissions** @@ -128,7 +131,7 @@ all from the `modgud` catalog, all on one role. ## Cloning a role -To make a variant of a role — say a tighter copy of an existing one — right-click it in the list → **Clone**. The Create modal opens pre-filled: the linked Application, the selected permission subset and the realm-admin flag are copied; only the **Name** is blank. Give the copy a new name, adjust the permission selection, and create. +To make a variant of a role — say a tighter copy of an existing one — right-click it in the list → **Clone**. The Create modal opens pre-filled: for an application role, the linked Application and selected permission subset are copied; for a realm-admin role, only the pure realm-admin mode is copied. The **Name** is blank. Give the copy a new name, adjust the selection, and create. ## Cross-app roles (special case) @@ -146,7 +149,7 @@ A role becomes a bypass role through either of two mechanisms: | Mechanism | Effect | | --- | --- | -| **Privileged role** checkbox set | realm-wide bypass (`realm:admin`) — works in every app, ignores the Application link | +| Pure **Privileged role** | current-realm-wide bypass (`realm:admin`) — works in every app in this realm and has no Application link | | A catalog entry with action `admin` checked (e.g. `user:admin`) | resource-wide bypass — every action on that resource, within the role's linked Application | There's no app-wide bypass in between — a role is either realm-wide or scoped down to individual resources. @@ -168,5 +171,9 @@ Many small roles, each tied to a clear resource, compose freely into groups. A " ::: ::: tip Per-app roles -Roles for Acme-Tasks link to the Acme-Tasks Application, not `modgud`. They show up in the right permission lists, and `[Authorize(Roles = "...")]` in the Acme-Tasks backend finds them via the `resource_access["acme-tasks"]` claim in the token. +Roles for Acme-Tasks link to the Acme-Tasks Application, not `modgud`. +If its backend is registered with Audience `acme-tasks-api`, then +`[Authorize(Roles = "...")]` finds them through +`resource_access["acme-tasks-api"].roles` when the token targets that +API and the `roles` scope was granted. ::: diff --git a/docs/admin/scheduled-jobs.md b/docs/admin/scheduled-jobs.md index a1fde5fd..74813fa8 100644 --- a/docs/admin/scheduled-jobs.md +++ b/docs/admin/scheduled-jobs.md @@ -5,7 +5,7 @@ description: Tenant-admin surface for the realm's background scheduled jobs — # 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. +**Scheduled Jobs** are the realm's recurring background tasks — garbage collection, retention sweeps, periodic housekeeping. Each job ships with a sensible default schedule baked into the build; admins can override the cron expression, tweak per-job parameters, disable scheduled runs (manual runs remain available), trigger an out-of-band run, or read the last 50 executions per job — all from one page. ## Surface @@ -17,21 +17,31 @@ description: Tenant-admin surface for the realm's background scheduled jobs — 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. +Every realm job has its own Quartz job + trigger. Run history (`JobRunHistoryEntry`) and per-job overrides (`JobConfig`) live in the **owning realm's** Marten DB. Changing or manually starting a job affects that realm only. ::: ## Registered jobs -Six jobs ship with Modgud today. Most of them iterate every active realm internally — you see one row per job, not one row per (job, realm). The exception is `security-audit-prune`, which operates on a single cross-realm store rather than per realm. +Nine job definitions ship with Modgud today: + +- Seven are **realm jobs**. Each active realm gets an independent Quartz job and trigger, so one customer can run at 18:00, another at 21:00, and another can disable its cron and run manually. +- Two are **system jobs**: `system-job-run-history-retention` and `platform-audit-prune`. Each exists exactly once because it operates on a deployment-wide store, and is visible/configurable only in the realm that currently holds the Control-Plane role. + +The Control-Plane realm is still a realm, so it also owns its own copies of all seven realm jobs. + +System-job configuration and history live in the non-tenanted global store, +not in the Control-Plane realm's database. Transferring the Control-Plane role +therefore moves visibility and authority, but not the system job's data or +schedule. ### `inbox-retention` — Inbox Retention -Applies the per-kind inbox retention policy across every active realm. +Applies this realm's per-kind inbox retention policy. - **Default cron:** `0 0 3 * * ?` (03:00 UTC daily) - **Parameters:** none — retention rules are configured separately under [Inbox Settings](/platform/inbox). -- **What it does:** loads 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)). +- **What it does:** loads the owning realm's `InboxRetentionSettings` doc, dismisses or hard-deletes items per the configured policy, and reports per-reason counts in the run summary. +- **On failure:** the failure is written to that realm's history and an inbox notification fires there (see [Failure notification](#failure-notification)). ### `job-run-history-retention` — Job-Run-History Retention @@ -41,7 +51,7 @@ Trims the per-tenant `JobRunHistoryEntry` document table so it doesn't grow unbo - **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. +- **What it does:** two independent passes in this realm (age cutoff + per-key count cap), summed and reported. - **On failure:** logged + inbox-notified. ::: tip Two independent caps @@ -54,7 +64,7 @@ Soft-deletes [Dynamic Client Registration](./dynamic-client-registration) client - **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. +- **What it does:** when DCR is enabled in this realm, finds DCR-registered clients whose last-used timestamp is older than `now − GcTtlDays` and soft-deletes them via the OAuth application aggregate. A realm with DCR disabled is skipped after a single indexed lookup. - **On failure:** logged + inbox-notified. Soft delete means client_id history stays intact for forensics. ### `signing-key-janitor` — Signing Key Janitor @@ -63,26 +73,69 @@ Hard-deletes per-realm OAuth/OIDC signing keys whose rotation overlap window has - **Default cron:** `0 0 5 * * ?` (05:00 UTC daily — after the GC + retention jobs) - **Parameters:** none — the overlap window is a fixed 30 days. -- **What it does:** for every realm (including deactivated ones, whose retired keys still hold private signing material), deletes signing keys where `RetiredAt + 30 days < now`. Active keys and keys still inside their overlap window are left untouched. Realms with nothing expired finish after a single indexed lookup. See [Realm Settings → Signing Keys](./realm-settings#signing-keys) for the rotation that produces these retired keys. +- **What it does:** in its owning realm, deletes signing keys where `RetiredAt + 30 days < now`. Active keys and keys still inside their overlap window are left untouched. This is the one realm job whose trigger remains scheduled while a realm is deactivated, because soft-delete retains that realm's database and private key material. See [Realm Settings → Signing Keys](./realm-settings#signing-keys) for the rotation that produces these retired keys. - **On failure:** logged + inbox-notified. ### `account-lifecycle-sweep` — Account Lifecycle Sweep -Drives the account-deletion deadlines across every active realm: sends "about to be deleted" reminders, erases self-service deletion requests whose grace period has passed, and auto-purges admin recycle-bin users past their retention deadline (when auto-purge is enabled for the realm). Also prunes used/expired registration invite codes as a hygiene side effect. +Drives this realm's account-deletion deadlines: sends "about to be deleted" reminders, erases self-service deletion requests whose grace period has passed, and auto-purges admin recycle-bin users past their retention deadline (when auto-purge is enabled for the realm). Also prunes used/expired registration invite codes as a hygiene side effect. - **Default cron:** `0 30 3 * * ?` (03:30 UTC daily) - **Parameters:** none — deadlines and lead times come from [Realm Settings → Account Deletion](./realm-settings#account-deletion). -- **What it does:** for each realm, runs the self-service reminder/erasure sweep, the admin recycle-bin auto-purge sweep, and the invite-code prune, then reports counts for each. See [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) for the lifecycle this job enforces. -- **On failure:** logged per realm; the sweep continues with the remaining realms. +- **What it does:** runs the self-service reminder/erasure sweep, the admin recycle-bin auto-purge sweep, and the invite-code prune in the owning realm, then reports counts for each. See [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) for the lifecycle this job enforces. +- **On failure:** that realm's run fails and is written to its own history; no other realm's run is affected. + +### `session-prune` — Session Prune + +Removes expired browser/SSO and native OAuth client-session documents from +this realm. + +- **Default cron:** `0 15 4 * * ?` (04:15 UTC daily) +- **Parameters:** none — expiry is determined from each session's idle and + absolute lifetime. +- **What it does:** deletes `UserSession` and `ClientSession` rows whose idle + or absolute expiry has passed. Runtime cookie and refresh-token validation + already rejects an expired row, so pruning is storage hygiene rather than + the enforcement boundary. +- **On failure:** that realm's run fails and is written to its own history; no + other realm's run is affected. ### `security-audit-prune` — Security Audit Prune -Hard-deletes security/ops audit entries older than a fixed 7-day retention window. +Hard-deletes this realm's structured Security events after its configured +retention window. + +This is a **realm job**: every realm has its own trigger, configuration and run +history. - **Default cron:** `0 0 2 * * ?` (02:00 UTC daily) -- **Parameters:** none — the 7-day retention is fixed and not configurable per realm. -- **What it does:** deletes entries older than the retention window from the single cross-realm audit store in one indexed delete — there's no per-realm iteration for this job. -- **On failure:** logged + inbox-notified. +- **Parameters:** none on the job. Retention is configured under **Realm + settings → Logs** (default 7 days, range 1–365). +- **What it does:** deletes only expired `RealmSecurityAuditEvent` documents + from the owning physical realm DB. +- **On failure:** only that realm's run fails. + +### `platform-audit-prune` — Platform Audit Prune + +Hard-deletes PII-free deployment events from the Global Store. This is a +deployment-wide **system job**, visible only in the Control Plane. + +- **Default cron:** `0 15 2 * * ?` (02:15 UTC daily) +- **Parameter:** `retentionDays` (default 365, range 1–3650) +- **What it does:** deletes expired `PlatformAuditEvent` documents only. + +### `system-job-run-history-retention` — System Job-Run-History Retention + +Trims only the execution history of deployment-wide system jobs in the non-tenanted global store. + +This is itself a deployment-wide **system job**: it appears only in the current Control-Plane realm and has only one Quartz trigger. It is deliberately separate from `job-run-history-retention`, because a realm-owned job must never read or mutate platform metadata. + +- **Default cron:** `0 45 3 * * ?` (03:45 UTC daily) +- **Parameters:** + - **Max. age in days** — runs older than this are deleted. Default `30`. Leave blank to disable the age sweep. + - **Max. entries per job** — keep only the N newest entries per system-job key. Default unlimited. +- **What it does:** applies the same two independent retention caps as the realm job, but exclusively inside the global store. +- **On failure:** logged + inbox-notified through the current Control-Plane realm. ## Job-detail modal @@ -91,7 +144,7 @@ Double-click any row (or open `/admin/scheduled-jobs#`) to get a three- | Tab | What it shows | | --- | --- | | **Schedule** | Cron expression input (placeholder shows the registration default), enabled toggle, **Run now** button, and the computed **Next run** timestamp. | -| **Configuration** | One field per `JobParameterField` declared by the job, grouped by `Section` when set. Empty value = fall back to the schema's `Default`. Tab is hidden for jobs with no tunable parameters — currently every job except `job-run-history-retention`. | +| **Configuration** | One field per `JobParameterField` declared by the job, grouped by `Section` when set. Empty value = fall back to the schema's `Default`. Tab is hidden for jobs with no tunable parameters — currently every job except the realm and system job-history-retention jobs. | | **History** | Last 50 runs, newest first. Success runs show duration + optional one-line summary. Failed runs show the first-line error message and an expandable stack trace. Manual triggers carry a `manual` tag. | The modal's footer **Save** button persists Schedule + Configuration in one shot; the trigger button on the Schedule tab is independent. @@ -107,7 +160,7 @@ The scheduled cron is unaffected — the job's next regular run still fires per ## 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 cron field on the Schedule tab is a **Quartz 7-field expression** (sec min hour day-of-month month day-of-week year). When the field is **empty** the job uses the registration default; when set, the override is persisted and applied to the live scheduler immediately. Realm-job overrides live in that realm's Marten DB; system-job overrides live only in the non-tenanted global store. The endpoint validates the expression server-side (`CronExpression.IsValidExpression`) and returns `400` with a clear error if it parses wrong — you won't see a runtime scheduler failure later. diff --git a/docs/admin/service-accounts.md b/docs/admin/service-accounts.md index e68977ff..cc1ed463 100644 --- a/docs/admin/service-accounts.md +++ b/docs/admin/service-accounts.md @@ -117,7 +117,7 @@ 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. +- `resource_access` — per-audience `roles` and `permissions` blocks built from the SA's group/role/permission chain when the request targets registered OAuth APIs and includes the corresponding claim scopes. A JWT carries the claim directly; a reference token keeps it in the server-side payload for authorized introspection. The `client_credentials` flow has no UserInfo round-trip in practice. The downstream API validates the token, reads `sub`, and gates access exactly the same way it does for a Person — the permission evaluator doesn't care whether the principal is a Person or a ServiceAccount. diff --git a/docs/concepts/abac.md b/docs/concepts/abac.md index 2806376b..cae7e932 100644 --- a/docs/concepts/abac.md +++ b/docs/concepts/abac.md @@ -9,7 +9,7 @@ This page explains where the line is, why it sits there, and how an app can laye - **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`. +- **Resolution** — a single decision per `(user, app, permission)`, propagated through an audience-keyed `resource_access` block when the corresponding OAuth API audience and claim scopes are present. That's the whole authorisation surface from the IAM. Every grant the IAM emits is **schema-free**: there is no `tenantId`, no `ownerId`, no row-level filter. Only "user X holds permission `app:resource:action` in app A". diff --git a/docs/concepts/apps-and-resource-access.md b/docs/concepts/apps-and-resource-access.md index 124a1190..c88999a7 100644 --- a/docs/concepts/apps-and-resource-access.md +++ b/docs/concepts/apps-and-resource-access.md @@ -2,9 +2,10 @@ This page explains the mental model behind Modgud's permission system: what an "App" is, how it relates to OAuth concepts, how Modgud's own -per-audience authorization claim — shaped like Keycloak's nested -`resource_access` format for familiarity — works, and how the -permission resolver gets from a logged-in user to a concrete answer. +audience-keyed authorization claim — shaped like Keycloak's nested +`resource_access` format for familiarity — works at the token boundary, +and how the permission resolver gets from a logged-in user to a +concrete answer. ## The four-axis model @@ -91,9 +92,11 @@ 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. +linked to all three Apps via its `AppIds` list. That link makes the +Apps' scopes requestable; when the request targets registered OAuth +APIs in those Apps and includes `roles` and/or `permissions`, the +issued token can carry one `resource_access` block per targeted API +Audience. Each backend reads its own block. The two flexibilities together let Modgud represent any reasonable architecture without forcing you into "everything is one app" or @@ -128,16 +131,17 @@ are handled differently by each: markers back from the resolver and check them lazily at gate time — `realm:admin` or `:admin` in the user's permission set is enough to pass, with no expansion into concrete strings. -- **The per-Audience `resource_access` block** on - `/connect/userinfo` bypass-pre-expands those same markers into - concrete catalog strings before the token is emitted (see below), - so token consumers never have to special-case them. +- **The per-Audience `resource_access` block** at the token boundary + bypass-pre-expands those same markers into concrete catalog strings + before emission (see below), so token consumers never have to + special-case them. ## The token shape -When a user logs in via an OAuth Client linked to apps `[billing, -shipping]`, the access token's `/connect/userinfo` response (with -appropriate scopes granted) contains a nested claim shaped like +When a user logs in via an OAuth Client entitled to the `billing` and +`shipping` Apps, requests scopes targeting the registered audiences +`billing-api` and `shipping-api`, and receives the appropriate claim +scopes, the access-token principal contains a nested claim shaped like Keycloak's `resource_access` format: ```json @@ -147,11 +151,11 @@ Keycloak's `resource_access` format: "name": "Alice", "resource_access": { - "billing": { + "billing-api": { "roles": ["Editor"], "permissions": ["invoice:read", "invoice:write"] }, - "shipping": { + "shipping-api": { "roles": ["Viewer"], "permissions": ["shipment:read"] } @@ -159,26 +163,32 @@ Keycloak's `resource_access` format: } ``` -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. +Each resource server reads its own exact Audience block. The Billing +API sees `resource_access["billing-api"]`; the Shipping API sees +`resource_access["shipping-api"]`. Both blocks may be present +side-by-side in a multi-audience claim, but each authentication scheme +projects only its configured Audience. -The `Modgud.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. +The `Modgud.AspNetCore.ResourceServer` authentication handlers take the +matching audience block and project its roles onto `ClaimTypes.Role`, so +`[Authorize(Roles="Editor")]` works out of the box without global claims +state or per-endpoint plumbing. ### What gets emitted is opt-in by scope +- A block is considered only when an `aud` value resolves to a + registered OAuth API linked to an App. - `scope=roles` → emit the `roles` array per Audience block. - `scope=permissions` → emit the `permissions` array per Audience block (bypass-pre-expanded and narrowed to that RS's `OAuthApi.PermissionIds` subset). -Without those scopes, the block is omitted (or empty). Clients ask -for exactly what they need; tokens stay lean. +Without either claim scope, or without a matching registered API +audience, the entire `resource_access` claim is omitted. Clients ask +for exactly what they need; tokens stay lean. JWT access tokens carry +the claim directly, reference tokens retain it in their server-side +payload for authorized introspection, and UserInfo returns the same +eligible block. ### Per-RS subset narrowing @@ -195,9 +205,10 @@ 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. +- **Blocks for audiences the token does not target.** An App link + controls which App-scoped scopes the client may request; only the + resulting registered OAuth API audiences become `resource_access` + keys. - **`realm:admin` as a literal string.** It's bypass-pre-expanded into concrete catalog strings before emission, so consumers do straight exact-match without needing to mirror the evaluator's @@ -231,11 +242,13 @@ 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. +**`OAuthApplication.AppIds` is `n:m` (a client can be entitled to +scopes from many apps).** **`OAuthApi.AppId` is `1:1` (a resource +server belongs to one app).** The client link does not itself create +claim blocks; requested scopes/resources create token audiences, and +each registered audience resolves through its API to exactly one App. +The asymmetry supports one frontend calling many resource servers +without muddling each server's catalog and audit context. ## Glossary @@ -254,6 +267,7 @@ would muddle the audit trail. (null when `IsRealmAdmin = true`). - **Permission** — `:` string within one App's catalog. App context is implicit from the catalog container. -- **`resource_access`** — Modgud's own per-audience UserInfo claim, - shaped like Keycloak's nested format, keyed by app slug, with - bypass-pre-expanded permissions narrowed per-RS. +- **`resource_access`** — Modgud's own token-bound authorization + claim, shaped like Keycloak's nested format and keyed by exact OAuth + API Audience, with scope-gated roles and bypass-pre-expanded + permissions narrowed per resource server. diff --git a/docs/concepts/authentication.md b/docs/concepts/authentication.md index 7f44ffef..ed60180e 100644 --- a/docs/concepts/authentication.md +++ b/docs/concepts/authentication.md @@ -18,12 +18,12 @@ Implemented in the **Authentication slice** | Method | When | Cookie lifetime | |---|---|---| -| **Password** | Default, allowed at AuthLevel 0/1 | Session or 30 days (RememberMe) | +| **Password** | Default, allowed at AuthLevel 0/1 | Session or realm browser-session policy (RememberMe) | | **TOTP** | Second factor after password | Inherits from the password step | | **Email OTP** | Second factor — or as an alternative login | Inherits from the password step | -| **Passkey (FIDO2)** | Second factor — or as a sole login (passwordless) | 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 | +| **Passkey (FIDO2)** | Second factor — or as a sole login (passwordless) | Realm browser-session policy | +| **Magic Link** | Email with single-use token; can also be sent by an admin | Realm browser-session policy | +| **OIDC/SAML External** | Federated login via an upstream IdP | Realm browser-session policy | See [Login flows](/integrate/login-flows) for details. @@ -44,7 +44,7 @@ authenticated requests from users without 2FA (with a grace period). | Cookie | Purpose | SameSite | Lifetime | |---|---|---|---| -| `Modgud.Auth` | Main session (HttpOnly) | Lax | Session or 30 days | +| `Modgud.Auth` | Main session (HttpOnly) | Lax | Session or realm browser-session policy | | `Modgud.2FA` | UserId between password step and 2FA step | Strict | 5 min | | `Modgud.External` | OIDC callback holder | Lax | 10 min | | `Modgud.Session` | Only for passkey attestation options | Strict | 5 min idle | @@ -125,36 +125,34 @@ Plus **recovery codes** as a last-resort backup. A passkey is registered against a WebAuthn relying-party ID, and Modgud uses the realm's **PrimaryDomain** as that ID. A passkey therefore only works when the user reaches the realm on its primary domain — not via a secondary domain in the realm's `Domains` list — and changing the realm's PrimaryDomain invalidates every existing passkey (affected users must re-register). See [Realms — primary domain](/operate/realms#primary-domain). ::: -## External login (OIDC IdPs and SAML) +## External login (OIDC and SAML) -Users can sign in via external OIDC providers (Entra ID, Google, -Auth0, ...). Configurable per realm. +Users can sign in through Microsoft Entra ID and standards-compatible OIDC +or SAML providers. Providers are configured independently 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 +1. Admin creates an OIDC or SAML `LoginProvider`. +2. The login page shows a button for every enabled external provider. +3. OIDC uses Authorization Code + PKCE. SAML uses an SP-initiated + AuthnRequest and a correlated ACS response. +4. After protocol validation, `ExternalLoginProcessor` runs: - Looks up `ExternalIdentityLink` (issuer + subject) → existing user or JIT-create - `UserUpdateScript` (Jint) maps claims to user fields -5. If the user has 2FA enabled, the normal 2FA flow runs afterwards -6. Login cookie is set (always 30 days) +5. If the user has 2FA enabled, the normal 2FA flow runs afterwards. +6. The realm's browser-session policy determines the login-cookie lifetime. -See [Login providers (OIDC)](/integrate/login-providers) -for details. - -Modgud also supports **SAML 2.0** as an external login provider type -(`LoginProvider` of `Type = Saml`), for IdPs that only speak SAML. It -follows the same JIT-create-on-first-login shape as OIDC. See -[SAML federation](/admin/saml-federation) for setup. +Modgud consumes SAML only as a Service Provider and accepts only +SP-initiated, correlated responses. IdP-initiated SSO, SAML Single Logout +and Artifact Binding are outside the v1 surface. See +[Login providers](/integrate/login-providers) and +[SAML federation](/admin/saml-federation). ## Account lifecycle | How does a user enter the system? | Mechanism | |---|---| | Self-registration | Registration form (when enabled for the realm) | -| External login | OIDC IdP → JIT-create on first login | +| External login | OIDC/SAML IdP → JIT-create on first login | | Admin-created | Admin creates the user via the UI | | Setup | First-time setup — the first user becomes system admin | diff --git a/docs/concepts/control-plane.md b/docs/concepts/control-plane.md index 684e1893..8615ca73 100644 --- a/docs/concepts/control-plane.md +++ b/docs/concepts/control-plane.md @@ -33,12 +33,10 @@ carries the **stored** `Realm.IsControlPlane` flag: public bool IsControlPlane { get; set; } // stored, transferable ``` -The bootstrap (`system`) realm is stamped with the flag at first boot -(`EnsureSystemRealmExistsAsync`), but the slug is only the default anchor -*name* — it no longer determines control-plane status. The flag is -**transferable** to any active realm, so a deployment that starts -single-tenant can later hand cross-realm administration to a different realm -and let the original system realm become an equal, deletable peer. +The first-installation API stamps the first ordinary realm with the flag only +after its first `realm:admin` has been created. No realm is special by slug. +The flag is **transferable** to any active realm, so a deployment that starts +single-tenant can later hand cross-realm administration to another realm. ### Authority = realm:admin in the flag-holding realm @@ -60,10 +58,9 @@ It is enforced defensively, not by a DB constraint: - `TransferControlPlaneAsync` clears the flag on every other holder in the same transaction — self-healing an accidental multi-holder state down to exactly the target. -- At boot, `EnsureSystemRealmExistsAsync` adopts the flag onto the system - realm **only when no realm currently holds it**. This is the load-bearing - guard that makes a transfer durable across reboots — without it every boot - would steal the flag back to `system`. +- The initial realm receives the flag only while the global realm registry is + empty. Normal realm creation never sets it, and startup never assigns or + moves it, so a transfer remains durable across reboots. `RealmProvisioningService` still blocks deactivating or deleting the realm that currently holds the flag — losing it would lock the deployment out of @@ -221,28 +218,25 @@ dotnet Modgud.Api.dll recover bootstrap-admin \ ### 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: +It is CP-only (gated by all three layers above). Realm creation and +administrator onboarding are separate operations: 1. Creates the realm (DB, OAuth scopes, login providers, app seeding) -2. 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 }}` +2. A CP admin may later call + `POST /api/admin/realms/{slug}/admin-invites` +3. The API issues a `PendingAdminInvite`, sends the email, and returns + its one-time `MagicLinkUrl` -The SPA reveals the `MagicLinkUrl` once after creation — useful in +The SPA reveals the `MagicLinkUrl` once after invitation — 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`) +- 24-hour TTL (`PendingAdminInvite.DefaultExpirationHours`) - 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 +- A new invite revokes every prior open invite — there is at most one + consumable admin invitation per realm ### Anti-race-window diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md index fb068ccd..2ab8b42e 100644 --- a/docs/concepts/glossary.md +++ b/docs/concepts/glossary.md @@ -200,9 +200,10 @@ aggregate per entry, with a `Type` discriminator. Configurable per realm. | **Saml** | Wired up | External SAML 2.0 IdPs. See [SAML federation](/admin/saml-federation). | | **Ldap** / **Kerberos** | Reserved | Enum values exist; create endpoint rejects with `LoginProvider.TypeNotSupported`. The shape ships now so the FE doesn't have to add a "not supported yet" UI per type later. | -Configured Oidc providers automatically show "Login with {Provider}" -buttons in the login UI. Internal never produces an SSO button — it -backs the local username/password form. +Configured OIDC and SAML providers automatically show +"Login with {Provider}" buttons in the login UI. Internal never produces an +SSO button — it backs the local username/password form. SAML is SP-only and +SP-initiated in v1. --- diff --git a/docs/concepts/groups-and-authorization.md b/docs/concepts/groups-and-authorization.md index 139d1673..3bfc1fa6 100644 --- a/docs/concepts/groups-and-authorization.md +++ b/docs/concepts/groups-and-authorization.md @@ -41,8 +41,9 @@ 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. +catalog; when a token-bound `resource_access` block is built, the +OAuth API identified by the audience determines which App catalog +applies. | Example | Meaning | | --- | --- | @@ -64,7 +65,7 @@ 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, +For the full evaluator + emission story (per-Audience token claims, bypass-pre-expansion, per-RS subset narrowing) see the canonical [Permissions reference](./permissions). @@ -72,7 +73,8 @@ bypass-pre-expansion, per-RS subset narrowing) see the canonical 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 +Application PermissionRoles bind to one app (via `AppId`); a pure +`realm:admin` role is the explicit exception. Groups carry an activation list (via `BoundTo`). A group's `BoundTo` field is the **activation switch**: it lists the diff --git a/docs/concepts/permissions.md b/docs/concepts/permissions.md index a68b00a9..28b72fa4 100644 --- a/docs/concepts/permissions.md +++ b/docs/concepts/permissions.md @@ -1,9 +1,9 @@ # 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). +sidebar item checks a single permission string. The IdP evaluates and +pre-expands grants; resource servers perform exact claim checks through +`Modgud.AspNetCore.ResourceServer`. ## Permission format @@ -13,9 +13,9 @@ 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). +- For resource-server gates, the token audience resolves an OAuth API; + that API's `AppId` selects the permission catalog. The OAuth API + Audience and App slug are separate identifiers and need not match. This is enforced at write-time: catalog entries are validated against the regex `^[a-z0-9-]+:[a-z0-9-]+$` — exactly two lowercase segments, @@ -73,8 +73,10 @@ in between). Per-area owners typically get per-resource `: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. +`Modgud.Permissions.Abstractions/PermissionEvaluator.cs` and is used +inside Modgud. At the token boundary Modgud pre-expands bypasses; the +resource-server package performs exact checks against the projected +concrete claims and does not run the evaluator. ## Resources @@ -114,10 +116,17 @@ a migration. ## How resource servers receive permissions -Resource servers read permissions from a standard OIDC UserInfo call. -For each audience (`aud`) the access token names, Modgud emits a -**`resource_access`** block on `/connect/userinfo`, shaped like -Keycloak's nested format: +For each token audience (`aud`) that resolves to a registered OAuth API +linked to an App, Modgud can build a **`resource_access[]`** +block shaped like Keycloak's nested format. `roles` must be granted to +emit its role array; `permissions` must be granted to emit its +permission array. If neither scope is present, or no audience resolves +to an OAuth API with an App, the whole claim is absent. + +For JWT access tokens the claim is carried on the wire. For reference +tokens it remains in the server-side payload and is exposed only through +authorized introspection. `/connect/userinfo` returns the same block for +an eligible bearer token: ```json { @@ -138,8 +147,9 @@ Keycloak's nested format: 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. + every concrete catalog string in the audience's linked App; a + `:admin` bypass is expanded into every `:*` string in that + App's catalog. Consumers do straight exact-match — no PermissionEvaluator port required client-side. - **Per-RS-subset narrowing** — each audience block is narrowed to @@ -149,12 +159,13 @@ What's in the block: 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. + permissions array and `scope=roles` to see the role array. Requesting + only one never implicitly adds the other. -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. +The `Modgud.AspNetCore.ResourceServer` authentication handlers project +the matching audience block onto the principal so standard ASP.NET Core +`[Authorize(Roles="…")]` and `RequireModgudPermission(…)` work out of +the box. ## Backend gating: `RequiresPermission` diff --git a/docs/concepts/realms.md b/docs/concepts/realms.md index dfdc3c3e..41ab8fe3 100644 --- a/docs/concepts/realms.md +++ b/docs/concepts/realms.md @@ -12,7 +12,7 @@ Per realm: - 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 **login providers** (Internal + OIDC/SAML IdPs) - its own **cookie domain** - its own **auth rate-limit ceilings** (per-IP request limits on login/register/etc., overridable per realm) diff --git a/docs/concepts/security-model.md b/docs/concepts/security-model.md index 925c343a..09b0df04 100644 --- a/docs/concepts/security-model.md +++ b/docs/concepts/security-model.md @@ -41,7 +41,7 @@ This page is one aggregated, honest view of Modgud's OAuth 2.0 / OpenID Connect | DCR / CIMD abuse | Both off by default, gated per realm + API + scope; CIMD's outbound fetch is SSRF-hardened; accepted residual risks (brand impersonation, targeted phishing via redirect URI) are documented rather than hidden. | [Dynamic Client Registration](/admin/dynamic-client-registration), [Client ID Metadata Documents](/admin/client-id-metadata-documents) | | Membership scripts | Auto-membership predicates run through a sandboxed TypeScript-to-LINQ translator, tested against an adversarial suite covering resource exhaustion, native-host escape, type confusion, cross-tenant probing, injection, and information disclosure. | [Automated tests](/contribute/testing/automated-tests) | | Tenant isolation | Realm boundaries are physical database separation, not a query filter. | [Realms](./realms) | -| Operational security | Per-realm rate-limit ceilings on auth endpoints, a 7-day security log for threat signals, and a separate GDPR-aware audit trail for admin/config changes. | [Auth Log](/admin/auth-log) | +| Operational security | Per-realm rate-limit ceilings, realm-owned structured security events with configurable 1–365 day retention (7-day default), and a separate event-sourced audit history. | [Auth Log](/admin/auth-log) | ## Verification diff --git a/docs/contribute/audit-storage-decision.md b/docs/contribute/audit-storage-decision.md new file mode 100644 index 00000000..68d097f8 --- /dev/null +++ b/docs/contribute/audit-storage-decision.md @@ -0,0 +1,28 @@ +# Audit storage ownership decision + +Status: accepted for pre-1.0 + +F2 (tenant isolation) and F4 (erasure) are resolved as one storage decision: + +1. The Control Plane is a normal realm. Its realm DB contains only its data. +2. Tenant-visible security events live in the owning realm DB; there is no + central realm-attributed table or hidden cross-DB union. +3. True deployment events live in the Global Store using a separate, + compile-time PII-free `PlatformAuditEvent` type. +4. Realm events use explicit actor/target/forensic fields. Free-form Actor, + Reason, Message and generic property bags are not persisted. + Cross-realm Control-Plane writes create correlated events: the actor and + request metadata stay in the Control-Plane realm, while the target realm + receives only an `ActorKind=ControlPlane` counterpart. +5. Known users are referenced by subject ID. Unknown identifiers become + realm-specific HMAC fingerprints before persistence. +6. Account erasure removes identity profile data; short-retention forensic + records keep pseudonymous IDs and technical context until their realm + retention expires. A realm hard-delete removes them with the database. +7. Realm Security retention defaults to 7 days (1–365); Platform retention + defaults to 365 days. Arbitrary clear/delete endpoints do not exist. +8. F7 assigns every streamless event type one enforced delivery class: + Required (transactional or synchronously durable), Incident + (synchronously durable), Abuse (bounded raw input plus retrying count + aggregates), or Telemetry (explicitly best-effort). The complete operational + contract is documented in [Security and platform logs](../admin/auth-log.md#delivery-guarantees). diff --git a/docs/contribute/local-ci.md b/docs/contribute/local-ci.md index c47269af..32f11f2b 100644 --- a/docs/contribute/local-ci.md +++ b/docs/contribute/local-ci.md @@ -119,7 +119,7 @@ What runs in dry-run: - `validate-version` — exercises the version-format check - `test-backend` — full unit + integration suite -- `pack-nuget` — packs the client-library nupkg +- `pack-nuget` — packs the resource-server nupkg - `build-docker` — builds the image, *doesn't push to GHCR* - `build-docs` — full VitePress build - `release-gate` — confirms all builds succeeded diff --git a/docs/contribute/testing/automated-tests.md b/docs/contribute/testing/automated-tests.md index 208f7da6..8ad3d11e 100644 --- a/docs/contribute/testing/automated-tests.md +++ b/docs/contribute/testing/automated-tests.md @@ -65,11 +65,13 @@ dotnet test | OAuthAdminMapping (extracted) | `Application/OAuthAdminMappingTests.cs` | 70+ | `BuildClientPermissions`, grant-type round-trip, `BuildClient*` defaults + property survival, `MapClient`/`MapScope`, `MapApiState` (id-stringification, defensive list copies), `MergeClientSettings`/`MergeClientProperties` partial-PATCH semantics (omit-preserve / value-overwrite / list-replace / no-mutation), BCrypt hash+verify round-trip and malformed-hash safety | | OAuth `*StateProjection` (3) + LoginProvider | `Infrastructure/Persistence/Marten/Projections/OAuth/*Tests.cs` + `LoginProviders/...Tests.cs` | 54 | Create + every Apply + replay (incl. AccessTokenType case-sensitive parse bug pinning, AppIds n:m projection, AppId set/null/created-default for Scope + Api) | -### ClaimsTransformation library +### Resource-server library | Area | File(s) | Tests | What's pinned | |---|---|---:|---| -| `ModgudClaimsTransformation` | `Client/AspNetCore/ModgudClaimsTransformationTests.cs` | 12 | per-app role flattening from `resource_access[].roles` to `ClaimTypes.Role`, cross-app isolation, malformed JSON tolerance, idempotence, anonymous short-circuit, `AppSlug` configuration validation | +| Scheme-local claims projection | `ResourceServer/ModgudClaimsProjectorTests.cs`, `ResourceServerRegistrationTests.cs` | — | audience isolation, role/permission projection, malformed JSON tolerance, idempotence, simultaneous JWT + introspection registration, startup validation, and absence of global claims transformation | +| Permission metadata | `ResourceServer/ModgudPermissionExtensionsTests.cs` | — | exact permission policy on endpoints and route groups | +| Reference-token introspection | `ResourceServer/IntrospectionHandlerTests.cs` | — | active/audience checks, claim construction, malformed response rejection, and scheme-local projection | ### ExternalAuth (OIDC IdP federation) @@ -83,10 +85,10 @@ dotnet test | Area | File(s) | Tests | What's pinned | |---|---|---:|---| -| Domain types | `Authentication/Domain/{EmailOtpChallenge, MagicLinkChallenge, UserSecurityData, UserSession, ApplicationUser}Tests.cs` | 51 | OTP/Magic-Link expiry + match semantics, security-stamp rotation asymmetry, session expiry, ApplicationUser default state | +| Domain types | `Authentication/Domain/{EmailOtpChallenge, MagicLinkChallenge, UserSecurityData, UserSession, ClientSession, ApplicationUser}Tests.cs` | — | OTP/Magic-Link expiry + match semantics, security-stamp rotation asymmetry, browser/native session expiry, ApplicationUser default state | | Extensions | `Authentication/ExtensionMethods/{HttpContextExtensions, HttpRequestExtensions, ErrorOrExtensions}Tests.cs` | 25 | tenant accessor on HttpContext, source-IP resolution incl. the X-Forwarded-For pinning bug, ErrorOr → ProblemDetails mapping | | TwoFactorEnforcementMiddleware | `Authentication/Account/TwoFactorEnforcementMiddlewareTests.cs` | 23 | whitelist paths, federated-MFA AMR detection, early-exit branches; DB branches unit-untested by design | -| Sessions / SessionTracker | `Authentication/Sessions/SessionTrackerTests.cs` | 5 | best-effort tracking, swallows failures from `ISessionService` | +| Session policy + mapping | `Applications/EffectiveSettingsTests.cs`, `Application/OAuthAdminMappingTests.cs` | — | realm/application/client lifetime precedence, bounds, and API mapping | | Device info parsing | `Sessions/DeviceInfoServiceTests.cs` | 8 | Wangkanai.Detection mapping pins driven by a fake `IDetectionService`: browser/platform/device → DeviceInfo, "Others" collapse to "Unknown", version-zero collapse to null, defensive throw-swallow. Mac-Safari-as-Mobile pin gone (fix landed with the swap) | | EmailOtpConfiguration | `Authentication/Identity/EmailOtpConfigurationTests.cs` | 2 | default values | | TwoFactorHelper (extracted) | `Authentication/Account/Services/TwoFactorHelperTests.cs` | 10 | `BuildMethodsList` order/conditions (TOTP/email-with-address-required/passkey count), `TryExpireSetupGrace` exempt-bypass + DueAt overwrite | @@ -124,7 +126,7 @@ dotnet test | `Security/` | 20 | AuthEnforcement (grace period, whitelist), MFA (TOTP), EmailOtp, MagicLink, ProfileSelfService (UserChangeRequest), OWASP Top 10 (see below), security-stamp/session revocation on password/2FA/credential changes, control-plane transfer + separation, passkey hardening, service-account revocation | | `Authorization/` | 37 | End-to-end permission gating (`PermissionResolutionTests`), plus the newer feature surfaces: invite-code self-registration, per-realm auth rate limits, CIMD, native cookieless grants (OTP/magic-link/passkey, incl. per-client WebAuthn RP-ID), the device authorization flow, dynamic client registration, OIDC federation (issuer anchoring, first-signal consistency), application settings + the settings cascade, signing-key rotation, roles/groups endpoint robustness | | `ColdStart/` | 15 | Full-process boot + declarative realm provisioning: cold-start bootstrap, login/magic-link/passkey contracts, realm create/hard-delete, manifest export/apply/parity, the provisioning test kit, recovery CLI commands | -| `ExternalAuth/` | 13 | OIDC IdpConfig CRUD, ExternalLoginProcessor (JIT account creation + linking), DynamicOidcSchemeManager, FlavorRegistry, ExternalIdentityLink aggregate + lifecycle, UserUpdateScriptRunner (JsEval), federation | +| `ExternalAuth/` | 14 | OIDC/SAML LoginProvider CRUD and logout boundaries, ExternalLoginProcessor (JIT account creation + linking), dynamic provider managers, flavor registries, ExternalIdentityLink lifecycle, UserUpdateScriptRunner (JsEval), federation | | `Admin/` | 1 | Projection-rebuild endpoint | | `Audit/` | 5 | Audit endpoint, GDPR erasure survival in the audit trail, auth-audit-view projection, login-failure-streak emission, security audit store | | `Observability/` | 1 | OpenTelemetry log redaction | diff --git a/docs/contribute/testing/manual-checklist.md b/docs/contribute/testing/manual-checklist.md index 5caeb4ba..d25506ca 100644 --- a/docs/contribute/testing/manual-checklist.md +++ b/docs/contribute/testing/manual-checklist.md @@ -110,12 +110,14 @@ See [Invite codes](../../admin/invite-codes) for the full flow. - [ ] List endpoint returns a paginated shape even without pagination params **(automated)** - [ ] Create a resource server, link it to an app -- [ ] Moving a resource server to a different app switches which `resource_access` block tokens for it carry +- [ ] Moving a resource server to a different app keeps the `resource_access` key equal to the API Audience but switches the App catalog from which its roles and narrowed permissions are resolved ## 14. Login providers (external IdP) - [ ] The built-in Internal login provider is listed and active by default - [ ] Create an OIDC/Entra ID login provider; discovery / test connection succeeds +- [ ] Create a SAML provider from IdP metadata; SP-initiated login succeeds +- [ ] SAML logout ends the local Modgud session without invoking an OIDC or SAML SLO endpoint - [ ] External login JIT-provisions a user and signs them in - [ ] Disabling a login provider removes its login button - [ ] Account-linking from `/profile` adds a second login provider to an existing user @@ -159,8 +161,8 @@ These need a separate demo SPA/backend acting as the client. ## 19. Token claims -- [ ] UserInfo carries `resource_access` keyed by app slug, with `roles` (not group names) per app -- [ ] No `resource_access` entry for an app the user isn't bound to +- [ ] JWT/UserInfo/introspection carry `resource_access` keyed by exact OAuth API Audience, with `roles` (not group names) from the API's linked App +- [ ] No `resource_access` entry without a matching registered audience and at least one of the `roles` / `permissions` scopes - [ ] No top-level `groups` claim (Modgud is an identity hub, not a groups-passthrough — see [Concepts → Authorization (RBAC)](../../concepts/groups-and-authorization)) ## 20. Permission gating & bypass tiers diff --git a/docs/end-user/profile.md b/docs/end-user/profile.md index 4899e71a..badb4b23 100644 --- a/docs/end-user/profile.md +++ b/docs/end-user/profile.md @@ -36,7 +36,12 @@ Sign-in methods and recovery state: ### Sessions -A list of your active sessions across all devices, with: +Two separate lists: + +- **Browser and SSO sessions** backed by the Modgud application cookie +- **Signed-in apps and devices** backed by OAuth refresh tokens, such as an iOS app + +Both show: - Device + browser (best-effort detection) - IP address @@ -44,8 +49,10 @@ A list of your active sessions across all devices, with: Actions: -- **End this session** on a single one -- **End all other sessions** — keeps the current one, signs you out everywhere else. Useful if you suspect somebody else has your credentials. +- **End this session/app** on a single entry. The current browser uses normal + **Sign out** instead of targeted deletion. +- **Sign out everywhere** — ends the current browser, every other browser and + every native/OAuth client session. Every device must authenticate again. ### Privacy diff --git a/docs/getting-started/features.md b/docs/getting-started/features.md index 22f54a1a..1aa8dac5 100644 --- a/docs/getting-started/features.md +++ b/docs/getting-started/features.md @@ -22,9 +22,13 @@ A point-by-point list of what Modgud delivers out of the box. ### External Identity Providers (SSO) - **Microsoft Entra ID** (Azure AD) - **Generic OIDC** (anything Discovery-compliant — Keycloak, Okta, Auth0, Cognito, etc.) +- **SAML 2.0 Service Provider federation** (Microsoft Entra Enterprise Apps, + ADFS, Okta and standards-compatible SAML IdPs) - Per-IdP user-update scripts for claim → profile mapping - Just-in-time user provisioning (toggle-able) - Mixed-mode realms (Internal + External providers side by side) +- SAML v1 is SP-initiated and SP-only; IdP-initiated SSO, SAML Single + Logout and Artifact Binding are not supported ### Magic-link sign-in - One-time token via email, no password required @@ -36,15 +40,15 @@ A point-by-point list of what Modgud delivers out of the box. ### 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 +- **Application roles** bound to one app, holding permissions on its resources; pure `realm:admin` roles are the explicit realm-local exception - **Groups** with `BoundTo` activation switch — wildcard `*`, specific apps, or dormant - Permission strings shaped `:` (two segments; app context implicit from the catalog container) with two bypass tiers (`realm:admin`, `:admin`) - Apps also carry their own soft configuration facet — origin, branding, and login posture — while still sharing the realm's user pool and a single `sub` per user ### Permission distribution to resource servers -- **Own `resource_access` claim** (shaped like Keycloak's nested format for familiarity) emitted in `/connect/userinfo`, keyed by app slug, per-Audience +- **Own `resource_access` claim** (shaped like Keycloak's nested format for familiarity), keyed by the exact registered OAuth API Audience when that audience and the `roles` and/or `permissions` scope are present - **Bypass-pre-expanded + per-RS narrowed** — consumers do straight exact-match without porting the evaluator -- **`Modgud.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 +- **`Modgud.AspNetCore.ResourceServer`** supports local JWT validation and reference-token introspection; each authentication scheme projects its own audience block into native role and permission claims ### ABAC @@ -143,13 +147,13 @@ Modgud is a pure RBAC + grouping IAM. Row-level access policies (ABAC) live in t ## 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 +- **`Modgud.AspNetCore.ResourceServer`** — explicit JWT and introspection handlers that validate tokens and project the configured audience block onto the principal +- JWT validation is local; reference-token validation uses RFC 7662 introspection for immediate revocation ### UserInfo as the permission delivery channel -- `/connect/userinfo` emits `resource_access` keyed by app slug, per Audience +- JWT access tokens, UserInfo and authorized introspection responses can expose the same audience-keyed `resource_access` claim - Bypass-pre-expanded server-side + narrowed to each RS's declared `OAuthApi.PermissionIds` subset -- Delivered via the standard OIDC UserInfo endpoint and standard JWT claims — any OIDC-aware consumer can parse it. `Modgud.Client.AspNetCore` adds the audience selection and claims projection for ASP.NET Core on top; it's not a custom protocol +- Delivered via standard JWT claims, UserInfo, and token-introspection responses — any OIDC-aware consumer can parse it. `Modgud.AspNetCore.ResourceServer` adds audience selection and scheme-local claims projection for ASP.NET Core; it's not a custom protocol ## Standards diff --git a/docs/getting-started/first-time-setup.md b/docs/getting-started/first-time-setup.md index c8feea91..7203c7e6 100644 --- a/docs/getting-started/first-time-setup.md +++ b/docs/getting-started/first-time-setup.md @@ -1,148 +1,135 @@ # 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. +A fresh Modgud deployment starts with **zero realms and zero users**. Startup +creates only the master database, the tenant registry and the Global Store. +The first installation then creates: -## The mental model +- the first ordinary realm; +- that realm's tenant database and standard seed data; +- the first user and its `realm:admin` membership; and +- the `Realm.IsControlPlane` flag on that first realm. -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. +There is no special runtime `system` realm. Every realm has the same data +shape. Cross-realm authority belongs to `realm:admin` users in whichever realm +currently carries `IsControlPlane`. -Instead, the first admin is created by someone with a **proven trust boundary**: +## 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. +The installation form is not anonymously claimable. An operator with shell +access first issues a short-lived, single-use installation token through the +recovery CLI. Only its SHA-256 hash is stored in the Global Store. -There are three concrete paths. Pick by your scenario: +Both the browser wizard and CI call the same HTTP API with that token. The API +never issues installation tokens itself. -| Scenario | Use | -| --- | --- | -| Local dev / first install on a self-hosted box | **Recovery CLI — direct mode** | -| Operator delegates the first sign-in to someone else (e.g. handing the system to a customer admin) | **Recovery CLI — invite mode** | -| Provisioning a new tenant realm in an already-running deployment (SaaS or multi-environment self-hosted) | **HTTP API — `POST /api/admin/realms`** | - -::: tip Running a single-tenant deployment? -If your deployment hosts one app for one company (no SaaS, no -per-customer isolation), you don't need to provision additional -realms — the system realm is fully featured and works on its own. -See [Single-tenant mode](single-tenant-mode) for the recipe. -::: - -All three paths end up with the same shape inside the realm: an `ApplicationUser`, the three default roles (System Admin / User Manager / Viewer), and an `Administrators` group containing the new user with `realm:admin` — exactly what every other admin in the system has. - -## Prerequisite — add your public hostname to the system realm +## Interactive installation -::: 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: +Start the container, then issue an installation link from inside it: ```bash -# 1. Register the public hostname on the system realm -docker exec modgud \ - dotnet Modgud.Api.dll recover realm-add-domain \ - --slug system \ - --domain auth.example.com - -# 2. Make it the realm's primary so outbound email links (magic-link, -# password reset, invites) resolve to the public host, not localhost docker exec modgud \ - dotnet Modgud.Api.dll recover realm-set-primary-domain \ - --slug system \ - --domain auth.example.com - -# 3. Restart the container to pick up the change -docker restart modgud + dotnet Modgud.Api.dll recover install-link \ + --base-url https://auth.example.com ``` -`realm-add-domain` is idempotent — re-running with the same domain is a no-op. `realm-set-primary-domain` requires the domain to already be on the realm (run `realm-add-domain` first); it also re-points the WebAuthn relying-party ID, so existing passkeys are invalidated by a primary-domain change. List the current domains with `recover realm-list`. Remove with `recover realm-remove-domain --slug system --domain auth.example.com` (you cannot remove the current primary — re-point it first). - -Skip this section if you're on the default `localhost` Docker quickstart — the seeded domains already cover `localhost`, `127.0.0.1`, and `system.localhost`. -::: - -::: warning Production boot guards (fail-closed) -The published image runs as **Production** and refuses to boot on a dev-shaped config. Before the container will start in production you must satisfy all of these: - -- `OpenIddict__DevelopmentMode` is `false` (the default) — ephemeral keys are rejected. -- If Prometheus scraping stays enabled (the default), `Observability__Prometheus__BearerToken` is set to a strong random string. Otherwise set `Observability__Prometheus__Enabled=false`. An unauthenticated `/metrics` endpoint on a public host leaks realm-labelled telemetry, so the guard blocks boot until you pick one. - -A misconfigured value throws at startup with a descriptive message rather than silently yielding an insecure IdP. See [Deployment](../operate/deployment) for the full env-var reference. -::: - -## Path A — Recovery CLI, direct mode +The command prints a URL like: -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] +```text +https://auth.example.com/install?token=... ``` -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). +Open the URL and enter: -Output: +- realm slug and display name; +- primary domain (normally the host used in `--base-url`); +- first administrator username, email and password. -``` -✓ Admin created in realm 'system': - UserName: admin - Email: admin@example.com - Mode: Direct (password set on creation) -``` +The API provisions the realm inactive, creates the administrator, activates +the realm and marks installation complete. Normal API and browser routes return +`503 not_initialized` or redirect to `/install` until that sequence succeeds. -Sign in immediately at the realm's host — `http://localhost/` for the default Docker quickstart. +Issuing another link revokes any previous unconsumed link. The default lifetime +is 30 minutes; `--minutes` accepts values from 1 to 1440. -::: tip Password rules apply -The CLI enforces the same Identity password policy the SPA uses (length ≥ 8, mixed case, at least one digit). A weak password is rejected with a clear error — no privileged bypass. See [Settings](../platform/settings) to relax the policy if your operational needs require it. +::: warning Production boot guards +The published image runs as **Production** and refuses dev-shaped security +configuration. In particular, OpenIddict development mode must be disabled and +an enabled Prometheus endpoint needs a strong bearer token. See +[Deployment](../operate/deployment). ::: -## Path B — Recovery CLI, invite mode +## Automated installation (CI/test) -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. +Use `--json` to make the recovery command's final output line +machine-readable: ```bash -docker exec modgud \ - dotnet Modgud.Api.dll recover bootstrap-admin \ - --email max@acme.com \ - --username max \ - [--realm system] +install_json="$( + docker exec modgud \ + dotnet Modgud.Api.dll recover install-link \ + --base-url https://auth.test.localhost \ + --minutes 10 \ + --json | + tail -n 1 +)" + +token="$(printf '%s' "$install_json" | jq -r .token)" ``` -Output: - -``` -✓ Bootstrap-invite issued for realm 'system': - UserName: max - Email: max@acme.com - Expires: 2026-05-12 10:26:51 +00:00 +Wait until `GET /health/live` succeeds, then call the completion API: - Link: http://localhost/bootstrap?token=… +```bash +curl --fail-with-body \ + --request POST \ + --header 'Content-Type: application/json' \ + --data @- \ + https://auth.test.localhost/api/install/complete <` explicitly. For +example: -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 +```bash +docker exec modgud \ + dotnet Modgud.Api.dll recover bootstrap-admin \ + --realm acme \ + --email recovery-admin@example.com \ + --username recovery-admin \ + --password 'StrongPass1!' ``` -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: +`bootstrap-admin` adds the user to the realm's existing Administrators group +and therefore restores a `realm:admin` path. See +[Recovery CLI](../operate/recovery-cli). -- 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 -Recommended next steps: +1. Enable TOTP or a passkey on the first administrator. +2. Configure SMTP and test outbound mail. +3. Register the first OAuth/OIDC application. +4. Configure external SSO if required. +5. Plan and test Control-Plane transfer before relying on it operationally. -1. **Enable 2FA on your admin account** — Profile → Security → TOTP or Passkey. -2. **Configure SMTP** — Settings → SMTP, then send a test email. Without real SMTP, outbound email is silently dropped (there is no on-disk dev mailbox); the recovery CLI and realm-creation API still surface invite / magic-link URLs directly. For local capture, point Modgud at a dev SMTP catcher such as Mailpit or smtp4dev. -3. **Seed demo data** (optional, dev/test only, repo checkout required) — run `node scripts/seed-demo.mjs` to fill the realm with users, groups, OAuth clients and a sample external IdP. Not in the published image. -4. **Bind your first SaaS app** — [SaaS Integration Walkthrough](../integrate/saas-walkthrough). -5. **Configure external SSO** (optional) — [Login Providers](../admin/login-providers). -6. **Plan additional realms** — [Realms admin](../admin/realms). - -## Lost the admin account? - -If the only admin in a realm loses their access, no UI flow can restore them — but the recovery CLI can. Run the same `bootstrap-admin` command again with a fresh email/username; it adds you to the existing `Administrators` group rather than duplicating it. See [Recovery CLI](../operate/recovery-cli) for related commands (`reset-2fa`, `magic-link`, `set-email`). - -## Tips - -::: tip Always set an email -Without an email address you have no recovery channel — no magic link, no password reset. Always set one on the first admin and verify SMTP works before you need it. -::: - -::: tip One Control-Plane realm per deployment -Exactly one realm in a deployment is the Control Plane — the realm carrying the persisted `Realm.IsControlPlane` flag. The `system` realm is stamped with it at first boot, so it's the default anchor, but the flag is **transferable**: a deployment that starts single-tenant can later hand cross-realm administration to a different realm via `recover control-plane transfer ` or `POST /api/admin/realms/{slug}/transfer-control-plane`. Once moved, the original system realm becomes an equal, deletable peer. The "exactly one" invariant is enforced on create and transfer. See [Concepts: Control Plane / Data Plane](../concepts/control-plane). -::: +The guard that prevents removal of the final realm or final effective +`realm:admin` path is a separate hardening concern. The recovery CLI remains the +break-glass path if an administrator is locked out. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 4c2ad766..ab1139fa 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -12,7 +12,7 @@ Pick the one that matches what you're trying to do right now: ## What Modgud is — in one paragraph -A self-hostable IdP. OAuth 2.0 + OpenID Connect server, runs on .NET 10, persists in PostgreSQL via Marten (event-sourced where it matters). Each customer / environment lives in an isolated realm with its own database. Apps within a realm declare their own permission catalogs and OAuth bindings. Tokens carry a `resource_access` claim (Keycloak-style nesting) keyed per Audience, with bypass-pre-expansion and per-RS subset narrowing — resource servers do straight exact-match against a flat permission list, no custom claim format required. +A self-hostable IdP. OAuth 2.0 + OpenID Connect server, runs on .NET 10, persists in PostgreSQL via Marten (event-sourced where it matters). Each customer / environment lives in an isolated realm with its own database. Apps within a realm declare their own permission catalogs and OAuth bindings. When a token targets a registered OAuth API and includes the `roles` and/or `permissions` scope, it can carry a Keycloak-shaped `resource_access` block keyed by that API's exact Audience, with bypass-pre-expansion and per-RS subset narrowing. Resource servers do straight exact-match against projected claims. ## What it isn't diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 2686ed74..7e9585cb 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -143,7 +143,7 @@ Register a client in the admin SPA: **OAuth & Federation → OAuth Clients → C 2. Add a redirect URI — e.g. the test redirect on [oidcdebugger.com](https://oidcdebugger.com). 3. Copy the discovery URL from step 4 and the client ID into oidcdebugger. -Click **Send Request** in oidcdebugger → log in as `admin` → consent → you'll see an access token. If you chose JWT, decode it at [jwt.io](https://jwt.io) — `sub`, `email`, `aud`, plus a `resource_access` block once you request the `roles` scope. +Click **Send Request** in oidcdebugger → log in as `admin` → consent → you'll see an access token. If you chose JWT, decode it at [jwt.io](https://jwt.io) — `sub`, `email` and `aud`; once the token targets a registered OAuth API, requesting `roles` and/or `permissions` adds the corresponding arrays under `resource_access[]`. ## 6. Bind your first SaaS app diff --git a/docs/getting-started/requirements.md b/docs/getting-started/requirements.md index 3476ca2a..3dd7a30e 100644 --- a/docs/getting-started/requirements.md +++ b/docs/getting-started/requirements.md @@ -107,8 +107,9 @@ The realm domain must reach the browser end-to-end via HTTPS. Common pitfalls: 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. +endpoint, with the bearer token's audience matching their registered +OAuth API Audience. The linked App may have a different slug. No +special CORS — server-to-server. ## Browser support diff --git a/docs/getting-started/single-tenant-mode.md b/docs/getting-started/single-tenant-mode.md index e40f42c0..e8da01fc 100644 --- a/docs/getting-started/single-tenant-mode.md +++ b/docs/getting-started/single-tenant-mode.md @@ -28,7 +28,7 @@ control-plane functions on top. | Users, Groups, Roles, Permissions | ✅ | | OAuth clients for your apps | ✅ | | OAuth scopes, resource APIs | ✅ | -| Login providers (Internal, OIDC federation) | ✅ | +| Login providers (Internal, OIDC and SAML federation) | ✅ | | Custom permissions, auto-membership scripts | ✅ | | Magic-link, 2FA, Passkeys, email-OTP | ✅ | | `/api/admin/realms` (cross-realm management) | ✅ control-plane only | diff --git a/docs/index.md b/docs/index.md index e6023e42..66d9b673 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,13 +23,13 @@ features: details: Every realm gets its own PostgreSQL database via Marten's master-table tenancy. Domain-based routing maps Host headers to tenants — no tenant_id columns, no cross-realm leaks possible. - icon: '' title: Multi-app permission model - details: Apps are first-class. Permissions are two-segment `:` strings (e.g. `todo:write`) scoped to an app via the role→App relationship, groups carry an activation list (BoundTo), roles bind to one app, and the resolver answers per-app permission queries in-memory. + details: Apps are first-class. Permissions are two-segment `:` strings (e.g. `todo:write`) scoped through the role→App relationship. Application roles bind to one app; a pure `realm:admin` role is the explicit realm-local exception. Groups carry an activation list (BoundTo), and the resolver answers per-app permission queries in-memory. - icon: '' - title: Per-app resource_access claim - details: Tokens carry resource_access keyed by app slug, shaped like Keycloak's nested claim for familiarity. A drop-in IClaimsTransformation library flattens the right block into ClaimTypes.Role so [Authorize(Roles="...")] works without per-endpoint plumbing. + title: Per-audience resource_access claim + details: When the corresponding OAuth API audience and `roles` and/or `permissions` scopes are present, tokens can carry a resource_access block keyed by that exact audience. The ASP.NET Core resource-server handlers project only their configured audience block into native role and permission claims. - icon: '' - title: Permissions on UserInfo - details: '/connect/userinfo emits per-Audience resource_access blocks with bypass-pre-expansion and per-RS subset narrowing, delivered via the standard UserInfo endpoint and JWT claims — any OIDC consumer can parse it. Modgud.Client.AspNetCore adds the ASP.NET Core audience selection and claims projection on top.' + title: Permissions at the token boundary + details: 'Per-audience resource_access blocks are bypass-pre-expanded and narrowed to each resource server. They can travel in JWT access tokens, UserInfo and authorized introspection responses; Modgud.AspNetCore.ResourceServer adds scheme-local audience selection and claims projection.' - icon: '' title: Full 2FA spectrum + WebAuthn details: TOTP, email-OTP, FIDO2/Passkey, magic-link. 2FA enforcement middleware with grace period and per-user override. diff --git a/docs/integrate/cookies-and-sessions.md b/docs/integrate/cookies-and-sessions.md index 692269d8..aa8d675a 100644 --- a/docs/integrate/cookies-and-sessions.md +++ b/docs/integrate/cookies-and-sessions.md @@ -34,14 +34,14 @@ Configured in `Program.cs`: | `HttpOnly` | `true` | XSS mitigation — JS can't read the cookie | | `SecurePolicy` | `SameAsRequest` | Cookie is marked `Secure` when the request itself is HTTPS (reflecting the real scheme behind a reverse proxy), so it's HTTPS-only in prod while still working over the plain-HTTP Vite dev proxy | | `SameSite` | `Lax` | Required for cross-site OIDC redirect-back navigations | -| `ExpireTimeSpan` | 30 days | Max lifetime of persistent cookies | -| `SlidingExpiration` | `true` | Refresh on active use | +| `ExpireTimeSpan` | 30 days | Framework fallback; the realm's browser-session policy sets the effective ticket expiry | +| `SlidingExpiration` | `true` | Refresh on active use, capped by the authoritative absolute lifetime | ## Cookies in detail | Cookie | SameSite | Purpose | Lifetime | |---|---|---|---| -| `Modgud.Auth` | `Lax` | Main session (app cookie) | 30 days (or session-only with `RememberMe=false`) | +| `Modgud.Auth` | `Lax` | Main browser/SSO session | Realm policy: 30-day idle / 180-day absolute by default; session-only when not persistent | | `Modgud.2FA` | `Strict` | UserId holder between password step and 2FA step | 5 min | | `Modgud.2FA.Remember` | `Strict` | "Remember this browser, skip 2FA" — Identity.TwoFactorRememberMe scheme | Identity default (30 days) | | `Modgud.External` | `Lax` | OIDC callback holder | 10 min | @@ -92,38 +92,57 @@ style domains. ## Session tracking -In parallel with the auth cookie, modgud maintains a `UserSession` -Marten document per active login. This enables session-management -features (list sessions, revoke individually, log out everywhere) that -a cookie alone can't provide. +The auth cookie carries a signed `modgud.session_id` claim bound to one +authoritative, realm-local `UserSession` document. Every authenticated +request verifies that the row still exists, belongs to the cookie subject +and has not expired. Deleting it therefore rejects the cookie on its next +request; it is not merely an activity log. -### Session cookie +### Browser-session binding -The `Modgud.Session` cookie (HttpOnly, Secure in prod) correlates the -browser with the `UserSession` document. On logout, the document is -deleted and the cookie is cleared. +The browser-session ID lives inside the encrypted `Modgud.Auth` ticket. +`Modgud.Session` is unrelated ASP.NET session state used for short-lived +passkey ceremony data. On normal logout, only the current `UserSession` +row is deleted and the auth cookie is cleared. ### UserSession document | Field | Source | Purpose | |---|---|---| | `UserId` | Auth system | Link | -| `SessionId` | Random GUID | Correlation with cookie | +| `Id` | UUIDv7/GUID | Correlation claim inside `Modgud.Auth` | | `IpAddress` | `HttpContext.Connection.RemoteIpAddress` (proxy-aware via `ForwardedHeaders`) | Audit | | `Browser`, `BrowserVersion` | UAParser | UI display | | `OperatingSystem`, `OsVersion` | UAParser | UI display | | `DeviceType` | UAParser | Desktop/Mobile/Tablet | -| `CreatedAt`, `LastActiveAt`, `ExpiresAt` | UTC | TTL + UI | +| `CreatedAt`, `LastActiveAt`, `ExpiresAt`, `AbsoluteExpiresAt` | UTC | Sliding idle window, hard limit and UI | -`SessionTracker` updates `LastActiveAt` on every authenticated request, -throttled (e.g. at most once per minute per session). +Validation updates `LastActiveAt` and the idle expiry at most once every +five minutes. Activity can never extend `AbsoluteExpiresAt`. Open SignalR +connections are bound to the same session and are aborted on targeted +revocation on the current node; hub invocations also revalidate the row. + +### Native/OAuth client sessions + +Native apps do not use the browser cookie. A refresh-token-capable login +(`offline_access`) creates a separate `ClientSession`, binds its ID into +the protected refresh token and roots that device's token family in a +unique OpenIddict authorization. Each refresh verifies and touches this +row. Revoking the row revokes exactly that device's tokens and +authorization. + +Policy resolution is OAuth client → Application → Realm. Defaults are +30 days idle and 365 days absolute; values up to 3650 days are supported. +Access-token lifetime remains independent and short. ### Self-service endpoints ```http GET /api/auth/sessions DELETE /api/auth/sessions/{id} -DELETE /api/auth/sessions # all except current +DELETE /api/auth/sessions/client/{id} +DELETE /api/auth/sessions/others # browser sessions except current +DELETE /api/auth/sessions # current + all browser/client sessions ``` ### Admin variants @@ -140,10 +159,10 @@ security-relevant events (password change, 2FA toggle) the stamp is invalidated; on the next cookie validation the cookie is rejected and the user is logged out. -Modgud uses that plus the `UserSession` documents: -"Log out everywhere" clears all `UserSession`s + invalidates the -security stamp → all of the user's cookies are rejected on the next -validation. +Modgud uses that together with both session document types. “Sign out +everywhere” clears all `UserSession` and `ClientSession` rows, revokes +OAuth tokens, invalidates the security stamp and clears the acting +cookie. Every browser and native app must authenticate again. ## Security summary @@ -153,5 +172,5 @@ validation. | Man-in-the-middle | `Secure` (prod) | | CSRF | `SameSite=Lax` on the main cookie + `Strict` on 2FA/Session step cookies + `CsrfDefenseMiddleware` on mutating endpoints | | Cross-realm leakage | Realm domain → own cookie domain | -| Forced logout | Security stamp + delete UserSession document | +| Forced logout | Per-request authoritative browser-session check + security stamp + OAuth client-session/token revocation | | Account lockout | 5 failed logins → 1 min lockout (DoS limit) | diff --git a/docs/integrate/index.md b/docs/integrate/index.md index 58c2519a..dc6c86b9 100644 --- a/docs/integrate/index.md +++ b/docs/integrate/index.md @@ -13,7 +13,7 @@ protocol-specific pages. - [Resource server (.NET)](./resource-server) — the most common Cocoar scenario: protect an ASP.NET Core API with Modgud-issued - tokens via the `Modgud.Client.AspNetCore` NuGet package. + tokens via the `Modgud.AspNetCore.ResourceServer` NuGet package. - [SaaS app walkthrough](./saas-walkthrough) — full user-facing-app integration: client registration, login redirect, resource_access claims, role-based gating. @@ -24,8 +24,8 @@ protocol-specific pages. the discovery document, JWT vs reference tokens. Browser-only SPAs (Authorization Code + PKCE, no BFF) are supported — register the SPA's origin under the client's Allowed CORS Origins. -- [Login providers (OIDC federation)](./login-providers) — federate - external IdPs (Entra ID, Google, Okta, any OIDC source) so users +- [Login providers (OIDC and SAML federation)](./login-providers) — federate + Microsoft Entra ID and standards-compatible OIDC or SAML providers so users sign in with their existing accounts. - [Login flows](./login-flows) — the on-wire shape of every supported user-facing flow. diff --git a/docs/integrate/login-flows.md b/docs/integrate/login-flows.md index cfdca904..90902196 100644 --- a/docs/integrate/login-flows.md +++ b/docs/integrate/login-flows.md @@ -201,10 +201,34 @@ GET /api/account/external-login/finish → maps claims to a `{ firstname, lastname, email, acronym }` patch 3. Email conflict (email belongs to a different user) → hard reject (`Idp.EmailConflict`) -4. Login cookie set (persistent, 30 days) +4. Login cookie set from the realm's browser-session policy Details on IdP setup and scripting: see -[Login Providers (OIDC)](./login-providers). +[Login Providers (OIDC and SAML)](./login-providers). + +## SAML external login + +```http +GET /saml/{slug}/login?returnUrl=/ +``` + +Modgud acts as the SAML Service Provider. It creates a signed AuthnRequest, +stores a one-time correlation record and redirects the browser to the +external IdP. The IdP returns the response through: + +```http +POST /saml/{slug}/acs +``` + +The ACS validates the response signature, issuer, audience, time conditions +and the one-time `InResponseTo` correlation before passing the claims to the +same `ExternalLoginProcessor` used by OIDC. The processor then resolves or +creates the local user and issues the Modgud application cookie. + +Only **SP-initiated** SAML login is supported. IdP-initiated responses are +rejected; SAML Single Logout and Artifact Binding are not available in v1. +See [SAML federation](/admin/saml-federation) for configuration and the +complete support boundary. ## OAuth authorize flow (external apps) @@ -226,3 +250,8 @@ the frontend the logout composable performs a `window.location` reload (not just a Vue Router navigation) so that the SignalR connection tears down cleanly. Otherwise an old subscription would hang on the previous user. + +For a live OIDC provider, the response can include +`ExternalLogoutUrl`, which performs RP-initiated logout at the upstream +OIDC provider when requested. SAML v1 has no Single Logout endpoint: +SAML-originated sessions end locally and return no external logout URL. diff --git a/docs/integrate/login-providers.md b/docs/integrate/login-providers.md index 8523c4e2..0b1887a6 100644 --- a/docs/integrate/login-providers.md +++ b/docs/integrate/login-providers.md @@ -1,4 +1,4 @@ -# Login Providers (OIDC Federated Login) +# Login Providers (OIDC and SAML Federation) ::: tip Looking for the admin walkthrough? This page is the technical / integration reference — provider model, @@ -7,47 +7,62 @@ For the step-by-step "set up Entra ID in the admin UI" walkthrough see [Admin → Login Providers](/admin/login-providers). ::: -The slice models login providers as a single `LoginProvider` aggregate per -realm with a `Type` discriminator. Today the wired-up types are: +The slice models login providers as one `LoginProvider` aggregate per realm +with a protocol `Type` discriminator. Today the wired-up types are: - `Internal` — built-in username + password (auto-seeded once per realm, not editable from the admin UI) -- `Oidc` — external OIDC IdPs: Entra ID (Microsoft), Google, Auth0, - Keycloak, any OIDC-compliant provider +- `Oidc` — Microsoft Entra ID and standards-compatible OIDC providers such + as Google, Auth0 or Keycloak +- `Saml` — Microsoft Entra Enterprise Applications and + standards-compatible SAML 2.0 providers such as ADFS or Okta Reserved (shape exists, handlers don't yet): -- `Saml`, `Ldap`, `Kerberos` — the create endpoint rejects these with a - centralized `LoginProvider.TypeNotSupported` error so the frontend doesn't - have to encode a separate "not supported" UI state per type. +- `Ldap`, `Kerberos` — creation is rejected with the centralized + `LoginProvider.TypeNotSupported` error. + +Modgud is an OIDC provider to downstream applications. In SAML federation it +acts only as the **Service Provider (SP)** that consumes an upstream +assertion; it does not issue SAML assertions. ## Mental model -- Each `LoginProvider` of type `Oidc` is an OIDC client against an external - IdP, registered at runtime as an ASP.NET Core authentication scheme by - `DynamicOidcSchemeManager` -- On login the OIDC flow is initiated against that scheme +- Each OIDC provider is registered at runtime as an ASP.NET Core + authentication scheme by `DynamicOidcSchemeManager`. +- Each SAML provider is registered in the realm-aware + `DynamicSamlSchemeManager`; its public entry point is + `/saml/{slug}/login`. +- OIDC completes through its per-provider callback and + `/api/account/external-login/finish`. SAML completes through the + per-provider `/saml/{slug}/acs` endpoint. +- Both protocols pass their validated external principal to the same + `ExternalLoginProcessor`. - `ExternalIdentityLink` (`Issuer + Subject → UserId`) is the only stable anchor — nobody maps users by email -- `UserUpdateScript` (Jint JavaScript) maps claims onto user fields +- `UserUpdateScript` (Jint JavaScript) maps external claims onto user fields. ## Flavors -`LoginProviderFlavorRegistry` holds the OIDC templates. Currently: +OIDC and SAML have separate flavor registries: -| Flavor | Notes | -|---|---| -| `EntraIdFlavor` | Microsoft Entra ID — tenant-specific authority, `?prompt=select_account` default | -| `GenericOidcFlavor` | Standard OIDC — authority + client ID + secret are enough | +| Protocol | Flavor | Notes | +|---|---|---| +| OIDC | `EntraId` | Microsoft Entra ID — tenant-specific authority and Entra defaults | +| OIDC | `GenericOidc` | Standards-compatible OIDC — authority + client ID + secret | +| SAML | `GenericSaml` | Vendor-neutral SAML 2.0 SP configuration | +| SAML | `EntraIdSaml` | Microsoft Entra Enterprise Application defaults | +| SAML | `AdfsSaml` | Active Directory Federation Services defaults | A flavor provides: -- Default values for `Authority`, `Scopes`, `ResponseType` -- Allowed `FlavorConfigField` list (which inputs the admin UI shows) +- Protocol-appropriate defaults such as OIDC authority/scopes or SAML + attribute mappings +- The allowed `FlavorConfigField` list, which controls the admin UI - An optional default for the `UserUpdateScript` -The flavor list can grow over time as new IdP-specific defaults are added; -today `EntraIdFlavor` and `GenericOidcFlavor` are the two that are wired up. +The flavor key does not change the protocol support boundary. Every SAML +flavor remains SP-only and SP-initiated in v1. ## LoginProvider document @@ -56,30 +71,32 @@ Marten document in the tenant store. Selected fields: | Field | Meaning | |---|---| | `Id` | GUID, internal identifier — also the base of the OIDC authentication scheme name | -| `Slug` | URL-stable, admin-chosen identifier. Immutable after creation. Used in the user-facing provider URLs (OIDC callback path) instead of `Id`, so deleting and recreating a provider can keep the same URLs | +| `Slug` | URL-stable, admin-chosen identifier. Immutable after creation. Used in OIDC callback and SAML SP/ACS URLs so deleting and recreating a provider can keep the same upstream configuration | | `Type` | `Internal` / `Oidc` / `Saml` / `Ldap` / `Kerberos` | | `IsBuiltIn` | True for the seeded Internal entry. Write commands reject edits. | | `DisplayName` | Display name in the login UI ("Login with Acme SSO") | | `Description` | Optional one-liner shown on hover / in admin UI | -| `Flavor` | `entra-id` / `generic-oidc` / ... (OIDC only) | -| `ClientId` | OIDC client ID | -| `Scopes` | Array (e.g. `["openid", "email", "profile"]`) | +| `Flavor` | Protocol-specific template key | +| `ClientId` | OIDC client ID; empty for SAML | +| `Scopes` | OIDC scopes; empty for SAML | +| `FlavorData` | OIDC connection settings or SAML metadata/attribute settings | | `UserUpdateScript` | JavaScript snippet (Jint) | | `StoreRawClaims` | bool — when true, every login stores the raw claims on the link (debug) | | `Enabled` | bool — disabled providers show no login button | | `IsDeleted` | bool — soft delete (Internal entries cannot be deleted) | -The **client secret** is not stored on the document but in a separate +The OIDC **client secret** is not stored on the document but in a separate `LoginProviderSecretStore` (Marten document, separate table). This keeps the -secret out of event streams and audit logs. +secret out of event streams and audit logs. SAML trust is established through +IdP metadata and its signing certificates rather than an OIDC client secret. -## Dynamic scheme registration +## Runtime provider registration -ASP.NET Core's `AuthenticationOptions` is normally static — all schemes -must be known at boot. We want to add realm-owned LoginProviders at -runtime. +Login providers are realm-owned and editable at runtime, while ASP.NET +Core's normal authentication-scheme registration is static. Modgud therefore +maintains parallel protocol-specific runtime registries. -Solution: +### OIDC 1. At boot, a **placeholder scheme** is registered that wires up the `OpenIdConnectHandler` type and the options plumbing. The placeholder @@ -96,18 +113,33 @@ Solution: the provider's `Slug` instead, so the callback URL survives a delete + recreate. -Internal providers don't participate in this — they are served by the -local password-login path. +### SAML + +1. `SamlSchemeBootstrap` loads enabled SAML providers from every active + realm at cold start. +2. `SamlLoginProviderEventHandlers` update the runtime registration when a + SAML provider changes. +3. `DynamicSamlSchemeManager` caches the realm, provider slug, IdP metadata, + trust material and SP configuration. +4. `SamlEndpoints` expose SP metadata, SP-initiated login and ACS routes: + `/saml/{slug}/sp-metadata`, `/saml/{slug}/login` and + `/saml/{slug}/acs`. + +Internal providers do not participate in either registry; the local +password/passkey/magic-link paths serve them directly. LDAP and Kerberos +remain unsupported. ## UserUpdateScript -Every IdP delivers different claim structures. We map them via a -JavaScript snippet, executed in `Jint`. +Every IdP delivers different claim structures. OIDC claims and validated +SAML attributes are normalized into the same claim dictionary and mapped by +a JavaScript snippet executed in `Jint`. -The script gets the raw OIDC claims and returns a partial user record: +The script gets the normalized external claims and returns a partial user +record: ```javascript -// claims: Dictionary — everything that came in the OIDC token +// claims: Dictionary — validated OIDC claims or SAML attributes return { firstname: claims['given_name']?.[0], @@ -159,7 +191,7 @@ session is `modgud.external.loginProviderId`. ## Email conflict handling -If an OIDC login brings an email that already belongs to another user +If an external login brings an email that already belongs to another user (or to the same UserId but a different identity), the processor throws `Idp.EmailConflict` and the login fails. Never merge accounts implicitly — that is an account-takeover vector. The admin must @@ -168,7 +200,7 @@ the new provider as an additional login). ## JIT user creation -If an OIDC login finds no existing ExternalIdentityLink: +If an OIDC or SAML login finds no existing `ExternalIdentityLink`: 1. A `UserName` is generated from the claims (email or `preferred_username`) 2. A new user is created without password and without 2FA requirement @@ -194,12 +226,41 @@ The browser runs through the OIDC flow, comes back, the processor recognises the logged-in user and creates an `ExternalIdentityLink` instead of creating a new user. +SAML self-service linking has a known v1 limitation: the assertion returns +through a cross-site POST to the ACS endpoint, so the `SameSite=Lax` +Modgud application cookie is not sent. The ACS therefore cannot reliably +identify the already signed-in user who started the link flow. A SAML +identity must currently resolve through normal SAML sign-in, trusted-email +linking or JIT provisioning. Once linked, its stable `(issuer, subject)` +resolves normally on every later login. + Unlink: ```http DELETE /api/account/external-links/{linkId} ``` -The user-facing OIDC endpoints (login button list, `/start`, callback) -only return `Type == Oidc` providers. Internal-typed entries never appear -on those surfaces. +The public `/api/account/external-logins` list includes enabled OIDC and +SAML providers and returns a `Kind` discriminator: + +- OIDC starts at + `/api/account/external-login/{loginProviderId}/start`. +- SAML starts at `/saml/{slug}/login`. + +The OIDC `/start` and callback routes accept only OIDC providers. SAML has +its own ACS surface; Internal, LDAP and Kerberos never appear in the public +provider list. + +## SAML v1 support boundary + +- Modgud acts as a SAML **Service Provider**, never as a SAML IdP. +- Login is **SP-initiated**. Every accepted response must match a one-time + AuthnRequest correlation record. +- IdP-initiated/unsolicited responses are rejected. +- SAML Single Logout (SLO) is not implemented. Logging out ends the local + Modgud session; no SAML logout request is sent upstream. +- HTTP-Redirect and HTTP-POST bindings are supported; Artifact Binding is + not. + +See [SAML federation](/admin/saml-federation) for setup, security behavior +and troubleshooting. diff --git a/docs/integrate/mcp-server.md b/docs/integrate/mcp-server.md index b8ede7dc..381af20b 100644 --- a/docs/integrate/mcp-server.md +++ b/docs/integrate/mcp-server.md @@ -123,10 +123,13 @@ After consent the agent holds an access token with `aud` narrowed to exactly `ht **Validate it on the MCP server.** Two options, same as any resource server: -- **JWT + JWKS (local):** validate signature against `https://auth.example.com/.well-known/jwks`, check `iss` equals the realm root and `aud` equals your MCP URL. CIMD clients are issued JWT access tokens, so this is the default MCP path. For an ASP.NET Core MCP server the wiring is identical to [Integrating a resource server](./resource-server) — same `AddJwtBearer` + `AddModgudClient`, with `Audience = "https://mcp.acme.example"`. +- **JWT + JWKS (local):** validate signature against `https://auth.example.com/.well-known/jwks`, check `iss` equals the realm root and `aud` equals your MCP URL. CIMD clients are issued JWT access tokens, so this is the default MCP path. For an ASP.NET Core MCP server the wiring is identical to [Integrating a resource server](./resource-server) — use `AddModgudResourceServer` with `Audience = "https://mcp.acme.example"`; `OnlyJwt` is the default mode. - **Introspection (server-side):** if your OAuth client issues reference tokens, `POST /connect/introspect` returns `active` plus the claims. Slower per call, but revocation is instant (see step 7). -Decode the token (or introspect it) and confirm `aud` is your MCP URL alone and the `permissions` array inside `resource_access[acme]` holds what you expect. +Decode the token (or introspect it) and confirm `aud` is your MCP URL +alone and the permissions array inside +`resource_access["https://mcp.acme.example"]` holds what you expect. +The `acme` App slug selects the catalog but is not the claim key. **Check the audit trail.** [Auth Log](/admin/auth-log) records the lifecycle. CIMD and DCR events are prefixed `DCR ` in the message and surface under the **operations** chip (rejected registrations under **security-ops**): diff --git a/docs/integrate/resource-server.md b/docs/integrate/resource-server.md index d207b7ad..7ba766c4 100644 --- a/docs/integrate/resource-server.md +++ b/docs/integrate/resource-server.md @@ -1,96 +1,82 @@ # Integrating a Resource Server -This guide walks through wiring an ASP.NET Core resource server to Modgud so it can: +`Modgud.AspNetCore.ResourceServer` protects ASP.NET Core APIs with access +tokens issued by Modgud. One registration method configures one public +authentication scheme for self-contained JWTs, opaque reference tokens, or +both. -1. Validate access tokens that Modgud issued (JWT signature + issuer + audience, against the realm's JWKS) -2. Pick up role claims so `[Authorize(Roles = "…")]` works -3. Read fine-grained permission strings from the per-audience `resource_access` block so it can gate on `:` checks +All modes: -The reference scenario is a fictional `acme` app with a `todo` resource — replace the slugs with yours throughout. +- validate issuer and audience; +- select the configured `resource_access[]` block; +- project roles to `ClaimTypes.Role`; +- project permissions to `ModgudClaimTypes.Permission`; +- support `RequireModgudPermission(":")`. -A runnable end-to-end sample lives in the Modgud source tree at `src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs` (the protected API) and `src/dotnet/TestApps/Modgud.TestApps.Bff/Program.cs` (a cookie-based BFF that obtains and forwards the token). The ResourceApi sample validates JWTs by default and switches to reference-token introspection with `TESTAPPS:TOKENMODE=reference` (+ `TESTAPPS:INTROSPECTIONSECRET`). The code below mirrors those samples; when in doubt, read them. Both library paths they use — JWT-bearer and reference-token introspection — are covered end-to-end by the integration-test rig (opaque/JWT token in → validation → `resource_access` → `RequiresModgudPermission` gate). +A runnable sample lives at +`src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs`. It uses JWT by +default. Set `TESTAPPS:TOKENMODE=reference` or `both` and provide +`TESTAPPS:INTROSPECTIONSECRET=` for the other modes. -## Prerequisites +## Admin prerequisites -Before wiring code, finish the admin setup in Modgud. The full admin walkthrough lives at [SaaS App Integration Walkthrough](./saas-walkthrough); the essentials are: +For the example audience `acme`: -1. Create the app `acme` with its permission catalog (`:` entries such as `todo:read`, `todo:write`) -2. Create an OAuth API (resource server) named `acme` under **OAuth → APIs**, link it to the `acme` app, and pick the catalog subset its `PermissionIds` cover. Linking an API to an app creates an implicit scope whose `Resources` include `acme` — that is what stamps `aud=acme` onto tokens requested with that scope. -3. Create an OAuth client (e.g. `acme-web`) for the app's frontend. Set its **Access Token Type** to **JWT (self-contained)** — see the prerequisite below. -4. Set up at least one role + group with `BoundTo: ["acme"]` and assign your test user. +1. Create the app `acme` and its permission catalog, such as `todo:read` and + `todo:write`. +2. Create an OAuth API named `acme`, link it to the app, and select the + permissions this API may receive. +3. Allow the OAuth client to request the API's implicit scope plus `roles` and + `permissions`. +4. Assign roles or permissions to the user through groups bound to the app. -### Two token modes — pick one +The authorization request must include the relevant scopes: -Modgud issues access tokens in one of two formats, and `Modgud.Client.AspNetCore` supports both. Choose per resource server: +- the API scope adds `aud=acme`; +- `roles` adds `resource_access[acme].roles`; +- `permissions` adds `resource_access[acme].permissions`. -- **JWT (self-contained)** — a signed bearer JWT carrying `aud`, `scope`, and the standard claims, validated locally against the realm's JWKS with no per-request IdP call. **This guide uses JWT.** It requires setting the OAuth client's **Access Token Type** to **JWT (self-contained)** (the field defaults to `Reference` in the client editor). -- **Reference (opaque)** — Modgud's **default** format: an opaque handle with no embedded claims, validated by calling `/connect/introspect` (RFC 7662). No client reconfiguration needed. Wire it with `AddModgudReferenceTokenClient` instead of `AddJwtBearer` + `AddModgudClient` — see [Reference-token mode](#reference-token-mode-opaque-tokens) below. +## Realm authority -The endpoint gates (`[Authorize(Roles=…)]`, `RequiresModgudPermission`) and the projected role/permission claims are identical in both modes — only the authentication registration differs. +Modgud resolves realms by host name, not by a URL path. `Authority` must be the +realm's host root: -### Prerequisite: request the right scopes +- correct: `https://auth.example.com` +- wrong: `https://auth.example.com/system` -The token only carries what was requested: +Discovery, JWKS, token, UserInfo, and introspection endpoints all live below +that host root. -- `aud=acme` is present only when a requested scope carries `Resources=[acme]` — i.e. the implicit scope created when you linked the `acme` API to the `acme` app (step 2). Without it the token has no `acme` audience and `AddJwtBearer` rejects it with an audience mismatch. -- The `permissions` array inside `resource_access[acme]` appears only when the client requested the `permissions` scope. -- The `roles` array inside `resource_access[acme]` appears only when the client requested the `roles` scope. - -Both `roles` and `permissions` are standard scopes seeded into every realm. Add them (plus the API's implicit scope) to the client's allowed scopes and to the authorization request. The [SaaS App Integration Walkthrough](./saas-walkthrough) covers this end to end. - -## Host-based realm routing — get the `Authority` right - -Modgud resolves realms by the **Host header only**. The issuer carries **no** realm path segment. Each realm answers on its own host (or hostname), and the OIDC discovery document, JWKS, token issuer, and UserInfo endpoint all live at the host root. - -That means `Authority` MUST be the realm's **host root**: - -- Correct: `https://auth.example.com` -- Wrong: `https://auth.example.com/system` or any `https://auth.example.com/` path - -A path-suffixed authority makes `AddJwtBearer` fetch discovery from `https://auth.example.com/system/.well-known/openid-configuration` (404) and validate the issuer against `https://auth.example.com/system` — both fail. Use the bare host root and let the Host header select the realm. - -## ASP.NET Core integration - -### 1. Add the package +## Install ```bash -dotnet add package Modgud.Client.AspNetCore -dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer +dotnet add package Modgud.AspNetCore.ResourceServer ``` -### 2. Configure authentication and the Modgud client +## JWT mode -`AddJwtBearer` validates the JWT. `AddModgudClient` adds the two pieces vanilla `AddJwtBearer` lacks: a post-configure on the JwtBearer scheme that makes sure the principal ends up with a `resource_access` claim — preferring the one already embedded in the token and calling `/connect/userinfo` only as a fallback when the token carries none (you do **not** set `GetClaimsFromUserInfoEndpoint`, that property is for `AddOpenIdConnect`) — and a claims transformation that flattens the per-audience block into native role/permission claims, plus the `RequiresModgudPermission` endpoint filter. +JWT is the recommended quickstart: validation is local and does not add an IdP +round-trip to each API request. Configure the issuing OAuth client to use +**JWT (self-contained)** access tokens. `OnlyJwt` is the default token mode. ```csharp -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Modgud.Client.AspNetCore; - -JwtSecurityTokenHandler.DefaultMapInboundClaims = false; +using Modgud.AspNetCore.ResourceServer; var builder = WebApplication.CreateBuilder(args); -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = "https://auth.example.com"; // realm host root — NO realm path segment - options.Audience = "acme"; // matches the OAuthApi name registered in Modgud - options.MapInboundClaims = false; - options.TokenValidationParameters.NameClaimType = "name"; - options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; - }); - -builder.Services.AddModgudClient(o => +builder.Services.AddModgudResourceServer(options => { - o.Authority = "https://auth.example.com"; // same as JwtBearer Authority - o.Audience = "acme"; // same as JwtBearer Audience + options.Authority = "https://auth.example.com"; + options.Audience = "acme"; + options.ConfigureJwtBearer = jwt => + { + jwt.MapInboundClaims = false; + jwt.TokenValidationParameters.NameClaimType = "name"; + jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; + }; }); -builder.Services.AddAuthorization(); - var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); @@ -99,131 +85,170 @@ app.MapGet("/me", (ClaimsPrincipal user) => new { sub = user.FindFirstValue("sub"), name = user.Identity?.Name, - roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value), - permissions = user.FindAll(ModgudClaimsTransformation.PermissionClaimType).Select(c => c.Value), + roles = user.FindAll(ClaimTypes.Role).Select(claim => claim.Value), + permissions = user.FindAll(ModgudClaimTypes.Permission) + .Select(claim => claim.Value), }).RequireAuthorization(); app.MapGet("/todos", () => Results.Ok(new[] { "buy milk" })) - .RequireAuthorization() - .RequiresModgudPermission("todo:read"); + .RequireModgudPermission("todo:read"); app.MapPost("/todos", () => Results.Ok()) - .RequireAuthorization() - .RequiresModgudPermission("todo:write"); + .RequireModgudPermission("todo:write"); app.Run(); ``` -`ModgudOptions` has exactly two required properties — `Authority` and `Audience` — plus an optional `JwtBearerScheme` (default `"Bearer"`) if you registered JwtBearer under a custom scheme name. Both `Authority` and `Audience` must match the values you passed to `AddJwtBearer`. - -### DPoP-bound tokens are enforced automatically +The package validates the token and projects only its embedded +`resource_access` claim. There is no global `IClaimsTransformation` and no +UserInfo fallback. A JWT without the required authorization data may still +authenticate, but role and permission gates remain fail-closed. -If a client obtains a [DPoP](../reference/oauth-api#dpop-sender-constrained-tokens)-bound access token (one carrying a `cnf.jkt` confirmation claim), `AddModgudClient` enforces the binding for you — no extra configuration. It accepts the token under the `DPoP` auth scheme (lifting it into JwtBearer, which only reads `Bearer` on its own), then requires a valid DPoP proof whose key matches `cnf.jkt` and whose `ath` hashes the presented token. A bound token replayed as a plain `Bearer`, or with a proof for the wrong key, is rejected. Unbound tokens are unaffected and keep working as bearer tokens. The same enforcement applies on the [reference-token path](#reference-token-mode-opaque-tokens) — the `cnf.jkt` is read from the introspection response instead of the JWT. +JWT authorization data reflects token issuance time. Grant changes become +visible when a new token is issued; revocation is bounded by the access-token +lifetime. -Behind a reverse proxy, wire up `UseForwardedHeaders` so the request's scheme + host match what the client signed into the proof's `htu`, or every proof fails the URL check. +## Reference-token mode -## Where permissions come from +Reference tokens are useful when revocation must take effect immediately. The +OAuth client may remain on Modgud's **Reference** access-token type. -`resource_access` is baked directly into the access token at issuance — for JWT clients (the type this guide sets up) it's a claim inside the token itself. `Modgud.Client.AspNetCore` prefers that embedded claim: if the JwtBearer-validated principal already carries `resource_access`, the library reads it as-is and never calls the IdP. It falls back to fetching `/connect/userinfo` only when the token carries no such claim — practically, that's tokens from setups predating this behavior, or resource servers validating opaque reference tokens by some means other than local JWT parsing (this guide's JWKS-based `AddJwtBearer` setup always sees the embedded claim, so the fallback path is dead code in practice for it). - -This is a pure performance win, not a freshness trade-off: `/connect/userinfo` has always echoed the exact same `resource_access` block already baked into the token, never a wider or narrower one, so preferring the token claim changes nothing about which permissions your resource server sees — it only removes a redundant HTTP round-trip for tokens that already carry the claim. - -| Source | Freshness | IdP dependency per request | -|---|---|---| -| Embedded in token (JWT `resource_access` claim, preferred) | As of token issuance — a grant or revocation takes effect once a new token is minted; propagation is bounded by the access token's lifetime | None | -| `/connect/userinfo` fallback (only when the token carries no `resource_access` claim) | Same as above — UserInfo echoes the token's baked block, it does not recompute a live view | One UserInfo call per request, only for tokens lacking the claim | - -## Performance and availability +```csharp +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "acme"; + options.TokenMode = ModgudTokenMode.OnlyReferenceToken; + options.IntrospectionClientSecret = + builder.Configuration["Modgud:IntrospectionSecret"]; +}); +``` -For tokens that already carry an embedded `resource_access` claim — every JWT-client token, per the prerequisite above — `AddModgudClient` makes **no IdP call at all**: the claims transformation runs purely against data already on the token, so there is no per-request round-trip and nothing to degrade. +Every authenticated request calls `/connect/introspect`. The single response +validates the token and carries the same audience-specific `resource_access` +block as a JWT. Responses are not cached. An inactive token, a failed response, +invalid JSON, or an unreachable IdP rejects authentication. -The `/connect/userinfo` fallback runs only for tokens without an embedded claim, and the following applies to that path alone. It **degrades without failing the authentication handler** — a `/connect/userinfo` failure never rejects the request outright. But authorization on that path stays **fail-closed**: if the IdP is unreachable or returns a non-2xx, no `resource_access` claim is added, so any endpoint gated with `RequiresModgudPermission` returns `403` (the principal simply carries no permissions) rather than the API 500ing during an IdP outage. +### Register the introspection client -One caveat, still true either way: fail-closed behavior only protects endpoints actually gated on a permission. An endpoint secured with a bare `.RequireAuthorization()` and no `RequiresModgudPermission` call has nothing checking `resource_access` in the first place, so it stays reachable straight through a fallback-path outage. If that matters for a given endpoint, gate it on a permission too. +Create a confidential OAuth client for the resource server: -Because a JWT-client token already carries the claim it needs, an IdP outage no longer 403s requests bearing a still-valid token — those requests never touch the IdP for authorization data in the first place. The fail-closed behavior above only bites setups still on the `/connect/userinfo` fallback path. +1. Set its client ID to the resource-server audience, for example `acme`. +2. Generate a client secret. +3. Put that secret in protected application configuration. -## Reading roles and permissions +`IntrospectionClientId` defaults to `Audience`. Modgud returns an active +introspection result only to the token's presenter or one of its audiences, +which is why the normal resource-server client ID equals the audience. -The claims transformation projects the per-audience block onto flat claims: +## Accept both formats -- Roles land on `ClaimTypes.Role`, so `[Authorize(Roles = "Editor")]`, `RequireRole(...)`, and `user.FindAll(ClaimTypes.Role)` all work. -- Permissions land on the claim type `ModgudClaimsTransformation.PermissionClaimType` (value `"permission"`). Read them with `user.FindAll(ModgudClaimsTransformation.PermissionClaimType)`. +One API can accept both token formats through the same registration and public +authentication scheme: ```csharp -// Coarse role gate. -app.MapGet("/admin/reports", () => Results.Ok()) - .RequireAuthorization(p => p.RequireRole("Editor")); - -// Granular permission gate — the canonical way. -app.MapPost("/todos", () => Results.Ok()) - .RequireAuthorization() - .RequiresModgudPermission("todo:write"); +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "acme"; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = + builder.Configuration["Modgud:IntrospectionSecret"]; +}); ``` -`RequiresModgudPermission(":")` is an extension on both `RouteHandlerBuilder` (per-endpoint) and `RouteGroupBuilder` (whole group). It does a straight exact-match against the principal's `"permission"` claims: `401` when anonymous, `403` when authenticated but lacking the permission. The permission string is bare 2-segment (`todo:write`) — the app context is implicit from the audience you configured. +The package routes signed Modgud JWTs, which consist of exactly three +dot-separated parts, to the JWT validator. Dotless opaque tokens go to +introspection. Routing is not validation: the selected handler still validates +the token completely and fails closed. It never retries a failed JWT through +introspection. -::: tip Roles and permissions compose -The same user can be `Roles = "Editor"` **and** hold `todo:write`. Pick role gates for coarse buckets (`Admin` / `Editor` / `Viewer`) and `RequiresModgudPermission` for per-action checks. Both flavours read from the same `resource_access` block — the token's own embedded copy by default — so there is no separate server-to-server call to wire up. -::: +Only one `AddModgudResourceServer(...)` call is allowed for a service +collection. This prevents accidentally registering conflicting Modgud modes. +An application can still intentionally add unrelated ASP.NET Core +authentication schemes alongside Modgud. -::: warning Groups are not emitted -The IdP never emits a `groups` block in `resource_access` (hub boundary). Group membership is resolved IdP-side and expanded into roles/permissions before emission. Gate on roles or permissions only — there is no group claim to read. -::: +## Roles and permissions -## What's in the permissions array +The IdP emits a Keycloak-shaped block: -The IdP does two transformations before emitting the per-audience block, so your resource server never needs an evaluator: +```json +"resource_access": { + "acme": { + "roles": ["Acme Editor"], + "permissions": ["todo:read", "todo:write"] + } +} +``` -- **Bypass pre-expansion**: bypass tiers are resolved to concrete catalog strings before emission. `realm:admin` expands to every concrete catalog entry of every reachable app; an `:admin` grant expands to every entry in that app's catalog; a `:admin` grant expands to every `:` in the app's catalog. Your check is always exact-match. -- **Per-RS subset narrowing**: each audience block is narrowed to the calling OAuth API's declared `PermissionIds`. A resource server within a multi-RS app sees only its own permissions, never a sibling's. +Use standard ASP.NET role policies for coarse access: -## Reference-token mode (opaque tokens) +```csharp +app.MapGet("/admin", () => Results.Ok()) + .RequireAuthorization(policy => policy.RequireRole("Acme Editor")); +``` -If you'd rather leave the OAuth client on Modgud's default **Reference** token type, validate via introspection instead of JWKS. Everything downstream — the claims transformation, `RequiresModgudPermission`, role gates — is unchanged; only the authentication registration differs: +Use Modgud permission metadata for action-level access: ```csharp -using Modgud.Client.AspNetCore; - -builder.Services - .AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - .AddModgudReferenceTokenClient(o => - { - o.Authority = "https://auth.example.com"; // realm host root - o.Audience = "acme"; // the OAuthApi name == introspection client_id - o.IntrospectionClientSecret = builder.Configuration["Modgud:IntrospectionSecret"]; - }); +app.MapPost("/todos", () => Results.Ok()) + .RequireModgudPermission("todo:write"); ``` -Each request calls `/connect/introspect`, and the introspection response carries the same per-audience `resource_access` block a JWT would — so a single call both validates the token and yields the permissions. Validation is **fail-closed** (an inactive token, a non-2xx, or an IdP outage rejects the request) and there is **no cache**, so a revoked reference token stops working immediately. +The extension works on route handlers and route groups. It requires an +authenticated user and the exact permission claim, returning `401` when +anonymous and `403` when authenticated without the permission. + +The IdP expands `realm:admin` and `:admin` bypass grants into +concrete catalog permissions before emission. It also narrows each audience to +that OAuth API's declared permission subset. Resource servers therefore do +exact matching and do not need `PermissionEvaluator`. + +Groups are not emitted across the IdP boundary. Group membership is resolved +to roles and permissions before token issuance. + +## DPoP -### Setup: register the introspection client +Both validation paths enforce DPoP binding automatically when a token contains +`cnf.jkt`. A bound token must use the `DPoP` authorization scheme and include a +valid proof whose key and access-token hash match. Replaying it as plain +`Bearer` is rejected. Unbound bearer tokens continue to work normally. -The IdP only reveals a token — its `active` status and its `resource_access` — to a caller that is one of the token's audiences or its presenter. So the resource server introspects with a confidential OAuth client whose **`client_id` equals its audience** (the RS's `OAuthApi` name, which RFC 8707 already puts in the token's `aud`): +Behind a reverse proxy, configure forwarded headers so the externally visible +scheme and host match the proof's signed `htu`. -1. In Modgud admin, create a **confidential OAuth Client** whose **Client ID** is exactly your audience (e.g. `acme`, or `https://mcp.acme.example` for the MCP case). Give it a secret; it needs no redirect URIs or grant types beyond existing to authenticate. -2. Pass that secret as `IntrospectionClientSecret`. `IntrospectionClientId` defaults to `Audience`, so you don't set it unless the introspection client is registered under a different (still audience-matching) id. +## Options and startup validation -Credentials go in the request body (`client_secret_post`), which also covers a URL-shaped audience id — HTTP Basic would break on the scheme colon. +| Option | Required | Description | +| --- | --- | --- | +| `Authority` | Always | Realm host root; HTTPS is required by default. | +| `Audience` | Always | Token audience and `resource_access` key. | +| `TokenMode` | No | `OnlyJwt` (default), `OnlyReferenceToken`, or `Both`. | +| `IntrospectionClientId` | No | Defaults to `Audience` in reference-capable modes. | +| `IntrospectionClientSecret` | Reference/Both | Confidential introspection secret. | +| `RequireHttpsMetadata` | No | Set `false` only for local development. | +| `ConfigureJwtBearer` | No | Advanced JWT configuration in JWT-capable modes. | -::: warning A separate introspection identity won't work -A confidential client whose `client_id` is *not* one of the token's audiences gets `active: false` from `/connect/introspect` — the IdP reveals nothing to a stranger. The `client_id == audience` registration above is what makes introspection return an active status and the `resource_access` block. -::: +C# `required` properties cannot express a requirement conditional on +`TokenMode`. The registration therefore validates the complete combination +immediately and throws `OptionsValidationException` for invalid or irrelevant +options. ## Common pitfalls -- **`Authority` has a realm path segment** — e.g. `https://auth.example.com/system`. Discovery fetch 404s and issuer validation fails. Realms route by Host header; `Authority` is the bare host root. -- **Client issues Reference (opaque) tokens** — `AddJwtBearer` cannot validate them. Set the OAuth client's **Access Token Type** to **JWT (self-contained)**. -- **Token's `aud` doesn't match `Audience`** — JWT validation rejects with an audience mismatch. `aud=acme` only appears when a requested scope carries `Resources=[acme]` (the implicit scope from linking the API to the app). Align the API name, the requested scope, and `options.Audience`. -- **`Authority` / `Audience` differ between `AddJwtBearer` and `AddModgudClient`** — UserInfo is fetched from the wrong host or the transformation reads the wrong `resource_access[…]` key, so roles/permissions silently go missing. Keep both pairs identical. -- **`permissions` scope not requested** — `resource_access[acme]` has no `permissions` array, so every `RequiresModgudPermission` gate denies. Add the `permissions` scope to the client's allowed scopes and to the authorization request (same for `roles`). -- **Resource server not linked to an app** — without a linked app there is no `PermissionIds` subset, so the audience block is empty. Open the OAuth API in Modgud admin and assign the app. +- A realm path is appended to `Authority`; use the bare realm host root. +- The configured mode does not accept the OAuth client's access-token type. +- The requested API scope did not add the configured audience. +- The authorization request omitted `roles` or `permissions`. +- The OAuth API is not linked to an app or has no selected permissions. +- The introspection client's ID is not the token audience. +- `UseAuthentication()` or `UseAuthorization()` is missing or ordered after + endpoint execution. ## Reference -- Working sample: `src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs` (+ BFF at `src/dotnet/TestApps/Modgud.TestApps.Bff/Program.cs`). Runs in JWT mode by default; set `TESTAPPS:TOKENMODE=reference` (+ `TESTAPPS:INTROSPECTIONSECRET`) for the reference-token introspection path. -- Admin walkthrough: [SaaS App Integration Walkthrough](./saas-walkthrough) -- Concept overview: [Apps and resource_access](../concepts/apps-and-resource-access.md) -- Permissions reference: [Permissions & gating](../concepts/permissions.md) -- OAuth endpoints: [reference/oauth-api](../reference/oauth-api.md) -- Library source: `src/dotnet/Modgud.Client.AspNetCore/` +- [SaaS App Integration Walkthrough](./saas-walkthrough) +- [Apps and resource_access](../concepts/apps-and-resource-access.md) +- [Permissions and gating](../concepts/permissions.md) +- [OAuth API](../reference/oauth-api.md) +- Source: `src/dotnet/Modgud.AspNetCore.ResourceServer/` diff --git a/docs/integrate/saas-walkthrough.md b/docs/integrate/saas-walkthrough.md index 47a01140..98cbc413 100644 --- a/docs/integrate/saas-walkthrough.md +++ b/docs/integrate/saas-walkthrough.md @@ -2,7 +2,8 @@ This page takes you from a freshly installed Modgud all the way to a working external app doing single-sign-on against Modgud and reading -per-Audience permission claims out of `/connect/userinfo`. +audience-keyed authorization claims from an access token, UserInfo or +authorized introspection response. > **Audience:** realm admins and developers integrating a SaaS app. > Regular end-user onboarding is documented in @@ -89,7 +90,7 @@ Navigate to **Administration → OAuth Clients**. Click **Create**. The Create m | Post-Logout Redirect URIs | `https://acme.dev.local/` | One per line | | Allowed Grant Types | `authorization_code` + `refresh_token` | For a web app pick `authorization_code` and `refresh_token`. There are no silent defaults — a client with no grant types cannot mint tokens. | | Allowed Scopes | `openid email profile roles permissions acme` | The OIDC scopes plus the resource-bearing `acme` scope you create in Station 3. Request `roles` to get the per-audience role list, `permissions` for the `:` list. | -| **Access Token Type** | `JWT` | **Required for local JWKS validation.** The default is `Reference` (opaque — the resource server would have to call `/connect/introspect` on every request). `AddJwtBearer` validates JWTs by signature, so choose **JWT** here. | +| **Access Token Type** | `JWT` | **Required for the local JWKS-validation path in this walkthrough.** Modgud's token-format default is Reference (opaque and resolved via `/connect/introspect`); explicitly choose JWT here because the resource server below uses `OnlyJwt`. | Click **Create**. The client secret is shown — copy it and store it safely; you'll never see it again. @@ -99,19 +100,21 @@ If your frontend is a pure SPA that talks to the IDP directly (PKCE, no server-s ::: ::: info What does the apps choice change? -On `/connect/userinfo` the access token's principal gets a -`resource_access` block per linked app, with the user's app-specific -roles (with `scope=roles`) and bypass-pre-expanded permissions narrowed -to the calling OAuthApi's `PermissionIds` (with `scope=permissions`). -The client may also only request scopes that belong to one of its apps -(plus the standard OIDC scopes). +The App selection controls which App-scoped scopes the client may +request. It does not itself create claim blocks. Requested +resource-bearing scopes create token audiences; each audience that +resolves to a registered OAuth API gets +`resource_access[]`, using that API's linked App for roles +(`scope=roles`) and its `PermissionIds` subset for permissions +(`scope=permissions`). ::: ## Station 3: create the resource server The resource server is the identity Modgud uses to compute the -per-Audience subset narrowing in `resource_access` UserInfo blocks. -Each App needs at least one. +per-Audience subset narrowing in `resource_access` blocks. +Each App whose authorization data must reach a downstream API needs +at least one OAuth API registration. Go to **Administration → OAuth → APIs** and click **Create**: @@ -161,7 +164,8 @@ usually want more nuanced roles. | **App** | `acme` | | Permissions | `todo:read`, `todo:write` | -Roles bind to one App via `AppId`; the `PermissionIds` reference +Application roles bind to one App via `AppId`; a pure `realm:admin` +role is the explicit realm-local exception. The `PermissionIds` reference specific catalog entries of that App. The same string `todo:read` in a different App's catalog is a different permission. @@ -194,45 +198,31 @@ A complete, runnable version of everything below ships in the repo at `src/dotne ### Packages ```bash -dotnet add package Modgud.Client.AspNetCore -dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer +dotnet add package Modgud.AspNetCore.ResourceServer ``` ### `Program.cs` ```csharp using System.Security.Claims; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Modgud.Client.AspNetCore; - -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - // Authority is the realm's HOST ROOT — realms resolve by Host - // header, so the issuer has NO realm path. Never append "/system" - // or any "/" segment: a path-suffixed Authority makes the - // discovery fetch 404 and fails issuer validation. - options.Authority = "https://auth.example.com"; - options.Audience = "acme"; // matches the OAuthApi name / aud claim - }); - -// AddModgudClient (hooks JwtBearerEvents.OnTokenValidated) makes sure the -// principal ends up with resource_access["acme"] — preferring the claim -// already embedded in the JWT (the normal case for a JWT-typed client -// like this one) and calling /connect/userinfo only as a fallback for -// tokens that carry none — then registers the ClaimsTransformation that -// flattens the block onto the principal: +using Modgud.AspNetCore.ResourceServer; + +builder.Services.AddModgudResourceServer(options => +{ + // Authority is the realm's HOST ROOT — realms resolve by Host + // header, so the issuer has NO realm path. Never append "/system" + // or any "/" segment: a path-suffixed Authority makes the + // discovery fetch 404 and fails issuer validation. + options.Authority = "https://auth.example.com"; + options.Audience = "acme"; // matches the OAuthApi name / aud claim + // TokenMode defaults to OnlyJwt. +}); + +// The scheme projects the JWT's embedded audience block directly: // - resource_access["acme"].roles → ClaimTypes.Role // - resource_access["acme"].permissions → "permission" claims // The IdP pre-expands bypass tiers (realm:admin, :admin) before // emission, so the RS only ever does exact-match — no evaluator on this side. -// Do NOT set GetClaimsFromUserInfoEndpoint on AddJwtBearer; AddModgudClient -// owns claim-sourcing (token first, UserInfo fallback). -builder.Services.AddModgudClient(o => -{ - o.Authority = "https://auth.example.com"; - o.Audience = "acme"; // must equal JwtBearerOptions.Audience above -}); builder.Services.AddAuthorization(); ``` @@ -245,28 +235,28 @@ app.MapGet("/admin", () => "Admin only") ``` `[Authorize(Roles = "Acme Editor")]` works the same way — the -transformation surfaces `resource_access["acme"].roles` as +authentication scheme projects `resource_access["acme"].roles` as `ClaimTypes.Role` claims. ### Granular permission check -Gate endpoints with `.RequiresModgudPermission(...)` — the filter reads -the flattened `permission` claims and does a straight exact-match: +Gate endpoints with `.RequireModgudPermission(...)` — the authorization +policy reads the flattened `permission` claims and does a straight +exact-match: ```csharp app.MapPost("/todos", () => Results.Ok()) - .RequireAuthorization() - .RequiresModgudPermission("todo:write"); + .RequireModgudPermission("todo:write"); ``` If you need to read permissions imperatively, they live under -`ModgudClaimsTransformation.PermissionClaimType`: +`ModgudClaimTypes.Permission`: ```csharp app.MapGet("/whoami", (ClaimsPrincipal user) => Results.Ok(new { permissions = user - .FindAll(ModgudClaimsTransformation.PermissionClaimType) + .FindAll(ModgudClaimTypes.Permission) .Select(c => c.Value), })).RequireAuthorization(); ``` @@ -286,11 +276,10 @@ common pitfalls) live in 7. The resulting access token already carries `sub`, `email`, `name`, and `resource_access.acme.roles = ["Acme Editor"]` plus `resource_access.acme.permissions = ["todo:read", "todo:write"]` — - `AddModgudClient` reads that straight off the validated JWT - (`/connect/userinfo` would show the same block, but the library only - calls it as a fallback for tokens that don't carry the claim) + `AddModgudResourceServer` reads that straight off the validated JWT + without a UserInfo round-trip 8. `[Authorize(Roles = "Acme Editor")]` lets you in, and - `.RequiresModgudPermission("todo:write")` passes — the resource + `.RequireModgudPermission("todo:write")` passes — the resource server validated the JWT against the realm's JWKS (because the client's Access Token Type is JWT) and matched the flattened `permission` claims @@ -299,17 +288,18 @@ Made it through? **Done. First SaaS app integrated.** ## What comes next - **Multiple apps in one client:** a frontend that bundles two apps - assigns its OAuth client to both. The user's UserInfo response then - carries a `resource_access[]` block and a - `resource_access[]` block. Each backend reads its own block. + assigns its OAuth client to both, then requests resource-bearing + scopes targeting APIs in each App. The resulting principal can + carry one block per targeted API Audience. Each backend projects + its own block. - **Microservice apps:** several resource servers under one app — create more OAuth APIs in the **OAuth APIs** admin and link them all to the same App, each with its own narrower `PermissionIds` subset. - **External login providers:** under - [Login Providers](./login-providers) you configure Google / - Microsoft / EntraID. Modgud stays the central IDP but delegates - the login step. + [Login Providers](./login-providers) you configure Microsoft Entra ID and + standards-compatible OIDC or SAML providers. Modgud remains the OIDC + provider for your application but delegates the user-authentication step. - **Standing up a second, similar app:** right-click an existing App, Client, Scope, API, Role, or Group in its list and choose **Clone** to pre-fill a new one from it, instead of repeating all five @@ -338,12 +328,13 @@ Made it through? **Done. First SaaS app integrated.** request. - **`scope=permissions` not requested.** Without it, the `permissions` array in the `resource_access` block is omitted — your - `RequiresModgudPermission(…)` check sees nothing. Same for `roles` + `RequireModgudPermission(…)` check sees nothing. Same for `roles` and the role list. Add the scope to the client's allowed-scopes list and to every authorization request. -- **Access Token Type left as Reference.** `AddJwtBearer` can only - validate JWTs locally. A Reference (opaque) token has nothing to - validate by signature — switch the client to **JWT** (Station 2). +- **Access Token Type set to Reference while the resource server uses + `OnlyJwt`.** A Reference token is opaque and has nothing to validate + by signature — switch the client to **JWT**, or configure + `AddModgudResourceServer` for reference-token introspection. - **Authority has a realm path.** `Authority` must be the host root (`https://auth.example.com`), never `…/system` or `…/`. Realms resolve by Host header; a path-suffixed Authority breaks discovery and diff --git a/docs/operate/backend-architecture.md b/docs/operate/backend-architecture.md index 01b57634..29eb7cde 100644 --- a/docs/operate/backend-architecture.md +++ b/docs/operate/backend-architecture.md @@ -14,7 +14,7 @@ src/dotnet/ ├── Modgud.Application/ ← DTOs, service interfaces ├── Modgud.Infrastructure/ ← OpenIddict stores, tenancy, realm cache, Wolverine handlers ├── Modgud.Permissions.Abstractions/ ← Shared permission evaluator (realm:admin / :admin bypass tiers) -├── Modgud.Client.AspNetCore/ ← Published NuGet package: ASP.NET Core integration for resource servers +├── Modgud.AspNetCore.ResourceServer/ ← Published NuGet package: JWT and introspection integration for resource servers ├── Modgud.Provisioning.TestKit/ ← Published NuGet package: throwaway realms for integration tests ├── Modgud.Api/ ← Minimal API endpoints, middleware, setup, SignalR hub ├── Modgud.Api.Tests/ ← Integration tests (Testcontainers + PostgreSQL) @@ -193,24 +193,17 @@ Plus two pipeline hooks: `Program.cs` runs an explicit bootstrap path at startup (before `app.Run()`): -1. **Create the master DB and the `_system` DB** (raw SQL, because - Marten can't `CREATE DATABASE` on its own connection) -2. **Apply Marten schema** (`Storage.ApplyAllConfiguredChangesToDatabaseAsync`) - → `realms.mt_tenant_databases` is created -3. **Register system tenant** (`tenancy.AddDatabaseRecordAsync("system", systemCs)`) - pointing at its own `_system` DB — the master DB is pure - control-plane infra (registry + global Realm store + Wolverine durability) - and holds no tenant content -4. **Apply Marten schema again** → per-tenant tables for the system realm, in - its own DB -5. **Seed system realm document** (`EnsureSystemRealmExistsAsync`), stamped as - the control plane -6. **Seed default OAuth scopes + internal login provider** - (`OAuthRealmSeeder.SeedAsync`) -7. **Seed the system Apps** (`AppRealmSeeder.SeedAsync`), including the - control-plane App, so App-scoped permissions resolve before the - first realm is created -8. **Warm up RealmCache** +1. **Create the master DB** (raw SQL, because Marten cannot create its own + target database) +2. **Apply the primary-store schema** so the tenant registry exists +3. **Apply the Global Store schema**, including installation state +4. **Load every existing active realm** and idempotently apply its realm seeders +5. **Warm RealmCache** + +On a fresh database this intentionally leaves the registry empty. The +shell-authorized installation API creates the first tenant database, its first +`realm:admin`, and the initial `IsControlPlane` assignment. See +[First-time setup](../getting-started/first-time-setup). Only after this does Kestrel start listening. diff --git a/docs/operate/database.md b/docs/operate/database.md index 44780b8a..2c6bfbe7 100644 --- a/docs/operate/database.md +++ b/docs/operate/database.md @@ -44,7 +44,8 @@ data — no event sourcing. | `PasskeyCeremony`, `PasskeyEnrollCeremony` | Single-use passkey login/enrollment ceremony state (native/bearer flow only — the cookie-based web flow keeps its ceremony state in ASP.NET Core session) | TTL ~5 min | | `OpenIddictAuthorizationDocument` | OAuth consent records | `ApplicationId`, `Subject` | | `OpenIddictTokenDocument` | Reference tokens, refresh tokens | `ApplicationId`, `Subject`, `ReferenceId` | -| `SecurityAuditEntry` | Streamless security / ops events (unknown-actor logins, probes, rate-limit hits, recovery-CLI actions). Lives in the **system DB**, attributed to a realm via `Realm`; short hard-retention prune (no per-subject erase) | `Realm`, `Timestamp` | +| `RealmSecurityAuditEvent` | Structured security/ops events owned by this realm. Explicit forensic fields; unknown identifiers are realm-HMACed; configurable 1–365 day hard retention | `Timestamp`, `EventType` | +| `RealmAuditFingerprintKey` | Per-realm random HMAC key for unresolved identifiers | Singleton | | `UserDeletionState` | GDPR delete workflow state | `UserId` | | `UserChangeRequest` | Profile self-service pending changes | Per `(UserId, Type)` | | `Principal` (polymorphic) | Person + Group + ServiceAccount | `mt_doc_type` discriminator | @@ -220,7 +221,8 @@ Enums are stored as strings (readable in the DB inspector). | `mt_doc_realmsettings` | Realm-admin-owned config | | `mt_doc_applicationsettings` | Per-App config overrides | | `mt_doc_auth_audit_view` | Per-realm tenant audit feed (`AuthAuditView` projection — metadata only) | -| `mt_doc_usersession` | Active sessions | +| `mt_doc_usersession` | Authoritative browser/SSO sessions | +| `mt_doc_clientsession` | Authoritative native/OAuth client sessions and refresh-token-family binding | In the master DB additionally: @@ -228,12 +230,13 @@ In the master DB additionally: |---|---| | `realms.mt_tenant_databases` | Marten tenant registry | | `global.mt_doc_realm` | Realm documents | +| `global.mt_doc_platform_audit_event` | PII-free deployment-wide operations | +| `global.mt_doc_job_config` | Deployment-wide job configuration | +| `global.mt_doc_job_run_history_entry` | Deployment-wide job history | -In the system DB (`_system`) additionally: - -| Table | Contents | -|---|---| -| `mt_doc_security_audit_entry` | Cross-realm streamless security / ops audit (`SecurityAuditEntry` — short hard-retention prune) | +Every realm DB, including `_system`, contains its own +`mt_doc_realm_security_audit_event`. No realm DB contains another realm's +security events. ## Backing up realms @@ -246,8 +249,7 @@ team already runs). What's specific to Modgud is *what* to back up: tenant registry (`realms.mt_tenant_databases`) and the global Realm store (`global.mt_doc_realm`); - **`_system`** — the bootstrap system realm, including its - users and its share of the cross-realm security audit - (`mt_doc_security_audit_entry`); + users and its own security events; - **every `_` DB** — one per realm. Because each realm is a physically separate database, backup and diff --git a/docs/operate/observability.md b/docs/operate/observability.md index b54624a7..2c40723f 100644 --- a/docs/operate/observability.md +++ b/docs/operate/observability.md @@ -149,7 +149,12 @@ Two limits worth knowing, both because the targeted values have no machine-recog ### Failure modes -The export is **best-effort and lossy by design**. It must never be load-bearing — the tenant audit (`/admin/audit`, `/admin/auth-log`) is a separate, durable pipeline and is unaffected whether export is on or off. +The export is **best-effort and lossy by design**. It must never be load-bearing. +The event-sourced tenant audit (`/admin/audit`) is a separate pipeline. The +structured Security and Platform feeds are also independent of observability +export and use their own per-event durability classes: transactional/synchronous +for required changes and incidents, bounded aggregation for abuse signals, and +best-effort only for reconstructable operations telemetry. | Situation | What happens | What to do | | --- | --- | --- | diff --git a/docs/operate/realms.md b/docs/operate/realms.md index 7aa8e0ef..80a1270b 100644 --- a/docs/operate/realms.md +++ b/docs/operate/realms.md @@ -204,9 +204,8 @@ public class Realm public string[] Domains { get; set; } // ["acme.example.com", ...] public string PrimaryDomain { get; set; } // must be one of Domains — see "Primary domain" above public Dictionary ApplicationDomains { get; set; } // subdomain -> Application id - // Stored, transferable: exactly one realm carries the flag. The - // bootstrap "system" realm is stamped at first boot, but the role - // can be moved to any active realm. + // Stored and transferable. The first installed realm receives the + // flag; it can later be moved to any active realm. public bool IsControlPlane { get; set; } public bool IsActive { get; set; } public DateTimeOffset CreatedAt { get; set; } @@ -220,37 +219,16 @@ public class Realm In `Program.cs` (before `app.Run`): -1. **Create the master DB and `_system`** (raw SQL) -2. **Apply Marten storage** → `realms.mt_tenant_databases` is created -3. **Register the system tenant in the tenancy table** - (`tenancy.AddDatabaseRecordAsync("system", systemCs)` — pointing at - `_system`, not the master DB) -4. **Apply Marten storage again** → the system tenant gets per-tenant - tables in its own DB -5. **Seed system realm document** (`EnsureSystemRealmExistsAsync`) -6. **OAuthRealmSeeder** seeds 6 default scopes - (`openid`, `email`, `profile`, `roles`, `offline_access`, - `permissions`) + internal LoginProvider into the system tenant -7. **Warm up RealmCache** -8. **Check the recovery-CLI path** or start Kestrel - -::: warning Upgrading across the system-DB split -Older deployments ran the system realm **inside** the master DB. This version -moves it to its own `_system` database. On a fresh install — or when -you can recreate data — nothing is needed; the boot block provisions the new -layout. For an existing deployment with data you must relocate the system -realm's data **before** first boot: - -1. Stop the app and terminate open connections, then - `CREATE DATABASE "_system" TEMPLATE "";` -2. Repoint the registry **before** the first boot (a boot-time self-correction - is not single-boot-safe — writes in the stale window strand in the master - DB): `UPDATE realms.mt_tenant_databases SET connection_string = replace(connection_string, 'Database=;', 'Database=_system;') WHERE tenant_id = 'system';` -3. Deploy the new code; verify the system realm resolves and data is intact. -4. Optionally clean the now-duplicated `mt_*` tables out of the master DB - (table-precise — do **not** `DROP SCHEMA public`, it is shared with - Wolverine infra). -::: +1. **Create the master DB** (raw SQL) +2. **Apply primary and Global Store schemas** +3. **Load and idempotently seed every existing active realm** +4. **Warm RealmCache** +5. **Check the recovery-CLI path** or start Kestrel + +A fresh boot stops here with zero realms. The first-installation flow creates +the first tenant database and assigns its realm the Control-Plane flag only +after the first `realm:admin` exists. Existing deployments keep their registered +realms and persisted Control-Plane assignment. ## Realm CRUD @@ -296,9 +274,7 @@ Backend: 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`). +9. Realm creation completes independently from administrator onboarding. The recipient consumes the invite at `POST /api/account/bootstrap-admin` on the new realm's host (anonymous, rate-limited under `bootstrap`), @@ -307,10 +283,9 @@ sets a password, gets auto-signed-in. Atomic with that consume, `PermissionRole`s (System Admin / User Manager / Viewer) and adds the user to the `Administrators` group with `realm:admin`. -If the invite link gets lost or expires before it's consumed, -`POST /api/admin/realms/{slug}/resend-bootstrap-invite` revokes the -previous invite and issues a fresh one for the same recipient, with a -new 7-day expiry. +`POST /api/admin/realms/{slug}/admin-invites` issues a single-use, +24-hour realm-admin invitation. Issuing a new invitation revokes every +previous open admin invitation in that realm. ### Update diff --git a/docs/operate/recovery-cli.md b/docs/operate/recovery-cli.md index 3d78b4bd..6c0546de 100644 --- a/docs/operate/recovery-cli.md +++ b/docs/operate/recovery-cli.md @@ -16,19 +16,18 @@ Most invocations write an entry to the security audit log (see dotnet Modgud.Api.dll recover [args...] [--realm ] ``` -The `--realm` flag defaults to `system`. Commands that don't need a -tenant context (the `realm-*`, `control-plane`, and `adopt-tenant` -commands carry their own `--slug`) ignore it. +Tenant-scoped commands infer the realm only when exactly one active realm +exists. With multiple realms, `--realm ` is required. With zero realms, +only deployment-wide commands such as `install-link` can run. For tenant-scoped commands the named realm is resolved up front: - A misspelled or unknown `--realm` **fails fast** with `error: Realm '' not found.` and a non-zero exit code — it never silently acts on the wrong tenant. -- When `--realm` is omitted **and more than one realm exists**, the CLI - prints a `note:` to stderr naming the realm it defaulted to, so a - multi-realm operator is never surprised. With a single realm the - default is unambiguous and stays quiet. +- When `--realm` is omitted and more than one realm exists, the command + fails and asks for an explicit target. With a single realm the target + is unambiguous and stays quiet. Every command exits `0` on success and a non-zero code on failure (a validation error, an unknown realm, or an unknown command); error text @@ -36,6 +35,28 @@ is written to stderr. ## Commands +### `install-link` + +Issue the short-lived, single-use authorization for the initial installation. +This command works while the deployment has zero realms. The browser wizard +and CI both submit the resulting token to `/api/install/complete`. + +```bash +dotnet Modgud.Api.dll recover install-link \ + --base-url https://auth.example.com \ + --minutes 30 + +# Machine-readable final output line for CI +dotnet Modgud.Api.dll recover install-link \ + --base-url https://auth.test.localhost \ + --minutes 10 \ + --json +``` + +Issuing a new link revokes older unconsumed links. The plaintext token is shown +only in CLI output; the Global Store contains its SHA-256 hash. See +[First-time setup](../getting-started/first-time-setup). + ### `list` List every active user with `UserName · Email · Active · Admin · 2FA · Passkeys`. @@ -81,7 +102,7 @@ dotnet Modgud.Api.dll recover rebuild-projections ``` ### `bootstrap-admin` -Create the first admin in a realm. Default realm: `system`. Two modes +Create or recover an admin in an existing realm. Two modes — **Direct** (password set immediately) and **Invite** (a magic-link URL is printed and emailed if SMTP is configured). @@ -109,7 +130,7 @@ Flags: | `--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`. | +| `--realm ` | when multiple realms exist | Inferred when exactly one active realm exists. | ### `migrate-cc-credentials` For every OAuth client that still has the `client_credentials` grant @@ -125,7 +146,7 @@ dotnet Modgud.Api.dll recover migrate-cc-credentials --realm system ``` ### `realm-list` -List every active realm with its slug, display name, primary domain, and configured domains (the control-plane realm is marked `[CP]`). Useful first probe after a fresh deploy — shows the system realm's seeded localhost domains so you know which Host header to use. +List every active realm with its slug, display name, primary domain, and configured domains (the control-plane realm is marked `[CP]`). A fresh, uninitialized deployment returns an empty list. ```bash dotnet Modgud.Api.dll recover realm-list diff --git a/docs/operate/supply-chain.md b/docs/operate/supply-chain.md index 144b5959..bcc85d49 100644 --- a/docs/operate/supply-chain.md +++ b/docs/operate/supply-chain.md @@ -13,7 +13,7 @@ Images and packages from v0.6.0 and earlier predate the signing pipeline — the | Trivy scan gate | The image had no known fixable CRITICAL/HIGH vulnerabilities at publish time — each architecture is scanned separately, and a finding blocks the whole release | Enforced in CI (`cd-release.yml`), not a downloadable artifact | | cosign signature (keyless) | The image digest was signed by the release workflow itself, via its short-lived OIDC identity — there is no long-lived signing key that could leak | GHCR, next to the image; log entry in the public Rekor transparency log | | Build-provenance attestation (image) | Which repository, workflow, commit, and run built the image | GitHub attestation store + GHCR | -| Build-provenance attestation (NuGet) | Same, for the `Modgud.Client.AspNetCore` package | GitHub attestation store | +| Build-provenance attestation (NuGet) | Same, for the `Modgud.AspNetCore.ResourceServer` package | GitHub attestation store | | SPDX SBOMs (per arch) | The full component inventory of the image, one file per platform | GitHub release assets (`modgud--linux-.spdx.json`) | | BuildKit inline SBOM + provenance | Machine-readable equivalents embedded in the image manifest | GHCR, part of the multi-arch manifest list | @@ -38,7 +38,7 @@ Both checks operate on the image digest, and every release tag (`:`, `: ## Verify the NuGet package ```bash -gh attestation verify Modgud.Client.AspNetCore..nupkg -R cocoar-dev/modgud +gh attestation verify Modgud.AspNetCore.ResourceServer..nupkg -R cocoar-dev/modgud ``` This proves the exact `.nupkg` you downloaded from nuget.org was produced by the release workflow in this repository, at the commit the attestation names. diff --git a/docs/platform/settings.md b/docs/platform/settings.md index e15b022c..3073d843 100644 --- a/docs/platform/settings.md +++ b/docs/platform/settings.md @@ -66,15 +66,16 @@ Configured as `TwoFactorGracePeriodDays` in deployment config — not currently editable from the admin UI. The shipping default is 14 days. -## Sign-in cookie lifetime +## Sign-in session lifetime -The auth cookie's `ExpireTimeSpan` is 30 days with sliding expiration. -"Remember me" controls whether the cookie is persistent at all — -without it, the cookie is session-only and dies with the browser tab. +Browser/SSO lifetimes are realm-owned and editable under +Administration → **Realm Settings → Sessions**. The defaults are a +30-day sliding idle window and a 180-day absolute limit. The same page +controls whether persistent “remember me” cookies are allowed. -These values currently come from deployment config (not the admin UI) -— see [Authentication cookies](../integrate/cookies-and-sessions) for the full -cookie inventory. +Native/OAuth client sessions have a separate realm default and can be +overridden per Application and per OAuth client. See +[Authentication cookies and sessions](../integrate/cookies-and-sessions). ## SMTP @@ -99,11 +100,10 @@ email-verification steps work. ## Auth-log retention -The auth log is hard-pruned to a fixed **7-day** window by a daily -scheduled job (visible and manually triggerable from -[Scheduled jobs](../admin/scheduled-jobs)). This window is not -currently runtime-configurable; it applies across every realm. See -[Auth Log](../admin/auth-log). +Each realm configures its own Security-log retention under **Realm settings → +Logs**. The default is **7 days**, the allowed range is **1–365 days**, and the +realm-owned `security-audit-prune` job hard-deletes only expired entries in +that realm DB. See [Security and platform logs](../admin/auth-log). ## Tips diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 384e31cf..3389382a 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -28,13 +28,17 @@ importers: version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@25.9.1)(postcss@8.5.15)(search-insights@2.17.3) vitepress-plugin-llms: specifier: latest - version: 1.13.0 + version: 1.13.4 vitepress-plugin-mermaid: specifier: ^2.0.17 version: 2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@25.9.1)(postcss@8.5.15)(search-insights@2.17.3)) packages: + '@11ty/gray-matter@2.1.0': + resolution: {integrity: sha512-fNdBOb3MgDz/1UCIoeY4wDuGbp+/3s5y6UrsyfMabECbh/CVycj7Er33JXqSxRwxrRKXcGE1Jipuq/1mODInsQ==} + engines: {node: '>=11'} + '@algolia/abtesting@1.18.1': resolution: {integrity: sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==} engines: {node: '>= 14.0.0'} @@ -358,79 +362,66 @@ packages: resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.4': resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.4': resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.4': resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.4': resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.4': resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.4': resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.4': resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.4': resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.4': resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} @@ -741,9 +732,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -814,9 +802,9 @@ packages: birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -1170,10 +1158,6 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} - gray-matter@4.0.3: - resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} - engines: {node: '>=6.0'} - hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -1235,8 +1219,8 @@ packages: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true katex@0.16.47: @@ -1382,8 +1366,8 @@ packages: resolution: {integrity: sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==} hasBin: true - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minisearch@7.2.0: @@ -1452,8 +1436,8 @@ packages: preact@10.29.2: resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} - pretty-bytes@7.1.0: - resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} + pretty-bytes@7.1.1: + resolution: {integrity: sha512-X+vn9z8nOFZQlxOLmfJ0iKDdMD7jYTsTW12OAlCpdoE3Igik6L37pugIZi+N3usuyp5McfgKPWi12q3zvHLeGQ==} engines: {node: '>=20'} progress@2.0.3: @@ -1568,9 +1552,6 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - streamx@2.25.0: resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} @@ -1585,10 +1566,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-bom-string@1.0.0: - resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} - engines: {node: '>=0.10.0'} - stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -1615,8 +1592,8 @@ packages: resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==} engines: {node: '>=18'} - tokenx@1.3.0: - resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} + tokenx@1.6.0: + resolution: {integrity: sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==} trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -1702,9 +1679,9 @@ packages: terser: optional: true - vitepress-plugin-llms@1.13.0: - resolution: {integrity: sha512-nYUC0MBkG9gH5nJCrpbwOVpXYqwBgbzTcMchPVQiZ5jylX1QCvMSYxvN4yWXo+OAEKv80361T61+HNPUXnnPwQ==} - engines: {node: '>=18.0.0'} + vitepress-plugin-llms@1.13.4: + resolution: {integrity: sha512-3/L1ceexjpJirWWGlfvqx2hmaDTFGSkSHrn3y2C7RZm6lNa/vmvU49Ayr4GxLYQfOFfo9CP6rsWdD+6rEbnIxA==} + engines: {node: '>=18'} vitepress-plugin-mermaid@2.0.17: resolution: {integrity: sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==} @@ -1777,6 +1754,11 @@ packages: snapshots: + '@11ty/gray-matter@2.1.0': + dependencies: + js-yaml: 4.3.0 + section-matter: 1.0.0 + '@algolia/abtesting@1.18.1': dependencies: '@algolia/client-common': 5.52.1 @@ -2465,10 +2447,6 @@ snapshots: dependencies: color-convert: 2.0.1 - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} ast-types@0.13.4: @@ -2517,7 +2495,7 @@ snapshots: birpc@2.9.0: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -2902,13 +2880,6 @@ snapshots: transitivePeerDependencies: - supports-color - gray-matter@4.0.3: - dependencies: - js-yaml: 3.14.2 - kind-of: 6.0.3 - section-matter: 1.0.0 - strip-bom-string: 1.0.0 - hachure-fill@0.5.2: {} hast-util-to-html@9.0.5: @@ -2969,10 +2940,9 @@ snapshots: is-what@5.5.0: {} - js-yaml@3.14.2: + js-yaml@4.3.0: dependencies: - argparse: 1.0.10 - esprima: 4.0.1 + argparse: 2.0.1 katex@0.16.47: dependencies: @@ -3246,9 +3216,9 @@ snapshots: dependencies: yargs: 17.7.2 - minimatch@10.2.5: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 minisearch@7.2.0: {} @@ -3318,7 +3288,7 @@ snapshots: preact@10.29.2: {} - pretty-bytes@7.1.0: {} + pretty-bytes@7.1.1: {} progress@2.0.3: {} @@ -3498,8 +3468,6 @@ snapshots: speakingurl@14.0.1: {} - sprintf-js@1.0.3: {} - streamx@2.25.0: dependencies: events-universal: 1.0.1 @@ -3524,8 +3492,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-bom-string@1.0.0: {} - stylis@4.4.0: {} superjson@2.2.6: @@ -3572,7 +3538,7 @@ snapshots: tinyexec@1.2.2: {} - tokenx@1.3.0: {} + tokenx@1.6.0: {} trim-lines@3.0.1: {} @@ -3649,20 +3615,20 @@ snapshots: '@types/node': 25.9.1 fsevents: 2.3.3 - vitepress-plugin-llms@1.13.0: + vitepress-plugin-llms@1.13.4: dependencies: - gray-matter: 4.0.3 + '@11ty/gray-matter': 2.1.0 markdown-it: 14.2.0 markdown-title: 1.0.2 mdast-util-from-markdown: 2.0.3 millify: 6.1.0 - minimatch: 10.2.5 + minimatch: 10.2.6 path-to-regexp: 6.3.0 picocolors: 1.1.1 - pretty-bytes: 7.1.0 + pretty-bytes: 7.1.1 remark: 15.0.1 remark-frontmatter: 5.0.0 - tokenx: 1.3.0 + tokenx: 1.6.0 unist-util-remove: 4.0.0 unist-util-visit: 5.1.0 transitivePeerDependencies: diff --git a/docs/reference/admin-api.md b/docs/reference/admin-api.md index 6f3b319f..16f230fc 100644 --- a/docs/reference/admin-api.md +++ b/docs/reference/admin-api.md @@ -236,14 +236,17 @@ app slug (`realm:read|write`), not under `modgud`. See ## Security log -The security/audit log. Reads are filtered by `category`, `eventType`, -and `limit` query parameters. Clearing the log is destructive and gated -behind the `realm:admin` bypass. +The Security log reads only the current realm's physical database. The +Control-Plane-only Platform log reads PII-free deployment events from the +Global Store. Both accept `category`, `eventType`, and `limit`. | Method | Path | Permission | |---|---|---| | `GET` | `/api/admin/auth-log?category=...&eventType=...&limit=...` | `auth-log:read` | -| `DELETE` | `/api/admin/auth-log` | `realm:admin` | +| `GET` | `/api/admin/platform-audit?category=...&eventType=...&limit=...` | `control-plane:platform-audit:read` | + +Neither surface has a clear/delete endpoint. Retention jobs delete only +expired entries. ## App info diff --git a/docs/reference/auth-api.md b/docs/reference/auth-api.md index 6f1bb53f..19292128 100644 --- a/docs/reference/auth-api.md +++ b/docs/reference/auth-api.md @@ -13,7 +13,7 @@ Full endpoint source in | Method | Path | Description | |---|---|---| | `POST` | `/api/account/login` | Login with username + password | -| `POST` | `/api/account/logout` | Logout (cookie removed, session invalidated) | +| `POST` | `/api/account/logout` | Remove the cookie and invalidate the browser session. `{ "EndIdpSession": true }` additionally returns an upstream logout URL only for a live OIDC provider; SAML ends locally. | | `GET` | `/api/account/self-registration-info` | Anonymous — public self-registration config the SPA reads before mounting `/register` | | `POST` | `/api/account/register` | Self-registration (when enabled per realm) | | `POST` | `/api/account/register/verify-email` | Anonymous — consume the registration email-verification token | @@ -98,20 +98,23 @@ For native/mobile clients that can't hold a session cookie. These mint a code, o | `GET` | `/connect/passkey` | Bearer-authenticated — list the signed-in token subject's own passkeys | | `DELETE` | `/connect/passkey/{id}` | Bearer-authenticated — revoke one of the token subject's own passkeys | -## External login (OIDC) +## External login (OIDC and SAML) | Method | Path | Description | |---|---|---| -| `GET` | `/api/account/external-logins` | List of active LoginProviders (no secrets) | +| `GET` | `/api/account/external-logins` | List active OIDC and SAML LoginProviders (no secrets); `Kind` selects the correct entry point | | `GET` | `/api/account/external-login/{loginProviderId}/start?returnUrl=/` | Start OIDC flow | | `GET` | `/api/account/external-login/finish` | OIDC callback from the external IdP | -| `GET` | `/api/account/external-logout/{loginProviderId}` | Single-logout signal back to the external IdP | +| `GET` | `/api/account/external-logout/{loginProviderId}` | OIDC RP-initiated logout; non-OIDC or unavailable providers fall back to `/logged-out` | +| `GET` | `/saml/{slug}/sp-metadata` | SAML Service Provider metadata | +| `GET` | `/saml/{slug}/login?returnUrl=/` | Start an SP-initiated SAML login | +| `POST` | `/saml/{slug}/acs` | Receive the correlated SAML response via HTTP-POST | -### Login flow +### OIDC login flow ``` 1. Frontend: GET /api/account/external-logins → shows provider buttons -2. User clicks "Login with Acme SSO" +2. User clicks an OIDC provider 3. Browser: GET /api/account/external-login/{loginProviderId}/start?returnUrl=/ 4. Backend: ASP.NET Challenge with the dynamically registered OIDC scheme 5. Browser: 302 → external IdP @@ -122,15 +125,33 @@ For native/mobile clients that can't hold a session cookie. These mint a code, o 9. Backend: 302 → returnUrl ``` +### SAML login flow + +``` +1. Frontend: GET /api/account/external-logins → sees Kind = Saml + Slug +2. Browser: GET /saml/{slug}/login?returnUrl=/ +3. Backend: signed AuthnRequest → IdP via HTTP-Redirect +4. IdP: form POST → /saml/{slug}/acs +5. Backend: validate signature, conditions, audience and one-time InResponseTo +6. ExternalLoginProcessor runs and issues the Modgud application cookie +7. Backend: 302 → sanitized returnUrl +``` + +Modgud is SAML **SP-only** and accepts only responses to AuthnRequests it +started. IdP-initiated SSO, SAML Single Logout and Artifact Binding are not +supported in v1. See [SAML federation](../admin/saml-federation). + ## Sessions These live under `/api/auth/...`, not `/api/account/...`. | Method | Path | Description | |---|---|---| -| `GET` | `/api/auth/sessions` | Active sessions | -| `DELETE` | `/api/auth/sessions/{id}` | Revoke a session | -| `DELETE` | `/api/auth/sessions` | Revoke all sessions except current ("logout everywhere") | +| `GET` | `/api/auth/sessions` | Browser sessions plus native/OAuth client sessions | +| `DELETE` | `/api/auth/sessions/{id}` | Revoke another browser session | +| `DELETE` | `/api/auth/sessions/client/{id}` | Revoke one native/OAuth client session and its token family | +| `DELETE` | `/api/auth/sessions/others` | Revoke every browser session except the current one | +| `DELETE` | `/api/auth/sessions` | Sign out everywhere, including the current browser and all OAuth client sessions | ## GDPR / privacy @@ -149,7 +170,7 @@ surface) because they're identity-lifecycle operations: There is no anonymous setup wizard. The first admin in any realm is created either through the recovery CLI (filesystem trust) or via a -Control-Plane admin issuing an invite through the realm-create API. +Control-Plane admin issuing an invitation for that realm. The single anonymous endpoint is the bootstrap-invite consumer: | Method | Path | Description | @@ -160,12 +181,12 @@ The token comes from one of: - `dotnet Modgud.Api.dll recover bootstrap-admin --email ` (without `--password`) — see [Recovery CLI](../operate/recovery-cli) -- `POST /api/admin/realms` with an `InitialAdmin` payload — see +- `POST /api/admin/realms/{slug}/admin-invites` — see [Realm API](./realm-api) -- `POST /api/admin/realms/{slug}/resend-bootstrap-invite` — re-issue a - fresh token for the same recipient +- `POST /api/admin/realms` with an optional `InitialAdmin` payload for + backwards-compatible create-and-invite automation -Token properties: SHA-256-hashed in the DB, 7-day TTL, single-use +Token properties: SHA-256-hashed in the DB, 24-hour TTL, single-use (reuse → 400 `BootstrapInvite.TokenUsed`). Endpoint is rate-limited under the `bootstrap` policy (10 attempts per IP per 15 minutes). diff --git a/docs/reference/oauth-api.md b/docs/reference/oauth-api.md index ce2b59b6..f87aa895 100644 --- a/docs/reference/oauth-api.md +++ b/docs/reference/oauth-api.md @@ -58,7 +58,7 @@ All under `/connect/...`, all realm-scoped via the domain: | `/connect/authorize` | `GET`/`POST` | Authorization endpoint (Code + PKCE). Also accepts a `request_uri` from `/connect/par`. | | `/connect/par` | `POST` | Pushed Authorization Request endpoint (RFC 9126). Back-channel; returns a one-time `request_uri`. | | `/connect/token` | `POST` | Token endpoint (code exchange, client credentials, refresh, device) | -| `/connect/userinfo` | `GET`/`POST` | UserInfo endpoint (claims + per-Audience `resource_access`) | +| `/connect/userinfo` | `GET`/`POST` | UserInfo endpoint (claims plus eligible per-Audience `resource_access`) | | `/connect/introspect` | `POST` | Token introspection | | `/connect/revoke` | `POST` | Token revocation | | `/connect/logout` | `GET`/`POST` | End-session endpoint (RP-initiated logout) | @@ -211,7 +211,7 @@ Authorization: DPoP DPoP: ``` -The [.NET client library](../integrate/resource-server) enforces the binding on both token formats: a bound token presented as a plain `Bearer`, or with a proof whose key doesn't match `cnf.jkt`, is rejected. +The [.NET resource-server library](../integrate/resource-server) enforces the binding on both token formats: a bound token presented as a plain `Bearer`, or with a proof whose key doesn't match `cnf.jkt`, is rejected. ### 3. Refresh tokens are bound too @@ -309,15 +309,17 @@ GET /connect/userinfo Authorization: Bearer ``` -Returns the claims for the bearer token, plus a `resource_access` -block (Keycloak-style nesting) keyed per Audience: +Returns the claims for the bearer token. It also returns the same +Keycloak-shaped `resource_access` claim as the access-token principal +when at least one token audience resolves to a registered OAuth API +linked to an App and `roles` and/or `permissions` was granted: ```json { "sub": "abc123…", "email": "alice@example.com", "resource_access": { - "billing": { + "billing-api": { "roles": ["Editor"], "permissions": ["invoice:read", "invoice:write"] } @@ -325,12 +327,14 @@ block (Keycloak-style nesting) keyed per Audience: } ``` +- The key is the exact OAuth API Audience, not its linked App slug. - `roles` is emitted when `scope=roles` was granted. - `permissions` is emitted when `scope=permissions` was granted, - **bypass-pre-expanded** and narrowed to the calling OAuthApi's + **bypass-pre-expanded** and narrowed to the matching OAuth API's `PermissionIds` subset. -- One block per audience listed in `aud`; a microservice within a - multi-RS App sees only its declared subset. +- Audiences that do not resolve to a registered OAuth API with a linked + App are skipped. If no eligible block remains, the whole claim is + absent. See [Apps and resource_access](../concepts/apps-and-resource-access) for the full emission story. @@ -345,9 +349,11 @@ Content-Type: application/x-www-form-urlencoded token= ``` -Returns `active: true/false` plus all the token's claims. Used by -resource servers that hold **reference tokens** (server-side opaque) -to validate them against the issuer. +Returns `active: true/false` plus the token claims authorized for that +introspection caller, including the same audience-keyed +`resource_access` object when eligible. Used by resource servers that +hold **reference tokens** (server-side opaque) to validate them +against the issuer. ## Revocation diff --git a/docs/reference/realm-api.md b/docs/reference/realm-api.md index 918a0f28..efded413 100644 --- a/docs/reference/realm-api.md +++ b/docs/reference/realm-api.md @@ -16,7 +16,7 @@ Endpoints in `Modgud.Api/Features/Admin/RealmsEndpoints.cs`. | `POST` | `/api/admin/realms` | `realm:write` | | `PATCH` | `/api/admin/realms/{slug}` | `realm:write` | | `DELETE` | `/api/admin/realms/{slug}` | `realm:write` (soft-delete = deactivate; `?hard=true` drops the tenant database) | -| `POST` | `/api/admin/realms/{slug}/resend-bootstrap-invite` | `realm:write` | +| `POST` | `/api/admin/realms/{slug}/admin-invites` | `realm:write` | | `POST` | `/api/admin/realms/import` | `realm:write` (create a realm from a manifest) | | `POST` | `/api/admin/realms/{slug}/apply` | `realm:write` (merge a manifest; `?prune=true` = full sync) | | `GET` | `/api/admin/realms/{slug}/export` | `realm:read` (structure-only manifest) | @@ -47,8 +47,10 @@ in the `modgud` App's catalog would be a different permission. The ## Create a realm -`POST` requires an `InitialAdmin` payload. A realm without a recipient -on file would have no admin path; the endpoint refuses to create one. +`POST` creates the realm independently from its administrators. +`InitialAdmin` remains an optional API convenience for callers that want +to create the realm and issue an invitation in one request; the admin UI +uses the separate invitation endpoint. The Control-Plane flag is a stored, transferable field; you cannot set it on create (new realms are never the control plane). See [Transfer the control plane](#transfer-the-control-plane). @@ -62,13 +64,7 @@ Content-Type: application/json "Slug": "acme", "DisplayName": "Acme Corp", "Description": "Acme Corporation Identity", - "Domains": ["acme.example.com"], - "InitialAdmin": { - "UserName": "max", - "Email": "max@acme.com", - "Firstname": "Max", - "Lastname": "Mustermann" - } + "Domains": ["acme.example.com"] } ``` @@ -76,28 +72,25 @@ Content-Type: application/json 1. **Slug validation**: regex `^[a-z][a-z0-9-]{1,61}[a-z0-9]$`, no reserved word (`system`, `health`, `swagger`, `api`, `connect`, …) -2. **`InitialAdmin` validation**: `UserName` and `Email` are required; - `Firstname` and `Lastname` are optional. -3. **Create PostgreSQL DB** (raw SQL): +2. **Create PostgreSQL DB** (raw SQL): `CREATE DATABASE _acme` -4. **Register in Marten tenancy**: +3. **Register in Marten tenancy**: `tenancy.AddDatabaseRecordAsync("acme", connStringForAcme)` -5. **Apply Marten schema** (tables, indexes, functions) -6. **`OAuthRealmSeeder.SeedAsync`** seeds the 6 default scopes +4. **Apply Marten schema** (tables, indexes, functions) +5. **`OAuthRealmSeeder.SeedAsync`** seeds the 6 default scopes (`openid`, `email`, `profile`, `roles`, `offline_access`, `permissions`) and the built-in Internal login provider. -7. **`AppRealmSeeder.SeedAsync`**: the `modgud` App is registered in +6. **`AppRealmSeeder.SeedAsync`**: the `modgud` App is registered in the new tenant DB. The `control-plane` App is **only** seeded for the system realm — tenant realms physically cannot grant `realm:read`/`realm:write` (the App that owns those catalog entries doesn't exist in their tenant DB). -8. **Realm document** persisted in `IGlobalStore` (master DB, schema +7. **Realm document** persisted in `IGlobalStore` (master DB, schema `global`). -9. **`RealmCache.Invalidate()`** — the next request loads it fresh. -10. **Bootstrap-invite issued** atomically into the new tenant DB. - The recipient's SHA-256-hashed token is stored as - `PendingAdminInvite`; the plaintext is embedded in the magic-link - URL emailed to `InitialAdmin.Email`. +8. **`RealmCache.Invalidate()`** — the next request loads it fresh. +9. When optional `InitialAdmin` is present, its invitation is issued + atomically and returned as `InitialAdminInvite`; otherwise that + response property is `null`/omitted. ### Response (201 Created) @@ -111,42 +104,35 @@ Content-Type: application/json "Domains": ["acme.example.com"], "IsControlPlane": false, "IsActive": true, - "NeedsSetup": false, "CreatedAt": "2026-05-05T10:00:00Z" - }, - "InitialAdminInvite": { - "UserName": "max", - "Email": "max@acme.com", - "ExpiresAt": "2026-05-12T10:00:00Z", - "MagicLinkUrl": "https://acme.example.com/bootstrap?token=…" } } ``` `IsControlPlane` is read-only — it appears in responses but is never -accepted in requests. `MagicLinkUrl` is returned **only here**, only -this once — capture it if SMTP delivery isn't reliable in the issuing -environment. To re-issue use the resend endpoint. - -The recipient consumes the token at `POST /api/account/bootstrap-admin` -on the new realm's host (see [Auth API](./auth-api)). +accepted in requests. -## Resend a bootstrap-invite +## Invite a realm admin ```http -POST /api/admin/realms/acme/resend-bootstrap-invite HTTP/1.1 +POST /api/admin/realms/acme/admin-invites HTTP/1.1 Host: auth.example.com -``` +Content-Type: application/json -Re-uses the recipient identity (UserName + Email + Firstname + -Lastname) from the **most recent prior invite** — no body needed. The -previous invite is revoked (`UsedAt` set), a fresh 7-day token is -issued, the email is sent again, and the new `MagicLinkUrl` is -returned in the response (same shape as `InitialAdminInvite` above). +{ + "UserName": "max", + "Email": "max@acme.com", + "Firstname": "Max", + "Lastname": "Mustermann" +} +``` -Returns `404 Realm.NoPriorInvite` if no invite was ever issued (e.g. -a realm whose first admin was created via the recovery CLI in direct -mode). +Issues a new single-use, 24-hour invitation. Every prior open admin +invitation in the realm is revoked, regardless of recipient, so at most +one link is active. The response contains `InitialAdminInviteDto`, +including the one-time `MagicLinkUrl` for SMTP-less development. +The recipient consumes the token at `POST /api/account/bootstrap-admin` +on the realm's host (see [Auth API](./auth-api)). ## Edit a realm diff --git a/docs/roadmap.md b/docs/roadmap.md index f4c2c7e8..7bb38356 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,10 +15,13 @@ in a changelog that ages between releases. - Password + TOTP + Email OTP + Passkey (FIDO2/WebAuthn) + Magic Link, combinable per user -- OIDC + SAML 2.0 federated login — Microsoft Entra ID, Google, GitHub, - ADFS, any OIDC/SAML IdP — with JIT user provisioning and a JavaScript - claim-mapping script; self-service account linking (Profile → Linked - accounts) + admin force-unlink, with re-link after disconnect +- OIDC + SAML 2.0 federated login — Microsoft Entra ID and + standards-compatible OIDC/SAML IdPs — with JIT user provisioning and a + JavaScript claim-mapping script. OIDC supports self-service account linking + (Profile → Linked accounts); SAML linking currently resolves through the + normal sign-in/JIT or trusted-email path because the cross-site ACS POST + cannot carry the `SameSite=Lax` application cookie. Admin force-unlink and + re-link after disconnect are supported for both. - Configurable authentication levels (password-only, secure-login with 2FA enrolment, passwordless-only) with a grace-period workflow for migrating existing users @@ -36,10 +39,11 @@ in a changelog that ages between releases. - Groups with manual or script-based ("Auto-Membership") membership, nested groups with cycle detection, per-group app activation via `BoundTo` -- Per-Audience `resource_access` emission on `/connect/userinfo` with - bypass pre-expansion and per-RS subset narrowing — drop-in for - Keycloak-shaped client libraries; native via the - `Modgud.Client.AspNetCore` NuGet package +- Per-Audience `resource_access` emission in JWT access tokens, + UserInfo and authorized introspection responses when the matching + audience and claim scopes are present, with bypass pre-expansion + and per-RS subset narrowing; native via the + `Modgud.AspNetCore.ResourceServer` NuGet package **OAuth 2.0 / OpenID Connect (OpenIddict 7)** diff --git a/package.json b/package.json index 6de17f56..ef9fef23 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "license": "Apache-2.0", "description": "Root convenience scripts for building the Vue SPA and the in-app docs straight into the backend's wwwroot — used when you want `dotnet run` (no Docker) to serve a current frontend + docs.", "scripts": { + "dev:landing": "cd src/landing-page && pnpm dev", + "build:landing": "cd src/landing-page && pnpm install --prefer-offline && pnpm build", "build:frontend": "cd src/frontend-vue && pnpm install --prefer-offline && pnpm exec vite build --outDir ../dotnet/Modgud.Api/wwwroot --emptyOutDir", "build:docs": "cd docs && pnpm install --prefer-offline && pnpm exec vitepress build --config .vitepress/config.in-app.ts --outDir ../src/dotnet/Modgud.Api/wwwroot/docs", "build:backend": "pnpm run build:frontend && pnpm run build:docs", diff --git a/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs b/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs index 460ca26e..4e99df09 100644 --- a/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs @@ -1,120 +1,232 @@ +using System.Net; using Marten; using Microsoft.Extensions.DependencyInjection; using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; using Modgud.Authentication.Gdpr; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Realms; namespace Modgud.Api.Tests.Audit; -/// -/// The streamless security/ops store (logging/audit redesign Track A, §A.5): -/// records about UNidentified actors + operational actions, in the system DB under -/// Art. 6(1)(f) legitimate interest. Two load-bearing claims are tested here: -/// (1) these records are NOT in the per-subject GDPR-erase path — they rely on the -/// short retention window, not erasure (Open Decision #4 = time-expiry only); and -/// (2) clearing the log is itself audited (audit-of-the-audit) with the operator's -/// identity. The control-plane test admin sees + clears the full cross-realm log. -/// [Collection(IntegrationTestCollection.Name)] public class SecurityAuditStoreTests : IntegrationTestBase { public SecurityAuditStoreTests(SharedPostgresFixture fixture) : base(fixture) { } [Fact] - public async Task Streamless_record_survives_user_permanent_erase() + public async Task Structured_forensic_record_survives_subject_erasure_until_retention() { var ct = TestContext.Current.CancellationToken; - - // A registered user whose email also appears as the ATTEMPTED actor on a - // pre-registration failed-login row in the streamless store. - const string email = "boundary-victim@acme.com"; - var user = await Factory.CreateTestUserWithIdentityAsync("Boundary", "Victim", "bv", email); - + var user = await Factory.CreateTestUserWithIdentityAsync( + "Boundary", "Victim", "bv", "boundary-victim@acme.com"); var rowId = Guid.NewGuid(); + await using (var write = GetTenantedDocumentSession("system")) { - write.Store(new SecurityAuditEntry + write.Store(new RealmSecurityAuditEvent { Id = rowId, Timestamp = DateTimeOffset.UtcNow, - Level = "Warning", - EventType = AuditEvents.LoginFailedUnknownUser, - Actor = email, - Ip = "203.0.113.50", - Realm = "system", - Message = $"Login failed for {email} — user not found or inactive", + Severity = AuditSeverity.Warning, + EventType = AuditEvents.LoginFailed, + Category = AuditEvents.CategoryOf(AuditEvents.LoginFailed), + ActorKind = AuditActorKind.User, + TargetSubjectId = user.Id, + IpAddress = "203.0.113.50", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "invalid-credentials", }); await write.SaveChangesAsync(ct); } - // Permanent-erase the user. The streamless store has no user stream to attach - // to and is deliberately OUTSIDE the per-subject erase path. using (var scope = Factory.Services.CreateScope()) { var gdpr = scope.ServiceProvider.GetRequiredService(); - var r = await gdpr.PermanentlyEraseAsync(user.Id, adminUserId: null, reason: "streamless-boundary-test", ct); - Assert.False(r.IsError, r.IsError ? r.FirstError.Description : null); + var result = await gdpr.PermanentlyEraseAsync( + user.Id, adminUserId: null, reason: "security-retention-test", ct); + Assert.False(result.IsError, result.IsError ? result.FirstError.Description : null); } - // The streamless record SURVIVES the erase (it expires only via retention). - await using (var read = GetTenantedDocumentSession("system")) - { - var survived = await read.LoadAsync(rowId, ct); - Assert.NotNull(survived); - Assert.Equal(email, survived!.Actor); - } + await using var read = GetTenantedDocumentSession("system"); + var survived = await read.LoadAsync(rowId, ct); + Assert.NotNull(survived); + Assert.Equal(user.Id, survived!.TargetSubjectId); + Assert.Equal("203.0.113.50", survived.IpAddress); } [Fact] - public async Task Clear_is_audited_with_the_operator_identity() + public async Task Unknown_identifier_is_persisted_only_as_realm_hmac() { var ct = TestContext.Current.CancellationToken; + const string rawIdentifier = "Unknown.Person@Example.test"; + var marker = $"hmac-test-{Guid.NewGuid():N}"; + var otherRealm = ($"hmac-{Guid.NewGuid():N}")[..13]; + var provisioned = await Factory.Services + .GetRequiredService() + .CreateRealmAsync(new CreateRealmDto + { + Slug = otherRealm, + DisplayName = "HMAC isolation", + Domains = [$"{otherRealm}.test"], + InitialAdmin = new InitialAdminDto + { + UserName = "admin", + Email = $"admin@{otherRealm}.test", + }, + }, ct); + Assert.False(provisioned.IsError); + var audit = Factory.Services.GetRequiredService(); - // Something to clear. - await using (var write = GetTenantedDocumentSession("system")) + using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter("system")) { - write.Store(new SecurityAuditEntry + audit.RecordAbuse(new SecurityAuditRecord { - Id = Guid.NewGuid(), - Timestamp = DateTimeOffset.UtcNow, - Level = "Warning", EventType = AuditEvents.LoginFailedUnknownUser, - Actor = "to-be-cleared", - Realm = "system", - Message = "seed row for clear test", + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = rawIdentifier, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = marker, }); - await write.SaveChangesAsync(ct); } + using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter(otherRealm)) + { + audit.RecordAbuse(new SecurityAuditRecord + { + EventType = AuditEvents.LoginFailedUnknownUser, + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = rawIdentifier, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = marker, + }); + } + + RealmSecurityAuditEvent? systemRecorded = null; + RealmSecurityAuditEvent? acmeRecorded = null; + for (var attempt = 0; attempt < 25 && + (systemRecorded is null || acmeRecorded is null); attempt++) + { + await using (var system = GetTenantedDocumentSession("system")) + { + systemRecorded = await system.Query() + .FirstOrDefaultAsync(x => x.ReasonCode == marker, ct); + } + await using (var acme = GetTenantedDocumentSession(otherRealm)) + { + acmeRecorded = await acme.Query() + .FirstOrDefaultAsync(x => x.ReasonCode == marker, ct); + } + if (systemRecorded is null || acmeRecorded is null) + await Task.Delay(200, ct); + } + + Assert.NotNull(systemRecorded); + Assert.NotNull(acmeRecorded); + Assert.Matches("^[0-9a-f]{64}$", systemRecorded!.UnknownIdentifierFingerprint); + Assert.DoesNotContain( + rawIdentifier, + systemRecorded.UnknownIdentifierFingerprint!, + StringComparison.OrdinalIgnoreCase); + Assert.NotEqual( + systemRecorded.UnknownIdentifierFingerprint, + acmeRecorded!.UnknownIdentifierFingerprint); + } + + [Fact] + public async Task Required_event_uses_the_callers_business_transaction() + { + var ct = TestContext.Current.CancellationToken; + var committedMarker = $"atomic-audit-{Guid.NewGuid():N}"; + var abandonedMarker = $"abandoned-audit-{Guid.NewGuid():N}"; + var audit = Factory.Services.GetRequiredService(); - // Control-plane admin clears the full cross-realm log. - var resp = await Client.DeleteAsync("/api/admin/auth-log", ct); - resp.EnsureSuccessStatusCode(); + using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter("system")) + { + await using (var abandoned = GetTenantedDocumentSession("system")) + { + audit.StoreRequired(abandoned, new SecurityAuditRecord + { + EventType = AuditEvents.SecurityRetentionChanged, + OperationCode = abandonedMarker, + RetentionDays = 14, + OutcomeCode = AuditOutcomes.Succeeded, + }); + // Deliberately no SaveChangesAsync: the business transaction + // is abandoned, therefore its audit row must be abandoned too. + } - // The clear emits a typed audit.log_cleared record AFTER the wipe (the - // forensic trail of who cleared what). It rides the best-effort async writer, - // so poll briefly for it to land. - var cleared = await PollForAsync( - r => r.EventType == AuditEvents.AuditLogCleared, ct); + await using (var committed = GetTenantedDocumentSession("system")) + { + audit.StoreRequired(committed, new SecurityAuditRecord + { + EventType = AuditEvents.SecurityRetentionChanged, + OperationCode = committedMarker, + RetentionDays = 30, + OutcomeCode = AuditOutcomes.Succeeded, + }); + await committed.SaveChangesAsync(ct); + } + } - Assert.NotNull(cleared); - Assert.Equal("cleared", cleared!.Status); - Assert.False(string.IsNullOrEmpty(cleared.Actor)); - Assert.NotEqual("(unknown)", cleared.Actor); + await using var read = GetTenantedDocumentSession("system"); + Assert.Null(await read.Query() + .FirstOrDefaultAsync(x => x.OperationCode == abandonedMarker, ct)); + var committedRow = await read.Query() + .FirstOrDefaultAsync(x => x.OperationCode == committedMarker, ct); + Assert.NotNull(committedRow); + Assert.Equal(30, committedRow!.RetentionDays); } - private async Task PollForAsync( - Func predicate, CancellationToken ct) + [Fact] + public async Task Abuse_burst_is_persisted_as_bounded_count_aggregate() { - for (var i = 0; i < 25; i++) + var ct = TestContext.Current.CancellationToken; + var marker = $"abuse-aggregate-{Guid.NewGuid():N}"; + var audit = Factory.Services.GetRequiredService(); + + using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter("system")) { - await using (var read = GetTenantedDocumentSession("system")) + for (var i = 0; i < 3; i++) { - var hit = (await read.Query().ToListAsync(ct)) - .FirstOrDefault(predicate); - if (hit is not null) return hit; + audit.RecordAbuse(new SecurityAuditRecord + { + EventType = AuditEvents.LoginFailedUnknownUser, + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = "aggregate@example.test", + IpAddress = "203.0.113.80", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = marker, + }); } + } + + IReadOnlyList rows = []; + for (var attempt = 0; attempt < 25; attempt++) + { + await using var read = GetTenantedDocumentSession("system"); + rows = await read.Query() + .Where(x => x.ReasonCode == marker) + .ToListAsync(ct); + if (rows.Sum(x => x.Count ?? 1) >= 3) + break; await Task.Delay(200, ct); } - return null; + + Assert.Equal(3, rows.Sum(x => x.Count ?? 1)); + Assert.Contains(rows, x => x.Count == 3); + Assert.All(rows, x => + { + Assert.NotNull(x.FirstObservedAt); + Assert.NotNull(x.LastObservedAt); + Assert.True(x.LastObservedAt >= x.FirstObservedAt); + }); + } + + [Fact] + public async Task Security_log_has_no_clear_endpoint() + { + var response = await Client.DeleteAsync( + "/api/admin/auth-log", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } } diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/AuthLogTenantVisibilityTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/AuthLogTenantVisibilityTests.cs index 8445af48..417f821e 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/AuthLogTenantVisibilityTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/AuthLogTenantVisibilityTests.cs @@ -1,57 +1,159 @@ +using System.Net; using System.Net.Http.Json; +using Marten; +using Microsoft.Extensions.DependencyInjection; using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; namespace Modgud.Api.Tests.Authorization; -/// -/// Streamless security-store entries live in the system DB and are attributed to a -/// realm. The read endpoint (GET /api/admin/auth-log) reaches that system DB -/// and returns the realm field; the control-plane (system) realm — which the default -/// test admin runs in — sees the full cross-realm log INCLUDING control-plane-only -/// (PlatformOnly) operational rows. The per-realm + tenant-visibility exclusion -/// of the filter itself is unit-tested deterministically in -/// AuthLogAttributionTests (a tenant realm-admin authenticated request needs -/// full multi-realm host routing + a per-tenant login, out of proportion here). -/// [Collection(IntegrationTestCollection.Name)] public class AuthLogTenantVisibilityTests : IntegrationTestBase { public AuthLogTenantVisibilityTests(SharedPostgresFixture fixture) : base(fixture) { } - private sealed record Row(string Message, string? Realm); + private sealed record RealmRow(string ReasonCode); + private sealed record PlatformRow(string? TargetRealmSlug, string? OperationCode); [Fact] - public async Task Read_AsControlPlaneAdmin_ReturnsAllRealms_IncludingPlatformOnly() + public async Task ControlPlane_realm_log_reads_only_its_own_physical_database() { var ct = TestContext.Current.CancellationToken; + var otherRealm = ($"log-{Guid.NewGuid():N}")[..12]; + var provisioned = await Factory.Services + .GetRequiredService() + .CreateRealmAsync(new CreateRealmDto + { + Slug = otherRealm, + DisplayName = "Log isolation", + Domains = [$"{otherRealm}.test"], + InitialAdmin = new InitialAdminDto + { + UserName = "admin", + Email = $"admin@{otherRealm}.test", + }, + }, ct); + Assert.False(provisioned.IsError); - // Entries live in the system DB regardless of which realm emitted them. - await using (var write = GetTenantedDocumentSession("system")) + await using (var system = GetTenantedDocumentSession("system")) { - write.Store(new SecurityAuditEntry { Timestamp = DateTimeOffset.UtcNow, Level = "Info", EventType = AuditEvents.LoginFailedUnknownUser, Message = "sk-vis-system", Realm = "system", PlatformOnly = false }); - write.Store(new SecurityAuditEntry { Timestamp = DateTimeOffset.UtcNow, Level = "Info", EventType = AuditEvents.LoginFailedUnknownUser, Message = "sk-vis-acme", Realm = "acme", PlatformOnly = false }); - write.Store(new SecurityAuditEntry { Timestamp = DateTimeOffset.UtcNow, Level = "Info", EventType = AuditEvents.LoginFailedUnknownUser, Message = "sk-vis-unattributed", Realm = null, PlatformOnly = false }); - // A control-plane-only operational row — visible to the control-plane reader. - write.Store(new SecurityAuditEntry { Timestamp = DateTimeOffset.UtcNow, Level = "Warning", EventType = AuditEvents.RecoveryCliInvoked, Message = "sk-vis-platform", Realm = "acme", PlatformOnly = true }); - await write.SaveChangesAsync(ct); + system.Store(NewRealmEvent("system-only")); + await system.SaveChangesAsync(ct); + } + await using (var acme = GetTenantedDocumentSession(otherRealm)) + { + acme.Store(NewRealmEvent("acme-only")); + await acme.SaveChangesAsync(ct); } - // The default Client is a realm-admin in the system (control-plane) realm. - var entries = await Client.GetFromJsonAsync>( + var rows = await Client.GetFromJsonAsync>( "/api/admin/auth-log?limit=500", ct); - Assert.NotNull(entries); - var byMessage = entries! - .Where(e => e.Message.StartsWith("sk-vis-")) - .ToDictionary(e => e.Message, e => e.Realm); - - // Control-plane sees its own realm AND other realms AND unattributed events AND - // control-plane-only operational rows. - Assert.Equal("system", byMessage["sk-vis-system"]); - Assert.Equal("acme", byMessage["sk-vis-acme"]); - Assert.True(byMessage.ContainsKey("sk-vis-unattributed")); - Assert.Null(byMessage["sk-vis-unattributed"]); - Assert.True(byMessage.ContainsKey("sk-vis-platform")); // PlatformOnly row visible to control-plane + Assert.NotNull(rows); + Assert.Contains(rows!, x => x.ReasonCode == "system-only"); + Assert.DoesNotContain(rows!, x => x.ReasonCode == "acme-only"); + } + + [Fact] + public async Task Platform_log_is_a_separate_global_store_surface() + { + var ct = TestContext.Current.CancellationToken; + using (var scope = Factory.Services.CreateScope()) + { + var global = scope.ServiceProvider.GetRequiredService(); + await using var session = global.LightweightSession(); + session.Store(new PlatformAuditEvent + { + Timestamp = DateTimeOffset.UtcNow, + EventType = AuditEvents.RealmProvisioned, + Category = AuditEvents.CategoryOf(AuditEvents.RealmProvisioned), + TargetRealmSlug = "acme", + OperationCode = "visibility-test", + OutcomeCode = AuditOutcomes.Succeeded, + }); + await session.SaveChangesAsync(ct); + } + + var rows = await Client.GetFromJsonAsync>( + "/api/admin/platform-audit?limit=500", ct); + + Assert.NotNull(rows); + Assert.Contains(rows!, x => + x.TargetRealmSlug == "acme" && x.OperationCode == "visibility-test"); } + + [Fact] + public async Task ControlPlane_action_keeps_actor_in_actor_realm_and_writes_pii_free_counterpart() + { + var ct = TestContext.Current.CancellationToken; + var targetRealm = ($"cp-audit-{Guid.NewGuid():N}")[..16]; + var response = await Client.PostAsJsonAsync( + "/api/admin/realms", + new CreateRealmDto + { + Slug = targetRealm, + DisplayName = "Cross-realm audit", + Domains = [$"{targetRealm}.test"], + InitialAdmin = new InitialAdminDto + { + UserName = "admin", + Email = $"admin@{targetRealm}.test", + }, + }, + ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + RealmSecurityAuditEvent? actorEvent = null; + RealmSecurityAuditEvent? counterpart = null; + for (var attempt = 0; attempt < 30 && + (actorEvent is null || counterpart is null); attempt++) + { + await using (var actorRealm = GetTenantedDocumentSession("system")) + { + actorEvent = await actorRealm.Query() + .FirstOrDefaultAsync( + x => x.EventType == AuditEvents.ControlPlaneRealmOperation && + x.TargetRealmSlug == targetRealm && + x.OperationCode == "provision-realm", + ct); + } + await using (var target = GetTenantedDocumentSession(targetRealm)) + { + counterpart = await target.Query() + .FirstOrDefaultAsync( + x => x.EventType == AuditEvents.ControlPlaneRealmOperation && + x.OperationCode == "provision-realm", + ct); + } + + if (actorEvent is null || counterpart is null) + await Task.Delay(200, ct); + } + + Assert.NotNull(actorEvent); + Assert.NotNull(counterpart); + Assert.Equal(AuditActorKind.User, actorEvent!.ActorKind); + Assert.NotNull(actorEvent.ActorSubjectId); + Assert.Equal(targetRealm, actorEvent.TargetRealmSlug); + Assert.Equal(actorEvent.CorrelationId, counterpart!.CorrelationId); + Assert.Equal(AuditActorKind.ControlPlane, counterpart.ActorKind); + Assert.Null(counterpart.ActorSubjectId); + Assert.Null(counterpart.TargetSubjectId); + Assert.Null(counterpart.IpAddress); + Assert.Null(counterpart.UserAgent); + Assert.Null(counterpart.TargetRealmSlug); + } + + private static RealmSecurityAuditEvent NewRealmEvent(string reasonCode) => new() + { + Timestamp = DateTimeOffset.UtcNow, + EventType = AuditEvents.LoginFailedUnknownUser, + Category = AuditEvents.CategoryOf(AuditEvents.LoginFailedUnknownUser), + ActorKind = AuditActorKind.AnonymousIdentifier, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = reasonCode, + }; } diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs index d66ff513..cfad310f 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs @@ -14,6 +14,7 @@ using Modgud.Application.Services; using Modgud.Authentication.Domain; using Modgud.Authentication.RealmSettings; +using Modgud.Authentication.Sessions; using Modgud.Authorization.Apps; using Modgud.Authorization.Events; using Modgud.Domain.OAuth.Apis; @@ -44,7 +45,9 @@ public CocoarNativeGrantFlowTests(SharedPostgresFixture fixture) : base(fixture) public async Task Otp_Grant_MintsTokens_ShortLifetime_NoCookie() { await EnableNativeGrantsAsync(); - await SeedNativeClientAsync("native-otp-app"); + await SeedNativeClientAsync( + "native-otp-app", + clientSessionAbsoluteLifetime: 3650 * 24 * 60 * 60); var code = await RequestNativeOtpCodeAsync(); @@ -66,6 +69,7 @@ public async Task Otp_Grant_MintsTokens_ShortLifetime_NoCookie() Assert.False(string.IsNullOrEmpty(accessToken)); Assert.True(json.RootElement.TryGetProperty("refresh_token", out var rt) && !string.IsNullOrEmpty(rt.GetString()), "expected a (reference) refresh_token because offline_access was requested"); + var refreshToken = rt.GetString()!; // ADR-0010 — native access tokens are short-lived JWTs. var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken); @@ -78,6 +82,34 @@ public async Task Otp_Grant_MintsTokens_ShortLifetime_NoCookie() // Cookieless guarantee — the token endpoint must not set an auth cookie. Assert.False(response.Headers.Contains("Set-Cookie"), "the native grant must mint tokens without setting any cookie"); + + // The native login is represented independently from this browser's + // cookie session and can be revoked without touching other devices. + var sessionList = await Client.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, TestContext.Current.CancellationToken); + var nativeSession = Assert.Single( + sessionList!.ClientSessions, x => x.ClientId == "native-otp-app"); + Assert.InRange( + nativeSession.AbsoluteExpiresAt - nativeSession.CreatedAt, + TimeSpan.FromDays(3649), + TimeSpan.FromDays(3651)); + + var revoke = await Client.DeleteAsync( + $"/api/auth/sessions/client/{nativeSession.Id}", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + + var rejectedRefresh = await PostTokenAsync(new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = "native-otp-app", + ["client_secret"] = "native-otp-app-secret", + ["refresh_token"] = refreshToken, + }); + Assert.Equal(HttpStatusCode.BadRequest, rejectedRefresh.StatusCode); + Assert.Contains( + "invalid_grant", + await rejectedRefresh.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); } [Fact] @@ -539,10 +571,18 @@ private static byte[] Base32Decode(string input) // ── Seeding ──────────────────────────────────────────────────────────── - private Task SeedNativeClientAsync(string clientId) => - SeedClientAsync(clientId, [CocoarGrantTypes.Otp, CocoarGrantTypes.Magic, "refresh_token"]); - - private async Task SeedClientAsync(string clientId, List grantTypes) + private Task SeedNativeClientAsync( + string clientId, + int? clientSessionAbsoluteLifetime = null) => + SeedClientAsync( + clientId, + [CocoarGrantTypes.Otp, CocoarGrantTypes.Magic, "refresh_token"], + clientSessionAbsoluteLifetime); + + private async Task SeedClientAsync( + string clientId, + List grantTypes, + int? clientSessionAbsoluteLifetime = null) { var app = await CreateAppAsync($"{clientId}-catalog", clientId); @@ -562,6 +602,7 @@ private async Task SeedClientAsync(string clientId, List grantTypes) RequireConsent = false, AccessTokenType = AccessTokenType.Jwt, AppIds = [new ShortGuid(app.Id).ToString()], + ClientSessionAbsoluteLifetime = clientSessionAbsoluteLifetime, }; var result = await oauthAdmin.CreateClientAsync(dto, TestContext.Current.CancellationToken); if (result.IsError) diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/DpopIssuanceTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/DpopIssuanceTests.cs index 5fa3d178..f64c3b67 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/DpopIssuanceTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/DpopIssuanceTests.cs @@ -348,7 +348,7 @@ public async Task An_unbound_refresh_token_is_redeemable_without_a_proof() [Fact] public async Task Introspection_of_a_dpop_bound_reference_token_echoes_cnf_jkt() { - // The resource-server client library reads cnf.jkt out of the + // The resource-server package reads cnf.jkt out of the // introspection response to enforce the DPoP binding on opaque reference // tokens — this pins that the AS actually surfaces it. var (clientId, secret, redirectUri) = await NewClientAsync("dpop-ref", AccessTokenType.Reference); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/RealmSettingsTokenLifetimeValidationTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/RealmSettingsTokenLifetimeValidationTests.cs index e1017d59..6412e07f 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/RealmSettingsTokenLifetimeValidationTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/RealmSettingsTokenLifetimeValidationTests.cs @@ -84,6 +84,37 @@ public async Task Cimd_OutOfBandLifetimes_Rejected() Assert.False(ok.IsError); } + [Fact] + public async Task SecurityAuditRetention_OutsideRealmBounds_IsRejected() + { + using var scope = NewSystemTenantScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + + foreach (var invalidDays in new[] { 0, 366 }) + { + var invalid = await settings.PatchAsync(new UpdateRealmSettingsDto + { + Audit = new UpdateAuditSettingsDto + { + SecurityRetentionDays = invalidDays, + }, + }, ct); + Assert.True(invalid.IsError); + } + + var valid = await settings.PatchAsync(new UpdateRealmSettingsDto + { + Audit = new UpdateAuditSettingsDto + { + SecurityRetentionDays = 30, + }, + }, ct); + + Assert.False(valid.IsError); + Assert.Equal(30, valid.Value.Audit.SecurityRetentionDays); + } + private IServiceScope NewSystemTenantScope() { var scope = Factory.Services.CreateScope(); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/RolesEndpointsRobustnessTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/RolesEndpointsRobustnessTests.cs index a7883f80..ba1b9e4a 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/RolesEndpointsRobustnessTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/RolesEndpointsRobustnessTests.cs @@ -55,4 +55,35 @@ public async Task Create_realm_admin_role_without_PermissionIds_succeeds() var body = await res.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Contains("Stage6 Realm Admin Role", body); } + + [Fact] + public async Task Create_realm_admin_role_with_AppId_is_rejected() + { + var res = await Client.PostAsJsonAsync("/api/role", new + { + Name = "Mixed Realm Admin Role", + AppId = Guid.NewGuid().ToString(), + IsRealmAdmin = true, + PermissionIds = Array.Empty(), + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + var body = await res.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("Role.RealmAdminMustBeUnscoped", body); + } + + [Fact] + public async Task Create_realm_admin_role_with_App_permissions_is_rejected() + { + var res = await Client.PostAsJsonAsync("/api/role", new + { + Name = "Mixed Realm Admin Grants", + IsRealmAdmin = true, + PermissionIds = new[] { Guid.NewGuid().ToString() }, + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + var body = await res.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("Role.RealmAdminMustBeUnscoped", body); + } } diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/ServiceAccountCreateCompletenessTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/ServiceAccountCreateCompletenessTests.cs new file mode 100644 index 00000000..432ff503 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/ServiceAccountCreateCompletenessTests.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Http.Json; +using BuildingBlocks.Helper; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.ServiceAccount; +using Modgud.Application.DTOs.OAuth; +using Modgud.Authorization.Principals; +using Modgud.Domain.OAuth.Applications; + +namespace Modgud.Api.Tests.Authorization; + +public class ServiceAccountCreateCompletenessTests(SharedPostgresFixture fixture) + : IntegrationTestBase(fixture) +{ + [Fact] + public async Task Create_can_commit_status_and_initial_credential_together() + { + var ct = TestContext.Current.CancellationToken; + var accountName = $"complete-{Guid.NewGuid():N}"[..32]; + + var response = await Client.PostAsJsonAsync("/api/service-account", new + { + AccountName = accountName, + Purpose = "Atomic create test", + IsActive = false, + InitialCredential = new + { + DisplayName = "Initial deployment credential", + Scopes = Array.Empty(), + AppIds = Array.Empty(), + AccessTokenLifetime = 900, + AccessTokenType = "Reference", + Enabled = false, + }, + }, ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var created = await response.Content.ReadFromJsonAsync(JsonOptions, ct); + Assert.NotNull(created); + Assert.False(created.IsActive); + Assert.NotNull(created.InitialCredential); + Assert.False(string.IsNullOrWhiteSpace(created.InitialCredential.ClientSecret)); + Assert.False(created.InitialCredential.Credential.Enabled); + Assert.True(ShortGuid.TryParse(created.Id, out Guid serviceAccountId)); + + using var scope = Factory.Services.CreateScope(); + var query = scope.ServiceProvider.GetRequiredService(); + var serviceAccount = await query.LoadAsync(serviceAccountId, ct); + var credential = await query.Query() + .SingleAsync(x => x.LinkedServiceAccountId == serviceAccountId && !x.IsDeleted, ct); + + Assert.False(serviceAccount!.IsActive); + Assert.Contains(OAuthApplicationPropertyKeys.Enabled, credential.Properties.Keys); + Assert.Equal("900", credential.Settings[OAuthApplicationSettingKeys.AccessTokenLifetime]); + } + + [Fact] + public async Task Invalid_initial_credential_leaves_no_service_account() + { + var ct = TestContext.Current.CancellationToken; + var accountName = $"invalid-{Guid.NewGuid():N}"[..31]; + + var response = await Client.PostAsJsonAsync("/api/service-account", new + { + AccountName = accountName, + InitialCredential = new + { + Scopes = Array.Empty(), + AppIds = Array.Empty(), + AccessTokenLifetime = 30, + AccessTokenType = "Reference", + }, + }, ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var scope = Factory.Services.CreateScope(); + var query = scope.ServiceProvider.GetRequiredService(); + var serviceAccount = await query.Query() + .FirstOrDefaultAsync(x => x.AccountName == accountName, ct); + Assert.Null(serviceAccount); + } + + [Fact] + public async Task OAuth_client_inline_service_account_preserves_create_status() + { + var ct = TestContext.Current.CancellationToken; + var accountName = $"oauth-inline-{Guid.NewGuid():N}"[..31]; + + var response = await Client.PostAsJsonAsync("/api/admin/oauth/clients", new + { + ClientId = $"client-{Guid.NewGuid():N}", + ClientType = "confidential", + ConsentType = "implicit", + AllowedGrantTypes = new[] { "client_credentials" }, + Scopes = Array.Empty(), + AppIds = Array.Empty(), + RequireClientSecret = true, + NewServiceAccount = new + { + AccountName = accountName, + Purpose = "Created from OAuth client", + IsActive = false, + }, + }, ct); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var created = await response.Content.ReadFromJsonAsync(JsonOptions, ct); + Assert.NotNull(created?.CreatedServiceAccount); + Assert.False(created.CreatedServiceAccount.IsActive); + + Assert.True(ShortGuid.TryParse(created.CreatedServiceAccount.Id, out Guid serviceAccountId)); + using var scope = Factory.Services.CreateScope(); + var query = scope.ServiceProvider.GetRequiredService(); + var serviceAccount = await query.LoadAsync(serviceAccountId, ct); + Assert.False(serviceAccount!.IsActive); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/SigningKeyRotationTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/SigningKeyRotationTests.cs index 52e6ceec..c492b63e 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/SigningKeyRotationTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/SigningKeyRotationTests.cs @@ -6,6 +6,7 @@ using Marten; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using Modgud.Infrastructure.Audit; namespace Modgud.Api.Tests.Authorization; @@ -38,7 +39,10 @@ private sealed class TestClock(DateTimeOffset start) : TimeProvider } private RealmKeyStore NewStore(TimeProvider clock) => - new(Factory.Services.GetRequiredService(), clock); + new( + Factory.Services.GetRequiredService(), + clock, + Factory.Services.GetRequiredService()); private static List Kids(IReadOnlyList keys) => keys.Select(k => k.KeyId).ToList(); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs index 2dd2a03e..4bd1c535 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs @@ -8,6 +8,7 @@ using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; using Modgud.Authentication.Domain; +using Modgud.Authentication.Sessions; using Modgud.Authorization.Apps; using Modgud.Authorization.Events; using Modgud.Domain.OAuth.Apis; @@ -15,7 +16,7 @@ using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Permissions.Abstractions; -using Modgud.Client.AspNetCore; +using Modgud.AspNetCore.ResourceServer; using Marten; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -185,6 +186,12 @@ await GrantAsync(testUser.Id, roleAppSlug: "app-alpha", resourceType: "policy", scope: $"openid offline_access roles permissions {alphaScopeName}", resources: [alphaAudience]); var refreshToken = tokens.RootElement.GetProperty("refresh_token").GetString()!; + await using (var beforeReuse = GetTenantedDocumentSession()) + { + Assert.Single(await beforeReuse.Query() + .Where(x => x.UserId == testUser.Id && x.ClientId == clientId) + .ToListAsync(TestContext.Current.CancellationToken)); + } // redeem the refresh token → fresh reference access token var newAccessToken = await RedeemRefreshTokenAsync( @@ -266,27 +273,34 @@ await CreateOAuthClientAsync( $"Replayed refresh token should have been rejected: {replayBody}"); Assert.Contains("invalid_grant", replayBody); + await using (var afterReuse = GetTenantedDocumentSession()) + { + Assert.Empty(await afterReuse.Query() + .Where(x => x.UserId == testUser.Id && x.ClientId == clientId) + .ToListAsync(TestContext.Current.CancellationToken)); + } + // The reuse rejection emits a best-effort security event on the async - // writer — poll briefly for it to land in the system-tenant streamless store. - var recorded = await PollForSecurityAuditEntryAsync( + // writer — poll briefly for it to land in the owning realm database. + var recorded = await PollForRealmSecurityAuditEventAsync( e => e.EventType == AuditEvents.RefreshTokenReuseDetected, TestContext.Current.CancellationToken); Assert.NotNull(recorded); - Assert.Equal("Warning", recorded!.Level); - Assert.Equal("revoked", recorded.Status); - Assert.Contains(clientId, recorded.Reason ?? "", StringComparison.Ordinal); - Assert.Equal(testUser.Id.ToString(), recorded.Actor); + Assert.Equal(AuditSeverity.Warning, recorded!.Severity); + Assert.Equal(AuditOutcomes.Blocked, recorded.OutcomeCode); + Assert.Equal(clientId, recorded.OAuthClientId); + Assert.Equal(testUser.Id, recorded.ActorSubjectId); } - private async Task PollForSecurityAuditEntryAsync( - Func predicate, CancellationToken ct) + private async Task PollForRealmSecurityAuditEventAsync( + Func predicate, CancellationToken ct) { for (var i = 0; i < 25; i++) { await using (var read = GetTenantedDocumentSession("system")) { - var hit = (await read.Query().ToListAsync(ct)) + var hit = (await read.Query().ToListAsync(ct)) .FirstOrDefault(predicate); if (hit is not null) return hit; } @@ -536,6 +550,27 @@ private async Task CreateFederatedCookieClientAsync(string userName, foreach (var gid in sessionGroupIds) identity.AddClaim(new Claim(FederationClaimTypes.SessionGroup, gid.ToString())); + // Browser sessions are authoritative since F3. A hand-forged cookie + // therefore needs the same signed session-id claim and backing row as + // a real SignInManager login; otherwise OnValidatePrincipal correctly + // rejects it before the authorize endpoint. + using (TenantContext.Enter(TenantConstants.SystemTenantId)) + { + var createdSession = await scope.ServiceProvider + .GetRequiredService() + .CreateSessionAsync( + user.Id, + ipAddress: null, + userAgent: "UserInfoPerAudienceTests", + TestContext.Current.CancellationToken); + Assert.False( + createdSession.IsError, + createdSession.IsError ? createdSession.FirstError.Description : null); + identity.AddClaim(new Claim( + SessionClaimTypes.BrowserSessionId, + createdSession.Value.Id.ToString())); + } + var cookieOptions = scope.ServiceProvider .GetRequiredService>() .Get(IdentityConstants.ApplicationScheme); @@ -644,7 +679,7 @@ public async Task Introspection_Carries_ResourceAccess_Only_For_Audience_Or_Pres { // #132 step 1 — pin whether /connect/introspect echoes the per-audience // resource_access block, and to which callers. This decides the - // reference-token client-lib design: if a resource server can introspect + // reference-token resource-server design: if a resource server can introspect // and read the permission block in one call, the lib needs no separate // /connect/userinfo round-trip. Nothing in-repo pinned this before (the // #132 issue explicitly flags the gap). @@ -1054,16 +1089,63 @@ await Factory.CreateTestGroupAsync( boundTo: groupBoundTo.ToList()); } - // ── #139: end-to-end reference-token resource server (client library) ─────── + // ── Resource-server package end-to-end ────────────────────────────────────── + + [Fact] + public async Task Jwt_ResourceServer_Gates_On_Embedded_Permission() + { + var app = await CreateAppAsync("rs-jwtapp", "RS JWT App", + permissions: [("policy", "read"), ("policy", "admin")]); + const string audience = "https://rs-jwt.example.com"; + await CreateOAuthApiAsync(audience, app.Id); + const string scopeName = "rs-jwt-api"; + await CreateScopeAsync(scopeName, [audience], app.Id); + + var clientSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var clientId = "test-jwtrs-" + Guid.NewGuid().ToString("N"); + const string redirectUri = "http://localhost/test-callback"; + await CreateOAuthClientAsync( + clientId, clientSecret, redirectUri, [app.Id], + ["openid", "roles", "permissions", scopeName], AccessTokenType.Jwt); + + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Jwt", lastname: "RS", acronym: "jr", + email: "jr@test.com", password: "TestPass1234"); + await GrantAsync(user.Id, roleAppSlug: "rs-jwtapp", resourceType: "policy", + actions: ["read"], groupBoundTo: ["rs-jwtapp"]); + + var jwt = await DriveAuthCodeFlowAsync( + username: "jr", password: "TestPass1234", + clientId: clientId, clientSecret: clientSecret, + redirectUri: redirectUri, + scope: $"openid roles permissions {scopeName}", + resources: [audience]); + Assert.Contains('.', jwt); + + using var rsHost = await BuildJwtResourceServerAsync(audience); + var rs = rsHost.GetTestClient(); + + Assert.Equal( + HttpStatusCode.OK, + (await SendWithTokenAsync(rs, "/policy/read", jwt)).StatusCode); + Assert.Equal( + HttpStatusCode.Forbidden, + (await SendWithTokenAsync(rs, "/policy/admin", jwt)).StatusCode); + Assert.Equal( + HttpStatusCode.Unauthorized, + (await rs.GetAsync( + "/policy/read", + TestContext.Current.CancellationToken)).StatusCode); + } [Fact] public async Task ReferenceToken_ResourceServer_Gates_On_Introspected_Permission() { // #139 — the runnable reference-token sample's path, proven end-to-end - // through the client library: an opaque access token is validated by - // `AddModgudReferenceTokenClient` via /connect/introspect, the per-audience + // through the resource-server package: an opaque access token is validated by + // `AddModgudResourceServer` via /connect/introspect, the per-audience // resource_access block is projected onto the principal, and a - // `RequiresModgudPermission` gate does exact-match. The IdP side (which + // `RequireModgudPermission` policy does exact-match. The IdP side (which // callers get resource_access) is pinned separately by // Introspection_Carries_ResourceAccess_Only_For_Audience_Or_Presenter_Client; // this pins the resource-server half. @@ -1102,11 +1184,6 @@ await GrantAsync(user.Id, roleAppSlug: "rs-refapp", resourceType: "policy", username: "rr", password: "TestPass1234", clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, scope: $"openid roles permissions {scopeName}", resources: [audience]); - // Point the library's introspection HttpClient at the in-memory IdP (its - // Authority is http://localhost, so the introspection Host resolves to the - // same realm the fixtures were created in). - ModgudTokenIntrospection.SharedClient = Factory.CreateClient(); - using var rsHost = await BuildReferenceTokenResourceServerAsync(audience, introspectionSecret); var rs = rsHost.GetTestClient(); @@ -1128,6 +1205,84 @@ await GrantAsync(user.Id, roleAppSlug: "rs-refapp", resourceType: "policy", Assert.Equal(HttpStatusCode.Unauthorized, (await rs.GetAsync("/policy/read", TestContext.Current.CancellationToken)).StatusCode); } + [Fact] + public async Task Both_Mode_Accepts_Jwt_And_Reference_Token_On_The_Same_Endpoint() + { + var app = await CreateAppAsync("rs-bothapp", "RS Both App", + permissions: [("policy", "read")]); + const string audience = "https://rs-both.example.com"; + await CreateOAuthApiAsync(audience, app.Id); + const string scopeName = "rs-both-api"; + await CreateScopeAsync(scopeName, [audience], app.Id); + + var jwtSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var jwtClientId = "test-both-jwt-" + Guid.NewGuid().ToString("N"); + const string jwtRedirectUri = "http://localhost/both-jwt-callback"; + await CreateOAuthClientAsync( + jwtClientId, jwtSecret, jwtRedirectUri, [app.Id], + ["openid", "permissions", scopeName], AccessTokenType.Jwt); + + var referenceSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var referenceClientId = "test-both-reference-" + Guid.NewGuid().ToString("N"); + const string referenceRedirectUri = "http://localhost/both-reference-callback"; + await CreateOAuthClientAsync( + referenceClientId, referenceSecret, referenceRedirectUri, [app.Id], + ["openid", "permissions", scopeName], AccessTokenType.Reference); + + var introspectionSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + await CreateOAuthClientAsync( + audience, + introspectionSecret, + "http://localhost/both-rs-callback", + [app.Id], + ["openid"]); + + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Both", + lastname: "RS", + acronym: "brs", + email: "brs@test.com", + password: "TestPass1234"); + await GrantAsync( + user.Id, + roleAppSlug: "rs-bothapp", + resourceType: "policy", + actions: ["read"], + groupBoundTo: ["rs-bothapp"]); + + var jwt = await DriveAuthCodeFlowAsync( + username: "brs", + password: "TestPass1234", + clientId: jwtClientId, + clientSecret: jwtSecret, + redirectUri: jwtRedirectUri, + scope: $"openid permissions {scopeName}", + resources: [audience]); + var referenceToken = await DriveAuthCodeFlowAsync( + username: "brs", + password: "TestPass1234", + clientId: referenceClientId, + clientSecret: referenceSecret, + redirectUri: referenceRedirectUri, + scope: $"openid permissions {scopeName}", + resources: [audience]); + + Assert.Contains('.', jwt); + Assert.DoesNotContain('.', referenceToken); + + using var rsHost = await BuildBothTokenResourceServerAsync( + audience, + introspectionSecret); + var rs = rsHost.GetTestClient(); + + Assert.Equal( + HttpStatusCode.OK, + (await SendWithTokenAsync(rs, "/policy/read", jwt)).StatusCode); + Assert.Equal( + HttpStatusCode.OK, + (await SendWithTokenAsync(rs, "/policy/read", referenceToken)).StatusCode); + } + [Fact] public async Task ReferenceClient_TokenFormat_IsNotLeaked_By_A_Prior_JwtClient() { @@ -1194,14 +1349,55 @@ private static async Task SendWithTokenAsync(HttpClient cli return await client.SendAsync(req, TestContext.Current.CancellationToken); } + private async Task BuildJwtResourceServerAsync(string audience) + { + var host = new HostBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddModgudResourceServer(options => + { + options.Authority = "http://localhost"; + options.Audience = audience; + options.RequireHttpsMetadata = false; + options.ConfigureJwtBearer = jwt => + { + jwt.MapInboundClaims = false; + jwt.BackchannelHttpHandler = Factory.Server.CreateHandler(); + }; + }); + services.AddAuthorization(); + }) + .Configure(builder => + { + builder.UseRouting(); + builder.UseAuthentication(); + builder.UseAuthorization(); + builder.UseEndpoints(endpoints => + { + endpoints.MapGet("/policy/read", () => Results.Ok()) + .RequireModgudPermission("policy:read"); + + endpoints.MapGet("/policy/admin", () => Results.Ok()) + .RequireModgudPermission("policy:admin"); + }); + })) + .Build(); + + await host.StartAsync(); + return host; + } + /// - /// Boots a minimal in-memory resource-server host over the published client - /// library exactly as the reference-token sample (Modgud.TestApps.ResourceApi - /// with TESTAPPS:TOKENMODE=reference) does — AddModgudReferenceTokenClient - /// plus RequiresModgudPermission gates — so the opaque-token path is + /// Boots a minimal in-memory resource-server host over the published package + /// exactly as the reference-token sample (Modgud.TestApps.ResourceApi + /// with TESTAPPS:TOKENMODE=reference) does — AddModgudResourceServer + /// plus RequireModgudPermission gates — so the opaque-token path is /// exercised end-to-end against the in-memory IdP. /// - private static async Task BuildReferenceTokenResourceServerAsync(string audience, string introspectionSecret) + private async Task BuildReferenceTokenResourceServerAsync(string audience, string introspectionSecret) { var host = new HostBuilder() .ConfigureWebHost(web => web @@ -1209,14 +1405,16 @@ private static async Task BuildReferenceTokenResourceServerAsync(string a .ConfigureServices(services => { services.AddRouting(); - services - .AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - .AddModgudReferenceTokenClient(o => - { - o.Authority = "http://localhost"; // introspection Host = the test realm - o.Audience = audience; // == the introspection client_id - o.IntrospectionClientSecret = introspectionSecret; - }); + services.AddModgudResourceServer(options => + { + options.Authority = "http://localhost"; // test realm host + options.Audience = audience; + options.TokenMode = ModgudTokenMode.OnlyReferenceToken; + options.IntrospectionClientSecret = introspectionSecret; + options.RequireHttpsMetadata = false; + }); + services.AddHttpClient(ModgudHttpClientNames.Introspection) + .ConfigurePrimaryHttpMessageHandler(() => Factory.Server.CreateHandler()); services.AddAuthorization(); }) .Configure(builder => @@ -1228,15 +1426,59 @@ private static async Task BuildReferenceTokenResourceServerAsync(string a { endpoints.MapGet("/me", (ClaimsPrincipal user) => Results.Ok(new { - permissions = user.FindAll(ModgudClaimsTransformation.PermissionClaimType) + permissions = user.FindAll(ModgudClaimTypes.Permission) .Select(c => c.Value).ToArray(), })).RequireAuthorization(); endpoints.MapGet("/policy/read", () => Results.Ok()) - .RequireAuthorization().RequiresModgudPermission("policy:read"); + .RequireModgudPermission("policy:read"); endpoints.MapGet("/policy/admin", () => Results.Ok()) - .RequireAuthorization().RequiresModgudPermission("policy:admin"); + .RequireModgudPermission("policy:admin"); + }); + })) + .Build(); + + await host.StartAsync(); + return host; + } + + private async Task BuildBothTokenResourceServerAsync( + string audience, + string introspectionSecret) + { + var host = new HostBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddModgudResourceServer(options => + { + options.Authority = "http://localhost"; + options.Audience = audience; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = introspectionSecret; + options.RequireHttpsMetadata = false; + options.ConfigureJwtBearer = jwt => + { + jwt.MapInboundClaims = false; + jwt.BackchannelHttpHandler = Factory.Server.CreateHandler(); + }; + }); + services.AddHttpClient(ModgudHttpClientNames.Introspection) + .ConfigurePrimaryHttpMessageHandler(() => Factory.Server.CreateHandler()); + services.AddAuthorization(); + }) + .Configure(builder => + { + builder.UseRouting(); + builder.UseAuthentication(); + builder.UseAuthorization(); + builder.UseEndpoints(endpoints => + { + endpoints.MapGet("/policy/read", () => Results.Ok()) + .RequireModgudPermission("policy:read"); }); })) .Build(); diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/FirstInstallationApiTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/FirstInstallationApiTests.cs new file mode 100644 index 00000000..f4a04591 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/FirstInstallationApiTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authorization.Principals; +using Modgud.Authorization.Roles; +using Modgud.Infrastructure.Realms; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Pins the operator/CI contract: the shell issues the bearer token and the +/// browser or automation completes installation through the same HTTP API. +/// +public class FirstInstallationApiTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Recovery_token_can_complete_zero_realm_installation_over_http() + { + await using var host = await Fixture.CreateUninitializedHostAsync(); + using var client = host.Factory.CreateClient(); + var ct = TestContext.Current.CancellationToken; + + var before = await client.GetFromJsonAsync( + "/api/install/status", ct); + Assert.NotNull(before); + Assert.False(before.IsInitialized); + Assert.False(before.HasRealms); + + var cli = await CliHarness.RunAsync( + host.Services, + "install-link", + "--base-url", "http://localhost", + "--minutes", "10", + "--json"); + Assert.Equal(0, cli.ExitCode); + Assert.Equal("", cli.StdErr); + + using var issuedJson = JsonDocument.Parse(cli.StdOut.Trim()); + var token = issuedJson.RootElement.GetProperty("token").GetString(); + Assert.False(string.IsNullOrWhiteSpace(token)); + + var complete = await client.PostAsJsonAsync( + "/api/install/complete", + new + { + Token = token, + Realm = new + { + Slug = "first", + DisplayName = "First Realm", + Description = "CI installation test", + Domains = new[] { "localhost" }, + PrimaryDomain = "localhost", + }, + Admin = new + { + UserName = "first-admin", + Email = "first-admin@localhost", + Firstname = "First", + Lastname = "Admin", + Password = "TestPass1234", + }, + }, + ct); + Assert.Equal(HttpStatusCode.OK, complete.StatusCode); + + var after = await client.GetFromJsonAsync( + "/api/install/status", ct); + Assert.NotNull(after); + Assert.True(after.IsInitialized); + Assert.True(after.HasRealms); + Assert.Equal("first", after.RealmSlug); + + var realms = host.Services.GetRequiredService(); + var first = await realms.GetRealmBySlugAsync("first", ct); + Assert.NotNull(first); + Assert.True(first.IsActive); + Assert.True(first.IsControlPlane); + + var store = host.Services.GetRequiredService(); + await using var session = store.QuerySession("first"); + var adminGroup = await session.Query() + .Where(g => g.Name == "Administrators") + .FirstAsync(ct); + var adminRole = await session.LoadAsync( + Assert.Single(adminGroup.RoleIds), ct); + Assert.NotNull(adminRole); + Assert.True(adminRole.IsRealmAdmin); + Assert.Single(adminGroup.MemberIds); + + var login = await client.PostAsJsonAsync( + "/api/account/login", + new { UserName = "first-admin", Password = "TestPass1234" }, + ct); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + + var replay = await client.PostAsJsonAsync( + "/api/install/complete", + new + { + Token = token, + Realm = new + { + Slug = "other", + DisplayName = "Other", + Domains = new[] { "other.localhost" }, + PrimaryDomain = "other.localhost", + }, + Admin = new + { + UserName = "other-admin", + Email = "other-admin@localhost", + Password = "TestPass1234", + }, + }, + ct); + Assert.Equal(HttpStatusCode.BadRequest, replay.StatusCode); + } + + private sealed record InstallationStatusResponse( + bool IsInitialized, + bool HasRealms, + string? RealmSlug, + DateTimeOffset? CompletedAt); +} diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RealmAdminInviteEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmAdminInviteEndpointsTests.cs new file mode 100644 index 00000000..6009f986 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RealmAdminInviteEndpointsTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Json; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; +using Modgud.Authentication.Domain; +using Modgud.Infrastructure.Persistence.Tenancy; + +namespace Modgud.Api.Tests.ColdStart; + +public class RealmAdminInviteEndpointsTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Realm_can_be_created_without_admin_and_new_invite_revokes_the_previous_one() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + var client = await factory.CreateRealmAdminAndLoginAsync(); + var slug = $"invite-{Guid.NewGuid():N}"[..20]; + + try + { + var createResponse = await client.PostAsJsonAsync( + "/api/admin/realms", + new CreateRealmDto + { + Slug = slug, + DisplayName = "Invite Test", + Domains = [$"{slug}.localhost"], + }, + factory.JsonOptions, + ct); + + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + var created = await createResponse.Content.ReadFromJsonAsync(factory.JsonOptions, ct); + Assert.NotNull(created); + Assert.Null(created!.InitialAdminInvite); + + var firstResponse = await client.PostAsJsonAsync( + $"/api/admin/realms/{slug}/admin-invites", + new InitialAdminDto { UserName = "first-admin", Email = "first@example.test" }, + factory.JsonOptions, + ct); + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + var first = await firstResponse.Content.ReadFromJsonAsync(factory.JsonOptions, ct); + Assert.NotNull(first); + + var secondIssuedAt = DateTimeOffset.UtcNow; + var secondResponse = await client.PostAsJsonAsync( + $"/api/admin/realms/{slug}/admin-invites", + new InitialAdminDto { UserName = "second-admin", Email = "second@example.test" }, + factory.JsonOptions, + ct); + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + var second = await secondResponse.Content.ReadFromJsonAsync(factory.JsonOptions, ct); + Assert.NotNull(second); + Assert.InRange(second!.ExpiresAt, + secondIssuedAt.AddHours(23).AddMinutes(59), + secondIssuedAt.AddHours(24).AddMinutes(1)); + + using (TenantContext.Enter(slug)) + using (var scope = factory.Services.CreateScope()) + { + var session = scope.ServiceProvider.GetRequiredService(); + var invites = await session.Query() + .OrderBy(i => i.CreatedAt) + .ToListAsync(ct); + + Assert.Equal(2, invites.Count); + Assert.NotNull(invites[0].UsedAt); + Assert.Null(invites[1].UsedAt); + Assert.Equal("second@example.test", invites[1].Email); + Assert.Single(invites, i => !i.IsUsed); + } + } + finally + { + await client.DeleteAsync($"/api/admin/realms/{slug}?hard=true", ct); + } + } +} diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/RecoveryCliTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/RecoveryCliTests.cs index 07b1011a..919a1dea 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/RecoveryCliTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/RecoveryCliTests.cs @@ -87,7 +87,7 @@ public async Task Tenant_scoped_command_with_unknown_realm_fails_with_a_clear_me } [Fact] - public async Task Tenant_scoped_command_announces_the_implicit_default_when_multiple_realms_exist() + public async Task Tenant_scoped_command_requires_an_explicit_target_when_multiple_realms_exist() { await using var host = await Fixture.CreateIsolatedHostAsync(); var ct = TestContext.Current.CancellationToken; @@ -112,9 +112,9 @@ public async Task Tenant_scoped_command_announces_the_implicit_default_when_mult var result = await CliHarness.RunAsync(host.Services, "list"); // no --realm - Assert.Equal(0, result.ExitCode); - Assert.Contains("no --realm specified", result.StdErr); - Assert.Contains("'system'", result.StdErr); + Assert.Equal(1, result.ExitCode); + Assert.Contains("2 active realms exist", result.StdErr); + Assert.Contains("--realm explicitly", result.StdErr); } // ── realm-domain guards (previously never validated) ───────────────── diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs new file mode 100644 index 00000000..d17104d7 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs @@ -0,0 +1,288 @@ +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Features.Admin.Jobs; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; +using Modgud.Application.Scheduling; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; +using Modgud.Infrastructure.Scheduling; +using Quartz; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Pins the scheduling ownership contract: realm jobs have independent Quartz +/// identities/config/history, while system jobs exist once and are exposed only +/// through the current Control-Plane realm. +/// +public class ScheduledJobsTenancyTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Realm_schedules_are_independent_and_system_jobs_are_ControlPlane_only() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + const string tenantSlug = "jobs-acme"; + + var realms = factory.Services.GetRequiredService(); + var created = await realms.CreateRealmAsync(new CreateRealmDto + { + Slug = tenantSlug, + DisplayName = "Jobs Acme", + Domains = [$"{tenantSlug}.localhost"], + InitialAdmin = new InitialAdminDto + { + UserName = "admin", + Email = "admin@jobs-acme.test", + }, + }, ct); + Assert.False(created.IsError); + + var scheduler = await factory.Services + .GetRequiredService() + .GetScheduler(ct); + + var controlPlaneRealmKey = new JobKey(DcrGcJob.Key, "realm:system"); + var tenantRealmKey = new JobKey(DcrGcJob.Key, $"realm:{tenantSlug}"); + var controlPlaneSecurityKey = + new JobKey(SecurityAuditPruneJob.Key, "realm:system"); + var tenantSecurityKey = + new JobKey(SecurityAuditPruneJob.Key, $"realm:{tenantSlug}"); + var singletonPlatformAuditKey = + new JobKey(PlatformAuditPruneJob.Key, "system"); + var singletonSystemRetentionKey = + new JobKey(SystemJobRunHistoryRetentionJob.Key, "system"); + + Assert.True(await scheduler.CheckExists(controlPlaneRealmKey, ct)); + Assert.True(await scheduler.CheckExists(tenantRealmKey, ct)); + Assert.True(await scheduler.CheckExists(controlPlaneSecurityKey, ct)); + Assert.True(await scheduler.CheckExists(tenantSecurityKey, ct)); + Assert.True(await scheduler.CheckExists(singletonPlatformAuditKey, ct)); + Assert.True(await scheduler.CheckExists(singletonSystemRetentionKey, ct)); + Assert.False(await scheduler.CheckExists( + new JobKey(SystemJobRunHistoryRetentionJob.Key, $"realm:{tenantSlug}"), ct)); + var globalStore = factory.Services.GetRequiredService(); + + await InTenantAsync(factory, TenantConstants.SystemTenantId, async jobs => + { + var visible = await jobs.GetAllAsync(ct); + Assert.Contains(visible, + j => j.Key == SecurityAuditPruneJob.Key && j.Scope == nameof(JobScope.Realm)); + Assert.Contains(visible, + j => j.Key == PlatformAuditPruneJob.Key && j.Scope == nameof(JobScope.System)); + Assert.Contains(visible, + j => j.Key == SystemJobRunHistoryRetentionJob.Key + && j.Scope == nameof(JobScope.System)); + Assert.Contains(visible, + j => j.Key == DcrGcJob.Key && j.Scope == nameof(JobScope.Realm)); + await jobs.UpdateAsync(DcrGcJob.Key, new JobUpdateDto + { + CronOverride = "0 0 21 * * ?", + Enabled = true, + }, ct); + await jobs.UpdateAsync(SecurityAuditPruneJob.Key, new JobUpdateDto + { + CronOverride = "0 17 1 * * ?", + Enabled = true, + }, ct); + await jobs.UpdateAsync(PlatformAuditPruneJob.Key, new JobUpdateDto + { + CronOverride = "0 18 1 * * ?", + Enabled = true, + }, ct); + await jobs.TriggerNowAsync(PlatformAuditPruneJob.Key, ct: ct); + }); + + await InTenantAsync(factory, tenantSlug, async jobs => + { + var visible = await jobs.GetAllAsync(ct); + Assert.DoesNotContain(visible, j => j.Scope == nameof(JobScope.System)); + Assert.NotNull(await jobs.GetAsync(SecurityAuditPruneJob.Key, ct)); + Assert.Null(await jobs.GetAsync(PlatformAuditPruneJob.Key, ct)); + Assert.Null(await jobs.GetAsync(SystemJobRunHistoryRetentionJob.Key, ct)); + + await jobs.UpdateAsync(DcrGcJob.Key, new JobUpdateDto + { + CronOverride = "0 0 18 * * ?", + Enabled = true, + }, ct); + await jobs.UpdateAsync(SecurityAuditPruneJob.Key, new JobUpdateDto + { + CronOverride = "0 19 1 * * ?", + Enabled = true, + }, ct); + }); + + Assert.Equal( + "0 0 21 * * ?", + await GetCronAsync(scheduler, controlPlaneRealmKey, ct)); + Assert.Equal( + "0 0 18 * * ?", + await GetCronAsync(scheduler, tenantRealmKey, ct)); + Assert.Equal( + "0 17 1 * * ?", + await GetCronAsync(scheduler, controlPlaneSecurityKey, ct)); + Assert.Equal( + "0 19 1 * * ?", + await GetCronAsync(scheduler, tenantSecurityKey, ct)); + + var systemRun = await WaitForGlobalManualRunAsync( + factory, PlatformAuditPruneJob.Key, ct); + Assert.NotNull(systemRun); + await using (var globalSession = globalStore.QuerySession()) + { + var systemConfig = await globalSession.LoadAsync( + PlatformAuditPruneJob.Key, ct); + Assert.Equal("0 18 1 * * ?", systemConfig?.CronOverride); + } + + await using (var tenantMetadataSession = factory.Services + .GetRequiredService() + .QuerySession(TenantConstants.SystemTenantId)) + { + var realmConfig = await tenantMetadataSession.LoadAsync( + SecurityAuditPruneJob.Key, ct); + Assert.Equal("0 17 1 * * ?", realmConfig?.CronOverride); + Assert.False(await tenantMetadataSession.Query() + .AnyAsync(h => h.JobKey == PlatformAuditPruneJob.Key, ct)); + } + + // Disabled means manual-only: the durable realm job remains, but its + // own trigger disappears and a manual run still writes tenant history. + await InTenantAsync(factory, tenantSlug, async jobs => + { + await jobs.UpdateAsync(DcrGcJob.Key, new JobUpdateDto + { + CronOverride = "0 0 18 * * ?", + Enabled = false, + }, ct); + await jobs.TriggerNowAsync(DcrGcJob.Key, Guid.NewGuid(), ct); + }); + + Assert.True(await scheduler.CheckExists(tenantRealmKey, ct)); + Assert.DoesNotContain( + await scheduler.GetTriggersOfJob(tenantRealmKey, ct), + trigger => trigger is ICronTrigger); + + var tenantRun = await WaitForManualRunAsync(factory, tenantSlug, DcrGcJob.Key, ct); + Assert.NotNull(tenantRun); + + await using var systemSession = factory.Services + .GetRequiredService() + .QuerySession(TenantConstants.SystemTenantId); + Assert.False(await systemSession.Query() + .AnyAsync(h => h.JobKey == DcrGcJob.Key && h.ManualTrigger, ct)); + + // Realm lifecycle reconciles the group immediately. Normal realm jobs + // stop while inactive; private-key hygiene deliberately remains. + var deactivated = await realms.UpdateRealmAsync( + tenantSlug, new UpdateRealmDto { IsActive = false }, ct); + Assert.False(deactivated.IsError); + Assert.False(await scheduler.CheckExists(tenantRealmKey, ct)); + Assert.True(await scheduler.CheckExists( + new JobKey(SigningKeyJanitorJob.Key, $"realm:{tenantSlug}"), ct)); + + var reactivated = await realms.UpdateRealmAsync( + tenantSlug, new UpdateRealmDto { IsActive = true }, ct); + Assert.False(reactivated.IsError); + Assert.True(await scheduler.CheckExists(tenantRealmKey, ct)); + Assert.DoesNotContain( + await scheduler.GetTriggersOfJob(tenantRealmKey, ct), + trigger => trigger is ICronTrigger); + + // Moving the Control-Plane role moves system-job visibility/context, + // while the reserved Quartz identity remains a single instance. + var transferred = await realms.TransferControlPlaneAsync(tenantSlug, ct); + Assert.False(transferred.IsError); + var systemDetail = await scheduler.GetJobDetail(singletonPlatformAuditKey, ct); + Assert.NotNull(systemDetail); + Assert.Equal( + tenantSlug, + systemDetail!.JobDataMap.GetString("__modgudTenantSlug")); + + await InTenantAsync(factory, TenantConstants.SystemTenantId, async jobs => + { + Assert.DoesNotContain( + await jobs.GetAllAsync(ct), + j => j.Scope == nameof(JobScope.System)); + }); + await InTenantAsync(factory, tenantSlug, async jobs => + { + var visible = await jobs.GetAllAsync(ct); + Assert.Contains(visible, + j => j.Key == PlatformAuditPruneJob.Key + && j.Scope == nameof(JobScope.System) + && j.EffectiveCron == "0 18 1 * * ?"); + Assert.Contains(visible, + j => j.Key == SecurityAuditPruneJob.Key + && j.Scope == nameof(JobScope.Realm) + && j.EffectiveCron == "0 19 1 * * ?"); + }); + } + + private static async Task InTenantAsync( + ColdStartWebApplicationFactory factory, + string slug, + Func action) + { + using var tenant = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await action(scope.ServiceProvider.GetRequiredService()); + } + + private static async Task GetCronAsync( + IScheduler scheduler, + JobKey jobKey, + CancellationToken ct) + { + var trigger = Assert.Single(await scheduler.GetTriggersOfJob(jobKey, ct)); + return Assert.IsAssignableFrom(trigger).CronExpressionString; + } + + private static async Task WaitForManualRunAsync( + ColdStartWebApplicationFactory factory, + string realmSlug, + string jobKey, + CancellationToken ct) + { + var store = factory.Services.GetRequiredService(); + for (var attempt = 0; attempt < 100; attempt++) + { + await using var session = store.QuerySession(realmSlug); + var entry = await session.Query() + .Where(h => h.JobKey == jobKey && h.ManualTrigger) + .OrderByDescending(h => h.StartedAt) + .FirstOrDefaultAsync(ct); + if (entry is not null) + return entry; + + await Task.Delay(TimeSpan.FromMilliseconds(50), ct); + } + + return null; + } + + private static async Task WaitForGlobalManualRunAsync( + ColdStartWebApplicationFactory factory, + string jobKey, + CancellationToken ct) + { + var store = factory.Services.GetRequiredService(); + for (var attempt = 0; attempt < 100; attempt++) + { + await using var session = store.QuerySession(); + var entry = await session.Query() + .Where(h => h.JobKey == jobKey && h.ManualTrigger) + .OrderByDescending(h => h.StartedAt) + .FirstOrDefaultAsync(ct); + if (entry is not null) + return entry; + + await Task.Delay(TimeSpan.FromMilliseconds(50), ct); + } + + return null; + } +} diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/TenantSilentFallbackTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/TenantSilentFallbackTests.cs index 8450d5ce..f501a837 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/TenantSilentFallbackTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/TenantSilentFallbackTests.cs @@ -10,8 +10,8 @@ namespace Modgud.Api.Tests.ColdStart; /// HTTP request that never resolved a realm used to silently fall back to the /// 'system' tenant — the "I created it, got no error, and it isn't where I /// expected" symptom (TenantedSessionFactory.ResolveTenantId). It must now -/// fail loudly. The load-bearing background fallback (no HttpContext) and the -/// explicit-TenantContext path must stay intact. +/// fail loudly. Background work without an explicit realm must fail in exactly +/// the same way; the explicit-TenantContext path stays intact. /// /// RealmMiddleware resolves (or 404s) every routed request, so the /// dangerous "HttpContext present but no tenant" state is reproduced directly at @@ -33,7 +33,7 @@ public void Write_session_during_an_http_request_with_no_resolved_tenant_is_reje accessor.HttpContext = new DefaultHttpContext(); var ex = Assert.Throws(() => sessions.OpenSession()); - Assert.Contains("system", ex.Message); + Assert.Contains("No realm/tenant resolved", ex.Message); } finally { @@ -42,19 +42,19 @@ public void Write_session_during_an_http_request_with_no_resolved_tenant_is_reje } [Fact] - public void Background_write_session_with_no_http_context_still_falls_back_to_system() + public void Background_write_session_with_no_http_context_is_rejected() { var accessor = Factory.Services.GetRequiredService(); var sessions = Factory.Services.GetRequiredService(); var previous = accessor.HttpContext; try { - // Genuine background path: no HttpContext, no ambient tenant. The - // system fallback here is load-bearing and must stay silent. + // Deployment-wide background work must use IGlobalStore. A realm + // job must explicitly enter the realm it is processing. accessor.HttpContext = null; - using var session = sessions.OpenSession(); - Assert.Equal(TenantConstants.SystemTenantId, session.TenantId); + var ex = Assert.Throws(() => sessions.OpenSession()); + Assert.Contains("No realm/tenant resolved", ex.Message); } finally { diff --git a/src/dotnet/Modgud.Api.Tests/ExternalAuth/DynamicOidcSchemeManagerTests.cs b/src/dotnet/Modgud.Api.Tests/ExternalAuth/DynamicOidcSchemeManagerTests.cs index 91da1095..03d644ee 100644 --- a/src/dotnet/Modgud.Api.Tests/ExternalAuth/DynamicOidcSchemeManagerTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ExternalAuth/DynamicOidcSchemeManagerTests.cs @@ -109,10 +109,10 @@ public async Task RegisterAsync_InternalType_DoesNotRegisterScheme() [InlineData(LoginProviderType.Saml)] [InlineData(LoginProviderType.Ldap)] [InlineData(LoginProviderType.Kerberos)] - public async Task RegisterAsync_NotYetSupportedTypes_AreSkipped(LoginProviderType type) + public async Task RegisterAsync_NonOidcTypes_AreSkipped(LoginProviderType type) { - // Saml/Ldap/Kerberos types must skip silently — same posture as - // Internal until their flavor surfaces land. + // SAML has its own DynamicSamlSchemeManager; LDAP/Kerberos remain + // unsupported. None of them may enter the OIDC scheme machinery. var config = new LoginProvider { Id = Guid.NewGuid(), diff --git a/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederatedLogoutTests.cs b/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederatedLogoutTests.cs new file mode 100644 index 00000000..bfe9e547 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederatedLogoutTests.cs @@ -0,0 +1,201 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using Marten; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authentication.Domain; +using Modgud.Authentication.Domain.LoginProviders; +using Modgud.Authentication.Sessions; +using Modgud.Infrastructure.Persistence.Tenancy; + +namespace Modgud.Api.Tests.ExternalAuth; + +[Collection(IntegrationTestCollection.Name)] +public class FederatedLogoutTests : IntegrationTestBase +{ + public FederatedLogoutTests(SharedPostgresFixture fixture) : base(fixture) { } + + [Theory] + [InlineData(LoginProviderType.Oidc, true, true)] + [InlineData(LoginProviderType.Oidc, false, false)] + [InlineData(LoginProviderType.Saml, true, false)] + [InlineData(LoginProviderType.Ldap, true, false)] + public async Task Logout_ReturnsUpstreamUrlOnlyForEnabledOidc( + LoginProviderType providerType, + bool enabled, + bool expectsUpstreamLogout) + { + var ct = TestContext.Current.CancellationToken; + var provider = await StoreProviderAsync(providerType, enabled, ct); + using var client = await CreateFederatedCookieClientAsync(provider.Id, ct); + + var response = await client.PostAsJsonAsync( + "/api/account/logout", + new { EndIdpSession = true }, + ct); + + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, ct); + Assert.NotNull(body); + Assert.Equal( + expectsUpstreamLogout + ? $"/api/account/external-logout/{provider.Id}" + : null, + body.ExternalLogoutUrl); + } + + [Fact] + public async Task Logout_OidcOptOut_EndsOnlyTheLocalSession() + { + var ct = TestContext.Current.CancellationToken; + var provider = await StoreProviderAsync(LoginProviderType.Oidc, enabled: true, ct); + using var client = await CreateFederatedCookieClientAsync(provider.Id, ct); + + var response = await client.PostAsJsonAsync( + "/api/account/logout", + new { EndIdpSession = false }, + ct); + + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, ct); + Assert.NotNull(body); + Assert.Null(body.ExternalLogoutUrl); + } + + [Fact] + public async Task Logout_UnknownProvider_StillEndsTheLocalSession() + { + var ct = TestContext.Current.CancellationToken; + using var client = await CreateFederatedCookieClientAsync(Guid.NewGuid(), ct); + + var response = await client.PostAsJsonAsync( + "/api/account/logout", + new { EndIdpSession = true }, + ct); + + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, ct); + Assert.NotNull(body); + Assert.Null(body.ExternalLogoutUrl); + } + + [Fact] + public async Task ExternalLogout_SamlProvider_DegradesToLocalLoggedOutPage() + { + var ct = TestContext.Current.CancellationToken; + var provider = await StoreProviderAsync(LoginProviderType.Saml, enabled: true, ct); + using var client = Factory.CreateDefaultClient(); + client.DefaultRequestHeaders.Referrer = new Uri("http://localhost/profile"); + + var response = await client.GetAsync( + $"/api/account/external-logout/{provider.Id}", + ct); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/logged-out", response.Headers.Location?.OriginalString); + } + + [Fact] + public async Task ExternalLogout_UnknownProvider_DegradesToLocalLoggedOutPage() + { + var ct = TestContext.Current.CancellationToken; + using var client = Factory.CreateDefaultClient(); + client.DefaultRequestHeaders.Referrer = new Uri("http://localhost/profile"); + + var response = await client.GetAsync( + $"/api/account/external-logout/{Guid.NewGuid()}", + ct); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/logged-out", response.Headers.Location?.OriginalString); + } + + private async Task StoreProviderAsync( + LoginProviderType type, + bool enabled, + CancellationToken ct) + { + var provider = new LoginProvider + { + Id = Guid.NewGuid(), + Type = type, + Flavor = type switch + { + LoginProviderType.Oidc => LoginProviderFlavor.GenericOidc, + LoginProviderType.Saml => LoginProviderFlavor.GenericSaml, + _ => type.ToString().ToLowerInvariant(), + }, + Slug = $"logout-{Guid.NewGuid():N}"[..24], + DisplayName = $"{type} logout test", + Enabled = enabled, + ClientId = type == LoginProviderType.Oidc ? "logout-client" : string.Empty, + }; + + await using var session = GetTenantedDocumentSession(); + session.Store(provider); + await session.SaveChangesAsync(ct); + return provider; + } + + private async Task CreateFederatedCookieClientAsync( + Guid loginProviderId, + CancellationToken ct) + { + using var scope = Factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var signInManager = scope.ServiceProvider.GetRequiredService>(); + + var user = await userManager.FindByIdAsync(DefaultUser!.Id.ToString()) + ?? throw new InvalidOperationException("Default test user not found."); + var principal = await signInManager.CreateUserPrincipalAsync(user); + var identity = (ClaimsIdentity)principal.Identity!; + identity.AddClaim(new Claim( + "modgud.external.loginProviderId", + loginProviderId.ToString())); + + using (TenantContext.Enter(TenantConstants.SystemTenantId)) + { + var createdSession = await scope.ServiceProvider + .GetRequiredService() + .CreateSessionAsync( + user.Id, + ipAddress: null, + userAgent: "FederatedLogoutTests", + ct); + Assert.False( + createdSession.IsError, + createdSession.IsError ? createdSession.FirstError.Description : null); + identity.AddClaim(new Claim( + SessionClaimTypes.BrowserSessionId, + createdSession.Value.Id.ToString())); + } + + var cookieOptions = scope.ServiceProvider + .GetRequiredService>() + .Get(IdentityConstants.ApplicationScheme); + var ticket = new AuthenticationTicket( + principal, + new AuthenticationProperties + { + IsPersistent = true, + IssuedUtc = DateTimeOffset.UtcNow, + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(1), + }, + IdentityConstants.ApplicationScheme); + + string cookieValue; + using (TenantContext.Enter(TenantConstants.SystemTenantId)) + cookieValue = cookieOptions.TicketDataFormat.Protect(ticket); + + var handler = new CookieContainerHandler(); + handler.Seed(new Uri("http://localhost"), cookieOptions.Cookie.Name!, cookieValue); + return Factory.CreateDefaultClient(handler); + } + + private sealed record LogoutResponse(string Message, string? ExternalLogoutUrl); +} diff --git a/src/dotnet/Modgud.Api.Tests/ExternalAuth/LoginProviderTests.cs b/src/dotnet/Modgud.Api.Tests/ExternalAuth/LoginProviderTests.cs index d4924982..52dad048 100644 --- a/src/dotnet/Modgud.Api.Tests/ExternalAuth/LoginProviderTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ExternalAuth/LoginProviderTests.cs @@ -6,6 +6,7 @@ using Modgud.Api.Tests.Infrastructure; using Modgud.Authentication.Domain.LoginProviders; using Modgud.Authentication.Domain.LoginProviders.Events; +using Modgud.Authentication.Identity.LoginProviders; using Wolverine; namespace Modgud.Api.Tests.ExternalAuth; @@ -203,9 +204,8 @@ public async Task Create_FullForm_AppliesAllOptionalFields() FlavorData: flavorData, Type: LoginProviderType.Oidc, Description: "single-modal full submit", - // Enabled stays at its default (false) — OIDC Create cannot enable - // because the client secret is set via the separate RotateSecret - // command. See Create_OidcEnabledTrue_Rejected for the gate. + // Enabled stays at its default (false). The dedicated test below + // covers atomic Enabled + InitialClientSecret creation. ClientId: "client-xyz", Scopes: ["openid", "profile", "email", "groups"], UserUpdateScript: "return { firstname: claims.given_name };", @@ -258,12 +258,11 @@ public async Task Create_FullForm_OverlongUserUpdateScript_Rejected() } [Fact] - public async Task Create_OidcEnabledTrue_Rejected() + public async Task Create_OidcEnabledTrue_WithoutInitialSecret_Rejected() { - // Readiness-gate parity with EnableLoginProviderHandler: OIDC needs a - // ClientSecret to authenticate, and Create has no secret surface (it - // lives on a separate command for audit reasons), so Enabled=true at - // Create is structurally unsafe and the command refuses it. + // Readiness-gate parity with EnableLoginProviderHandler: an enabled + // OIDC provider must still have a ClientSecret. Atomic create supports + // one, but omitting it must remain unsafe. using var scope = Factory.Services.CreateScope(); var bus = GetTenantedMessageBus(scope); @@ -280,6 +279,38 @@ public async Task Create_OidcEnabledTrue_Rejected() Assert.Equal("LoginProvider.SecretRequired", result.FirstError.Code); } + [Fact] + public async Task Create_OidcEnabledTrue_WithInitialSecret_SucceedsAtomically() + { + // The expert modal submits the complete provider in one request. The + // plaintext initial secret is encrypted before it enters the event and + // the readiness gate evaluates that encrypted value in the same + // command, so no create-then-rotate round-trip is needed. + using var scope = Factory.Services.CreateScope(); + var bus = GetTenantedMessageBus(scope); + + const string initialSecret = "integration-test-secret"; + var flavorData = JsonDocument.Parse("""{"MetadataUri": "https://idp.test/.well-known/openid-configuration"}"""); + var result = await bus.InvokeAsync>(new CreateLoginProviderCommand( + Flavor: LoginProviderFlavor.GenericOidc, + DisplayName: $"OidcReady-{Guid.NewGuid():N}"[..18], + Slug: "oidc-ready", + FlavorData: flavorData, + Enabled: true, + ClientId: "client-xyz", + InitialClientSecret: initialSecret)); + + Assert.False(result.IsError, result.IsError ? result.FirstError.Description : ""); + Assert.True(result.Value.Enabled); + Assert.NotNull(result.Value.ClientSecretEncrypted); + Assert.NotEqual( + initialSecret, + System.Text.Encoding.UTF8.GetString(result.Value.ClientSecretEncrypted!)); + + var secretStore = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(initialSecret, secretStore.Decrypt(result.Value.ClientSecretEncrypted!)); + } + [Fact] public async Task Create_SamlEnabledTrue_WithoutMetadata_Rejected() { @@ -330,8 +361,8 @@ public async Task Create_SamlEnabledTrue_WithMetadataUrl_Succeeds() [InlineData(LoginProviderType.Kerberos)] public async Task Create_OtherUnsupportedTypes_ReturnSameErrorCode(LoginProviderType type) { - // Phase 2: TypeNotSupported is a single centralized error — Saml/Ldap/ - // Kerberos all share the same code so the frontend can render one message. + // LDAP and Kerberos share the centralized unsupported-type error. + // SAML is a supported protocol with its own flavor registry. using var scope = Factory.Services.CreateScope(); var bus = GetTenantedMessageBus(scope); diff --git a/src/dotnet/Modgud.Api.Tests/Infrastructure/ColdStartFixture.cs b/src/dotnet/Modgud.Api.Tests/Infrastructure/ColdStartFixture.cs index 825066b9..e5b8c0b3 100644 --- a/src/dotnet/Modgud.Api.Tests/Infrastructure/ColdStartFixture.cs +++ b/src/dotnet/Modgud.Api.Tests/Infrastructure/ColdStartFixture.cs @@ -82,6 +82,27 @@ public async Task CreateIsolatedHostAsync() return new IsolatedColdStartHost(factory); } + /// + /// Boots the production-shaped zero-realm state for first-installation + /// tests. Unlike , the test factory + /// does not provision the legacy "system" test tenant. + /// + public async Task CreateUninitializedHostAsync() + { + var isolatedDb = "install_" + Guid.NewGuid().ToString("N")[..12]; + var isolatedConnectionString = Container.GetConnectionString() + .Replace($"Database={MasterDbName}", $"Database={isolatedDb}", StringComparison.OrdinalIgnoreCase); + + var ctx = BuildContext(isolatedConnectionString); + CocoarTestConfiguration.Apply(ctx); + + var factory = new UninitializedModgudWebApplicationFactory(); + factory.CreateClient().Dispose(); + + CocoarTestConfiguration.Apply(TestContext); + return new UninitializedColdStartHost(factory); + } + private static TestConfigurationContext BuildContext(string connectionString) => TestConfigurationContext.Replace(rule => [ @@ -134,6 +155,14 @@ public sealed class IsolatedColdStartHost(ColdStartWebApplicationFactory factory public async ValueTask DisposeAsync() => await Factory.DisposeAsync(); } +public sealed class UninitializedColdStartHost( + UninitializedModgudWebApplicationFactory factory) : IAsyncDisposable +{ + public UninitializedModgudWebApplicationFactory Factory { get; } = factory; + public IServiceProvider Services => Factory.Services; + public async ValueTask DisposeAsync() => await Factory.DisposeAsync(); +} + /// /// Cold-start collection. Separate from the integration-test collection and, like /// it, non-parallel — both serialize relative to each other, which keeps the diff --git a/src/dotnet/Modgud.Api.Tests/Infrastructure/IntegrationTestBase.cs b/src/dotnet/Modgud.Api.Tests/Infrastructure/IntegrationTestBase.cs index ef1e3d76..68f9f0b5 100644 --- a/src/dotnet/Modgud.Api.Tests/Infrastructure/IntegrationTestBase.cs +++ b/src/dotnet/Modgud.Api.Tests/Infrastructure/IntegrationTestBase.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Cocoar.Configuration.Testing; using Modgud.Infrastructure.Persistence.Marten.Projections.Users; +using Modgud.Infrastructure.Persistence.Tenancy; using Marten; using Microsoft.Extensions.DependencyInjection; using Wolverine; @@ -19,6 +20,7 @@ namespace Modgud.Api.Tests.Infrastructure; public abstract class IntegrationTestBase : IAsyncLifetime, IDisposable { private readonly SharedPostgresFixture _fixture; + private readonly IDisposable _tenantContext; private const string DefaultPassword = "TestPass1234"; @@ -35,6 +37,7 @@ protected IntegrationTestBase(SharedPostgresFixture fixture) // Apply test configuration in constructor - this runs in the test's async context CocoarTestConfiguration.Apply(fixture.TestContext); + _tenantContext = TenantContext.Enter(TenantConstants.SystemTenantId); } public async ValueTask InitializeAsync() @@ -134,6 +137,7 @@ public async ValueTask DisposeAsync() public void Dispose() { + _tenantContext.Dispose(); // Clear test configuration when test class is disposed CocoarTestConfiguration.Clear(); } diff --git a/src/dotnet/Modgud.Api.Tests/Infrastructure/ModgudWebApplicationFactory.cs b/src/dotnet/Modgud.Api.Tests/Infrastructure/ModgudWebApplicationFactory.cs index 61d1a5de..4370f28b 100644 --- a/src/dotnet/Modgud.Api.Tests/Infrastructure/ModgudWebApplicationFactory.cs +++ b/src/dotnet/Modgud.Api.Tests/Infrastructure/ModgudWebApplicationFactory.cs @@ -24,6 +24,11 @@ using Modgud.Authorization.Events; using Modgud.Authorization.Principals; using Modgud.Authorization.Roles; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; +using Modgud.Infrastructure.Scheduling; +using Marten.Storage; +using Npgsql; namespace Modgud.Api.Tests.Infrastructure; @@ -35,9 +40,12 @@ namespace Modgud.Api.Tests.Infrastructure; public class ModgudWebApplicationFactory : WebApplicationFactory { private IHost? _host; + private readonly bool _enableDirectScopeTenantFallback; + protected virtual bool ProvisionLegacySystemRealm => true; public ModgudWebApplicationFactory(SharedPostgresFixture fixture) { + _enableDirectScopeTenantFallback = true; // No configuration needed here - CocoarTestConfiguration.Apply() // was already called in the fixture } @@ -49,6 +57,7 @@ public ModgudWebApplicationFactory(SharedPostgresFixture fixture) /// protected ModgudWebApplicationFactory() { + _enableDirectScopeTenantFallback = false; } /// @@ -88,6 +97,19 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { + // Production deliberately refuses tenant-scoped sessions when no + // request or explicit TenantContext exists. Most legacy + // integration tests also arrange/assert through direct DI scopes, + // so give those scopes an explicit test tenant without restoring a + // production fallback. Real HTTP requests still replace this + // accessor's ambient context for the duration of the request. + if (_enableDirectScopeTenantFallback) + { + services.RemoveAll(); + services.AddSingleton( + new TestTenantHttpContextAccessor(TenantConstants.SystemTenantId)); + } + // .NET HostOptions.ShutdownTimeout defaults to 5 seconds. That's // not enough for Wolverine + Marten + Testcontainer to release // Postgres ownership cleanly during teardown — under load on the @@ -160,6 +182,25 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } + private sealed class TestTenantHttpContextAccessor(string tenantId) : IHttpContextAccessor + { + private readonly AsyncLocal _current = new(); + private readonly HttpContext _fallback = CreateFallbackContext(tenantId); + + public HttpContext? HttpContext + { + get => _current.Value ?? _fallback; + set => _current.Value = value; + } + + private static HttpContext CreateFallbackContext(string tenantId) + { + var context = new DefaultHttpContext(); + context.Items[TenantConstants.HttpContextTenantIdKey] = tenantId; + return context; + } + } + /// In-memory stand-in for the CIMD metadata endpoint. Returns the /// document registered for the exact request URL, or 404. private sealed class StubCimdHandler( @@ -183,9 +224,67 @@ protected override Task SendAsync( protected override IHost CreateHost(IHostBuilder builder) { _host = base.CreateHost(builder); + if (ProvisionLegacySystemRealm) + ProvisionLegacyTestRealmAsync(_host.Services).GetAwaiter().GetResult(); return _host; } + /// + /// Most pre-installation integration tests historically use a tenant named + /// "system". Production no longer creates that realm implicitly, so the + /// test harness provisions it explicitly. Fresh-installation tests opt out + /// through . + /// + private static async Task ProvisionLegacyTestRealmAsync(IServiceProvider services) + { + var masterCs = services.GetRequiredService().Value; + var systemDbName = + $"{new NpgsqlConnectionStringBuilder(masterCs).Database}_{TenantConstants.SystemTenantId}"; + var systemCs = new NpgsqlConnectionStringBuilder(masterCs) + { + Database = systemDbName, + }.ConnectionString; + + var adminCs = new NpgsqlConnectionStringBuilder(masterCs) { Database = "postgres" }; + await using (var connection = new NpgsqlConnection(adminCs.ConnectionString)) + { + await connection.OpenAsync(); + await using var exists = new NpgsqlCommand( + "SELECT 1 FROM pg_database WHERE datname = @name", connection); + exists.Parameters.AddWithValue("name", systemDbName); + if (await exists.ExecuteScalarAsync() is null) + { + var quoted = "\"" + systemDbName.Replace("\"", "\"\"") + "\""; +#pragma warning disable CA2100 + await using var create = new NpgsqlCommand($"CREATE DATABASE {quoted}", connection); +#pragma warning restore CA2100 + await create.ExecuteNonQueryAsync(); + } + } + + var store = services.GetRequiredService(); + var tenancy = (MasterTableTenancy)store.Options.Tenancy; + await tenancy.AddDatabaseRecordAsync(TenantConstants.SystemTenantId, systemCs); + await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + await services.GetRequiredService() + .EnsureProvisionedAsync(TenantConstants.SystemTenantId); + + await using var scope = services.CreateAsyncScope(); + var provisioning = scope.ServiceProvider.GetRequiredService(); + await provisioning.EnsureSystemRealmExistsAsync(); + await Modgud.Infrastructure.OAuth.OAuthRealmSeeder.SeedAsync( + scope.ServiceProvider, TenantConstants.SystemTenantId); + await scope.ServiceProvider + .GetRequiredService() + .SeedAsync(TenantConstants.SystemTenantId); + await Modgud.Infrastructure.Authorization.AppRealmSeeder.SeedAsync( + scope.ServiceProvider, + TenantConstants.SystemTenantId, + isControlPlane: true); + await scope.ServiceProvider.GetRequiredService().InitializeAsync(); + await scope.ServiceProvider.GetRequiredService().ReconcileAsync(); + } + /// /// Creates a test user via event stream and returns the UserView. /// @@ -195,6 +294,7 @@ public async Task CreateTestUserAsync( string? acronym = "TU", string? email = null) { + using var tenant = TenantContext.Enter(TenantConstants.SystemTenantId); using var scope = Services.CreateScope(); var session = scope.ServiceProvider.GetRequiredService(); @@ -252,6 +352,7 @@ public async Task CreateTestUserWithIdentityAsync( var userName = (acronym ?? $"{firstname[0]}{lastname[0]}").ToLowerInvariant(); // Step 2: Apply identity setup event (sets UserName + IsActive on UserView) + using var tenant = TenantContext.Enter(TenantConstants.SystemTenantId); using var scope = Services.CreateScope(); var session = scope.ServiceProvider.GetRequiredService(); session.Events.Append(userView.Id, new UserIdentitySetupEvent(userView.Id, userName, true)); @@ -341,6 +442,7 @@ public async Task CreateTestRoleAsync( string? appSlug = null, bool isRealmAdmin = false) { + using var tenant = TenantContext.Enter(TenantConstants.SystemTenantId); var perms = permissions ?? []; using var scope = Services.CreateScope(); @@ -401,6 +503,7 @@ public async Task CreateTestGroupAsync( string? description = null, List? boundTo = null) { + using var tenant = TenantContext.Enter(TenantConstants.SystemTenantId); using var scope = Services.CreateScope(); var session = scope.ServiceProvider.GetRequiredService(); @@ -478,6 +581,7 @@ public Task WaitForProjectionsAsync(TimeSpan? timeout = null) /// public async Task GetDocumentAsync(Guid id) where T : class { + using var tenant = TenantContext.Enter(TenantConstants.SystemTenantId); using var scope = Services.CreateScope(); var session = scope.ServiceProvider.GetRequiredService(); return await session.LoadAsync(id, TestContext.Current.CancellationToken); @@ -515,3 +619,12 @@ private async Task CatchUpAsyncProjectionsAsync(TimeSpan? timeout = null) "Async-projection catch-up failed after Marten reset/append.", errors); } } + +/// +/// Test host that exposes the real production cold-start state: master/global +/// schemas exist, but the realm registry is empty. +/// +public sealed class UninitializedModgudWebApplicationFactory : ModgudWebApplicationFactory +{ + protected override bool ProvisionLegacySystemRealm => false; +} diff --git a/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj b/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj index 67c2e282..59538357 100644 --- a/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj +++ b/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj @@ -37,9 +37,9 @@ - - + diff --git a/src/dotnet/Modgud.Api.Tests/Security/MfaTests.cs b/src/dotnet/Modgud.Api.Tests/Security/MfaTests.cs index 4fe5cd5f..736f3b06 100644 --- a/src/dotnet/Modgud.Api.Tests/Security/MfaTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Security/MfaTests.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using System.Text.Json; using Modgud.Api.Tests.Infrastructure; +using Modgud.Authentication.Sessions; namespace Modgud.Api.Tests.Security; @@ -41,6 +42,24 @@ public async Task MfaSetup_ReturnsSharedKeyAndAuthenticatorUri() Assert.Contains("Modgud", uri); } + [Fact] + public async Task MfaSetup_RefreshSignIn_PreservesTheBrowserSessionId() + { + var ct = TestContext.Current.CancellationToken; + var before = await Client.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var currentBefore = Assert.Single(before!.Sessions, x => x.IsCurrent); + + var response = await Client.PostAsync("/api/account/mfa/setup", null, ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var after = await Client.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var currentAfter = Assert.Single(after!.Sessions, x => x.IsCurrent); + Assert.Equal(currentBefore.Id, currentAfter.Id); + Assert.Equal(before.Sessions.Count, after.Sessions.Count); + } + [Fact] public async Task MfaVerify_WithInvalidCode_ReturnsBadRequest() { diff --git a/src/dotnet/Modgud.Api.Tests/Security/SecurityAuditWave1Tests.cs b/src/dotnet/Modgud.Api.Tests/Security/SecurityAuditWave1Tests.cs index 8168e057..1a14a08a 100644 --- a/src/dotnet/Modgud.Api.Tests/Security/SecurityAuditWave1Tests.cs +++ b/src/dotnet/Modgud.Api.Tests/Security/SecurityAuditWave1Tests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Modgud.Api.Tests.Infrastructure; using Modgud.Authentication.Domain; +using Modgud.Authentication.Sessions; using BuildingBlocks.Helper; namespace Modgud.Api.Tests.Security; @@ -15,12 +16,8 @@ namespace Modgud.Api.Tests.Security; /// (OAuth tokens + device-session rows + auth cookies), not just rotate the stamp /// or delete tracking rows. /// -/// Note on what is asserted: -/// - #1 "revoke all" does NOT rotate the stamp today (only deletes rows), so the -/// OTHER device's cookie survives — asserted directly via a second cookie client. -/// - #2/#3 password reset ALREADY rotates the Identity stamp (so cookies die), but -/// leaves OAuth tokens AND device-session rows alive. We assert the device-session -/// rows are revoked (proves RevokeAllAccessAsync ran), which is RED before the fix. +/// "Sign out everywhere" includes the acting browser and every other browser or +/// native client session. Password resets use the same access-revocation path. /// [Collection(IntegrationTestCollection.Name)] public class SecurityAuditWave1Tests : IntegrationTestBase @@ -31,7 +28,7 @@ public SecurityAuditWave1Tests(SharedPostgresFixture fixture) : base(fixture) { // #1 — self-service "log out everywhere" must invalidate other devices' cookies. [Fact] - public async Task RevokeAllSessions_InvalidatesOtherDeviceCookie_KeepsActingSession() + public async Task RevokeAllSessions_InvalidatesEveryDeviceIncludingTheCaller() { var ct = TestContext.Current.CancellationToken; var deviceA = await CreateAuthenticatedClientAsync("tu", Password); @@ -39,21 +36,62 @@ public async Task RevokeAllSessions_InvalidatesOtherDeviceCookie_KeepsActingSess Assert.Equal(HttpStatusCode.OK, (await deviceB.GetAsync("/api/account/me", ct)).StatusCode); + var sessionsSeenByA = await deviceA.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var sessionsSeenByB = await deviceB.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var currentA = Assert.Single(sessionsSeenByA!.Sessions, x => x.IsCurrent); + var currentB = Assert.Single(sessionsSeenByB!.Sessions, x => x.IsCurrent); + Assert.NotEqual(currentA.Id, currentB.Id); + var revoke = await deviceA.DeleteAsync("/api/auth/sessions", ct); - Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + Assert.True( + revoke.StatusCode == HttpStatusCode.NoContent, + $"Expected 204, got {(int)revoke.StatusCode}: {await revoke.Content.ReadAsStringAsync(ct)}"); // Other device must now be rejected at the next SecurityStampValidator pass // (ValidationInterval=0 in the harness). RED today: revoke-all only deletes // tracking rows, never rotates the stamp, so device B keeps authenticating. Assert.Equal(HttpStatusCode.Unauthorized, (await deviceB.GetAsync("/api/account/me", ct)).StatusCode); - // Acting device survives (RefreshSignInAsync re-issues its cookie). + Assert.Equal(HttpStatusCode.Unauthorized, (await deviceA.GetAsync("/api/account/me", ct)).StatusCode); + Assert.Equal(0, await SessionCountAsync(DefaultUser!.Id, ct)); + } + + [Fact] + public async Task TargetedRevoke_InvalidatesOnlyTheSelectedBrowserSession() + { + var ct = TestContext.Current.CancellationToken; + var deviceA = await CreateAuthenticatedClientAsync("tu", Password); + var deviceB = await CreateAuthenticatedClientAsync("tu", Password); + var sessionsSeenByB = await deviceB.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var deviceBSession = Assert.Single(sessionsSeenByB!.Sessions, x => x.IsCurrent); + + var revoke = await deviceA.DeleteAsync( + $"/api/auth/sessions/{deviceBSession.Id}", ct); + + Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await deviceB.GetAsync("/api/account/me", ct)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await deviceA.GetAsync("/api/account/me", ct)).StatusCode); + } - // Re-audit regression guard: revoke-all deletes EVERY session row including - // the acting device's; the acting session must be re-recorded so the user's - // own "active sessions" list isn't left empty while they're still signed in. - Assert.True(await SessionCountAsync(DefaultUser!.Id, ct) >= 1); + [Fact] + public async Task NormalLogout_RemovesOnlyTheActingBrowserSession() + { + var ct = TestContext.Current.CancellationToken; + var deviceA = await CreateAuthenticatedClientAsync("tu", Password); + var deviceB = await CreateAuthenticatedClientAsync("tu", Password); + var list = await deviceA.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var deviceASession = Assert.Single(list!.Sessions, x => x.IsCurrent); + + var logout = await deviceA.PostAsync("/api/account/logout", null, ct); + + Assert.Equal(HttpStatusCode.OK, logout.StatusCode); + await using var read = GetTenantedDocumentSession(); + Assert.Null(await read.LoadAsync(Guid.Parse(deviceASession.Id), ct)); + Assert.Equal(HttpStatusCode.OK, (await deviceB.GetAsync("/api/account/me", ct)).StatusCode); } // #2 — admin password reset must revoke the target user's live access. @@ -70,7 +108,9 @@ public async Task AdminPasswordReset_RevokesTargetUserSessions() // Admin (default Client = realm admin) resets the target's password. var resp = await Client.PutAsJsonAsync( $"/api/user/{new ShortGuid(target.Id)}/password", new { Password = "NewPass4567!" }, ct); - Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.True( + resp.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)resp.StatusCode}: {await resp.Content.ReadAsStringAsync(ct)}"); // RED today: admin reset rotates the stamp but never calls RevokeAllAccessAsync, // so the device-session rows survive (and so do OAuth tokens). @@ -97,7 +137,9 @@ public async Task SelfServicePasswordReset_RevokesUserSessions() var anon = Factory.CreateDefaultClient(new CookieContainerHandler()); var resp = await anon.PostAsJsonAsync("/api/account/reset-password", new { UserId = DefaultUser!.Id.ToString(), Token = token, NewPassword = "NewPass4567!" }, ct); - Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.True( + resp.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)resp.StatusCode}: {await resp.Content.ReadAsStringAsync(ct)}"); // RED today: reset-password rotates the stamp but never calls RevokeAllAccessAsync. Assert.Equal(0, await SessionCountAsync(DefaultUser!.Id, ct)); diff --git a/src/dotnet/Modgud.Api.Tests/Security/UserLifecycleRevocationTests.cs b/src/dotnet/Modgud.Api.Tests/Security/UserLifecycleRevocationTests.cs index 4524083b..cc15388a 100644 --- a/src/dotnet/Modgud.Api.Tests/Security/UserLifecycleRevocationTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Security/UserLifecycleRevocationTests.cs @@ -54,7 +54,12 @@ public async Task Delete_revokes_tokens_authorizations_and_sessions() var response = await Client.DeleteAsync( $"/api/user/{new ShortGuid(user.Id)}", TestContext.Current.CancellationToken); - response.EnsureSuccessStatusCode(); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + throw new Xunit.Sdk.XunitException( + $"Delete returned HTTP {(int)response.StatusCode}: {body}"); + } var subject = user.Id.ToString(); await using var read = GetTenantedSession(); diff --git a/src/dotnet/Modgud.Api.Tests/Users/UserCreateCompletenessTests.cs b/src/dotnet/Modgud.Api.Tests/Users/UserCreateCompletenessTests.cs new file mode 100644 index 00000000..d7880303 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Users/UserCreateCompletenessTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using BuildingBlocks.Helper; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.User; +using Modgud.Authentication.Domain; +using Modgud.Authorization.Principals; + +namespace Modgud.Api.Tests.Users; + +public class UserCreateCompletenessTests(SharedPostgresFixture fixture) : IntegrationTestBase(fixture) +{ + [Fact] + public async Task Create_commits_profile_membership_and_security_policy_together() + { + var ct = TestContext.Current.CancellationToken; + var group = await Factory.CreateTestGroupAsync($"Complete_{Guid.NewGuid():N}", []); + var groupId = new ShortGuid(group.Id).ToString(); + + var response = await Client.PostAsJsonAsync("/api/user", new + { + Firstname = "Ada", + Lastname = "Complete", + Acronym = "AC", + Email = $"ada-{Guid.NewGuid():N}@test.com", + UserName = $"ada-{Guid.NewGuid():N}", + Password = "TestPass1234", + EmailConfirmed = true, + IsActive = true, + GroupIds = new[] { groupId }, + GracePeriodDaysOverride = 30, + TwoFactorExempt = true, + }, ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var created = await response.Content.ReadFromJsonAsync(JsonOptions, ct); + Assert.NotNull(created); + Assert.True(ShortGuid.TryParse(created.Id, out Guid userId)); + + await Factory.WaitForProjectionsAsync(); + + using var scope = Factory.Services.CreateScope(); + var query = scope.ServiceProvider.GetRequiredService(); + var storedGroup = await query.LoadAsync(group.Id, ct); + var security = await query.LoadAsync(userId, ct); + + Assert.Contains(userId, storedGroup!.MemberIds); + Assert.Equal(30, security!.GracePeriodDaysOverride); + Assert.True(security.TwoFactorExempt); + Assert.False(string.IsNullOrWhiteSpace(security.PasswordHash)); + } + + [Fact] + public async Task Create_with_invalid_group_writes_no_user() + { + var ct = TestContext.Current.CancellationToken; + var email = $"invalid-group-{Guid.NewGuid():N}@test.com"; + + var response = await Client.PostAsJsonAsync("/api/user", new + { + Email = email, + UserName = email, + GroupIds = new[] { "not-a-group-id" }, + }, ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var scope = Factory.Services.CreateScope(); + var query = scope.ServiceProvider.GetRequiredService(); + var person = await query.Query() + .FirstOrDefaultAsync(p => p.NormalizedEmail == email.ToUpperInvariant(), ct); + var applicationUser = await query.Query() + .FirstOrDefaultAsync(u => u.NormalizedEmail == email.ToUpperInvariant(), ct); + + Assert.Null(person); + Assert.Null(applicationUser); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/Users/UserCrudTests.cs b/src/dotnet/Modgud.Api.Tests/Users/UserCrudTests.cs index 3e144c30..54ca6f1d 100644 --- a/src/dotnet/Modgud.Api.Tests/Users/UserCrudTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Users/UserCrudTests.cs @@ -38,6 +38,66 @@ public async Task Create_User_ReturnsCreatedUser() Assert.Equal("john.doe@test.com", result.Email); } + /// + /// A user created with IsActive=false must READ BACK as inactive. The read + /// model takes IsActive only from UserActivatedEvent / UserDeactivatedEvent + /// and UserView defaults it to true, so setting the flag on the + /// ApplicationUser document alone left the list query (and the admin grid) + /// reporting the user as active while the document said otherwise. This + /// asserts the projection, not just the create response. + /// + [Fact] + public async Task Create_InactiveUser_ReadsBackAsInactive() + { + var createDto = new UserCreateDto + { + Firstname = "Staged", + Lastname = "Starter", + Email = "staged.starter@test.com", + IsActive = false, + }; + + var response = await Client.PostAsJsonAsync("/api/user", createDto, JsonOptions, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + var created = await response.ReadSuccessJsonAsync(JsonOptions); + Assert.False(created.IsActive); + + // UserView is an async projection. The create response is intentionally + // optimistic (Status=Pending), so wait for the projection before + // asserting the read model instead of racing the daemon on slower CI + // runners. + await Factory.WaitForProjectionsAsync(); + + var readBack = await Client.GetAsync($"/api/user/{created.Id}", TestContext.Current.CancellationToken); + readBack.EnsureSuccessStatusCode(); + var fetched = await readBack.ReadSuccessJsonAsync(JsonOptions); + Assert.False(fetched.IsActive); + } + + /// + /// The create endpoint has always accepted an initial password; the admin + /// form now offers it, so a user can be created ready to sign in instead of + /// needing a second "set password" round-trip. + /// + [Fact] + public async Task Create_UserWithPassword_ReportsHasPassword() + { + var createDto = new UserCreateDto + { + Firstname = "With", + Lastname = "Password", + Email = "with.password@test.com", + Password = "ABC12abc!", + }; + + var response = await Client.PostAsJsonAsync("/api/user", createDto, JsonOptions, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + var created = await response.ReadSuccessJsonAsync(JsonOptions); + + Assert.True(created.HasPassword); + Assert.True(created.IsActive); + } + [Fact] public async Task Get_AllUsers_ReturnsAllUsers() { diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs index 9d2e454d..5a1f062f 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs @@ -1,9 +1,5 @@ -using Marten; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Modgud.Authentication.Gdpr; using Modgud.Authentication.SelfRegistration; -using Modgud.Domain.Realms; using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Persistence.Tenancy; using Quartz; @@ -22,18 +18,15 @@ namespace Modgud.Api.Features.Admin.Jobs; /// (only when the realm has AutoPurge enabled). /// /// -/// Mirrors 's multi-tenant shape: it reads the -/// realm list from the master DB, then runs the per-realm work inside each -/// realm's so the scoped IGdprService (and -/// its Marten session + RealmSettings) resolve against the right tenant DB — -/// there is no HttpContext in a scheduled job. +/// Quartz creates one instance per realm. The scheduler enters that +/// realm's before resolving this job, so all +/// constructor-injected services bind to exactly one tenant database. /// [DisallowConcurrentExecution] public class AccountLifecycleSweepJob( - IServiceScopeFactory scopeFactory, - IDocumentStore store, - ISecurityAuditLog securityAudit, - ILogger logger) : IJob + IGdprService gdpr, + IRegistrationInviteService inviteService, + ISecurityAuditLog securityAudit) : IJob { public const string Key = "account-lifecycle-sweep"; public const string Name = "Account Lifecycle Sweep"; @@ -48,56 +41,31 @@ public class AccountLifecycleSweepJob( public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; + var realmSlug = TenantContext.Current; - await using var masterSession = store.LightweightSession(TenantConstants.SystemTenantId); - var realms = await masterSession.Query() - .Where(r => r.IsActive) - .ToListAsync(ct); + var (reminded, erased) = await gdpr.RunSelfServiceSweepAsync(ct); + var purged = await gdpr.RunAdminRetentionPurgeAsync(ct); - int realmsTouched = 0, totalReminded = 0, totalErased = 0, totalPurged = 0, totalInviteCodesPruned = 0; - foreach (var realm in realms) - { - if (ct.IsCancellationRequested) break; - try - { - using var scope = scopeFactory.CreateScope(); - using (TenantContext.Enter(realm.Slug)) - { - // Resolve INSIDE the tenant context so the scoped GdprService's - // Marten session binds to this realm's DB. - var gdpr = scope.ServiceProvider.GetRequiredService(); - var (reminded, erased) = await gdpr.RunSelfServiceSweepAsync(ct); - var purged = await gdpr.RunAdminRetentionPurgeAsync(ct); + // ADR-0012 §8 — prune used/expired invite codes (hygiene only). + var inviteCodesPruned = await inviteService.PruneAsync(ct); - // ADR-0012 §8 — prune used/expired invite codes (hygiene only). - var inviteService = scope.ServiceProvider.GetRequiredService(); - var inviteCodesPruned = await inviteService.PruneAsync(ct); - - totalReminded += reminded; - totalErased += erased; - totalPurged += purged; - totalInviteCodesPruned += inviteCodesPruned; - if (reminded + erased + purged + inviteCodesPruned > 0) - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.AccountLifecycleSwept, - Level = "Info", - Realm = realm.Slug, - Status = "swept", - Reason = $"reminded={reminded} selfErased={erased} autoPurged={purged} inviteCodesPruned={inviteCodesPruned}", - Message = $"Account-lifecycle sweep — Realm={realm.Slug} Reminded={reminded} SelfErased={erased} AutoPurged={purged} InviteCodesPruned={inviteCodesPruned}", - }); - } - realmsTouched++; - } - catch (Exception ex) + if (reminded + erased + purged + inviteCodesPruned > 0) + { + securityAudit.RecordTelemetry(new SecurityAuditRecord { - logger.LogError(ex, - "Account-lifecycle sweep failed for realm {Realm}", realm.Slug); - } + EventType = AuditEvents.AccountLifecycleSwept, + RealmSlug = realmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "sweep", + RemindedCount = reminded, + SelfErasedCount = erased, + AutoPurgedCount = purged, + InviteCodesPrunedCount = inviteCodesPruned, + }); } context.Result = - $"{realmsTouched} realm(s): {totalReminded} reminded, {totalErased} self-erased, {totalPurged} auto-purged, {totalInviteCodesPruned} invite-codes pruned"; + $"{reminded} reminded, {erased} self-erased, {purged} auto-purged, {inviteCodesPruned} invite-codes pruned"; } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs index d02579fa..bfea965d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs @@ -1,13 +1,11 @@ using System.Text.Json; using Marten; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Quartz; using Modgud.Application.Dcr; using Modgud.Application.Scheduling; using Modgud.Domain.OAuth.Applications; -using Modgud.Domain.Realms; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; namespace Modgud.Api.Features.Admin.Jobs; @@ -29,7 +27,7 @@ namespace Modgud.Api.Features.Admin.Jobs; /// [DisallowConcurrentExecution] public class DcrGcJob( - IServiceScopeFactory scopeFactory, + IDocumentSession session, ISecurityAuditLog securityAudit) : IJob { public const string Key = "dcr-gc"; @@ -45,41 +43,24 @@ public class DcrGcJob( public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; + var realmSlug = TenantContext.Current; + var swept = await SweepRealmAsync(session, realmSlug, ct); - using var rootScope = scopeFactory.CreateScope(); - var store = rootScope.ServiceProvider.GetRequiredService(); - - // Realms live in the master DB. The control-plane realm uses the - // "system" tenant; tenant realms use their slug. - await using var masterSession = store.LightweightSession("system"); - var realms = await masterSession.Query() - .Where(r => r.IsActive) - .ToListAsync(ct); - - int realmsTouched = 0; - int totalSwept = 0; - foreach (var realm in realms) + context.Result = swept switch { - if (ct.IsCancellationRequested) break; - var swept = await SweepRealmAsync(store, realm.Slug, ct); - if (swept >= 0) - { - realmsTouched++; - totalSwept += swept; - } - } - - context.Result = totalSwept == 0 - ? $"No DCR clients aged out ({realmsTouched} realm(s) checked)" - : $"Soft-deleted {totalSwept} DCR client(s) across {realmsTouched} realm(s)"; + < 0 => "Skipped because DCR is disabled", + 0 => "No DCR clients aged out", + _ => $"Soft-deleted {swept} DCR client(s)", + }; } /// Returns swept count, or -1 if the realm was skipped (DCR disabled). - private async Task SweepRealmAsync(IDocumentStore store, string tenantId, CancellationToken ct) + private async Task SweepRealmAsync( + IDocumentSession tenantSession, + string tenantId, + CancellationToken ct) { - await using var session = store.LightweightSession(tenantId); - - var settings = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var settings = await tenantSession.LoadAsync(RealmSettingsDoc.SingletonId, ct); var dcr = settings?.Dcr; if (dcr is null || !dcr.Enabled) return -1; @@ -88,7 +69,7 @@ private async Task SweepRealmAsync(IDocumentStore store, string tenantId, C // match — pull the candidates and filter in memory. Set size is // bounded by the realm-rate-limit (default 100/d × TTL=90d = 9000 // max-ever), tiny enough for an in-memory pass). - var candidates = await session.Query() + var candidates = await tenantSession.Query() .Where(x => !x.IsDeleted) .ToListAsync(ct); @@ -102,27 +83,28 @@ private async Task SweepRealmAsync(IDocumentStore store, string tenantId, C var lastUsedAt = ParseTimestamp(state.Properties, OAuthApplicationPropertyKeys.DcrLastUsedAt); if (lastUsedAt is null || lastUsedAt > cutoff) continue; - var aggregate = await session.Events + var aggregate = await tenantSession.Events .AggregateStreamAsync(state.Id, token: ct); if (aggregate is null || aggregate.IsDeleted) continue; - session.Events.Append(state.Id, aggregate.Delete()); + tenantSession.Events.Append(state.Id, aggregate.Delete()); swept++; - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { EventType = AuditEvents.DcrClientGarbageCollected, - Realm = tenantId, - Level = "Info", - Status = "collected", - Reason = $"clientId {state.ClientId}, ttl {dcr.GcTtlDays}d", - Message = $"DCR client garbage-collected: {state.ClientId}", + RealmSlug = tenantId, + ActorKind = AuditActorKind.System, + OAuthClientId = state.ClientId, + OutcomeCode = AuditOutcomes.Pruned, + OperationCode = "garbage-collect", + RetentionDays = dcr.GcTtlDays, }); } if (swept > 0) { - await session.SaveChangesAsync(ct); + await tenantSession.SaveChangesAsync(ct); } return swept; } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobRunHistoryRetentionJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobRunHistoryRetentionJob.cs index af570dc9..70bd103d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobRunHistoryRetentionJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobRunHistoryRetentionJob.cs @@ -1,25 +1,20 @@ using System.Text.Json; using Marten; -using Microsoft.Extensions.DependencyInjection; using Quartz; using Modgud.Application.Scheduling; -using Modgud.Infrastructure.Persistence.Tenancy; -using Modgud.Infrastructure.Realms; using Modgud.Infrastructure.Scheduling; namespace Modgud.Api.Features.Admin.Jobs; /// -/// Trims the document table for every active -/// realm. Iterates tenants via ; each tenant gets -/// its own DI scope so the injected -/// opens its Marten session against the right tenant DB. Two independent caps — -/// both tunable in the admin UI without a code change. +/// Trims the owning realm's document table. +/// Quartz creates one instance per realm, with two independent caps tunable +/// from that realm's admin UI. /// [DisallowConcurrentExecution] public class JobRunHistoryRetentionJob( - IServiceScopeFactory scopeFactory, - IRealmCache realmCache) : IJob + IDocumentSession session, + IJobRunHistoryRetentionService retention) : IJob { public const string Key = "job-run-history-retention"; public const string Name = "Job-Run-History Retention"; @@ -54,46 +49,20 @@ public static IReadOnlyList GetParameterSchema() => public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; - var realms = await realmCache.GetAllActiveAsync(); - - int totalByAge = 0; - int totalByCount = 0; - int tenantsProcessed = 0; - - foreach (var realm in realms) - { - try - { - using var scope = scopeFactory.CreateScope(); - using var _ = TenantContext.Enter(realm.Slug); - - var session = scope.ServiceProvider.GetRequiredService(); - var retention = scope.ServiceProvider.GetRequiredService(); - - var config = await BuildConfigAsync(session, ct); - var result = await retention.ExecuteAsync(config, ct); - - totalByAge += result.DeletedByAge; - totalByCount += result.DeletedByCount; - tenantsProcessed++; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Serilog.Log.Error(ex, - "job-run-history-retention failed for realm {Slug}", - realm.Slug); - } - } - - var total = totalByAge + totalByCount; + var config = await BuildConfigAsync(session, Key, ct); + var result = await retention.ExecuteAsync(config, ct); + var total = result.DeletedByAge + result.DeletedByCount; context.Result = total == 0 - ? $"Nothing to delete ({tenantsProcessed} tenant(s) checked)" - : $"Deleted {total} entries across {tenantsProcessed} tenant(s) (age: {totalByAge}, count: {totalByCount})"; + ? "Nothing to delete" + : $"Deleted {total} entries (age: {result.DeletedByAge}, count: {result.DeletedByCount})"; } - private static async Task BuildConfigAsync(IDocumentSession session, CancellationToken ct) + internal static async Task BuildConfigAsync( + IQuerySession session, + string configKey, + CancellationToken ct) { - var cfg = await session.LoadAsync(Key, ct); + var cfg = await session.LoadAsync(configKey, ct); var raw = cfg?.Parameters ?? new Dictionary(); return new JobRunHistoryRetentionConfig( MaxAgeDays: ReadInt(raw, MaxAgeDaysKey) ?? DefaultMaxAgeDays, diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobsEndpoints.cs index 0e1abaed..78029e9d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobsEndpoints.cs @@ -7,12 +7,12 @@ namespace Modgud.Api.Features.Admin.Jobs; /// -/// Admin surface for scheduled jobs. Per-tenant — JobConfig overrides + run -/// history are stored in the calling tenant's Marten session. Realm-admin -/// bypass (per Modgud's 3-tier permission model) lets any realm admin -/// drive the scheduler; granular delegation works via -/// scheduled-job:read + scheduled-job:write seeded in the -/// modgud App catalog. +/// Admin surface for scheduled jobs. Realm-job configuration and history are +/// stored in the calling realm's Marten session and address only that realm's +/// Quartz identities. Deployment-wide system jobs are additionally returned +/// only for the current Control-Plane realm. Realm-admin bypass and granular +/// scheduled-job:read/scheduled-job:write delegation apply within +/// that visibility boundary. /// public static class JobsEndpoints { @@ -36,7 +36,16 @@ public static WebApplication MapJobsEndpoints(this WebApplication app, string pa .RequiresPermission("scheduled-job:read"); group.MapGet("{key}/history", async (string key, IJobsService jobs, int take, CancellationToken ct) => - Results.Ok(await jobs.GetHistoryAsync(key, take == 0 ? 50 : take, ct))) + { + try + { + return Results.Ok(await jobs.GetHistoryAsync(key, take == 0 ? 50 : take, ct)); + } + catch (InvalidOperationException ex) + { + return Results.NotFound(new { error = ex.Message }); + } + }) .WithName("V2_AdminJobs_GetHistory") .RequiresPermission("scheduled-job:read"); diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/PlatformAuditPruneJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/PlatformAuditPruneJob.cs new file mode 100644 index 00000000..aa7ae423 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/PlatformAuditPruneJob.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Marten; +using Modgud.Application.Scheduling; +using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Scheduling; +using Quartz; + +namespace Modgud.Api.Features.Admin.Jobs; + +/// +/// Single deployment-wide hard-prune for PII-free platform events in the +/// non-tenanted Global Store. +/// +[DisallowConcurrentExecution] +public sealed class PlatformAuditPruneJob(IGlobalStore globalStore) : IJob +{ + public const string Key = "platform-audit-prune"; + public const string Name = "Platform Audit Prune"; + public const string Description = + "Hard-deletes PII-free deployment-wide platform events after the configured retention period."; + public const string DefaultCron = "0 15 2 * * ?"; + public const string RetentionDaysKey = "retentionDays"; + public const int DefaultRetentionDays = 365; + + public static IReadOnlyList GetParameterSchema() => + [ + new() + { + Key = RetentionDaysKey, + Label = "Retention in days", + Type = JobParameterType.Number, + Default = DefaultRetentionDays, + Description = "Deployment-wide platform-event retention (1–3650 days).", + }, + ]; + + public async Task Execute(IJobExecutionContext context) + { + var ct = context.CancellationToken; + await using var session = globalStore.LightweightSession(); + var config = await session.LoadAsync(Key, ct); + var retentionDays = ReadInt(config?.Parameters, RetentionDaysKey) ?? DefaultRetentionDays; + if (retentionDays is < 1 or > 3650) + throw new JobExecutionException( + $"Platform audit retention must be between 1 and 3650 days, got {retentionDays}."); + + var cutoff = DateTimeOffset.UtcNow.AddDays(-retentionDays); + var doomed = await session.Query() + .CountAsync(x => x.Timestamp < cutoff, ct); + session.DeleteWhere(x => x.Timestamp < cutoff); + await session.SaveChangesAsync(ct); + + context.Result = doomed == 0 + ? "No entries to prune" + : $"Pruned {doomed} platform event(s) older than {retentionDays} day(s)"; + } + + private static int? ReadInt( + IReadOnlyDictionary? values, + string key) + { + if (values is null || !values.TryGetValue(key, out var value) || value is null) + return null; + + return value switch + { + int number => number, + long number => checked((int)number), + double number => checked((int)number), + JsonElement { ValueKind: JsonValueKind.Number } json + when json.TryGetInt32(out var number) => number, + JsonElement { ValueKind: JsonValueKind.String } json + when int.TryParse(json.GetString(), out var number) => number, + string text when int.TryParse(text, out var number) => number, + _ => null, + }; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SecurityAuditPruneJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SecurityAuditPruneJob.cs index 76ffa29a..eb81f7b3 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SecurityAuditPruneJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SecurityAuditPruneJob.cs @@ -1,65 +1,42 @@ using Marten; -using Microsoft.Extensions.DependencyInjection; -using Quartz; +using Modgud.Authentication.RealmSettings; using Modgud.Infrastructure.Audit; -using Modgud.Infrastructure.Persistence.Tenancy; +using Quartz; namespace Modgud.Api.Features.Admin.Jobs; /// -/// Daily hard-prune of the streamless security/ops audit store -/// (). Replaces the legacy -/// AuthLogPersistenceService cleanup loop with a Quartz job admins can see, -/// re-cron, and trigger from /admin/jobs. -/// -/// The short, FIXED retention window is the GDPR proportionality control -/// for this store: it holds personal data about unidentified actors (attempted -/// identifiers, IPs under CJEU Breyer) processed under Art. 6(1)(f) legitimate -/// interest, with no per-subject erase path — so a genuine hard delete on a tight -/// window keeps the processing proportionate. Deliberately NOT per-realm configurable -/// (unlike the per-realm GDPR-audit visibility window, which is a view bound, -/// not a deletion). See the maintainers' logging-audit-redesign design note §A.6 -/// + the Legitimate-Interest Assessment. -/// -/// The store is a single cross-realm doc set in the system DB, so this is one -/// indexed delete — no per-realm iteration. +/// Realm-owned hard-prune. Quartz creates one instance per realm; it reads the +/// owning realm's policy and deletes only that physical database's events. /// [DisallowConcurrentExecution] -public class SecurityAuditPruneJob(IServiceScopeFactory scopeFactory) : IJob +public sealed class SecurityAuditPruneJob( + IDocumentSession session, + IRealmSettingsService realmSettings) : IJob { public const string Key = "security-audit-prune"; public const string Name = "Security Audit Prune"; public const string Description = - "Hard-deletes streamless security/ops audit entries older than the fixed " + - "short retention window (7 days). This retention is the GDPR proportionality " + - "control for the legitimate-interest data the store holds; deliberately fixed, " + - "not per-realm configurable."; - - /// Fixed short hard-retention for the legitimate-interest streamless store. - /// (The per-realm GDPR-audit visibility window is a separate, configurable concept.) - public static readonly TimeSpan Retention = TimeSpan.FromDays(7); - - /// 02:00 UTC daily. + "Hard-deletes this realm's structured security events after its configured retention period."; public const string DefaultCron = "0 0 2 * * ?"; public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; - - using var scope = scopeFactory.CreateScope(); - var store = scope.ServiceProvider.GetRequiredService(); - - await using var session = store.LightweightSession(TenantConstants.SystemTenantId); - - var cutoff = DateTimeOffset.UtcNow - Retention; - var doomed = await session.Query() + var settings = await realmSettings.LoadAsync(ct); + var retentionDays = settings.Audit?.SecurityRetentionDays ?? 7; + if (retentionDays is < 1 or > 365) + throw new JobExecutionException( + $"SecurityRetentionDays must be between 1 and 365, got {retentionDays}."); + + var cutoff = DateTimeOffset.UtcNow.AddDays(-retentionDays); + var doomed = await session.Query() .CountAsync(x => x.Timestamp < cutoff, ct); - - session.DeleteWhere(x => x.Timestamp < cutoff); + session.DeleteWhere(x => x.Timestamp < cutoff); await session.SaveChangesAsync(ct); context.Result = doomed == 0 ? "No entries to prune" - : $"Pruned {doomed} security-audit entr(ies) older than {Retention.TotalDays:0} day(s)"; + : $"Pruned {doomed} realm security event(s) older than {retentionDays} day(s)"; } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SessionPruneJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SessionPruneJob.cs new file mode 100644 index 00000000..03a3a3a2 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SessionPruneJob.cs @@ -0,0 +1,22 @@ +using Modgud.Authentication.Sessions; +using Quartz; + +namespace Modgud.Api.Features.Admin.Jobs; + +[DisallowConcurrentExecution] +public sealed class SessionPruneJob( + ISessionService browserSessions, + IClientSessionService clientSessions) : IJob +{ + public const string Key = "session-prune"; + public const string Name = "Session Retention"; + public const string Description = "Remove expired browser and native OAuth client/device sessions in this realm."; + public const string DefaultCron = "0 15 4 * * ?"; + + public async Task Execute(IJobExecutionContext context) + { + var browser = await browserSessions.PruneExpiredAsync(context.CancellationToken); + var clients = await clientSessions.PruneExpiredAsync(context.CancellationToken); + context.Result = $"Deleted {browser} browser sessions and {clients} client sessions"; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs index 3322a227..e2f8c861 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs @@ -1,9 +1,6 @@ -using Marten; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Quartz; -using Modgud.Domain.Realms; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; namespace Modgud.Api.Features.Admin.Jobs; @@ -19,17 +16,15 @@ namespace Modgud.Api.Features.Admin.Jobs; /// a ValidUntil), and this janitor removes the now-dead row from the /// tenant DB so retired private material doesn't accumulate. /// -/// Per-realm and idempotent: a realm with no expired retired keys ends -/// after a single indexed query. Mirrors : realms live in -/// the master DB; the control-plane realm uses the "system" tenant, tenant -/// realms use their slug. +/// Per-realm and idempotent: Quartz creates one instance per realm, and a +/// realm with no expired retired keys ends after a single indexed query. Its +/// schedule deliberately remains active for deactivated realms because their +/// soft-deleted tenant databases still contain private key material. /// [DisallowConcurrentExecution] public class SigningKeyJanitorJob( - IServiceScopeFactory scopeFactory, IRealmKeyStore keyStore, - ISecurityAuditLog securityAudit, - ILogger logger) : IJob + ISecurityAuditLog securityAudit) : IJob { public const string Key = "signing-key-janitor"; public const string Name = "Signing Key Janitor"; @@ -44,55 +39,24 @@ public class SigningKeyJanitorJob( public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; + var realmSlug = TenantContext.Current; + var purged = await keyStore.PurgeExpiredRetiredKeysAsync(realmSlug, ct); - using var rootScope = scopeFactory.CreateScope(); - var store = rootScope.ServiceProvider.GetRequiredService(); - - // Realms live in the master DB. The control-plane realm uses the - // "system" tenant; tenant realms use their slug. NOTE: we deliberately - // do NOT filter on IsActive — a deactivated realm is a soft-delete that - // keeps its tenant DB, and its retired keys still hold private signing - // material that must not accumulate indefinitely. - await using var masterSession = store.LightweightSession("system"); - var realms = await masterSession.Query() - .ToListAsync(ct); - - int realmsTouched = 0; - int totalPurged = 0; - foreach (var realm in realms) + if (purged > 0) { - if (ct.IsCancellationRequested) break; - if (string.IsNullOrWhiteSpace(realm.Slug)) continue; - - try - { - var purged = await keyStore.PurgeExpiredRetiredKeysAsync(realm.Slug, ct); - if (purged > 0) - { - realmsTouched++; - totalPurged += purged; - // Realm-iterating job: bind the explicit iterated slug. - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.SigningKeyPurged, - Realm = realm.Slug, - Level = "Info", - Status = "purged", - Reason = $"purged {purged} expired retired key(s)", - Message = $"signing-key janitor purged {purged} expired retired key(s)", - }); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) + securityAudit.RecordTelemetry(new SecurityAuditRecord { - // One unreachable/broken tenant DB must not abort the whole sweep. - logger.LogWarning(ex, - "Signing-key janitor failed for realm {Realm} — skipping", realm.Slug); - } + EventType = AuditEvents.SigningKeyPurged, + RealmSlug = realmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Pruned, + OperationCode = "purge-expired-retired-keys", + Count = purged, + }); } - context.Result = totalPurged == 0 - ? $"No expired signing keys ({realms.Count} realm(s) checked)" - : $"Purged {totalPurged} expired signing key(s) across {realmsTouched} realm(s)"; + context.Result = purged == 0 + ? "No expired signing keys" + : $"Purged {purged} expired signing key(s)"; } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SystemJobRunHistoryRetentionJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SystemJobRunHistoryRetentionJob.cs new file mode 100644 index 00000000..deb0bbc1 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SystemJobRunHistoryRetentionJob.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Scheduling; +using Quartz; + +namespace Modgud.Api.Features.Admin.Jobs; + +/// +/// Trims deployment-wide system-job history in the non-tenanted global store. +/// This is intentionally a separate system job: no realm-owned retention job +/// may read or mutate platform job metadata. +/// +[DisallowConcurrentExecution] +public sealed class SystemJobRunHistoryRetentionJob( + IGlobalStore globalStore, + ILogger logger) : IJob +{ + public const string Key = "system-job-run-history-retention"; + public const string Name = "System Job-Run-History Retention"; + public const string Description = + "Trims deployment-wide system-job run history in the non-tenanted global store."; + + /// 03:45 UTC daily, after realm history retention. + public const string DefaultCron = "0 45 3 * * ?"; + + public async Task Execute(IJobExecutionContext context) + { + var ct = context.CancellationToken; + await using var session = globalStore.LightweightSession(); + var config = await JobRunHistoryRetentionJob.BuildConfigAsync(session, Key, ct); + var result = await JobRunHistoryRetentionService.ExecuteAsync( + session, config, logger, ct); + + var total = result.DeletedByAge + result.DeletedByCount; + context.Result = total == 0 + ? "Nothing to delete" + : $"Deleted {total} system-job entries (age: {result.DeletedByAge}, count: {result.DeletedByCount})"; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs index 3a027b01..50351b48 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs @@ -3,7 +3,9 @@ using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; using Modgud.Authentication.ExtensionMethods; +using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; +using Modgud.Authorization.Services; namespace Modgud.Api.Features.Admin.OAuth; @@ -38,12 +40,24 @@ public static WebApplication MapOAuthClientsEndpoints(this WebApplication app, s .WithName("OAuth_Clients_Get") .RequiresPermission("oauth-client:read"); - group.MapPost("", async (CreateOAuthClientDto dto, OAuthAdminService svc, IDocumentSession session, DataEventDispatcher dispatcher, CancellationToken ct) => + group.MapPost("", async (CreateOAuthClientDto dto, HttpContext http, IPermissionService permissions, OAuthAdminService svc, IDocumentSession session, DataEventDispatcher dispatcher, CancellationToken ct) => { + if (dto.NewServiceAccount is not null) + { + var userId = http.GetUserId(); + if (userId is null || !await permissions.HasPermissionAsync( + userId.Value, AppSlugs.Modgud, "service-account:write", ct)) + return Results.Forbid(); + } + var result = await svc.CreateClientAsync(dto, ct); // Broadcast only the client view (never the one-time secret in the wrapper). if (!result.IsError) + { dispatcher.DispatchCreatedEvent("OAuthClient", result.Value.Client, session.TenantId); + if (result.Value.CreatedServiceAccount is { } serviceAccount) + dispatcher.DispatchCreatedEvent("ServiceAccount", serviceAccount, session.TenantId); + } return result.ToResult(created => Results.Created($"{path}/admin/oauth/clients/{created.Client.Id}", created)); }) .WithName("OAuth_Clients_Create") diff --git a/src/dotnet/Modgud.Api/Features/Admin/ProjectionEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/ProjectionEndpoints.cs index b36da837..b33458bc 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/ProjectionEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/ProjectionEndpoints.cs @@ -44,15 +44,15 @@ public static WebApplication MapProjectionEndpoints(this WebApplication applicat // the host they hit determines which tenant DB gets replayed. We // intentionally do NOT iterate every active realm: rebuild is heavy, // non-resumable mid-flight, and per-realm host gating is the right - // authorization boundary. Defensive fallback to the system tenant if - // RealmMiddleware didn't run (shouldn't happen for this authenticated - // route, but opening against system is safer than throwing). + // authorization boundary. RealmMiddleware must have resolved the + // authenticated request; a missing realm fails closed. // // Under MasterTableTenancy a session without an explicit tenant id is // an error ("Default tenant does not supported"), so passing the id // explicitly here is REQUIRED, not optional. var tenantId = httpContext.Items[TenantConstants.HttpContextTenantIdKey] as string - ?? TenantConstants.SystemTenantId; + ?? throw new InvalidOperationException( + "Projection rebuild requires a resolved realm."); Serilog.Log.Information("Admin: Starting full projection rebuild for tenant {TenantId}", tenantId); try diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs index b458d524..cb0aa97e 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs @@ -203,13 +203,13 @@ public sealed record RealmManifestRole [Description("Optional description.")] public string? Description { get; init; } - [Description("App slug whose catalog Permissions resolve into. Omit for a pure realm-admin role.")] + [Description("App slug whose catalog Permissions resolve into. Required for an App role; forbidden for a realm-admin role.")] public string? App { get; init; } - [Description("If true, this role confers realm:admin — the realm-wide bypass (full administration). A realm-admin role needs no App/Permissions. Provisioning is trusted, so this is allowed from the manifest.")] + [Description("If true, this role confers realm:admin across every App in this realm. App and Permissions must both be omitted.")] public bool IsRealmAdmin { get; init; } - [Description("Permissions from the linked app's catalog this role grants (requires App).")] + [Description("Permissions from the linked App's catalog this role grants. Requires App and is forbidden for a realm-admin role.")] public List Permissions { get; init; } = []; public string ResolveKey() => Key ?? Name; diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs index ea6b2fca..ab437f1d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs @@ -1,6 +1,7 @@ using BuildingBlocks.Helper; using ErrorOr; using Marten; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Modgud.Api.Features.Admin.Apps; @@ -11,6 +12,7 @@ using Modgud.Application.Services; using Modgud.Authentication.Api.Users; using Modgud.Authentication.Applications; +using Modgud.Authentication.Domain; using Modgud.Authentication.RealmSettings; using Modgud.Authentication.Sessions; using Modgud.Authorization.Apps; @@ -26,7 +28,6 @@ using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; using Modgud.Permissions; -using Wolverine; namespace Modgud.Api.Features.Admin.Provisioning; @@ -38,14 +39,13 @@ namespace Modgud.Api.Features.Admin.Provisioning; /// operation the admin UI / admin API uses (, /// , , /// , , the user/group -/// Wolverine commands), so the manifest path and the manual path can never drift. +/// command handlers), so the manifest path and the manual path can never drift. /// /// Tenant routing: the realm shell is created via the global store, then the /// per-tenant config runs inside TenantContext.Enter(slug) + a fresh DI scope — /// TenantedSessionFactory prefers the AsyncLocal TenantContext over the -/// ambient (control-plane) HttpContext. Wolverine handlers resolve their session -/// from the message-envelope tenant, so the user/group commands use -/// InvokeForTenantAsync(slug, ...). +/// ambient (control-plane) HttpContext. Handlers resolved in that fresh scope +/// therefore write to the newly provisioned tenant. /// /// Cross-references resolve in dependency order: apps → apis/scopes/clients → /// roles → users → groups. Keys (app slug, role/user key, resource:action) are @@ -272,24 +272,28 @@ private async Task> ApplyTenantConfigAsync( roleIds[r.ResolveKey()] = created.Value.Id; } - // ── Users — Wolverine commands, dispatched for the realm tenant ─────────── - var bus = sp.GetRequiredService(); + // ── Users — canonical handler on the manifest's tenant session ──────────── + // Realm provisioning has already initialized Wolverine's inbox/outbox. + // Direct invocation keeps manifest application sequential and exposes the + // canonical handler result immediately for contextual import errors. + var userSession = sp.GetRequiredService(); + var createUser = new CreateUserHandler( + userSession, + sp.GetRequiredService>(), + sp.GetRequiredService()); foreach (var u in manifest.Users) { var cmd = new CreateUserCommand(u.Firstname, u.Lastname, u.Acronym, u.Email, u.UserName ?? string.Empty, u.Password, u.EmailConfirmed); - var created = await bus.InvokeForTenantAsync>(slug, cmd, ct); + var created = await createUser.Handle(cmd, ct); EnsureOk(created, $"user '{u.Email}'"); if (ShortGuid.TryParse(created.Value.Id, out Guid uid)) userIds[u.ResolveKey()] = uid; } - // ── Groups — committed via a PLAIN tenant-scoped session (NOT the Wolverine - // outbox session). InvokeForTenantAsync would enroll the Wolverine outbox, and - // the durable-inbox auto-membership event forwarding (ReferenceSync) would try - // to write wolverine_incoming_envelopes in the tenant DB, which a fresh realm - // lacks. A plain session skips that forwarding (auto-membership re-derives at - // login). We call the canonical CreateGroupHandler directly with this session. + // ── Groups — canonical handler on the manifest's tenant session ─────────── + // Keep the same explicit, sequential dispatch used for users so reference + // resolution and contextual import failures remain deterministic. if (manifest.Groups.Count > 0) { var groupHandler = new CreateGroupHandler( @@ -530,8 +534,11 @@ private async Task> ApplyTenantUpdateAsync( } // ── Users (natural key = email or username) ──────────────────────────────── - var bus = sp.GetRequiredService(); var setPassword = sp.GetRequiredService(); + var createUser = new CreateUserHandler( + session, + sp.GetRequiredService>(), + sp.GetRequiredService()); foreach (var u in manifest.Users) { var ctx = $"user '{u.Email}'"; @@ -547,7 +554,7 @@ private async Task> ApplyTenantUpdateAsync( { var createCmd = new CreateUserCommand(u.Firstname, u.Lastname, u.Acronym, u.Email, u.UserName ?? string.Empty, u.Password, u.EmailConfirmed); - var created = await bus.InvokeForTenantAsync>(slug, createCmd, ct); + var created = await createUser.Handle(createCmd, ct); EnsureOk(created, ctx); uid = ShortGuid.TryParse(created.Value.Id, out Guid cid) ? cid : null; } @@ -558,12 +565,8 @@ private async Task> ApplyTenantUpdateAsync( var updateCmd = new UpdateUserCommand(existing.Id, OptionalOf(u.Firstname), OptionalOf(u.Lastname), OptionalOf(u.Acronym), new Optional(u.Email), OptionalOf(u.UserName)); - // Plain-session direct call (NOT the bus): UpdateUserHandler appends - // UserUpdatedEvent straight to its session, and under InvokeForTenantAsync - // that's the Wolverine outbox session — the durable ReferenceSync forwarding - // would then write wolverine_*_envelopes tables the tenant DB doesn't have - // (the same reason groups use a plain session). CreateUser is unaffected: it - // persists via UserManager on a separate, non-outbox session. + // Direct invocation keeps the manifest update sequential and makes + // the canonical handler result available for contextual errors. var updateHandler = new UpdateUserHandler(session); var updated = await updateHandler.Handle(updateCmd, sp.GetRequiredService(), @@ -581,9 +584,7 @@ private async Task> ApplyTenantUpdateAsync( if (uid.HasValue) userIds[u.ResolveKey()] = uid.Value; } - // ── Groups (natural key = Name) — PLAIN tenant-scoped session, NOT the Wolverine - // outbox: the durable-inbox auto-membership forwarding would write to wolverine - // tables the tenant DB doesn't have (see ApplyTenantConfigAsync). ────────────── + // ── Groups (natural key = Name) ─────────────────────────────────────────── if (manifest.Groups.Count > 0) { var groupSession = sp.GetRequiredService(); diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs index ab73724c..55608872 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs @@ -132,10 +132,16 @@ public async Task> ExportRealmAsync(string slug, Cancella { Name = r.Name, Description = r.Description, - App = r.AppId is { } aid && appSlugById.TryGetValue(aid, out var slugOf) ? slugOf : null, + App = !r.IsRealmAdmin + && r.AppId is { } aid + && appSlugById.TryGetValue(aid, out var slugOf) + ? slugOf + : null, IsRealmAdmin = r.IsRealmAdmin, - Permissions = r.PermissionIds - .Where(permKeyById.ContainsKey).Select(id => permKeyById[id]).ToList(), + Permissions = r.IsRealmAdmin + ? [] + : r.PermissionIds + .Where(permKeyById.ContainsKey).Select(id => permKeyById[id]).ToList(), }).ToList(); // ── Users (raw Person for the human list + ApplicationUser for EmailConfirmed) ─ @@ -270,6 +276,7 @@ public async Task> ExportRealmAsync(string slug, Cancella Audit = new UpdateAuditSettingsDto { VisibilityWindowDays = s.Audit.VisibilityWindowDays, + SecurityRetentionDays = s.Audit.SecurityRetentionDays, }, }; diff --git a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs index 856a91d3..111b6fae 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Security.Claims; using ErrorOr; using Modgud.Api.Features.Admin.Provisioning; @@ -9,6 +10,7 @@ using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Services; using Modgud.Domain.Realms; +using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Observability; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; @@ -58,9 +60,28 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, IRealmProvisioningService svc, IServiceProvider sp, HttpContext http, + ISecurityAuditLog securityAudit, ILoggerFactory loggerFactory, CancellationToken ct) => { + if (dto.InitialAdmin is not null) + { + if (string.IsNullOrWhiteSpace(dto.InitialAdmin.UserName)) + { + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "Realm.AdminInvite.UserNameRequired", + detail: "InitialAdmin.UserName is required when InitialAdmin is provided."); + } + if (string.IsNullOrWhiteSpace(dto.InitialAdmin.Email) || !dto.InitialAdmin.Email.Contains('@')) + { + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "Realm.AdminInvite.EmailRequired", + detail: "InitialAdmin.Email must be a valid address when InitialAdmin is provided."); + } + } + var result = await svc.CreateRealmAsync(dto, ct); if (result.IsError) return result.ToResult(); @@ -70,9 +91,9 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, ? (http.User.FindFirstValue(ClaimTypes.Name) ?? http.User.Identity.Name) : null; - // Issue the bootstrap-invite atomically with the realm. We hop - // into the new tenant's context so the IPendingAdminInviteService - // resolves a session against the just-provisioned tenant DB. + // Backwards-compatible API convenience: callers may still request + // an admin invite together with realm creation. The admin UI keeps + // this as a separate action and omits InitialAdmin entirely. // Living in the API layer (not Infrastructure) avoids the // Authentication ↔ Infrastructure circular reference. // @@ -82,46 +103,63 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, // this call 409s, and recovery is filesystem-CLI-only. Compensate // by rolling the realm back so the create is all-or-nothing from // the caller's view and a retry is clean. - IssuedInvite issued; - try + IssuedInvite? issued = null; + if (dto.InitialAdmin is not null) { - using var inviteScope = sp.CreateScope(); - using (TenantContext.Enter(realm.Slug)) + try { - var inviteService = inviteScope.ServiceProvider.GetRequiredService(); - issued = await inviteService.IssueAsync( - dto.InitialAdmin.UserName, - dto.InitialAdmin.Email, - dto.InitialAdmin.Firstname, - dto.InitialAdmin.Lastname, - issuedBy, - realm, - ct); + using var inviteScope = sp.CreateScope(); + using (TenantContext.Enter(realm.Slug)) + { + var inviteService = inviteScope.ServiceProvider.GetRequiredService(); + issued = await inviteService.IssueAsync( + dto.InitialAdmin.UserName, + dto.InitialAdmin.Email, + dto.InitialAdmin.Firstname, + dto.InitialAdmin.Lastname, + issuedBy, + realm, + ct); + } } - } - catch (Exception ex) - { - var log = loggerFactory.CreateLogger("Modgud.Api.Features.Admin.RealmsEndpoints"); - log.LogError(ex, - "Bootstrap-invite issuance failed for realm {Slug}; rolling back the partially-provisioned realm.", - realm.Slug); - - await svc.RollbackProvisionedRealmAsync(realm.Slug, ct); + catch (Exception ex) + { + var log = loggerFactory.CreateLogger("Modgud.Api.Features.Admin.RealmsEndpoints"); + log.LogError(ex, + "Admin-invite issuance failed for realm {Slug}; rolling back the partially-provisioned realm.", + realm.Slug); + + await svc.RollbackProvisionedRealmAsync(realm.Slug, ct); + await RecordControlPlaneRealmOperationAsync( + securityAudit, + http, + realm.Slug, + "provision-realm", + AuditOutcomes.Failed, + "admin-invite-failed", + writeTargetRealm: false); - return Results.Problem( - statusCode: StatusCodes.Status500InternalServerError, - title: "Realm.Provisioning.InviteFailed", - detail: $"Realm '{realm.Slug}' was provisioned but issuing the initial-admin invite failed. " - + "The partially-provisioned realm has been rolled back — it is safe to retry. " - + "See the server logs / the realm error feed for the underlying cause."); + return Results.Problem( + statusCode: StatusCodes.Status500InternalServerError, + title: "Realm.Provisioning.InviteFailed", + detail: $"Realm '{realm.Slug}' was provisioned but issuing the requested admin invite failed. " + + "The partially-provisioned realm has been rolled back — it is safe to retry. " + + "See the server logs / the realm error feed for the underlying cause."); + } } + await RecordControlPlaneRealmOperationAsync( + securityAudit, + http, + realm.Slug, + "provision-realm"); + return Results.Created( $"{path}/admin/realms/{realm.Slug}", new CreatedRealmDto { Realm = MapToDto(realm), - InitialAdminInvite = new InitialAdminInviteDto + InitialAdminInvite = issued is null ? null : new InitialAdminInviteDto { UserName = issued.UserName, Email = issued.Email, @@ -133,27 +171,34 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, .WithName("Realms_Create") .RequiresPermission("realm:write", AppSlugs.ControlPlane); - // C15c — Resend bootstrap-invite. Re-uses the recipient identity - // from the most recent prior invite in the tenant DB (typically - // the one the realm was created with). The previous invite is - // revoked inside IssueAsync; the new token has a fresh 7-day - // expiry. Returns the magic-link URL just like Create does, for - // SMTP-less dev visibility. - group.MapPost("{slug}/resend-bootstrap-invite", async ( + // A realm-admin invitation is independent from realm creation and can + // be issued at any time. IssueAsync revokes every previous open invite + // in this realm, so at most one link remains active. + group.MapPost("{slug}/admin-invites", async ( string slug, + InitialAdminDto dto, IRealmProvisioningService svc, IServiceProvider sp, HttpContext http, + ISecurityAuditLog securityAudit, CancellationToken ct) => { var realm = await svc.GetRealmBySlugAsync(slug, ct); if (realm is null) return Results.NotFound(); - if (!realm.IsActive) + + if (string.IsNullOrWhiteSpace(dto.UserName)) + { + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "Realm.AdminInvite.UserNameRequired", + detail: "UserName is required."); + } + if (string.IsNullOrWhiteSpace(dto.Email) || !dto.Email.Contains('@')) { return Results.Problem( statusCode: StatusCodes.Status400BadRequest, - title: "Realm.Inactive", - detail: $"Realm '{slug}' is inactive."); + title: "Realm.AdminInvite.EmailRequired", + detail: "A valid email address is required."); } var issuedBy = http.User.Identity?.IsAuthenticated == true @@ -165,35 +210,36 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, using (TenantContext.Enter(slug)) { var session = inviteScope.ServiceProvider.GetRequiredService(); - // Audit #32 — only an UNUSED invite may be resent. Without the - // UsedAt==null filter, resend returns the most recent invite even - // after it was consumed, re-arming a fresh 7-day realm:admin token - // (and a misleading "invite issued" audit entry) for an already- - // bootstrapped realm. With the filter a bootstrapped realm has no - // pending invite to resend → 404. - var lastInvite = await session.Query() - .Where(i => i.UsedAt == null) - .OrderByDescending(i => i.CreatedAt) - .FirstOrDefaultAsync(ct); - if (lastInvite is null) + var normalizedUserName = dto.UserName.Trim().ToUpperInvariant(); + var normalizedEmail = dto.Email.Trim().ToUpperInvariant(); + var targetExists = await session.Query() + .AnyAsync(u => !u.IsDeleted && + (u.NormalizedUserName == normalizedUserName || u.NormalizedEmail == normalizedEmail), ct); + if (targetExists) { return Results.Problem( - statusCode: StatusCodes.Status404NotFound, - title: "Realm.NoPriorInvite", - detail: "No pending bootstrap-invite for this realm — there is no unused invite to resend (it may already be bootstrapped)."); + statusCode: StatusCodes.Status409Conflict, + title: "Realm.AdminInvite.UserExists", + detail: "A user with this username or email already exists in the realm."); } var inviteService = inviteScope.ServiceProvider.GetRequiredService(); issued = await inviteService.IssueAsync( - lastInvite.UserName, - lastInvite.Email, - lastInvite.Firstname, - lastInvite.Lastname, + dto.UserName, + dto.Email, + dto.Firstname, + dto.Lastname, issuedBy, realm, ct); } + await RecordControlPlaneRealmOperationAsync( + securityAudit, + http, + slug, + "issue-admin-invite"); + return Results.Ok(new InitialAdminInviteDto { UserName = issued.UserName, @@ -202,16 +248,26 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, MagicLinkUrl = issued.MagicLinkUrl, }); }) - .WithName("Realms_ResendBootstrapInvite") + .WithName("Realms_IssueAdminInvite") .RequiresPermission("realm:write", AppSlugs.ControlPlane); group.MapPatch("{slug}", async ( string slug, UpdateRealmDto dto, IRealmProvisioningService svc, + HttpContext http, + ISecurityAuditLog securityAudit, CancellationToken ct) => { var result = await svc.UpdateRealmAsync(slug, dto, ct); + if (!result.IsError) + { + await RecordControlPlaneRealmOperationAsync( + securityAudit, + http, + slug, + "update-realm"); + } return result.ToResult(realm => Results.Ok(MapToDto(realm))); }) .WithName("Realms_Update") @@ -220,11 +276,26 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, // ?hard=true escalates from the reversible soft-delete to the prod-safe hard // delete that DROPs the tenant database (HardDeleteRealmAsync). Default false keeps // the existing soft-delete behaviour. Hard-delete is refused for the control plane. - group.MapDelete("{slug}", async (string slug, IRealmProvisioningService svc, CancellationToken ct, bool hard = false) => + group.MapDelete("{slug}", async ( + string slug, + IRealmProvisioningService svc, + HttpContext http, + ISecurityAuditLog securityAudit, + CancellationToken ct, + bool hard = false) => { var result = hard ? await svc.HardDeleteRealmAsync(slug, ct) : await svc.DeleteRealmAsync(slug, ct); + if (!result.IsError) + { + await RecordControlPlaneRealmOperationAsync( + securityAudit, + http, + slug, + hard ? "hard-delete-realm" : "deactivate-realm", + writeTargetRealm: !hard); + } return result.IsError ? result.ToResult() : Results.NoContent(); }) .WithName("Realms_Delete") @@ -237,11 +308,20 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, // realm back (hard-delete). Returns the created slug + primary domain + the // plaintext secrets of any confidential clients (only available at create time). group.MapPost("import", async ( - RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct) => + RealmManifest manifest, + RealmManifestApplier applier, + HttpContext http, + ISecurityAuditLog securityAudit, + CancellationToken ct) => { var result = await applier.ImportNewRealmAsync(manifest, ct); if (result.IsError) return ManifestError(result.Errors); ModgudMeters.RecordRealmProvisioned(); + await RecordControlPlaneRealmOperationAsync( + securityAudit, + http, + result.Value.Slug, + "import-realm"); return Results.Created($"{path}/admin/realms/{result.Value.Slug}", result.Value); }) .WithName("Realms_Import") @@ -253,7 +333,13 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, // ?prune=true makes it a full sync that also deletes the absent entities (k8s // apply --prune — infrastructure + every realm:admin path are protected, never pruned). group.MapPost("{slug}/apply", async ( - string slug, RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct, bool prune = false) => + string slug, + RealmManifest manifest, + RealmManifestApplier applier, + HttpContext http, + ISecurityAuditLog securityAudit, + CancellationToken ct, + bool prune = false) => { if (!string.Equals(slug, manifest.Realm.Slug, StringComparison.Ordinal)) return Results.BadRequest(new @@ -263,6 +349,14 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, }); var result = await applier.UpdateRealmAsync(manifest, prune, ct); + if (!result.IsError) + { + await RecordControlPlaneRealmOperationAsync( + securityAudit, + http, + slug, + prune ? "apply-manifest-prune" : "apply-manifest"); + } return result.IsError ? ManifestError(result.Errors) : Results.Ok(result.Value); }) .WithName("Realms_Apply") @@ -308,6 +402,8 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, string slug, IRealmProvisioningService svc, IServiceProvider sp, + HttpContext http, + ISecurityAuditLog securityAudit, CancellationToken ct) => { var target = await svc.GetRealmBySlugAsync(slug, ct); @@ -330,6 +426,14 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, } var result = await svc.TransferControlPlaneAsync(slug, ct); + if (!result.IsError) + { + await RecordControlPlaneRealmOperationAsync( + securityAudit, + http, + slug, + "transfer-control-plane"); + } return result.ToResult(realm => Results.Ok(MapToDto(realm))); }) .WithName("Realms_TransferControlPlane") @@ -369,6 +473,46 @@ private static async Task TargetHasUsableAdminAsync( } } + private static async Task RecordControlPlaneRealmOperationAsync( + ISecurityAuditLog securityAudit, + HttpContext http, + string targetRealmSlug, + string operationCode, + string outcomeCode = AuditOutcomes.Succeeded, + string? reasonCode = null, + bool writeTargetRealm = true) + { + var actorRealmSlug = TenantContext.Current; + var correlationId = Activity.Current?.TraceId.ToString() ?? http.TraceIdentifier; + + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord + { + EventType = AuditEvents.ControlPlaneRealmOperation, + RealmSlug = actorRealmSlug, + TargetRealmSlug = targetRealmSlug, + OutcomeCode = outcomeCode, + ReasonCode = reasonCode, + OperationCode = operationCode, + CorrelationId = correlationId, + }, http.RequestAborted); + + if (!writeTargetRealm || + string.Equals(actorRealmSlug, targetRealmSlug, StringComparison.OrdinalIgnoreCase)) + return; + + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord + { + EventType = AuditEvents.ControlPlaneRealmOperation, + RealmSlug = targetRealmSlug, + CaptureRequestContext = false, + ActorKind = AuditActorKind.ControlPlane, + OutcomeCode = outcomeCode, + ReasonCode = reasonCode, + OperationCode = operationCode, + CorrelationId = correlationId, + }, http.RequestAborted); + } + // Renders a RealmManifestApplier ErrorOr error with the code in the body — the manifest // codes (Realm.AlreadyExists / Realm.NotFound / Manifest.*) are how a test-kit / caller // distinguishes outcomes, so don't collapse them through the shared ToResult. @@ -396,7 +540,6 @@ private static IResult ManifestError(List errors) PrimaryDomain = realm.PrimaryDomain, IsControlPlane = realm.IsControlPlane, IsActive = realm.IsActive, - NeedsSetup = false, // per-realm setup detection comes in a later etappe CreatedAt = realm.CreatedAt, }; } diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs index 9bf8373d..bd8fe4b3 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Text.Json; using Modgud.Authentication.Applications; +using Modgud.Authentication.Sessions; using Modgud.Authentication.Domain; using Modgud.Authentication.Identity; using Modgud.Authorization.Apps; @@ -275,6 +276,7 @@ private static async Task ExchangeAsync( SignInManager signInManager, UserManager userManager, IPermissionService permissionService, + IClientSessionService clientSessionService, CimdClientResolver cimdResolver, IEmailOtpService emailOtpService, RealmScopedFido2Factory fido2Factory, @@ -364,7 +366,68 @@ private static async Task ExchangeAsync( var principal = await CreateClaimsPrincipalAsync( user, request, scopeManager, originalScopes, userManager, cookiePrincipal: result.Principal); - principal.SetAuthorizationId(result.Principal?.GetAuthorizationId()); + + var authorizationId = result.Principal?.GetAuthorizationId(); + if (request.IsRefreshTokenGrantType()) + { + var rawClientSessionId = result.Principal? + .FindFirstValue(SessionClaimTypes.ClientSessionId); + if (!Guid.TryParse(rawClientSessionId, out var clientSessionId) || + string.IsNullOrEmpty(request.ClientId) || + await clientSessionService.ValidateAndTouchAsync( + user.Id, + clientSessionId, + request.ClientId, + authorizationId, + httpContext.RequestAborted) is null) + { + return ForbidInvalidGrant("The client session has expired or was revoked; please sign in again."); + } + + principal.SetAuthorizationId(authorizationId); + principal.SetClaim(SessionClaimTypes.ClientSessionId, clientSessionId.ToString()); + } + else if (principal.HasScope(Scopes.OfflineAccess)) + { + var application = await applicationManager.FindByClientIdAsync(request.ClientId!) + ?? throw new InvalidOperationException("The application cannot be found."); + var clientPk = await applicationManager.GetIdAsync(application) ?? string.Empty; + var sessionAuthorization = await authorizationManager.CreateAsync( + principal: principal, + subject: user.Id.ToString(), + client: clientPk, + type: AuthorizationTypes.AdHoc, + scopes: principal.GetScopes()); + authorizationId = await authorizationManager.GetIdAsync(sessionAuthorization) + ?? throw new InvalidOperationException("The client-session authorization has no id."); + principal.SetAuthorizationId(authorizationId); + + var clientSession = await clientSessionService.CreateAsync( + new CreateClientSessionRequest( + user.Id, + request.ClientId!, + clientPk, + authorizationId, + await applicationManager.GetDisplayNameAsync(application), + httpContext.Connection.RemoteIpAddress?.ToString(), + httpContext.Request.Headers.UserAgent.ToString()), + httpContext.RequestAborted); + principal.SetClaim(SessionClaimTypes.ClientSessionId, clientSession.Id.ToString()); + } + else + { + // No refresh token will be issued, so this is not a long-lived + // client/device session. Keep the authorization produced by the + // code/device flow and do not add an orphan ClientSession row. + principal.SetAuthorizationId(authorizationId); + } + + if (principal.HasScope(Scopes.OfflineAccess)) + { + var clientSessionPolicy = await clientSessionService.ResolvePolicyAsync( + request.ClientId!, httpContext.RequestAborted); + principal.SetRefreshTokenLifetime(clientSessionPolicy.IdleLifetime); + } // Federation v1.1: bake the federated resource_access (durable ∪ // session-derived) into the access token HERE, while the carrier is @@ -473,7 +536,7 @@ await BakeFederatedResourceAccessAsync( return await ExchangeNativeOtpAsync( request, httpContext, applicationSettingsResolver, session, userManager, signInManager, scopeManager, applicationManager, authorizationManager, permissionService, - emailOtpService, httpContext.RequestAborted); + clientSessionService, emailOtpService, httpContext.RequestAborted); } if (string.Equals(request.GrantType, CocoarGrantTypes.Magic, StringComparison.Ordinal)) @@ -481,7 +544,7 @@ await BakeFederatedResourceAccessAsync( return await ExchangeNativeMagicAsync( request, httpContext, applicationSettingsResolver, session, userManager, signInManager, scopeManager, applicationManager, authorizationManager, permissionService, - httpContext.RequestAborted); + clientSessionService, httpContext.RequestAborted); } if (string.Equals(request.GrantType, CocoarGrantTypes.Passkey, StringComparison.Ordinal)) @@ -489,7 +552,7 @@ await BakeFederatedResourceAccessAsync( return await ExchangeNativePasskeyAsync( request, httpContext, applicationSettingsResolver, session, userManager, signInManager, scopeManager, applicationManager, authorizationManager, permissionService, - fido2Factory, rpIdResolver, httpContext.RequestAborted); + clientSessionService, fido2Factory, rpIdResolver, httpContext.RequestAborted); } throw new InvalidOperationException("The specified grant type is not supported."); @@ -564,6 +627,8 @@ private static async Task IssueNativeGrantAsync( IOpenIddictAuthorizationManager authorizationManager, IDocumentSession session, IPermissionService permissionService, + IClientSessionService clientSessionService, + HttpContext httpContext, NativeGrantSettings nativeSettings) { // userManager (NOT a plain session load) so the security stamp is @@ -574,26 +639,36 @@ private static async Task IssueNativeGrantAsync( await BakeFederatedResourceAccessAsync(principal, user.Id, request, session, permissionService); - // Find-or-create the permanent (subject, client) authorization so - // refresh, logout-all and revocation-by-authorization behave exactly like - // the authorization-code flow (mirrors AuthorizeAsync). Fail loud (like the - // code/refresh + client_credentials branches) rather than mint an - // authorization-less, non-revocable refresh chain — the client is - // guaranteed present here (OpenIddict's ValidateClientId + the per-client - // gt:urn:cocoar:* permission check both ran upstream). + // Each native device/login gets its own ad-hoc authorization. Consent is + // still represented by the permanent authorization created by the web + // flow; this authorization is solely the independently revocable token + // family root for one ClientSession. var application = await applicationManager.FindByClientIdAsync(request.ClientId!) ?? throw new InvalidOperationException("The application cannot be found."); var subject = user.Id.ToString(); var clientPk = await applicationManager.GetIdAsync(application) ?? string.Empty; - var authorizations = await authorizationManager.FindAsync( - subject: subject, client: clientPk, status: Statuses.Valid, - type: AuthorizationTypes.Permanent, scopes: principal.GetScopes()).ToListAsync(); - var authorization = authorizations.LastOrDefault() - ?? await authorizationManager.CreateAsync( - principal: principal, subject: subject, client: clientPk, - type: AuthorizationTypes.Permanent, scopes: principal.GetScopes()); - principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + var authorization = await authorizationManager.CreateAsync( + principal: principal, subject: subject, client: clientPk, + type: AuthorizationTypes.AdHoc, scopes: principal.GetScopes()); + var authorizationId = await authorizationManager.GetIdAsync(authorization) + ?? throw new InvalidOperationException("The client-session authorization has no id."); + principal.SetAuthorizationId(authorizationId); + + if (principal.HasScope(Scopes.OfflineAccess)) + { + var clientSession = await clientSessionService.CreateAsync( + new CreateClientSessionRequest( + user.Id, + request.ClientId!, + clientPk, + authorizationId, + await applicationManager.GetDisplayNameAsync(application), + httpContext.Connection.RemoteIpAddress?.ToString(), + httpContext.Request.Headers.UserAgent.ToString()), + httpContext.RequestAborted); + principal.SetClaim(SessionClaimTypes.ClientSessionId, clientSession.Id.ToString()); + } // ADR-0010 — short JWT access TTL for native clients (per-realm tunable, // validated at write time). Clamp defensively so even a settings doc @@ -602,8 +677,12 @@ private static async Task IssueNativeGrantAsync( // JWT access token. The refresh token stays a revocable reference token. principal.SetAccessTokenLifetime( ClampLifetime(nativeSettings.AccessTokenLifetime, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(60))); - principal.SetRefreshTokenLifetime( - ClampLifetime(nativeSettings.RefreshTokenLifetime, TimeSpan.FromDays(1), TimeSpan.FromDays(30))); + if (principal.HasScope(Scopes.OfflineAccess)) + { + var clientSessionPolicy = await clientSessionService.ResolvePolicyAsync( + request.ClientId!, httpContext.RequestAborted); + principal.SetRefreshTokenLifetime(clientSessionPolicy.IdleLifetime); + } return Results.SignIn(principal, properties: null, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } @@ -642,6 +721,7 @@ private static async Task ExchangeNativeOtpAsync( IOpenIddictApplicationManager applicationManager, IOpenIddictAuthorizationManager authorizationManager, IPermissionService permissionService, + IClientSessionService clientSessionService, IEmailOtpService emailOtpService, CancellationToken ct) { @@ -696,7 +776,8 @@ private static async Task ExchangeNativeOtpAsync( return await IssueNativeGrantAsync( user, request, scopeManager, userManager, applicationManager, - authorizationManager, session, permissionService, nativeSettings); + authorizationManager, session, permissionService, clientSessionService, + httpContext, nativeSettings); } /// urn:cocoar:magic — verify a magic-link (user_id + token) @@ -715,6 +796,7 @@ private static async Task ExchangeNativeMagicAsync( IOpenIddictApplicationManager applicationManager, IOpenIddictAuthorizationManager authorizationManager, IPermissionService permissionService, + IClientSessionService clientSessionService, CancellationToken ct) { var nativeSettings = await LoadNativeGrantSettingsAsync(settingsResolver, httpContext, request.ClientId, ct); @@ -776,7 +858,8 @@ private static async Task ExchangeNativeMagicAsync( return await IssueNativeGrantAsync( user, request, scopeManager, userManager, applicationManager, - authorizationManager, session, permissionService, nativeSettings); + authorizationManager, session, permissionService, clientSessionService, + httpContext, nativeSettings); } /// urn:cocoar:passkey — verify a WebAuthn assertion against a @@ -797,6 +880,7 @@ private static async Task ExchangeNativePasskeyAsync( IOpenIddictApplicationManager applicationManager, IOpenIddictAuthorizationManager authorizationManager, IPermissionService permissionService, + IClientSessionService clientSessionService, RealmScopedFido2Factory fido2Factory, RpIdResolver rpIdResolver, CancellationToken ct) @@ -912,7 +996,8 @@ private static async Task ExchangeNativePasskeyAsync( // (the begin endpoint requires UV), so we do not additionally demand totp_code. return await IssueNativeGrantAsync( user, request, scopeManager, userManager, applicationManager, - authorizationManager, session, permissionService, nativeSettings); + authorizationManager, session, permissionService, clientSessionService, + httpContext, nativeSettings); } private static async Task UserinfoAsync( @@ -1050,7 +1135,7 @@ private static async Task UserinfoAsync( // declared as its gating surface. Anything outside that subset is // not "this RS's business" and is excluded from the block. This // prevents permission strings from one microservice leaking into a - // sibling's UserInfo block when both belong to the same App but + // sibling's audience block when both belong to the same App but // declare disjoint subsets. // // Audience entries that don't resolve to a registered OAuthApi @@ -1293,7 +1378,8 @@ private static async Task LogoutAsync( title: "id_token_hint required", detail: "The end-session endpoint requires an id_token_hint per " + "OpenID Connect RP-Initiated Logout 1.0. Use /api/account/logout " + - "for IdP-internal logout (cookie-only)."); + "for Modgud application-session logout (with optional upstream " + + "OIDC logout; SAML sessions end locally)."); } // OpenIddict already validates the hint's signature (against the realm's @@ -1873,6 +1959,7 @@ public static IEnumerable GetDestinations(Claim claim) yield break; case "AspNet.Identity.SecurityStamp": + case SessionClaimTypes.ClientSessionId: yield break; // Federation v1 (hub boundary, decision D): the session-group carrier diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs index 4ccf7aae..dbe2cfde 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs @@ -129,6 +129,15 @@ private static async Task RegisterAsync( sourceIp, settings.AccessTokenLifetime, settings.RefreshTokenLifetime), + transaction => securityAudit.StoreRequired(transaction, new SecurityAuditRecord + { + EventType = AuditEvents.DcrClientRegistered, + ActorKind = AuditActorKind.OAuthClient, + OAuthClientId = normalized.ClientId, + IpAddress = sourceIp, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "register", + }), ct); if (createResult.IsError) { @@ -159,16 +168,6 @@ private static async Task RegisterAsync( // store, so without this push the grid stays stale until a manual reload. dispatcher.DispatchCreatedEvent("OAuthClient", created, session.TenantId); - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.DcrClientRegistered, - Level = "Info", - Actor = created.DisplayName, - Ip = sourceIp, - Status = "registered", - Reason = $"clientId {created.ClientId}", - Message = $"DCR client registered: {created.DisplayName ?? "(none)"} ({created.ClientId})", - }); ModgudMeters.RecordDcrRegistration(ModgudMeters.DcrOutcome.Success); // ───────── Response ───────── @@ -222,27 +221,28 @@ private static string ResolveRealmSlug(HttpContext ctx) private static void LogRejected(ISecurityAuditLog securityAudit, string ip, string? clientName, DcrRejectionReason reason) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.DcrRegistrationRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"{reason} clientName={clientName ?? "(none)"}", - Message = $"DCR registration rejected: {reason}", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.OAuthClient, + IpAddress = ip, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = reason.ToString(), }); } private static void LogRateLimit(ISecurityAuditLog securityAudit, string ip, DcrRejectionReason reason) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.RateLimitTriggered, - Level = "Warning", - Ip = ip, - Status = "rate_limited", - Reason = reason.ToString(), - Message = $"DCR rate limit triggered: {reason}", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + IpAddress = ip, + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = reason.ToString(), + OperationCode = "dcr-rate-limit", }); } } diff --git a/src/dotnet/Modgud.Api/Features/Inbox/InboxRetentionJob.cs b/src/dotnet/Modgud.Api/Features/Inbox/InboxRetentionJob.cs index b74f67ef..895f4354 100644 --- a/src/dotnet/Modgud.Api/Features/Inbox/InboxRetentionJob.cs +++ b/src/dotnet/Modgud.Api/Features/Inbox/InboxRetentionJob.cs @@ -1,16 +1,12 @@ -using Microsoft.Extensions.DependencyInjection; using Quartz; using Modgud.Application.Inbox; -using Modgud.Infrastructure.Persistence.Tenancy; -using Modgud.Infrastructure.Realms; namespace Modgud.Api.Features.Inbox; /// /// Quartz wrapper around . Runs daily -/// (default 03:00 UTC) and applies the per-kind retention policy stored in -/// for every active realm — each tenant -/// has its own retention settings doc in its own DB. +/// (default 03:00 UTC) and applies the owning realm's per-kind retention policy +/// stored in . /// /// The job itself is intentionally dumb — no parameter schema, no per-run /// config. Admins configure retention under /admin/inbox-settings, @@ -18,54 +14,23 @@ namespace Modgud.Api.Features.Inbox; /// [DisallowConcurrentExecution] public class InboxRetentionJob( - IServiceScopeFactory scopeFactory, - IRealmCache realmCache) : IJob + IInboxRetentionService retention) : IJob { public const string Key = "inbox-retention"; public const string Name = "Inbox Retention"; public const string Description = - "Applies the inbox retention policy (configured under /admin/inbox-settings) " + - "across every active realm."; + "Applies this realm's inbox retention policy (configured under /admin/inbox-settings)."; /// 03:00 UTC every day — before the other two retention jobs. public const string DefaultCron = "0 0 3 * * ?"; public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; - var realms = await realmCache.GetAllActiveAsync(); + var result = await retention.ExecuteAsync(ct); - int totalAffected = 0; - var breakdown = new Dictionary(); - int tenantsProcessed = 0; - - foreach (var realm in realms) - { - try - { - using var scope = scopeFactory.CreateScope(); - using var _ = TenantContext.Enter(realm.Slug); - - var retention = scope.ServiceProvider.GetRequiredService(); - var result = await retention.ExecuteAsync(ct); - - totalAffected += result.TotalAffected; - foreach (var (reason, count) in result.AffectedByReason) - { - breakdown[reason] = breakdown.GetValueOrDefault(reason) + count; - } - tenantsProcessed++; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Serilog.Log.Error(ex, - "inbox-retention failed for realm {Slug}", - realm.Slug); - } - } - - context.Result = totalAffected == 0 - ? $"Nothing to do ({tenantsProcessed} tenant(s) checked)" - : $"Touched {totalAffected} item(s) across {tenantsProcessed} tenant(s) — " + - string.Join(", ", breakdown.Select(kv => $"{kv.Key}={kv.Value}")); + context.Result = result.TotalAffected == 0 + ? "Nothing to do" + : $"Touched {result.TotalAffected} item(s) — " + + string.Join(", ", result.AffectedByReason.Select(kv => $"{kv.Key}={kv.Value}")); } } diff --git a/src/dotnet/Modgud.Api/Features/Installation/InstallationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Installation/InstallationEndpoints.cs new file mode 100644 index 00000000..6a4c79e0 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Installation/InstallationEndpoints.cs @@ -0,0 +1,222 @@ +using ErrorOr; +using Microsoft.AspNetCore.Mvc; +using Modgud.Application.DTOs.Realms; +using Modgud.Authentication.Setup; +using Modgud.Infrastructure.Installation; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; +using Npgsql; + +namespace Modgud.Api.Features.Installation; + +public sealed record InstallationRealmRequest( + string Slug, + string DisplayName, + string? Description, + string[] Domains, + string? PrimaryDomain); + +public sealed record InstallationAdminRequest( + string UserName, + string Email, + string? Firstname, + string? Lastname, + string Password); + +public sealed record CompleteInstallationRequest( + string Token, + InstallationRealmRequest Realm, + InstallationAdminRequest Admin); + +public sealed record CompleteInstallationResponse( + string RealmSlug, + string PrimaryDomain, + string LoginUrl); + +public sealed class InstallationCompletionService( + IInstallationChallengeService challenges, + IRealmProvisioningService realms, + IMasterConnectionString masterConnection, + IServiceProvider services) +{ + // Stable deployment-wide lock id. PostgreSQL session advisory locks work + // across API replicas and cover the cross-database provisioning saga. + private const long InstallationLockId = 0x4D4F44475544; + + public async Task> CompleteAsync( + CompleteInstallationRequest request, + CancellationToken ct) + { + if (request.Realm is null || request.Admin is null) + return Error.Validation("Installation.PayloadRequired", "Realm and admin are required."); + if (string.IsNullOrWhiteSpace(request.Realm.Slug) + || string.IsNullOrWhiteSpace(request.Realm.DisplayName)) + { + return Error.Validation( + "Installation.RealmRequired", + "Realm slug and display name are required."); + } + if (request.Realm.Domains is not { Length: > 0 } + || request.Realm.Domains.All(string.IsNullOrWhiteSpace)) + return Error.Validation("Installation.DomainRequired", "At least one realm domain is required."); + if (string.IsNullOrWhiteSpace(request.Admin.UserName) + || string.IsNullOrWhiteSpace(request.Admin.Email) + || string.IsNullOrWhiteSpace(request.Admin.Password)) + { + return Error.Validation( + "Installation.AdminRequired", + "Admin username, email and password are required."); + } + + var tokenResult = await challenges.ValidateAsync(request.Token, ct); + if (tokenResult.IsError) return tokenResult.Errors; + var loginScheme = new Uri(tokenResult.Value.BaseUrl).Scheme; + + await using var lockConnection = new NpgsqlConnection(masterConnection.Value); + await lockConnection.OpenAsync(ct); + await using (var lockCommand = new NpgsqlCommand( + "SELECT pg_advisory_lock(@lockId)", lockConnection)) + { + lockCommand.Parameters.AddWithValue("lockId", InstallationLockId); + await lockCommand.ExecuteNonQueryAsync(ct); + } + + var realmCreated = false; + var adminCreated = false; + try + { + // Revalidate under the cross-replica lock. A second request may have + // completed while this one was waiting. + var status = await challenges.GetStatusAsync(ct); + if (status.IsInitialized) + return Error.Conflict("Installation.AlreadyInitialized", "The deployment is already initialized."); + tokenResult = await challenges.ValidateAsync(request.Token, ct); + if (tokenResult.IsError) return tokenResult.Errors; + + var dto = new CreateRealmDto + { + Slug = request.Realm.Slug.Trim(), + DisplayName = request.Realm.DisplayName.Trim(), + Description = request.Realm.Description?.Trim(), + Domains = request.Realm.Domains + .Select(d => d.Trim().ToLowerInvariant()) + .Where(d => d.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(), + PrimaryDomain = request.Realm.PrimaryDomain?.Trim().ToLowerInvariant(), + InitialAdmin = new InitialAdminDto + { + UserName = request.Admin.UserName.Trim(), + Email = request.Admin.Email.Trim(), + Firstname = request.Admin.Firstname?.Trim(), + Lastname = request.Admin.Lastname?.Trim(), + }, + }; + + var realmResult = await realms.CreateInitialRealmAsync(dto, ct); + if (realmResult.IsError) return realmResult.Errors; + realmCreated = true; + + // Resolve a fresh tenant-scoped graph only after the tenant DB exists. + using (TenantContext.Enter(dto.Slug)) + await using (var scope = services.CreateAsyncScope()) + { + var bootstrapper = scope.ServiceProvider.GetRequiredService(); + var adminResult = await bootstrapper.BootstrapDirectAsync( + request.Admin.UserName, + request.Admin.Password, + request.Admin.Email, + request.Admin.Firstname, + request.Admin.Lastname, + ct); + if (adminResult.IsError) + return adminResult.Errors; + adminCreated = true; + } + + var activation = await realms.ActivateInitialRealmAsync(dto.Slug, ct); + if (activation.IsError) return activation.Errors; + + var completion = await challenges.CompleteAsync(request.Token, dto.Slug, ct); + if (completion.IsError) return completion.Errors; + + var primaryDomain = activation.Value.PrimaryDomain; + return new CompleteInstallationResponse( + dto.Slug, + primaryDomain, + $"{loginScheme}://{primaryDomain}/login"); + } + finally + { + // Before the admin exists a failed attempt is safely compensatable. + // Once credentials exist, keep the inactive realm for forensic/manual + // recovery instead of silently deleting an operator identity. + if (realmCreated && !adminCreated) + await realms.RollbackProvisionedRealmAsync(request.Realm.Slug.Trim(), CancellationToken.None); + + await using var unlockCommand = new NpgsqlCommand( + "SELECT pg_advisory_unlock(@lockId)", lockConnection); + unlockCommand.Parameters.AddWithValue("lockId", InstallationLockId); + await unlockCommand.ExecuteNonQueryAsync(CancellationToken.None); + } + } +} + +public static class InstallationEndpoints +{ + public static WebApplication MapInstallationEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/install") + .WithTags("Installation") + .AllowAnonymous(); + + group.MapGet("status", async ( + IInstallationChallengeService service, + CancellationToken ct) => + { + var status = await service.GetStatusAsync(ct); + return Results.Ok(status); + }).WithName("Installation_Status"); + + group.MapPost("validate", async ( + [FromBody] TokenRequest request, + IInstallationChallengeService service, + CancellationToken ct) => + { + var result = await service.ValidateAsync(request.Token, ct); + return result.IsError + ? Problem(result.FirstError) + : Results.Ok(new { valid = true, expiresAt = result.Value.ExpiresAt }); + }) + .WithName("Installation_Validate") + .RequireRateLimiting("bootstrap"); + + group.MapPost("complete", async ( + [FromBody] CompleteInstallationRequest request, + InstallationCompletionService service, + CancellationToken ct) => + { + var result = await service.CompleteAsync(request, ct); + return result.IsError + ? Problem(result.FirstError) + : Results.Ok(result.Value); + }) + .WithName("Installation_Complete") + .RequireRateLimiting("bootstrap"); + + return app; + } + + public sealed record TokenRequest(string Token); + + private static IResult Problem(Error error) + { + var status = error.Type switch + { + ErrorType.Conflict => StatusCodes.Status409Conflict, + ErrorType.NotFound => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest, + }; + return Results.Problem(statusCode: status, title: error.Code, detail: error.Description); + } +} diff --git a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs index e78d9f4e..de440e66 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs @@ -94,8 +94,9 @@ public async Task> DeleteRoleAsync(Guid id, CancellationToken c /// /// Validates a payload into a (Id minted here): AppId /// resolves to an existing App, every PermissionId resolves to that App's catalog, - /// PermissionIds require an App link, and a role must grant something (App link or - /// IsRealmAdmin). + /// and PermissionIds require an App link. Roles have exactly one scope: ordinary + /// roles are App-bound, while realm-admin roles are deliberately unscoped and may + /// carry neither an App link nor catalog permissions. /// public async Task> BuildRoleAsync(RolePayload dto, CancellationToken ct = default) { @@ -104,6 +105,14 @@ public async Task> BuildRoleAsync(RolePayload dto, Cance var permissionIdsInput = dto.PermissionIds ?? []; + if (dto.IsRealmAdmin + && (!string.IsNullOrWhiteSpace(dto.AppId) || permissionIdsInput.Count > 0)) + { + return Error.Validation( + "Role.RealmAdminMustBeUnscoped", + "A realm-admin role cannot be linked to an App or carry App permissions."); + } + Guid? appId = null; App? linkedApp = null; if (!string.IsNullOrEmpty(dto.AppId)) diff --git a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs index e332b191..818068ca 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs @@ -9,9 +9,9 @@ namespace Modgud.Api.Features.Roles; /// /// Create/Update payload for a . -/// is the (ShortGuid) FK into the role's App; null = the role is a pure -/// realm-admin role and must therefore set to -/// true and leave empty. Each +/// is the (ShortGuid) FK into an ordinary role's App. A realm-admin role +/// must instead leave null and +/// empty. Each /// entry is an AppPermission.Id /// (ShortGuid) FK into App.Permissions of the linked App; the /// admin endpoint validates them at write-time. diff --git a/src/dotnet/Modgud.Api/Features/ServiceAccounts/ServiceAccountsEndpoints.cs b/src/dotnet/Modgud.Api/Features/ServiceAccounts/ServiceAccountsEndpoints.cs index abe9ef1b..64eaeedf 100644 --- a/src/dotnet/Modgud.Api/Features/ServiceAccounts/ServiceAccountsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/ServiceAccounts/ServiceAccountsEndpoints.cs @@ -2,11 +2,13 @@ using BuildingBlocks.EventDispatcher; using BuildingBlocks.Helper; using Modgud.Application.DTOs.ServiceAccount; +using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; using Modgud.Authentication.ExtensionMethods; using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Principals; using Modgud.Domain.ValueObjects; +using Modgud.Domain.OAuth.Common; using Modgud.Infrastructure.OpenIddict; using Marten; @@ -52,7 +54,12 @@ public static WebApplication MapServiceAccountsEndpoints(this WebApplication app .WithName("V2_ServiceAccount_GetById") .RequiresPermission("service-account:read"); - group.MapPost("", async (ServiceAccountCreateDto dto, IDocumentSession session, DataEventDispatcher dispatcher) => + group.MapPost("", async ( + ServiceAccountCreateDto dto, + IDocumentSession session, + OAuthAdminService oauth, + DataEventDispatcher dispatcher, + CancellationToken ct) => { var normalised = (dto.AccountName ?? string.Empty).Trim().ToLowerInvariant(); var validation = ValidateAccountName(normalised); @@ -69,15 +76,62 @@ public static WebApplication MapServiceAccountsEndpoints(this WebApplication app return Results.Conflict(new { Error = "ServiceAccount.AccountNameTaken", Message = $"Account name '{normalised}' is already in use." }); + // When an initial credential is supplied, delegate to the OAuth + // create path that already supports inline ServiceAccount + // creation. It stages principal, OAuth stream and hashed secret + // in the same Marten session and commits them atomically. + if (dto.InitialCredential is { } initialCredential) + { + var clientId = string.IsNullOrWhiteSpace(initialCredential.ClientId) + ? $"{normalised}.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}" + : initialCredential.ClientId.Trim(); + var result = await oauth.CreateClientAsync(new CreateOAuthClientDto + { + ClientId = clientId, + DisplayName = string.IsNullOrWhiteSpace(initialCredential.DisplayName) + ? normalised + : initialCredential.DisplayName.Trim(), + ClientType = OAuthClientTypes.Confidential, + ConsentType = OAuthConsentTypes.Implicit, + AllowedGrantTypes = ["client_credentials"], + Scopes = initialCredential.Scopes, + RequireClientSecret = true, + RequireConsent = false, + Enabled = initialCredential.Enabled, + AccessTokenType = initialCredential.AccessTokenType, + AccessTokenLifetime = initialCredential.AccessTokenLifetime, + AppIds = initialCredential.AppIds, + NewServiceAccount = new ServiceAccountCreateDto + { + AccountName = normalised, + Purpose = dto.Purpose, + IsActive = dto.IsActive, + }, + }, ct); + if (result.IsError) return result.ToResult(); + + var createdWithCredential = result.Value.CreatedServiceAccount!; + createdWithCredential.InitialCredential = new ServiceAccountCredentialIssuedDto + { + Credential = result.Value.Client, + ClientSecret = result.Value.ClientSecret!, + }; + // Keep both admin grids in sync: this endpoint created both + // aggregate types even though the response is SA-shaped. + dispatcher.DispatchCreatedEvent("OAuthClient", result.Value.Client, session.TenantId); + dispatcher.DispatchCreatedEvent("ServiceAccount", createdWithCredential, session.TenantId); + return Results.Ok(createdWithCredential); + } + var sa = new ServiceAccount { Id = Guid.NewGuid(), AccountName = normalised, Purpose = string.IsNullOrWhiteSpace(dto.Purpose) ? null : dto.Purpose.Trim(), - IsActive = true, + IsActive = dto.IsActive, }; session.Store(sa); - await session.SaveChangesAsync(); + await session.SaveChangesAsync(ct); var created = ToDto(sa); dispatcher.DispatchCreatedEvent("ServiceAccount", created, session.TenantId); diff --git a/src/dotnet/Modgud.Api/Features/Users/Commands/CreateUserCommand.cs b/src/dotnet/Modgud.Api/Features/Users/Commands/CreateUserCommand.cs index 4d66a178..f718ba89 100644 --- a/src/dotnet/Modgud.Api/Features/Users/Commands/CreateUserCommand.cs +++ b/src/dotnet/Modgud.Api/Features/Users/Commands/CreateUserCommand.cs @@ -5,14 +5,29 @@ using Modgud.Application.DTOs.User; using Modgud.Domain.Errors; using Modgud.Domain.Realms; +using Modgud.Authentication.Events; using Modgud.Authentication.Applications; using Modgud.Authentication.Domain; +using Modgud.Authorization.Events; using Modgud.Authorization.Principals; +using Modgud.Domain.Users.Events; +using Modgud.Infrastructure.Persistence.Marten.Projections.Users; namespace Modgud.Api.Features.Users.Commands; -public record CreateUserCommand(string? Firstname, string? Lastname, string? Acronym, string? Email, string UserName, string? Password, bool EmailConfirmed = false); +public record CreateUserCommand( + string? Firstname, + string? Lastname, + string? Acronym, + string? Email, + string UserName, + string? Password, + bool EmailConfirmed = false, + bool IsActive = true, + IReadOnlyList? GroupIds = null, + int? GracePeriodDaysOverride = null, + bool TwoFactorExempt = false); public class CreateUserHandler( IDocumentSession session, @@ -78,8 +93,26 @@ public async Task> Handle( return DomainErrors.User.EmailTaken(command.Email); } + // Resolve and validate every requested membership before creating the + // user. This keeps the command all-or-nothing: a malformed, missing or + // automatic group can never leave a bare user behind. + var groups = new List(); + foreach (var rawGroupId in command.GroupIds?.Distinct() ?? []) + { + if (!ShortGuid.TryParse(rawGroupId, out Guid groupId)) + return Error.Validation("User.InvalidGroupId", $"Group ID '{rawGroupId}' is invalid"); + + var group = await session.LoadAsync(groupId, ct); + if (group is null || group.IsDeleted) + return Error.NotFound("User.GroupNotFound", $"Group with ID '{rawGroupId}' was not found"); + if (group.MembershipMode == MembershipMode.Auto) + return Error.Validation("User.AutoGroupMembership", + $"Group '{group.Name}' has automatic membership and cannot receive direct members"); + + groups.Add(group); + } + var id = Guid.NewGuid(); - var hasPassword = false; var appUser = new ApplicationUser(normalizedUserName, command.Email) { @@ -87,29 +120,79 @@ public async Task> Handle( Firstname = command.Firstname, Lastname = command.Lastname, Acronym = command.Acronym, - IsActive = true, + IsActive = command.IsActive, EmailConfirmed = command.EmailConfirmed, }; - // Store handles event stream creation (StartStream + UserCreatedEvent + UserUserNameChangedEvent) - // and document persistence (ApplicationUser + UserSecurityData) - IdentityResult createResult; + // Run the same configured Identity validators used by UserManager, + // then stage the Identity documents ourselves. EventSourcedUserStore's + // CreateAsync commits immediately, which made it impossible to include + // memberships and the per-user 2FA policy in the same transaction. + appUser.NormalizedUserName = userManager.NormalizeName(appUser.UserName) ?? string.Empty; + appUser.NormalizedEmail = userManager.NormalizeEmail(appUser.Email); + + var identityErrors = new List(); + foreach (var validator in userManager.UserValidators) + { + var result = await validator.ValidateAsync(userManager, appUser); + if (!result.Succeeded) identityErrors.AddRange(result.Errors); + } + if (!string.IsNullOrWhiteSpace(command.Password)) { - createResult = await userManager.CreateAsync(appUser, command.Password); - hasPassword = createResult.Succeeded; + foreach (var validator in userManager.PasswordValidators) + { + var result = await validator.ValidateAsync(userManager, appUser, command.Password); + if (!result.Succeeded) identityErrors.AddRange(result.Errors); + } } - else + if (identityErrors.Count > 0) { - createResult = await userManager.CreateAsync(appUser); + return Error.Validation("User.IdentityError", + string.Join("; ", identityErrors.Select(e => e.Description))); } - if (!createResult.Succeeded) + if (!string.IsNullOrWhiteSpace(command.Password)) + appUser.PasswordHash = userManager.PasswordHasher.HashPassword(appUser, command.Password); + + var userEvents = new List { - return Error.Validation("User.IdentityError", - string.Join("; ", createResult.Errors.Select(e => e.Description))); + new UserCreatedEvent(id, command.Firstname, command.Lastname, command.Acronym, command.Email), + new UserUserNameChangedEvent(id, normalizedUserName), + }; + if (appUser.PasswordHash is not null) + userEvents.Add(new UserPasswordChangedEvent(id, null)); + if (!command.IsActive) + userEvents.Add(new UserDeactivatedEvent(id)); + session.Events.StartStream(id, userEvents); + + session.Store(appUser); + + var securityData = UserSecurityData.Create(id, appUser.PasswordHash); + if (!string.IsNullOrEmpty(appUser.SecurityStamp)) + securityData.SecurityStamp = appUser.SecurityStamp; + securityData.GracePeriodDaysOverride = command.GracePeriodDaysOverride is null + ? null + : Math.Max(0, command.GracePeriodDaysOverride.Value); + securityData.TwoFactorExempt = command.TwoFactorExempt; + session.Store(securityData); + + foreach (var group in groups) + { + session.Events.Append(group.Id, new GroupUpdatedEvent( + group.Id, group.Name, group.Description, + group.MemberIds.Append(id).Distinct().ToList(), group.RoleIds, + group.MembershipMode, group.MembershipScript, group.CompiledMembershipScript, + group.MembershipScriptDependencies, + group.Email, group.EmailMode, + BoundTo: group.BoundTo, + ExternallyDrivable: group.ExternallyDrivable)); } + // One Marten SaveChanges = one PostgreSQL transaction for the complete + // object: user stream, authentication documents, policy and memberships. + await session.SaveChangesAsync(ct); + return new UserDto { Id = new ShortGuid(id).ToString(), @@ -118,8 +201,8 @@ public async Task> Handle( Acronym = command.Acronym, Email = command.Email, UserName = normalizedUserName, - IsActive = true, - HasPassword = hasPassword, + IsActive = command.IsActive, + HasPassword = appUser.PasswordHash is not null, EmailConfirmed = command.EmailConfirmed, }; } diff --git a/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs b/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs index ada60f67..a867bd00 100644 --- a/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs @@ -84,7 +84,11 @@ public static WebApplication MapUsersEndpoints(this WebApplication application, // that marks Username=Required rejects a blank one. createDto.UserName ?? "", createDto.Password, - createDto.EmailConfirmed); + createDto.EmailConfirmed, + createDto.IsActive, + createDto.GroupIds, + createDto.GracePeriodDaysOverride, + createDto.TwoFactorExempt); var result = await bus.InvokeAsync>(command); return result.ToResult(dto => { diff --git a/src/dotnet/Modgud.Api/HealthChecks/MartenSchemaHealthCheck.cs b/src/dotnet/Modgud.Api/HealthChecks/MartenSchemaHealthCheck.cs index e15737f2..8b3cfa72 100644 --- a/src/dotnet/Modgud.Api/HealthChecks/MartenSchemaHealthCheck.cs +++ b/src/dotnet/Modgud.Api/HealthChecks/MartenSchemaHealthCheck.cs @@ -6,18 +6,16 @@ namespace Modgud.Api.HealthChecks; /// -/// End-to-end Marten readiness probe: opens a session against the master -/// tenant () and runs a no-op -/// query against the Realm document. If the Marten schema isn't applied, -/// the master DB connection string is wrong, or the multi-tenant master -/// table isn't readable, this fails — which is exactly what readiness -/// should refuse traffic for. +/// End-to-end Marten readiness probe: opens the global store and runs a no-op +/// query against the Realm directory. If the Marten schema isn't applied or +/// the master DB connection string is wrong, this fails — which is exactly +/// what readiness should refuse traffic for. /// /// Per-tenant DBs are deliberately NOT probed here — they're /// initialised on-demand and the count grows over time. Probing each /// would make readiness latency O(realms). /// -public sealed class MartenSchemaHealthCheck(IDocumentStore store) : IHealthCheck +public sealed class MartenSchemaHealthCheck(IGlobalStore globalStore) : IHealthCheck { public async Task CheckHealthAsync( HealthCheckContext context, @@ -25,7 +23,7 @@ public async Task CheckHealthAsync( { try { - await using var session = store.QuerySession(TenantConstants.SystemTenantId); + await using var session = globalStore.QuerySession(); var _ = await session.Query().AnyAsync(cancellationToken); return HealthCheckResult.Healthy("Marten master schema reachable."); } diff --git a/src/dotnet/Modgud.Api/Middleware/AuthRateLimitResolutionMiddleware.cs b/src/dotnet/Modgud.Api/Middleware/AuthRateLimitResolutionMiddleware.cs index 4f3c446d..c6da63e1 100644 --- a/src/dotnet/Modgud.Api/Middleware/AuthRateLimitResolutionMiddleware.cs +++ b/src/dotnet/Modgud.Api/Middleware/AuthRateLimitResolutionMiddleware.cs @@ -46,8 +46,14 @@ public async Task InvokeAsync(HttpContext context) { if (context.GetEndpoint()?.Metadata.GetMetadata() is not null) { - var slug = context.Items[TenantConstants.HttpContextTenantIdKey] as string - ?? TenantConstants.SystemTenantId; + var slug = context.Items[TenantConstants.HttpContextTenantIdKey] as string; + if (string.IsNullOrEmpty(slug)) + { + // Installation/health routes have no realm. The limiter uses + // its shipped defaults without touching tenant settings. + await next(context); + return; + } if (!_cache.TryGetValue(slug, out var entry) || entry.Expires <= DateTimeOffset.UtcNow) { diff --git a/src/dotnet/Modgud.Api/Middleware/InstallationGateMiddleware.cs b/src/dotnet/Modgud.Api/Middleware/InstallationGateMiddleware.cs new file mode 100644 index 00000000..2a6792cc --- /dev/null +++ b/src/dotnet/Modgud.Api/Middleware/InstallationGateMiddleware.cs @@ -0,0 +1,66 @@ +using Modgud.Infrastructure.Installation; + +namespace Modgud.Api.Middleware; + +/// +/// Keeps a zero-realm deployment closed until the shell-authorized first +/// installation completes. Health and installation assets/API remain reachable; +/// every normal API returns 503 and browser navigation is sent to /install. +/// +public sealed class InstallationGateMiddleware(RequestDelegate next) +{ + private volatile bool _knownInitialized; + + private static readonly string[] AllowedPrefixes = + [ + "/api/install", + "/install", + "/health", + "/assets", + "/favicon", + ]; + + public async Task InvokeAsync( + HttpContext context, + IInstallationChallengeService installation) + { + if (_knownInitialized) + { + await next(context); + return; + } + + var status = await installation.GetStatusAsync(context.RequestAborted); + if (status.IsInitialized) + { + _knownInitialized = true; + await next(context); + return; + } + + if (IsAllowed(context.Request.Path)) + { + await next(context); + return; + } + + if (HttpMethods.IsGet(context.Request.Method) + && !context.Request.Path.StartsWithSegments("/api") + && context.Request.Headers.Accept.Any(v => + v?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true)) + { + context.Response.Redirect("/install"); + return; + } + + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync(new + { + error = "not_initialized", + message = "This Modgud deployment has not been initialized.", + }, context.RequestAborted); + } + + private static bool IsAllowed(PathString path) => + AllowedPrefixes.Any(prefix => path.StartsWithSegments(prefix)); +} diff --git a/src/dotnet/Modgud.Api/Middleware/RealmMiddleware.cs b/src/dotnet/Modgud.Api/Middleware/RealmMiddleware.cs index f88b5c4b..eb3e5ef2 100644 --- a/src/dotnet/Modgud.Api/Middleware/RealmMiddleware.cs +++ b/src/dotnet/Modgud.Api/Middleware/RealmMiddleware.cs @@ -29,17 +29,19 @@ public sealed class RealmMiddleware // NOTE: /signalr is deliberately NOT here. SignalR is realm-scoped: the hub // is [Authorize], and the auth cookie is encrypted with the realm's own // DataProtection keys (TenantedDataProtectionProvider). Skipping realm - // resolution leaves TenantContext at the "system" fallback, so a non-system - // realm's cookie can't be decrypted on /signalr/*/negotiate — the connection - // 401s and the whole realtime/CRUD layer dies for every tenant realm. The - // realm is host-resolvable here exactly like any other request, so we let - // it resolve normally. + // resolution would leave the request without any tenant, so its cookie + // cannot be decrypted on /signalr/*/negotiate. The realm is host-resolvable + // here exactly like any other request, so we let it resolve normally. private static readonly string[] SkipPaths = [ "/health", "/swagger", "/openapi", "/_framework", + "/api/install", + "/install", + "/assets", + "/favicon", ]; public RealmMiddleware(RequestDelegate next, IRealmCache realmCache) diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 739473a8..c01fd7f1 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.AspNetCore.SignalR; using Serilog; using Serilog.Sinks.OpenTelemetry; using Serilog.Sinks.SystemConsole.Themes; @@ -36,6 +37,7 @@ using Modgud.Api.Features.Roles; using Modgud.Api.Features.Shared; using Modgud.Api.Features.Users; +using Modgud.Api.Features.Installation; using Modgud.Api.Helper; using Modgud.Domain.Common; using AuthRateLimitPolicy = Modgud.Domain.Realms.AuthRateLimitPolicy; @@ -245,7 +247,13 @@ }); - builder.Services.AddSignalR() + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSignalR(options => + { + options.AddFilter(); + }) .AddJsonProtocol(options => { options.PayloadSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; @@ -341,6 +349,11 @@ if (!newIdentity.HasClaim(claim.Type, claim.Value)) newIdentity.AddClaim(new Claim(claim.Type, claim.Value)); + foreach (var claim in current.FindAll( + Modgud.Authentication.Sessions.SessionClaimTypes.BrowserSessionId)) + if (!newIdentity.HasClaim(claim.Type, claim.Value)) + newIdentity.AddClaim(new Claim(claim.Type, claim.Value)); + return Task.CompletedTask; }; }); @@ -348,6 +361,7 @@ builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme) .AddCookie(IdentityConstants.ApplicationScheme, options => { + options.EventsType = typeof(Modgud.Authentication.Sessions.BrowserSessionCookieEvents); options.Cookie.HttpOnly = true; // COOKIE-01: Lax (was Strict). Strict prevents the browser from sending // the cookie on top-level navigations from third-party origins — which @@ -370,37 +384,9 @@ options.CookieManager = new Modgud.Api.Cookies.TenantApexCookieManager(); options.ExpireTimeSpan = TimeSpan.FromDays(30); // Max lifetime for persistent (RememberMe) cookies options.SlidingExpiration = true; - // SESSION-01 — re-validate the user's security stamp on every - // request (with a small per-request cache configured via - // SecurityStampValidatorOptions.ValidationInterval). When the - // stamp on disk no longer matches the cookie's stamp, the - // cookie is rejected and the user must re-authenticate. - options.Events.OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync; - options.Events.OnRedirectToLogin = ctx => - { - // /api/* is the SPA's data plane — surface 401 so the - // SPA can decide where to navigate. Everything else - // (including /connect/authorize for inbound OAuth flows - // from third-party clients) needs a real 302 redirect - // so the browser actually lands on the login page. - if (ctx.Request.Path.StartsWithSegments("/api")) - { - ctx.Response.StatusCode = 401; - return Task.CompletedTask; - } - ctx.Response.Redirect(ctx.RedirectUri); - return Task.CompletedTask; - }; - options.Events.OnRedirectToAccessDenied = ctx => - { - if (ctx.Request.Path.StartsWithSegments("/api")) - { - ctx.Response.StatusCode = 403; - return Task.CompletedTask; - } - ctx.Response.Redirect(ctx.RedirectUri); - return Task.CompletedTask; - }; + // BrowserSessionCookieEvents performs authoritative session + // validation, delegates security-stamp validation and preserves + // the API-specific 401/403 redirect behavior. // Login/access-denied paths — the SPA handles these client-side // (Vue Router routes for /login + /access-denied), but they need // to be valid URLs so the redirect emitted above resolves to the @@ -648,9 +634,10 @@ // by RecoveryCli `bootstrap-admin` and the future invite-mode endpoint. builder.Services.AddScoped(); + builder.Services.AddScoped(); // C15b — One-shot Pending-Admin-Invite (issued by CLI without --password, - // by RealmProvisioning's InitialAdmin path, or by the resend endpoint; + // optionally together with realm creation, or by the admin-invite endpoint; // consumed by POST /api/account/bootstrap-admin). builder.Services.AddScoped(); @@ -738,6 +725,12 @@ Modgud.Authentication.Sessions.DeviceInfoService>(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => + sp.GetRequiredService()); + builder.Services.AddScoped(sp => + sp.GetRequiredService()); builder.Services.AddScoped(); @@ -945,52 +938,68 @@ ReferenceSyncRegistration.RegisterAll(opts, typeof(Program).Assembly); }); - // Streamless security/ops audit store (logging/audit redesign Track A, Phase 3). - // Typed best-effort sink (bounded channel) + background writer to the system DB. - // Replaced the legacy "Auth:"-message-prefix Serilog sink (AuthLogSink + - // AuthLogPersistenceService, now deleted). The realm is captured from - // TenantContext.Current at emit; the retention prune is a Quartz job (below). + // Structured best-effort security-event sink. Realm events are routed to the + // owning physical realm DB; PII-free deployment events go to the Global Store. builder.Services.AddSingleton(); builder.Services.AddSingleton( sp => sp.GetRequiredService()); builder.Services.AddHostedService(); - // Quartz-based scheduling framework + the system jobs we host. The DCR - // garbage collector was a hand-rolled BackgroundService before Phase 1A; - // now it runs as a Quartz job so admins can see runs, override the cron, - // and trigger manually from /admin/jobs. + // Quartz-based scheduling framework. Realm jobs get one independent + // Quartz job + trigger per realm; deployment-wide jobs are registered once + // and are visible only from the current Control-Plane realm. builder.Services.AddScheduling(); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.Key, name: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.Description, getParameterSchema: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.GetParameterSchema); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.DcrGcJob.Key, name: Modgud.Api.Features.Admin.Jobs.DcrGcJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.DcrGcJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.DcrGcJob.Description); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Inbox.InboxRetentionJob.Key, name: Modgud.Api.Features.Inbox.InboxRetentionJob.Name, defaultCron: Modgud.Api.Features.Inbox.InboxRetentionJob.DefaultCron, description: Modgud.Api.Features.Inbox.InboxRetentionJob.Description); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.AccountLifecycleSweepJob.Key, name: Modgud.Api.Features.Admin.Jobs.AccountLifecycleSweepJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.AccountLifecycleSweepJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.AccountLifecycleSweepJob.Description); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( + key: Modgud.Api.Features.Admin.Jobs.SessionPruneJob.Key, + name: Modgud.Api.Features.Admin.Jobs.SessionPruneJob.Name, + defaultCron: Modgud.Api.Features.Admin.Jobs.SessionPruneJob.DefaultCron, + description: Modgud.Api.Features.Admin.Jobs.SessionPruneJob.Description); + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.Key, name: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.DefaultCron, - description: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.Description); - builder.Services.AddSystemJob( + description: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.Description, + // Soft-delete keeps the tenant DB and its private key material. This + // realm-owned hygiene therefore continues while the realm is inactive. + runWhenRealmInactive: true); + builder.Services.AddSystemJob( + key: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.Key, + name: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.Name, + defaultCron: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.DefaultCron, + description: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.Description, + getParameterSchema: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.GetParameterSchema); + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.Key, name: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.Description); + builder.Services.AddSystemJob( + key: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.Key, + name: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.Name, + defaultCron: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.DefaultCron, + description: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.Description, + getParameterSchema: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.GetParameterSchema); // Inbox — per-recipient notifications with SignalR live push. Both // services are scoped (tenant-aware IDocumentSession). The InboxHub @@ -1155,9 +1164,49 @@ app.AddLogging(); + // Static SPA files are deployment assets, not realm data. Serve them before + // tenant resolution so the first-installation UI can load while zero realms + // exist. The fallback endpoint registered here is executed by the + // realm-independent branch below for /install. + app.UseSpaUI(); app.UseRouting(); + // A fresh deployment intentionally has no realm. Keep every normal route + // closed until the shell-authorized installation API creates the first one. + app.UseMiddleware(); + + // The installation API must be able to run before a realm -- and therefore + // before realm-scoped cookies, DataProtection keys and Marten sessions -- + // exist. Give it a terminal branch that only runs endpoint routing and the + // endpoint rate limiter. Normal realm/auth middleware can never be resolved + // from this branch. + app.MapWhen( + context => + context.Request.Path.StartsWithSegments("/api/install", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/install", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/openapi", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/swagger", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/assets", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/favicon", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/_framework", StringComparison.OrdinalIgnoreCase), + realmIndependentBranch => + { + realmIndependentBranch.UseRateLimiter(); + realmIndependentBranch.Run(async context => + { + var endpoint = context.GetEndpoint(); + if (endpoint?.RequestDelegate is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + await endpoint.RequestDelegate(context); + }); + }); + // Resolve tenant from the Host header BEFORE auth runs so the // TenantedSessionFactory sees the correct tenant for every Marten session // opened during authentication / authorization (e.g. Identity user lookup). @@ -1205,6 +1254,7 @@ // must keep /metrics off the public internet — bind via reverse-proxy // ACL or localhost-only listener. app.MapModgudObservability(observabilitySettings); + app.MapInstallationEndpoints(); // OpenIddict OAuth/OIDC endpoints (/connect/authorize, /token, /userinfo, /logout, /consent). @@ -1291,8 +1341,6 @@ app.MapHARRRController("/signalr/ui"); - app.UseSpaUI(); - // ResourceRegistry is now instance-based and configured via AddModgudAuthorization // in AddInfrastructure — no static init required. @@ -1302,139 +1350,65 @@ Modgud.Infrastructure.Events.ProjectionSideEffects.Enabled = true); // ──────────────────────────────────────────────────────────────────────── - // Multi-tenant bootstrap (must run BEFORE app.Run() so the daemon and any - // hosted services see a fully provisioned master + system tenant) - // - // Order matters: - // 1. Make sure BOTH the master DB and the system tenant's own DB - // ({master}_system) physically exist (raw SQL — Marten cannot - // `CREATE DATABASE` on a connection that already targets it). - // 2. Apply Marten storage to the master DB so `realms.mt_tenant_databases` - // is created — required before any tenant can be registered. - // 3. Register the "system" tenant pointing at its OWN DB {master}_system - // (NOT the master DB). The master DB stays pure control-plane infra - // (tenant registry + global Realm store + Wolverine durability), so the - // system realm is an equal, deletable peer and the control plane can be - // transferred off it. "system" is also the fallback tenant when no - // HttpContext is available (background/hosted services, CLI) and during - // single-realm dev boots. - // 4. Apply schema again so the system tenant gets all per-tenant tables - // inside {master}_system. - // 5. Ensure the system Realm document exists in IGlobalStore. - // 6. Warm the realm cache so middleware never blocks on first request. + // Multi-tenant bootstrap. A fresh deployment intentionally has ZERO + // realms. Only the master database + Global Store are prepared here; the + // shell-authorized installation flow provisions the first ordinary realm. + // Existing realms are schema-applied and idempotently seeded on every boot. // ──────────────────────────────────────────────────────────────────────── var mainCs = conf.DbSettings.ConnectionString; var bootstrapBuilder = new NpgsqlConnectionStringBuilder(mainCs); var baseDbName = bootstrapBuilder.Database ?? throw new InvalidOperationException("DbSettings.ConnectionString is missing 'Database='"); - // The system tenant gets its OWN physical database `{master}_system`, - // following the same `{master}_{slug}` convention every realm uses. The - // master DB is then pure control-plane infrastructure (tenant registry + - // global Realm store + Wolverine durability), never tenant content. - var systemDbName = $"{baseDbName}_{TenantConstants.SystemTenantId}"; - var systemCs = new NpgsqlConnectionStringBuilder(mainCs) { Database = systemDbName }.ConnectionString; - bootstrapBuilder.Database = "postgres"; await using (var bootstrapConn = new NpgsqlConnection(bootstrapBuilder.ConnectionString)) { await bootstrapConn.OpenAsync(); - // Create the master DB and the system tenant's DB if missing. Both names - // originate from the operator-supplied connection string (parsed by - // NpgsqlConnectionStringBuilder), never from an HTTP request path; the - // quoted-identifier escaping below is defense-in-depth (CA2100). - foreach (var dbName in new[] { baseDbName, systemDbName }) + await using var checkCmd = new NpgsqlCommand( + "SELECT 1 FROM pg_database WHERE datname = @dbName", bootstrapConn); + checkCmd.Parameters.AddWithValue("@dbName", baseDbName); + if (await checkCmd.ExecuteScalarAsync() is null) { - await using var checkCmd = new NpgsqlCommand( - "SELECT 1 FROM pg_database WHERE datname = @dbName", bootstrapConn); - checkCmd.Parameters.AddWithValue("@dbName", dbName); - if (await checkCmd.ExecuteScalarAsync() is not null) continue; - - var quotedName = "\"" + dbName.Replace("\"", "\"\"") + "\""; + var quotedName = "\"" + baseDbName.Replace("\"", "\"\"") + "\""; #pragma warning disable CA2100 await using var createCmd = new NpgsqlCommand( $"CREATE DATABASE {quotedName}", bootstrapConn); #pragma warning restore CA2100 await createCmd.ExecuteNonQueryAsync(); - Log.Information("Created database {DbName}", dbName); + Log.Information("Created master database {DbName}", baseDbName); } } - // Apply master-table tenancy schema (creates realms.mt_tenant_databases etc.) + // The primary store owns the tenant registry and all registered realm DBs. + // The Global Store owns deployment-wide state, including the realm registry + // and first-installation challenges. var store = app.Services.GetRequiredService(); - var tenancy = (MasterTableTenancy)store.Options.Tenancy; - await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); - - // Fail-closed upgrade guard. On a pre-split deployment the "system" tenant - // was registered against the MASTER DB, where all its data physically lived. - // Silently re-pointing it to a fresh {master}_system below would strand that - // data (and invalidate signing/DataProtection keys → all live cookies). If - // the registry already has a "system" row pointing anywhere other than - // {master}_system, refuse to boot until the operator relocates the data (see - // the "Upgrading across the system-DB split" runbook) or recreates it. - await using (var registryConn = new NpgsqlConnection(mainCs)) - { - await registryConn.OpenAsync(); - await using var tableCmd = new NpgsqlCommand( - "SELECT to_regclass('realms.mt_tenant_databases')::text", registryConn); - if (await tableCmd.ExecuteScalarAsync() is not (null or DBNull)) - { - await using var rowCmd = new NpgsqlCommand( - "SELECT connection_string FROM realms.mt_tenant_databases WHERE tenant_id = @id", registryConn); - rowCmd.Parameters.AddWithValue("@id", TenantConstants.SystemTenantId); - if (await rowCmd.ExecuteScalarAsync() is string existingSystemCs) - { - var existingDb = new NpgsqlConnectionStringBuilder(existingSystemCs).Database; - if (!string.Equals(existingDb, systemDbName, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Refusing to boot: the 'system' tenant is registered against database '{existingDb}', but this version expects its own '{systemDbName}' database (the master/system DB split). " + - "Relocate the system realm's data before first boot — see the 'Upgrading across the system-DB split' runbook in docs/operate/realms.md — " + - "or, if this deployment's data is disposable, drop the databases and let a fresh boot provision the new layout."); - } - } - } - } - - // Register the "system" tenant pointing at its OWN DB {master}_system (NOT - // the master DB). MasterTableTenancy has no "default tenant" concept — every - // session needs a tenant id; "system" is the fallback when no HttpContext is - // available (background/hosted services, CLI). - await tenancy.AddDatabaseRecordAsync(TenantConstants.SystemTenantId, systemCs); - - // Apply schema again now that the system tenant is registered — this - // materializes the per-tenant documents/events/projections inside the - // system tenant's own database ({master}_system). await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + var globalStore = app.Services.GetRequiredService(); + await globalStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); - // Ensure the system Realm document exists in the global store using (var realmScope = app.Services.CreateScope()) { var realmService = realmScope.ServiceProvider.GetRequiredService(); - await realmService.EnsureSystemRealmExistsAsync(); - - // Seed default OAuth scopes + Internal login provider into the system tenant DB. - // Idempotent — re-running on later boots is a no-op. - await Modgud.Infrastructure.OAuth.OAuthRealmSeeder.SeedAsync( - realmScope.ServiceProvider, - TenantConstants.SystemTenantId, - realmScope.ServiceProvider.GetRequiredService>()); - await realmScope.ServiceProvider - .GetRequiredService() - .SeedAsync( - TenantConstants.SystemTenantId, - realmScope.ServiceProvider.GetRequiredService>()); - - // Seed the system apps into the system tenant DB so app-scoped - // permissions can resolve before the first realm creation. - // The system realm is always the Control Plane (see - // EnsureSystemRealmExistsAsync), so the control-plane app is - // seeded here too. Idempotent. - await Modgud.Infrastructure.Authorization.AppRealmSeeder.SeedAsync( - realmScope.ServiceProvider, - TenantConstants.SystemTenantId, - isControlPlane: true, - realmScope.ServiceProvider.GetRequiredService>()); + var configuredRealms = (await realmService.GetAllRealmsAsync()) + .Where(r => r.IsActive) + .OrderBy(r => r.CreatedAt) + .ToList(); + var startupLogger = realmScope.ServiceProvider.GetRequiredService>(); + + foreach (var realm in configuredRealms) + { + await Modgud.Infrastructure.OAuth.OAuthRealmSeeder.SeedAsync( + realmScope.ServiceProvider, realm.Slug, startupLogger); + await realmScope.ServiceProvider + .GetRequiredService() + .SeedAsync(realm.Slug, startupLogger); + await Modgud.Infrastructure.Authorization.AppRealmSeeder.SeedAsync( + realmScope.ServiceProvider, + realm.Slug, + isControlPlane: realm.IsControlPlane, + startupLogger); + } // Warm the realm cache (used by RealmMiddleware for fast Host → tenant resolution) var realmCache = realmScope.ServiceProvider.GetRequiredService(); @@ -1448,55 +1422,48 @@ await Modgud.Infrastructure.Authorization.AppRealmSeeder.SeedAsync( // is then under 15ms forever. // // We touch every shape the admin SPA hits during normal navigation - // here, against the system tenant. Marten caches LINQ→SQL per + // here, against the first active tenant. Marten caches LINQ→SQL per // DocumentStore, not per tenant — so warming with one tenant is // enough for all tenants. Costs: ~2-3s extra at boot, then no // user-visible cliff for the rest of the host's lifetime. try { - // IGlobalStore — realm-admin queries + // IGlobalStore — realm-admin queries. await realmService.GetAllRealmsAsync(); - await realmService.GetRealmBySlugAsync(TenantConstants.SystemTenantId); - - // Tenant-scoped queries — open one IDocumentSession against the - // system tenant and touch every read-shape the admin endpoints - // use. Tiny ToList() against the persisted documents — even on - // an empty tenant it's enough to compile the shape. - using (TenantContext.Enter(TenantConstants.SystemTenantId)) - await using (var session = realmScope.ServiceProvider - .GetRequiredService().QuerySession(TenantConstants.SystemTenantId)) + var warmupRealm = configuredRealms.FirstOrDefault(); + if (warmupRealm is not null) { - await session.Query() - .Where(u => !u.IsDeleted).Take(1).ToListAsync(); - // UserView is the read model the /api/user list endpoint queries — - // distinct from ApplicationUser, separate Marten LINQ shape. - await session.Query() - .Where(u => !u.IsDeleted).OrderBy(u => u.UserName).Take(1).ToListAsync(); - // Principal polymorphism — Person + Group share a discriminator. - // /api/account/me's permission BFS walks this projection. - await session.Query() - .Where(p => !p.IsDeleted).Take(1).ToListAsync(); - await session.Query() - .Where(r => !r.IsDeleted).Take(1).ToListAsync(); - await session.Query() - .Where(g => !g.IsDeleted).Take(1).ToListAsync(); - await session.Query() - .Where(p => !p.IsDeleted).Take(1).ToListAsync(); - await session.Query() - .OrderByDescending(l => l.Timestamp).Take(1).ToListAsync(); - await session.Query() - .Take(1).ToListAsync(); - } + await realmService.GetRealmBySlugAsync(warmupRealm.Slug); + using (TenantContext.Enter(warmupRealm.Slug)) + await using (var session = realmScope.ServiceProvider + .GetRequiredService().QuerySession(warmupRealm.Slug)) + { + await session.Query() + .Where(u => !u.IsDeleted).Take(1).ToListAsync(); + await session.Query() + .Where(u => !u.IsDeleted).OrderBy(u => u.UserName).Take(1).ToListAsync(); + await session.Query() + .Where(p => !p.IsDeleted).Take(1).ToListAsync(); + await session.Query() + .Where(r => !r.IsDeleted).Take(1).ToListAsync(); + await session.Query() + .Where(g => !g.IsDeleted).Take(1).ToListAsync(); + await session.Query() + .Where(p => !p.IsDeleted).Take(1).ToListAsync(); + await session.Query() + .OrderByDescending(l => l.Timestamp).Take(1).ToListAsync(); + await session.Query() + .Take(1).ToListAsync(); + } - // OAuthAdminService — separate read paths for clients/scopes/apis. - // Each goes through OpenIddict-Marten stores which have their own - // LINQ shapes; touching the service methods compiles them. - var oauthAdmin = realmScope.ServiceProvider.GetRequiredService(); - using (TenantContext.Enter(TenantConstants.SystemTenantId)) - { - await oauthAdmin.GetClientsAsync(new Modgud.Application.DTOs.OAuth.PaginationRequest { PageSize = 1 }); - await oauthAdmin.GetScopesAsync(); - await oauthAdmin.GetApisAsync(new Modgud.Application.DTOs.OAuth.PaginationRequest { PageSize = 1 }); + var oauthAdmin = realmScope.ServiceProvider + .GetRequiredService(); + using (TenantContext.Enter(warmupRealm.Slug)) + { + await oauthAdmin.GetClientsAsync(new Modgud.Application.DTOs.OAuth.PaginationRequest { PageSize = 1 }); + await oauthAdmin.GetScopesAsync(); + await oauthAdmin.GetApisAsync(new Modgud.Application.DTOs.OAuth.PaginationRequest { PageSize = 1 }); + } } } catch (Exception ex) @@ -1506,21 +1473,13 @@ await Modgud.Infrastructure.Authorization.AppRealmSeeder.SeedAsync( Log.Warning(ex, "Marten LINQ warmup failed (non-fatal)."); } - // No Control-Plane hostname validation needed: the gate reads the - // stored `Realm.IsControlPlane` flag (transferable; stamped on the - // system realm at first boot) off `tenant.IsControlPlane` at request - // time. The DB data is the single source of truth — there's no ENV var - // to keep in sync, no chicken-and-egg between operator config and - // seeded realm Domains. - // - // Operators add their public hostname(s) to the system realm's - // Domains via the Recovery CLI: - // recover realm-add-domain --slug system --domain auth.example.com + if (configuredRealms.Count == 0) + Log.Information("No realm exists yet; Modgud is waiting for first installation."); } // Headless command dispatch — run a recovery command instead of starting - // Kestrel. Two ways in, both run AFTER the full bootstrap block above so the - // command sees a provisioned master + system tenant: + // Kestrel. Two ways in, both run AFTER the master/global bootstrap above; + // realm-scoped commands additionally require an existing realm: // 1. CLI args: dotnet Modgud.Api.dll recover [args...] // → runs the command, returns its exit code (process exits). // 2. STARTUP_COMMAND env var (Portainer/Compose-friendly, no entrypoint @@ -1542,12 +1501,12 @@ await Modgud.Infrastructure.Authorization.AppRealmSeeder.SeedAsync( var exitCode = await Modgud.Authentication.Api.Admin.RecoveryCli.RunAsync( app.Services, cliArgs[1..], conf, app.Environment); - // This path never starts the host, so the SecurityAuditWriter background - // drain never runs — flush the recovery CLI's enqueued security-audit - // records to the system DB synchronously before the process exits, or the - // break-glass forensic trail would be lost. + // This path never starts the hosted writer, so route the queued realm and + // platform records synchronously before the process exits. await app.Services.GetRequiredService() - .FlushAsync(app.Services.GetRequiredService()); + .FlushAsync( + app.Services.GetRequiredService(), + app.Services.GetRequiredService()); if (fromEnv) { diff --git a/src/dotnet/Modgud.Api/Realtime/BrowserSessionHubFilter.cs b/src/dotnet/Modgud.Api/Realtime/BrowserSessionHubFilter.cs new file mode 100644 index 00000000..ec235aaa --- /dev/null +++ b/src/dotnet/Modgud.Api/Realtime/BrowserSessionHubFilter.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.SignalR; +using Modgud.Authentication.Sessions; + +namespace Modgud.Api.Realtime; + +/// +/// Binds every authenticated SignalR connection to the browser-session claim. +/// Targeted revocation aborts the upgraded connection immediately; each hub +/// invocation also re-checks the authoritative row. +/// +public sealed class BrowserSessionHubFilter( + IBrowserSessionConnectionRegistry connections) : IHubFilter +{ + private readonly ConcurrentDictionary _registrations = new(); + + public async Task OnConnectedAsync( + HubLifetimeContext context, + Func next) + { + var http = context.Context.GetHttpContext(); + var raw = context.Context.User?.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (http is null || !Guid.TryParse(raw, out var sessionId)) + { + context.Context.Abort(); + return; + } + + var registration = connections.Register( + sessionId, context.Context.ConnectionId, http); + if (_registrations.TryGetValue(context.Context.ConnectionId, out var previous)) + previous.Dispose(); + _registrations[context.Context.ConnectionId] = registration; + + try + { + await next(context); + } + catch + { + RemoveRegistration(context.Context.ConnectionId); + throw; + } + } + + public async Task OnDisconnectedAsync( + HubLifetimeContext context, + Exception? exception, + Func next) + { + RemoveRegistration(context.Context.ConnectionId); + await next(context, exception); + } + + public async ValueTask InvokeMethodAsync( + HubInvocationContext invocationContext, + Func> next) + { + var http = invocationContext.Context.GetHttpContext(); + var userId = http?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + var rawSessionId = http?.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (http is null || + !Guid.TryParse(userId, out var parsedUserId) || + !Guid.TryParse(rawSessionId, out var sessionId)) + { + invocationContext.Context.Abort(); + throw new HubException("The browser session is no longer valid."); + } + + await using var scope = http.RequestServices.CreateAsyncScope(); + var sessions = scope.ServiceProvider.GetRequiredService(); + if (await sessions.ValidateSessionAsync( + parsedUserId, sessionId, touch: true, http.RequestAborted) is null) + { + invocationContext.Context.Abort(); + throw new HubException("The browser session is no longer valid."); + } + + return await next(invocationContext); + } + + private void RemoveRegistration(string connectionId) + { + if (_registrations.TryRemove(connectionId, out var registration)) + registration.Dispose(); + } +} diff --git a/src/dotnet/Modgud.Api/TenantContextMiddleware.cs b/src/dotnet/Modgud.Api/TenantContextMiddleware.cs index 0655330f..3f6e71a4 100644 --- a/src/dotnet/Modgud.Api/TenantContextMiddleware.cs +++ b/src/dotnet/Modgud.Api/TenantContextMiddleware.cs @@ -12,8 +12,8 @@ namespace Modgud.Api; /// own per-handler middleware runs, so the tenant must be set on the bus itself /// before anything is invoked. /// -/// Falls back to the "system" tenant when HttpContext has nothing set — keeps -/// background services and integration tests working without changes. +/// If no realm was resolved (health/installation routes), the bus remains +/// tenantless. Such routes must not dispatch realm-scoped messages. /// /// Must register AFTER RealmMiddleware in the pipeline. /// @@ -22,9 +22,8 @@ public class TenantContextMiddleware(RequestDelegate next) public Task InvokeAsync(HttpContext context, IMessageBus bus) { var tenantId = context.Items["TenantId"] as string; - bus.TenantId = string.IsNullOrEmpty(tenantId) - ? TenantConstants.SystemTenantId - : tenantId; + if (!string.IsNullOrEmpty(tenantId)) + bus.TenantId = tenantId; return next(context); } } diff --git a/src/dotnet/Modgud.Application/DTOs/Applications/ApplicationSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/Applications/ApplicationSettingsDtos.cs index ab15f027..9a414d7f 100644 --- a/src/dotnet/Modgud.Application/DTOs/Applications/ApplicationSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/Applications/ApplicationSettingsDtos.cs @@ -15,6 +15,7 @@ public record ApplicationSettingsDto public ApplicationEmailBrandingDto? EmailBranding { get; init; } public ApplicationSelfRegistrationDto? SelfRegistration { get; init; } public ApplicationNativeGrantsDto? NativeGrants { get; init; } + public ApplicationClientSessionsDto? ClientSessions { get; init; } public ApplicationDcrDto? Dcr { get; init; } public ApplicationCimdDto? Cimd { get; init; } public ApplicationRegistrationFieldsDto? RegistrationFields { get; init; } @@ -64,6 +65,12 @@ public record ApplicationNativeGrantsDto public int? RefreshTokenLifetimeDays { get; init; } } +public record ApplicationClientSessionsDto +{ + public int? IdleLifetimeDays { get; init; } + public int? AbsoluteLifetimeDays { get; init; } +} + public record ApplicationDcrDto { public bool? Enabled { get; init; } diff --git a/src/dotnet/Modgud.Application/DTOs/LoginProviders/LoginProviderDto.cs b/src/dotnet/Modgud.Application/DTOs/LoginProviders/LoginProviderDto.cs index ae1c6781..2925b6ee 100644 --- a/src/dotnet/Modgud.Application/DTOs/LoginProviders/LoginProviderDto.cs +++ b/src/dotnet/Modgud.Application/DTOs/LoginProviders/LoginProviderDto.cs @@ -75,6 +75,12 @@ public record FlavorDto public required List DefaultScopes { get; init; } public required string DefaultUserUpdateScript { get; init; } public required bool DefaultStoreRawClaims { get; init; } + /// + /// Complete flavor-derived initial configuration shown in the create + /// editor. SAML flavors use this for seeded attribute/AMR mappings and + /// protocol defaults so the admin sees the exact object before saving. + /// + public JsonElement? DefaultFlavorData { get; init; } public required List ConfigSchema { get; init; } /// /// Protocol family this flavor implements — "Oidc" or "Saml". diff --git a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthApiDtos.cs b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthApiDtos.cs index 06b46bfe..efc3dc26 100644 --- a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthApiDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthApiDtos.cs @@ -11,8 +11,8 @@ public record OAuthApiDto public required List UserClaims { get; init; } /// /// FK to App.Id (Guid string). Null = unassigned (the RS exists - /// but /connect/userinfo emits no resource_access block - /// for this audience). + /// but no JWT/UserInfo/introspection resource_access block is + /// emitted for this audience). /// public string? AppId { get; init; } diff --git a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs index 055d87bb..29041eaa 100644 --- a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs @@ -1,4 +1,5 @@ using Modgud.Domain.OAuth.Common; +using Modgud.Application.DTOs.ServiceAccount; namespace Modgud.Application.DTOs.OAuth; @@ -28,7 +29,8 @@ public record OAuthClientDto public int? AccessTokenLifetime { get; init; } public int? AuthorizationCodeLifetime { get; init; } public int? SlidingRefreshTokenLifetime { get; init; } - + public int? ClientSessionIdleLifetime { get; init; } + public int? ClientSessionAbsoluteLifetime { get; init; } public bool AlwaysSendClientClaims { get; init; } public bool UpdateAccessTokenClaimsOnRefresh { get; init; } public string? ClientClaimsPrefix { get; init; } @@ -68,9 +70,10 @@ public record OAuthClientDto /// /// Apps this client is linked to (Guid strings). Empty = realm-wide / /// unassigned. One id = typical SPA. Many = a frontend that bundles - /// multiple resource servers (Keycloak-style resource_access in - /// the issued token's UserInfo claims). The frontend joins these - /// against its apps store to resolve slugs. + /// scopes from several Apps. The link controls App-scope entitlement; + /// requested registered OAuth API Audiences, not these ids, determine + /// resource_access keys. The frontend joins these against its + /// apps store to resolve slugs. /// public List AppIds { get; init; } = []; @@ -139,6 +142,8 @@ public record CreateOAuthClientDto public int? AccessTokenLifetime { get; init; } public int? AuthorizationCodeLifetime { get; init; } public int? SlidingRefreshTokenLifetime { get; init; } + public int? ClientSessionIdleLifetime { get; init; } + public int? ClientSessionAbsoluteLifetime { get; init; } public bool AlwaysSendClientClaims { get; init; } public bool UpdateAccessTokenClaimsOnRefresh { get; init; } @@ -176,6 +181,14 @@ public record CreateOAuthClientDto /// is present — endpoint-level validation enforces the split. /// public string? LinkedServiceAccountId { get; init; } + + /// + /// Optional ServiceAccount to create atomically with this OAuth client. + /// Mutually exclusive with . This keeps + /// first-time M2M setup in one save without leaving an orphaned principal + /// when client validation or persistence fails. + /// + public ServiceAccountCreateDto? NewServiceAccount { get; init; } } public record UpdateOAuthClientDto @@ -201,6 +214,10 @@ public record UpdateOAuthClientDto public int? AccessTokenLifetime { get; init; } public int? AuthorizationCodeLifetime { get; init; } public int? SlidingRefreshTokenLifetime { get; init; } + public int? ClientSessionIdleLifetime { get; init; } + public int? ClientSessionAbsoluteLifetime { get; init; } + public bool ClearClientSessionIdleLifetime { get; init; } + public bool ClearClientSessionAbsoluteLifetime { get; init; } public bool? AlwaysSendClientClaims { get; init; } public bool? UpdateAccessTokenClaimsOnRefresh { get; init; } @@ -252,4 +269,5 @@ public record OAuthClientCreatedDto { public required OAuthClientDto Client { get; init; } public string? ClientSecret { get; init; } + public ServiceAccountDto? CreatedServiceAccount { get; init; } } diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuditSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuditSettingsDtos.cs index dfca6633..26257971 100644 --- a/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuditSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuditSettingsDtos.cs @@ -7,6 +7,7 @@ namespace Modgud.Application.DTOs.RealmSettings; public record AuditSettingsDto { public int VisibilityWindowDays { get; init; } = 90; + public int SecurityRetentionDays { get; init; } = 7; } /// Patch payload for the tenant-audit sub-section. Nullable = no change on @@ -15,4 +16,5 @@ public record AuditSettingsDto public record UpdateAuditSettingsDto { public int? VisibilityWindowDays { get; init; } + public int? SecurityRetentionDays { get; init; } } diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs index eb8546b1..cf458ec4 100644 --- a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs @@ -16,6 +16,8 @@ public record RealmSettingsDto public DcrSettingsDto Dcr { get; init; } = new(); public CimdSettingsDto Cimd { get; init; } = new(); public NativeGrantSettingsDto NativeGrants { get; init; } = new(); + public BrowserSessionPolicyDto BrowserSessions { get; init; } = new(); + public ClientSessionPolicyDto ClientSessions { get; init; } = new(); public AuthRateLimitsDto AuthRateLimits { get; init; } = new(); public BrandingSettingsDto Branding { get; init; } = new(); public RegistrationFieldsSettingsDto RegistrationFields { get; init; } = new(); @@ -37,6 +39,8 @@ public record UpdateRealmSettingsDto public UpdateDcrSettingsDto? Dcr { get; init; } public UpdateCimdSettingsDto? Cimd { get; init; } public UpdateNativeGrantSettingsDto? NativeGrants { get; init; } + public UpdateBrowserSessionPolicyDto? BrowserSessions { get; init; } + public UpdateClientSessionPolicyDto? ClientSessions { get; init; } public UpdateAuthRateLimitsDto? AuthRateLimits { get; init; } public UpdateBrandingSettingsDto? Branding { get; init; } public UpdateRegistrationFieldsSettingsDto? RegistrationFields { get; init; } diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/SessionPolicyDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/SessionPolicyDtos.cs new file mode 100644 index 00000000..a3c009c3 --- /dev/null +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/SessionPolicyDtos.cs @@ -0,0 +1,27 @@ +namespace Modgud.Application.DTOs.RealmSettings; + +public record BrowserSessionPolicyDto +{ + public int IdleLifetimeMinutes { get; init; } + public int AbsoluteLifetimeMinutes { get; init; } + public bool AllowRememberMe { get; init; } +} + +public record UpdateBrowserSessionPolicyDto +{ + public int? IdleLifetimeMinutes { get; init; } + public int? AbsoluteLifetimeMinutes { get; init; } + public bool? AllowRememberMe { get; init; } +} + +public record ClientSessionPolicyDto +{ + public int IdleLifetimeDays { get; init; } + public int AbsoluteLifetimeDays { get; init; } +} + +public record UpdateClientSessionPolicyDto +{ + public int? IdleLifetimeDays { get; init; } + public int? AbsoluteLifetimeDays { get; init; } +} diff --git a/src/dotnet/Modgud.Application/DTOs/Realms/RealmDtos.cs b/src/dotnet/Modgud.Application/DTOs/Realms/RealmDtos.cs index 74ee3340..a7dc10f7 100644 --- a/src/dotnet/Modgud.Application/DTOs/Realms/RealmDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/Realms/RealmDtos.cs @@ -13,7 +13,6 @@ public record RealmDto public string PrimaryDomain { get; init; } = string.Empty; public bool IsControlPlane { get; init; } public bool IsActive { get; init; } - public bool NeedsSetup { get; init; } public DateTimeOffset CreatedAt { get; init; } } @@ -92,14 +91,18 @@ public record CreateRealmDto public string? PrimaryDomain { get; init; } /// - /// First-admin invite issued atomically with the realm (C15c). - /// Required: a realm with no admin path is unusable. The CP-admin - /// fills UserName + Email; the recipient gets a magic-link mail and - /// sets their own password — the CP-admin never sees the password, - /// which keeps SaaS scenarios clean (tenant requester is the only - /// person who knows the credentials). + /// Initial activation state for ordinary realm creation. Defaults to true. + /// First-installation realms remain inactive until installation completes, + /// regardless of this value. /// - public InitialAdminDto InitialAdmin { get; init; } = new(); + public bool? IsActive { get; init; } + + /// + /// Optional backwards-compatible convenience for API callers that want + /// to issue an admin invite together with realm creation. The Realm admin + /// UI deliberately keeps this as a separate action. + /// + public InitialAdminDto? InitialAdmin { get; init; } } public record InitialAdminDto @@ -122,7 +125,7 @@ public record CreatedRealmDto /// secret-equivalent and either copy it to a secure channel or trust /// that the recipient will get the email. /// - public InitialAdminInviteDto InitialAdminInvite { get; init; } = new(); + public InitialAdminInviteDto? InitialAdminInvite { get; init; } } public record InitialAdminInviteDto diff --git a/src/dotnet/Modgud.Application/DTOs/ServiceAccount/ServiceAccountCredentialDtos.cs b/src/dotnet/Modgud.Application/DTOs/ServiceAccount/ServiceAccountCredentialDtos.cs index 50a026f6..d87000e9 100644 --- a/src/dotnet/Modgud.Application/DTOs/ServiceAccount/ServiceAccountCredentialDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/ServiceAccount/ServiceAccountCredentialDtos.cs @@ -32,6 +32,9 @@ public class IssueServiceAccountCredentialDto /// Optional override for the access-token lifetime (seconds). public int? AccessTokenLifetime { get; set; } + /// Whether the credential may issue tokens immediately. + public bool Enabled { get; set; } = true; + /// /// Access-token format. Defaults to /// (opaque, stored, INSTANTLY revocable) — so deactivating/deleting/rotating diff --git a/src/dotnet/Modgud.Application/DTOs/ServiceAccount/ServiceAccountDto.cs b/src/dotnet/Modgud.Application/DTOs/ServiceAccount/ServiceAccountDto.cs index b3433141..7815b17b 100644 --- a/src/dotnet/Modgud.Application/DTOs/ServiceAccount/ServiceAccountDto.cs +++ b/src/dotnet/Modgud.Application/DTOs/ServiceAccount/ServiceAccountDto.cs @@ -16,12 +16,20 @@ public class ServiceAccountDto public string? Purpose { get; set; } public bool IsActive { get; set; } = true; public EntityStatus Status { get; set; } = EntityStatus.Active; + + /// + /// Present only on create when an initial credential was requested. The + /// plaintext secret is returned exactly once and is never persisted. + /// + public ServiceAccountCredentialIssuedDto? InitialCredential { get; set; } } public class ServiceAccountCreateDto { public string AccountName { get; set; } = string.Empty; public string? Purpose { get; set; } + public bool IsActive { get; set; } = true; + public IssueServiceAccountCredentialDto? InitialCredential { get; set; } } public class ServiceAccountUpdateDto diff --git a/src/dotnet/Modgud.Application/DTOs/User/UserCreateDto.cs b/src/dotnet/Modgud.Application/DTOs/User/UserCreateDto.cs index e76b1ead..958c9ffa 100644 --- a/src/dotnet/Modgud.Application/DTOs/User/UserCreateDto.cs +++ b/src/dotnet/Modgud.Application/DTOs/User/UserCreateDto.cs @@ -13,4 +13,29 @@ public class UserCreateDto /// skips the magic-link verify step for internal/trusted users. /// public bool EmailConfirmed { get; set; } + + /// + /// Whether the account can sign in. Defaults to true; set false to stage an + /// account that only becomes usable later (onboarding ahead of a start date). + /// The admin UI offers the same switch on create as on edit, so a user can be + /// created complete in one step. + /// + public bool IsActive { get; set; } = true; + + /// + /// Direct manual group memberships that should be part of the newly + /// created user. The create endpoint validates all groups before writing + /// anything and commits the user and memberships together. + /// + public List GroupIds { get; set; } = []; + + /// + /// Per-user 2FA grace-period override. Null uses the application default. + /// + public int? GracePeriodDaysOverride { get; set; } + + /// + /// Whether this user bypasses the 2FA grace period and enforcement. + /// + public bool TwoFactorExempt { get; set; } } diff --git a/src/dotnet/Modgud.Application/Errors/OAuthErrors.cs b/src/dotnet/Modgud.Application/Errors/OAuthErrors.cs index ebb696f5..f9b745cd 100644 --- a/src/dotnet/Modgud.Application/Errors/OAuthErrors.cs +++ b/src/dotnet/Modgud.Application/Errors/OAuthErrors.cs @@ -68,9 +68,21 @@ public static Error ServiceAccountNotFound(string id) => Error.Validation( code: "OAuth.ServiceAccountNotFound", description: $"ServiceAccount '{id}' not found or deleted."); + public static Error ServiceAccountLinkModesAreMutuallyExclusive => Error.Validation( + code: "OAuth.ServiceAccountLinkModesAreMutuallyExclusive", + description: "Provide either LinkedServiceAccountId or NewServiceAccount, not both."); + + public static Error InvalidNewServiceAccountName => Error.Validation( + code: "OAuth.InvalidNewServiceAccountName", + description: "The new ServiceAccount account name must be 2-64 characters and contain only lowercase letters, digits, dots, hyphens, or underscores."); + + public static Error ServiceAccountNameAlreadyExists(string accountName) => Error.Conflict( + code: "OAuth.ServiceAccountNameAlreadyExists", + description: $"Account name '{accountName}' is already used by a person or ServiceAccount."); + public static Error ClientCredentialsRequiresServiceAccountLink => Error.Validation( code: "OAuth.ClientCredentialsRequiresServiceAccountLink", - description: "A client with the 'client_credentials' grant must be linked to a ServiceAccount. Create the SA first, then link it via LinkedServiceAccountId."); + description: "A client with the 'client_credentials' grant must reference LinkedServiceAccountId or include NewServiceAccount."); public static Error ServiceAccountLinkRequiresClientCredentialsOnly => Error.Validation( code: "OAuth.ServiceAccountLinkRequiresClientCredentialsOnly", diff --git a/src/dotnet/Modgud.Application/Scheduling/IJobsService.cs b/src/dotnet/Modgud.Application/Scheduling/IJobsService.cs index 5d1c35a9..42a97b87 100644 --- a/src/dotnet/Modgud.Application/Scheduling/IJobsService.cs +++ b/src/dotnet/Modgud.Application/Scheduling/IJobsService.cs @@ -2,9 +2,10 @@ namespace Modgud.Application.Scheduling; /// /// Admin-facing facade combining the static job registry (compiled jobs), -/// the persisted JobConfig overrides, the running Quartz scheduler, -/// and the JobRunHistoryEntry ledger. Used by the admin endpoints -/// at /api/admin/jobs; not consumed from the request path elsewhere. +/// persisted JobConfig overrides, Quartz identities, and the +/// JobRunHistoryEntry ledger. Realm state is tenant-owned. System state +/// lives in the non-tenanted global store and is exposed only when the current +/// realm is the Control Plane. /// public interface IJobsService { @@ -34,6 +35,7 @@ public sealed record JobOverviewDto public required string Name { get; init; } public string? Description { get; init; } public required string Kind { get; init; } // "System" | "Script" + public required string Scope { get; init; } // "Realm" | "System" public required string EffectiveCron { get; init; } // override if present, else default public required string DefaultCron { get; init; } public bool HasOverride { get; init; } diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs index 0af0ae9f..1c5431fa 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs @@ -168,6 +168,8 @@ internal static Dictionary BuildClientSettings(CreateOAuthClient if (dto.AccessTokenLifetime.HasValue) settings[OAuthApplicationSettingKeys.AccessTokenLifetime] = dto.AccessTokenLifetime.Value.ToString(); if (dto.AuthorizationCodeLifetime.HasValue) settings[OAuthApplicationSettingKeys.AuthorizationCodeLifetime] = dto.AuthorizationCodeLifetime.Value.ToString(); if (dto.SlidingRefreshTokenLifetime.HasValue) settings[OAuthApplicationSettingKeys.SlidingRefreshTokenLifetime] = dto.SlidingRefreshTokenLifetime.Value.ToString(); + if (dto.ClientSessionIdleLifetime.HasValue) settings[OAuthApplicationSettingKeys.ClientSessionIdleLifetime] = dto.ClientSessionIdleLifetime.Value.ToString(); + if (dto.ClientSessionAbsoluteLifetime.HasValue) settings[OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime] = dto.ClientSessionAbsoluteLifetime.Value.ToString(); if (dto.ClientClaimsPrefix is not null) settings[OAuthApplicationSettingKeys.ClientClaimsPrefix] = dto.ClientClaimsPrefix; // ADR-0009 — store the normalized (trimmed, lowercased) per-client RP ID; a // blank value leaves it realm-scoped (no key). Format is validated upstream. @@ -304,6 +306,10 @@ internal static Dictionary MergeClientSettings( if (dto.AccessTokenLifetime.HasValue) settings[OAuthApplicationSettingKeys.AccessTokenLifetime] = dto.AccessTokenLifetime.Value.ToString(); if (dto.AuthorizationCodeLifetime.HasValue) settings[OAuthApplicationSettingKeys.AuthorizationCodeLifetime] = dto.AuthorizationCodeLifetime.Value.ToString(); if (dto.SlidingRefreshTokenLifetime.HasValue) settings[OAuthApplicationSettingKeys.SlidingRefreshTokenLifetime] = dto.SlidingRefreshTokenLifetime.Value.ToString(); + if (dto.ClearClientSessionIdleLifetime) settings.Remove(OAuthApplicationSettingKeys.ClientSessionIdleLifetime); + if (dto.ClearClientSessionAbsoluteLifetime) settings.Remove(OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime); + if (dto.ClientSessionIdleLifetime.HasValue) settings[OAuthApplicationSettingKeys.ClientSessionIdleLifetime] = dto.ClientSessionIdleLifetime.Value.ToString(); + if (dto.ClientSessionAbsoluteLifetime.HasValue) settings[OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime] = dto.ClientSessionAbsoluteLifetime.Value.ToString(); if (dto.ClientClaimsPrefix is not null) settings[OAuthApplicationSettingKeys.ClientClaimsPrefix] = dto.ClientClaimsPrefix; // ADR-0009 PATCH: null = omit; empty/blank = clear back to realm-scoped; // non-blank = set (normalized). Format is validated upstream. @@ -462,6 +468,26 @@ internal static Dictionary MergeClientSettings( private static string ToLifetimeString(int seconds) => TimeSpan.FromSeconds(seconds).ToString("c", CultureInfo.InvariantCulture); + internal static Error? ValidateClientSessionLifetimes( + int? idleLifetimeSeconds, + int? absoluteLifetimeSeconds) + { + const int min = 24 * 60 * 60; + const int max = 3650 * 24 * 60 * 60; + + if (idleLifetimeSeconds is { } idle && (idle < min || idle > max)) + return Error.Validation("OAuthClient.InvalidClientSessionIdleLifetime", + $"ClientSessionIdleLifetime must be between {min} and {max} seconds."); + if (absoluteLifetimeSeconds is { } absolute && (absolute < min || absolute > max)) + return Error.Validation("OAuthClient.InvalidClientSessionAbsoluteLifetime", + $"ClientSessionAbsoluteLifetime must be between {min} and {max} seconds."); + if (idleLifetimeSeconds is { } i && absoluteLifetimeSeconds is { } a && a < i) + return Error.Validation("OAuthClient.InvalidClientSessionAbsoluteLifetime", + "ClientSessionAbsoluteLifetime must be at least ClientSessionIdleLifetime."); + + return null; + } + /// /// Merges an over the client's /// Properties dictionary. Each property field on the DTO is @@ -538,6 +564,8 @@ internal static OAuthClientDto MapClient(OAuthApplicationState s) AccessTokenLifetime = GetIntSetting(OAuthApplicationSettingKeys.AccessTokenLifetime), AuthorizationCodeLifetime = GetIntSetting(OAuthApplicationSettingKeys.AuthorizationCodeLifetime), SlidingRefreshTokenLifetime = GetIntSetting(OAuthApplicationSettingKeys.SlidingRefreshTokenLifetime), + ClientSessionIdleLifetime = GetIntSetting(OAuthApplicationSettingKeys.ClientSessionIdleLifetime), + ClientSessionAbsoluteLifetime = GetIntSetting(OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime), AlwaysSendClientClaims = GetBoolProp(props, OAuthApplicationPropertyKeys.AlwaysSendClientClaims, false), UpdateAccessTokenClaimsOnRefresh = GetBoolProp(props, OAuthApplicationPropertyKeys.UpdateAccessTokenClaimsOnRefresh, false), ClientClaimsPrefix = prefix, diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs index 7da404ee..3e186cfb 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Text.Json; +using System.Text.RegularExpressions; using BuildingBlocks.Helper; using Modgud.Application.Dcr; using Modgud.Application.DTOs.OAuth; @@ -28,6 +29,9 @@ namespace Modgud.Application.Services; /// public class OAuthAdminService { + private static readonly Regex ServiceAccountNamePattern = + new("^[a-z0-9][a-z0-9._-]{1,63}$", RegexOptions.Compiled); + private readonly IDocumentSession _session; public OAuthAdminService(IDocumentSession session) @@ -80,6 +84,23 @@ public Task> CreateClientAsync( /// public async Task> CreateClientAsync( CreateOAuthClientDto dto, DcrMetadataInput? dcrMetadata, CancellationToken ct = default) + => await CreateClientAsync( + dto, + dcrMetadata, + enlistInTransaction: null, + ct); + + /// + /// DCR-capable create path with an optional same-session enlistment hook. + /// The API layer uses this to add its infrastructure-owned required audit + /// document before this service commits, without introducing an + /// Application-to-Infrastructure dependency. + /// + public async Task> CreateClientAsync( + CreateOAuthClientDto dto, + DcrMetadataInput? dcrMetadata, + Action? enlistInTransaction, + CancellationToken ct = default) { if (dto.ClientType is not (OAuthClientTypes.Public or OAuthClientTypes.Confidential)) return OAuthErrors.InvalidClientType(dto.ClientType); @@ -112,16 +133,51 @@ public async Task> CreateClientAsync( } } - // ServiceAccount-link validation. The endpoint accepts a raw - // LinkedServiceAccountId on the create DTO so M2M setup is a single - // round-trip; downstream mutations of the link (rotate, unlink, etc.) - // go through the SA-scoped credentials endpoints instead. Parse, then - // confirm the SA exists, then enforce the SA-link invariant - // (R1/R2/R3) against the combination of grants + link the admin - // submitted. DCR clients never come with a link — the DCR pipeline - // doesn't surface it. + // ServiceAccount-link validation. Admin-created M2M clients may either + // reference an existing SA or create one inline. Inline creation is + // stored in this same Marten session and committed together with the + // OAuth event stream, so a later validation/persistence failure cannot + // leave an orphaned principal behind. DCR never supplies either shape. Guid? linkedServiceAccountId = null; - if (!string.IsNullOrWhiteSpace(dto.LinkedServiceAccountId)) + ServiceAccountDto? createdServiceAccount = null; + if (!string.IsNullOrWhiteSpace(dto.LinkedServiceAccountId) && dto.NewServiceAccount is not null) + return OAuthErrors.ServiceAccountLinkModesAreMutuallyExclusive; + + if (dto.NewServiceAccount is not null) + { + var accountName = (dto.NewServiceAccount.AccountName ?? string.Empty) + .Trim() + .ToLowerInvariant(); + if (!ServiceAccountNamePattern.IsMatch(accountName)) + return OAuthErrors.InvalidNewServiceAccountName; + + var personTaken = await _session.Query() + .AnyAsync(p => !p.IsDeleted && p.AccountName == accountName, ct); + var serviceAccountTaken = await _session.Query() + .AnyAsync(sa => !sa.IsDeleted && sa.AccountName == accountName, ct); + if (personTaken || serviceAccountTaken) + return OAuthErrors.ServiceAccountNameAlreadyExists(accountName); + + var serviceAccount = new ServiceAccount + { + Id = Guid.NewGuid(), + AccountName = accountName, + Purpose = string.IsNullOrWhiteSpace(dto.NewServiceAccount.Purpose) + ? null + : dto.NewServiceAccount.Purpose.Trim(), + IsActive = dto.NewServiceAccount.IsActive, + }; + _session.Store(serviceAccount); + linkedServiceAccountId = serviceAccount.Id; + createdServiceAccount = new ServiceAccountDto + { + Id = new ShortGuid(serviceAccount.Id).ToString(), + AccountName = serviceAccount.AccountName, + Purpose = serviceAccount.Purpose, + IsActive = serviceAccount.IsActive, + }; + } + else if (!string.IsNullOrWhiteSpace(dto.LinkedServiceAccountId)) { if (!ShortGuid.TryParse(dto.LinkedServiceAccountId, out Guid parsedSa)) return OAuthErrors.InvalidServiceAccountId(dto.LinkedServiceAccountId); @@ -165,6 +221,10 @@ public async Task> CreateClientAsync( // Settings (primitive lifetime + token-type values). var settings = BuildClientSettings(dto); + if (ValidateClientSessionLifetimes( + dto.ClientSessionIdleLifetime, + dto.ClientSessionAbsoluteLifetime) is { } clientSessionError) + return clientSessionError; if (dcrMetadata is null) { // Issue #115 — standard (non-DCR) clients: wire the admin's @@ -253,6 +313,7 @@ public async Task> CreateClientAsync( _session.Store(sec); } + enlistInTransaction?.Invoke(_session); await _session.SaveChangesAsync(ct); // Reload projected state so the response reflects the persisted view. @@ -261,6 +322,7 @@ public async Task> CreateClientAsync( { Client = MapClient(state!), ClientSecret = clientSecret, + CreatedServiceAccount = createdServiceAccount, }; } @@ -341,6 +403,15 @@ public async Task> UpdateClientAsync( // Settings — partial-PATCH merge; only emit the event when the merge // actually produced a different dictionary. var newSettings = MergeClientSettings(aggregate.Settings, dto); + if ((dto.ClearClientSessionIdleLifetime && dto.ClientSessionIdleLifetime.HasValue) || + (dto.ClearClientSessionAbsoluteLifetime && dto.ClientSessionAbsoluteLifetime.HasValue)) + return Error.Validation( + "OAuthClient.ConflictingClientSessionLifetimeUpdate", + "A client-session lifetime cannot be set and cleared in the same update."); + if (ValidateClientSessionLifetimes( + dto.ClientSessionIdleLifetime, + dto.ClientSessionAbsoluteLifetime) is { } clientSessionError) + return clientSessionError; // Issue #115 — same native tkn_lft:* wiring as CreateClientAsync, PATCH // semantics: a field omitted from the DTO leaves any existing @@ -527,7 +598,7 @@ public async Task> IssueServiceAccoun Scopes = dto.Scopes, RequireClientSecret = true, RequireConsent = false, - Enabled = true, + Enabled = dto.Enabled, // Audit #6/#7/#8 — default Reference (opaque + instantly revocable) so // SA deactivate/delete/rotate cuts off live M2M access immediately. JWT // is opt-in for resource servers that must self-validate (its already- diff --git a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopProofValidator.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopProofValidator.cs similarity index 98% rename from src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopProofValidator.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopProofValidator.cs index cbae2544..072e4d35 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopProofValidator.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopProofValidator.cs @@ -1,7 +1,7 @@ // ───────────────────────────────────────────────────────────────────────── // DUPLICATED, KEEP IN SYNC. Verbatim copy (namespace aside) of // Modgud.Infrastructure/OpenIddict/Dpop/. The resource-server side -// needs the identical DPoP crypto, but this client library is a published NuGet +// needs the identical DPoP crypto, but this resource-server library is a published NuGet // kept deliberately dependency-light, so the code is duplicated rather than // shared. Any change to the server-side original MUST be mirrored here. // ───────────────────────────────────────────────────────────────────────── @@ -11,7 +11,7 @@ using System.Text; using System.Text.Json; -namespace Modgud.Client.AspNetCore.Dpop; +namespace Modgud.AspNetCore.ResourceServer.Dpop; /// /// Validates a DPoP proof JWT (RFC 9449 §4.3): a compact JWS sent in the @@ -30,7 +30,7 @@ namespace Modgud.Client.AspNetCore.Dpop; /// jti replay detection is left to the caller (it needs a per-realm store /// and a TTL policy that live outside this crypto core). Keeping it side-effect /// free is what lets the identical file be duplicated into the dependency-light -/// Modgud.Client.AspNetCore NuGet for the resource-server side. +/// Modgud.AspNetCore.ResourceServer NuGet for the resource-server side. /// /// /// diff --git a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopResourceValidator.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopResourceValidator.cs similarity index 98% rename from src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopResourceValidator.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopResourceValidator.cs index 7ee35922..a8581440 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopResourceValidator.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopResourceValidator.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace Modgud.Client.AspNetCore.Dpop; +namespace Modgud.AspNetCore.ResourceServer.Dpop; /// Outcome of validating a DPoP proof presented at a resource server. internal enum DpopResourceResult diff --git a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopValidationResult.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopValidationResult.cs similarity index 96% rename from src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopValidationResult.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopValidationResult.cs index bf94a807..a621f30c 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopValidationResult.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopValidationResult.cs @@ -1,12 +1,12 @@ // ───────────────────────────────────────────────────────────────────────── // DUPLICATED, KEEP IN SYNC. Verbatim copy (namespace aside) of // Modgud.Infrastructure/OpenIddict/Dpop/. The resource-server side -// needs the identical DPoP crypto, but this client library is a published NuGet +// needs the identical DPoP crypto, but this resource-server library is a published NuGet // kept deliberately dependency-light, so the code is duplicated rather than // shared. Any change to the server-side original MUST be mirrored here. // ───────────────────────────────────────────────────────────────────────── -namespace Modgud.Client.AspNetCore.Dpop; +namespace Modgud.AspNetCore.ResourceServer.Dpop; /// /// Why a DPoP proof was rejected (RFC 9449 §4.3 / §5). means diff --git a/src/dotnet/Modgud.Client.AspNetCore/Dpop/JwkThumbprint.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/JwkThumbprint.cs similarity index 96% rename from src/dotnet/Modgud.Client.AspNetCore/Dpop/JwkThumbprint.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/JwkThumbprint.cs index 14c35542..bf42ba21 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Dpop/JwkThumbprint.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/JwkThumbprint.cs @@ -1,7 +1,7 @@ // ───────────────────────────────────────────────────────────────────────── // DUPLICATED, KEEP IN SYNC. Verbatim copy (namespace aside) of // Modgud.Infrastructure/OpenIddict/Dpop/. The resource-server side -// needs the identical DPoP crypto, but this client library is a published NuGet +// needs the identical DPoP crypto, but this resource-server library is a published NuGet // kept deliberately dependency-light, so the code is duplicated rather than // shared. Any change to the server-side original MUST be mirrored here. // ───────────────────────────────────────────────────────────────────────── @@ -10,7 +10,7 @@ using System.Security.Cryptography; using System.Text; -namespace Modgud.Client.AspNetCore.Dpop; +namespace Modgud.AspNetCore.ResourceServer.Dpop; /// /// RFC 7638 JWK thumbprint (SHA-256, base64url) for the two key types DPoP @@ -38,7 +38,7 @@ namespace Modgud.Client.AspNetCore.Dpop; /// /// Kept dependency-free (BCL only: + /// ) so the exact same file can be -/// duplicated verbatim into the dependency-light Modgud.Client.AspNetCore +/// duplicated verbatim into the dependency-light Modgud.AspNetCore.ResourceServer /// NuGet for resource-server-side validation. Any change here MUST be mirrored /// there — see the "keep in sync" note on the client copy. /// diff --git a/src/dotnet/Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj b/src/dotnet/Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj similarity index 83% rename from src/dotnet/Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj index a19fbfa8..741880ad 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj @@ -1,14 +1,13 @@ - Modgud.Client.AspNetCore + Modgud.AspNetCore.ResourceServer ASP.NET Core integration for Modgud resource servers. Validates - either JWT access tokens (AddModgudClient, on top of AddJwtBearer) - or Modgud's default opaque reference tokens via introspection - (AddModgudReferenceTokenClient), then surfaces the per-audience + JWT access tokens, opaque reference tokens via introspection, or both + through one AddModgudResourceServer registration, then surfaces the per-audience resource_access roles/permissions as flat claims so - [Authorize(Roles="...")] and .RequiresModgudPermission("...") work + [Authorize(Roles="...")] and .RequireModgudPermission("...") work natively. Bypass tiers are pre-expanded by the IdP, so the lib does pure exact-match — no evaluator logic. @@ -19,7 +18,7 @@ source-of-truth for versioning is the git tag, not a checked-in string. --> - Modgud.Client.AspNetCore + Modgud.AspNetCore.ResourceServer Cocoar Cocoar Copyright © Cocoar @@ -52,7 +51,7 @@ + to project resource_access onto the validated principal. --> + and routes the named introspection HttpClient to the in-memory IdP. --> diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudClaimsProjector.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudClaimsProjector.cs new file mode 100644 index 00000000..81e33af2 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudClaimsProjector.cs @@ -0,0 +1,86 @@ +using System.Security.Claims; +using System.Text.Json; + +namespace Modgud.AspNetCore.ResourceServer; + +/// Claim names projected by the Modgud resource-server package. +public static class ModgudClaimTypes +{ + /// A concrete <resource>:<action> permission. + public const string Permission = "permission"; + + /// The per-audience authorization object emitted by Modgud. + public const string ResourceAccess = "resource_access"; +} + +/// +/// Projects one scheme's configured audience block directly onto its +/// authenticated identity. This deliberately does not use +/// IClaimsTransformation: the audience belongs to the authentication +/// scheme that validated the token, not to global application state. +/// +internal static class ModgudClaimsProjector +{ + public static void Project(ClaimsPrincipal? principal, string audience) + { + if (principal?.Identity is not ClaimsIdentity identity || + !identity.IsAuthenticated || + string.IsNullOrWhiteSpace(audience)) + { + return; + } + + var raw = identity.FindFirst(ModgudClaimTypes.ResourceAccess)?.Value; + if (string.IsNullOrWhiteSpace(raw) || + !TryParseJson(raw, out var resourceAccess) || + resourceAccess.ValueKind != JsonValueKind.Object || + !resourceAccess.TryGetProperty(audience, out var audienceBlock) || + audienceBlock.ValueKind != JsonValueKind.Object) + { + return; + } + + FlattenStringArray(identity, audienceBlock, "roles", ClaimTypes.Role); + FlattenStringArray(identity, audienceBlock, "permissions", ModgudClaimTypes.Permission); + } + + private static void FlattenStringArray( + ClaimsIdentity identity, + JsonElement audienceBlock, + string property, + string claimType) + { + if (!audienceBlock.TryGetProperty(property, out var array) || + array.ValueKind != JsonValueKind.Array) + { + return; + } + + var existing = new HashSet( + identity.FindAll(claimType).Select(c => c.Value), + StringComparer.Ordinal); + + foreach (var element in array.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.String) continue; + var value = element.GetString(); + if (string.IsNullOrEmpty(value) || !existing.Add(value)) continue; + identity.AddClaim(new Claim(claimType, value)); + } + } + + private static bool TryParseJson(string raw, out JsonElement element) + { + try + { + using var document = JsonDocument.Parse(raw); + element = document.RootElement.Clone(); + return true; + } + catch (JsonException) + { + element = default; + return false; + } + } +} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudDpopJwtBearer.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudDpopJwtBearer.cs similarity index 96% rename from src/dotnet/Modgud.Client.AspNetCore/ModgudDpopJwtBearer.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudDpopJwtBearer.cs index c9624678..1ddc5217 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudDpopJwtBearer.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudDpopJwtBearer.cs @@ -3,9 +3,9 @@ using System.Text.Json; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; -using Modgud.Client.AspNetCore.Dpop; +using Modgud.AspNetCore.ResourceServer.Dpop; -namespace Modgud.Client.AspNetCore; +namespace Modgud.AspNetCore.ResourceServer; /// /// Resource-server DPoP enforcement for the JWT-bearer validation path @@ -13,7 +13,7 @@ namespace Modgud.Client.AspNetCore; /// . /// /// Two hooks into the JwtBearer pipeline, wired by -/// : +/// AddModgudResourceServer in a JWT-capable mode: /// /// OnMessageReceived () — a /// DPoP-bound token is presented under the DPoP auth scheme, not @@ -150,8 +150,8 @@ public static BindingResult EvaluateBinding(HttpRequest request, ClaimsPrincipal /// /// OnTokenValidated hook: runs and, on any /// rejection, fails the authentication (→ 401) with an RFC-flavoured reason. - /// A pass leaves the context untouched so downstream handlers (UserInfo - /// enrichment) continue. + /// A pass leaves the context untouched so scheme-local claims projection + /// can continue. /// public static void EnforceBinding(TokenValidatedContext context) { diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudIntrospectionHandler.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudIntrospectionHandler.cs new file mode 100644 index 00000000..ef43282d --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudIntrospectionHandler.cs @@ -0,0 +1,194 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Modgud.AspNetCore.ResourceServer; + +internal sealed class ModgudIntrospectionHandler : AuthenticationHandler +{ + private readonly IHttpClientFactory _httpClientFactory; + + public ModgudIntrospectionHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IHttpClientFactory httpClientFactory) + : base(options, logger, encoder) + { + _httpClientFactory = httpClientFactory; + } + + protected override async Task HandleAuthenticateAsync() + { + var rawAuthorization = Request.Headers.Authorization.ToString(); + if (string.IsNullOrEmpty(rawAuthorization) || + !AuthenticationHeaderValue.TryParse(rawAuthorization, out var header) || + string.IsNullOrEmpty(header.Parameter)) + { + return AuthenticateResult.NoResult(); + } + + var isBearer = string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase); + var isDpop = string.Equals(header.Scheme, Dpop.DpopResource.Scheme, StringComparison.OrdinalIgnoreCase); + if (!isBearer && !isDpop) + return AuthenticateResult.NoResult(); + + var client = _httpClientFactory.CreateClient(ModgudHttpClientNames.Introspection); + var principal = await ModgudTokenIntrospection.IntrospectAsync( + client, + Options, + header.Parameter, + Scheme.Name, + Logger, + Context.RequestAborted); + if (principal is null) + return AuthenticateResult.Fail("Modgud introspection did not affirmatively validate the token."); + + var boundJkt = principal.FindFirst(Dpop.DpopResource.ConfirmationJktClaimType)?.Value; + if (isDpop) + { + if (string.IsNullOrEmpty(boundJkt)) + return AuthenticateResult.Fail("The DPoP scheme was used but the token is not DPoP-bound."); + + var outcome = Dpop.DpopResourceValidator.Validate( + Request, + header.Parameter, + boundJkt, + DateTimeOffset.UtcNow); + if (outcome != Dpop.DpopResourceResult.Valid) + return AuthenticateResult.Fail($"The DPoP proof did not validate ({outcome})."); + } + else if (!string.IsNullOrEmpty(boundJkt)) + { + return AuthenticateResult.Fail( + "This access token is DPoP-bound and must be presented with the DPoP scheme."); + } + + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } +} + +internal static class ModgudTokenIntrospection +{ + public static async Task IntrospectAsync( + HttpClient client, + ModgudIntrospectionOptions options, + string token, + string authenticationType, + ILogger logger, + CancellationToken ct) + { + var url = options.Authority.TrimEnd('/') + "/connect/introspect"; + using var content = new FormUrlEncodedContent( + [ + new("token", token), + new("token_type_hint", "access_token"), + new("client_id", options.ClientId), + new("client_secret", options.ClientSecret), + ]); + + string body; + try + { + using var response = await client.PostAsync(url, content, ct); + if (!response.IsSuccessStatusCode) + { + logger.LogDebug( + "Modgud: /connect/introspect returned {Status}; rejecting the token.", + (int)response.StatusCode); + return null; + } + + body = await response.Content.ReadAsStringAsync(ct); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + logger.LogWarning(ex, "Modgud: /connect/introspect call failed; rejecting the token."); + return null; + } + + return BuildPrincipal(body, options.Audience, authenticationType, logger); + } + + internal static ClaimsPrincipal? BuildPrincipal( + string introspectionBody, + string audience, + string authenticationType, + ILogger logger) + { + JsonElement root; + try + { + using var document = JsonDocument.Parse(introspectionBody); + root = document.RootElement.Clone(); + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Modgud: /connect/introspect returned unparseable JSON; rejecting the token."); + return null; + } + + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("active", out var active) || + active.ValueKind != JsonValueKind.True || + !AudienceContains(root, audience)) + { + return null; + } + + var identity = new ClaimsIdentity( + authenticationType, + nameType: "name", + roleType: ClaimTypes.Role); + + foreach (var property in root.EnumerateObject()) + { + switch (property.Name) + { + case "resource_access" when property.Value.ValueKind == JsonValueKind.Object: + identity.AddClaim(new Claim( + ModgudClaimTypes.ResourceAccess, + property.Value.GetRawText(), + Microsoft.IdentityModel.JsonWebTokens.JsonClaimValueTypes.Json)); + break; + + case "cnf" when property.Value.ValueKind == JsonValueKind.Object && + property.Value.TryGetProperty("jkt", out var jkt) && + jkt.ValueKind == JsonValueKind.String: + identity.AddClaim(new Claim(Dpop.DpopResource.ConfirmationJktClaimType, jkt.GetString()!)); + break; + + case "sub" when property.Value.ValueKind == JsonValueKind.String: + identity.AddClaim(new Claim("sub", property.Value.GetString()!)); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, property.Value.GetString()!)); + break; + + case "name" or "preferred_username" or "email" or "scope" or "client_id" + when property.Value.ValueKind == JsonValueKind.String: + identity.AddClaim(new Claim(property.Name, property.Value.GetString()!)); + break; + } + } + + var principal = new ClaimsPrincipal(identity); + ModgudClaimsProjector.Project(principal, audience); + return principal; + } + + private static bool AudienceContains(JsonElement root, string audience) + { + if (!root.TryGetProperty("aud", out var audiences)) return false; + return audiences.ValueKind switch + { + JsonValueKind.String => string.Equals(audiences.GetString(), audience, StringComparison.Ordinal), + JsonValueKind.Array => audiences.EnumerateArray().Any( + item => item.ValueKind == JsonValueKind.String && + string.Equals(item.GetString(), audience, StringComparison.Ordinal)), + _ => false, + }; + } +} diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudPermissionExtensions.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudPermissionExtensions.cs new file mode 100644 index 00000000..6673ae03 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudPermissionExtensions.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Modgud.AspNetCore.ResourceServer; + +/// ASP.NET Core authorization helpers for Modgud permissions. +public static class ModgudPermissionExtensions +{ + /// + /// Requires an authenticated principal with the exact Modgud permission. + /// The requirement is attached as ASP.NET Core authorization metadata. + /// + public static RouteHandlerBuilder RequireModgudPermission( + this RouteHandlerBuilder builder, + string permission) + { + ArgumentNullException.ThrowIfNull(builder); + builder.RequireAuthorization(BuildPolicy(permission)); + return builder; + } + + /// Route-group variant of . + public static RouteGroupBuilder RequireModgudPermission( + this RouteGroupBuilder builder, + string permission) + { + ArgumentNullException.ThrowIfNull(builder); + builder.RequireAuthorization(BuildPolicy(permission)); + return builder; + } + + internal static AuthorizationPolicy BuildPolicy(string permission) + { + ArgumentException.ThrowIfNullOrWhiteSpace(permission); + return new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .RequireClaim(ModgudClaimTypes.Permission, permission) + .Build(); + } +} diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudResourceServerOptions.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudResourceServerOptions.cs new file mode 100644 index 00000000..b3e73e41 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudResourceServerOptions.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Modgud.AspNetCore.ResourceServer; + +/// The access-token formats accepted by a Modgud resource server. +public enum ModgudTokenMode +{ + /// Accept only self-contained JWT access tokens. + OnlyJwt, + + /// Accept only opaque reference tokens through introspection. + OnlyReferenceToken, + + /// Accept both formats and route each token to the matching validator. + Both, +} + +/// Defaults for the single Modgud resource-server authentication scheme. +public static class ModgudResourceServerDefaults +{ + /// The public scheme registered by AddModgudResourceServer. + public const string AuthenticationScheme = "Modgud"; +} + +/// Configuration for a Modgud-protected ASP.NET Core resource server. +public sealed class ModgudResourceServerOptions +{ + /// The realm host root, for example https://id.example.com. + public string Authority { get; set; } = string.Empty; + + /// The resource-server audience expected in every accepted token. + public string Audience { get; set; } = string.Empty; + + /// The accepted token format. Defaults to local JWT validation. + public ModgudTokenMode TokenMode { get; set; } = ModgudTokenMode.OnlyJwt; + + /// + /// Confidential introspection client ID. Defaults to . + /// Used only by and + /// . + /// + public string? IntrospectionClientId { get; set; } + + /// + /// Confidential introspection client secret. Required by + /// and + /// . + /// + public string? IntrospectionClientSecret { get; set; } + + /// + /// Requires an HTTPS authority. Disable only for local development. + /// + public bool RequireHttpsMetadata { get; set; } = true; + + /// + /// Optional advanced configuration applied to the internal JWT bearer + /// handler before Modgud wires DPoP and audience-local claims projection. + /// Used only by modes that accept JWTs. + /// + public Action? ConfigureJwtBearer { get; set; } +} + +internal sealed class ModgudIntrospectionOptions : AuthenticationSchemeOptions +{ + public string Authority { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; +} + +internal static class ModgudSchemeNames +{ + public const string Jwt = "Modgud.Internal.Jwt"; + public const string Introspection = "Modgud.Internal.Introspection"; +} diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/README.md b/src/dotnet/Modgud.AspNetCore.ResourceServer/README.md new file mode 100644 index 00000000..d45556f8 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/README.md @@ -0,0 +1,145 @@ +# Modgud.AspNetCore.ResourceServer + +ASP.NET Core integration for APIs protected by a +[Modgud](https://github.com/cocoar-dev/modgud) identity provider. + +The package has one registration method and one public authentication scheme. +`ModgudTokenMode` controls whether the API accepts self-contained JWTs, opaque +reference tokens, or both. In `Both` mode, the package routes three-part JWTs to +local validation and opaque tokens to RFC 7662 introspection. + +Both validation paths select `resource_access[]` and project its roles +and permissions onto the authenticated identity. Roles use `ClaimTypes.Role`; +permissions use `ModgudClaimTypes.Permission`. + +## Install + +```bash +dotnet add package Modgud.AspNetCore.ResourceServer +``` + +## JWT quickstart + +JWT is the default mode: + +```csharp +using Modgud.AspNetCore.ResourceServer; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "event-tree-api"; +}); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/admin/ping", () => "pong") + .RequireAuthorization(policy => policy.RequireRole("Editor")); + +app.MapPost("/calendars/{id}", (string id) => Results.Ok()) + .RequireModgudPermission("calendar:write"); + +app.Run(); +``` + +JWT mode makes no per-request call to Modgud. A token must contain the +configured audience and its `resource_access` block. There is deliberately no +UserInfo fallback. + +## Reference-token mode + +```csharp +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "event-tree-api"; + options.TokenMode = ModgudTokenMode.OnlyReferenceToken; + options.IntrospectionClientSecret = + builder.Configuration["Modgud:IntrospectionSecret"]; +}); +``` + +The resource server authenticates to `/connect/introspect` with a confidential +OAuth client. `IntrospectionClientId` defaults to `Audience`; in the usual setup +the introspection client's ID therefore equals the resource-server audience. +Validation is fail-closed and uncached, so revocation takes effect on the next +request. + +## Accept both formats + +```csharp +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "event-tree-api"; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = + builder.Configuration["Modgud:IntrospectionSecret"]; +}); +``` + +The application still exposes one authentication scheme. Token shape only +selects the internal validator; it never bypasses signature, issuer, audience, +expiry, active-state, or DPoP validation. A second +`AddModgudResourceServer(...)` call is rejected. + +## Permission gates + +`RequireModgudPermission` adds normal ASP.NET Core authorization metadata. It +works on both `RouteHandlerBuilder` and `RouteGroupBuilder` and yields `401` for +anonymous callers or `403` for authenticated callers without the exact +permission: + +```csharp +var writeApi = app.MapGroup("/write") + .RequireModgudPermission("calendar:write"); +``` + +Bypass grants such as `realm:admin` and `:admin` are expanded by the +IdP before token issuance. The resource server performs only an exact claim +check. + +## Claims + +Given: + +```json +"resource_access": { + "event-tree-api": { + "roles": ["Editor"], + "permissions": ["calendar:read", "calendar:write"] + } +} +``` + +read the projected values with: + +```csharp +var roles = user.FindAll(ClaimTypes.Role).Select(claim => claim.Value); +var permissions = user.FindAll(ModgudClaimTypes.Permission) + .Select(claim => claim.Value); +``` + +## Configuration + +| Option | Description | +| --- | --- | +| `Authority` | Required realm host root. | +| `Audience` | Required token audience and `resource_access` key. | +| `TokenMode` | `OnlyJwt` (default), `OnlyReferenceToken`, or `Both`. | +| `IntrospectionClientId` | Introspection client ID; defaults to `Audience`. | +| `IntrospectionClientSecret` | Required when the mode accepts reference tokens. | +| `RequireHttpsMetadata` | Requires an HTTPS authority; defaults to `true`. | +| `ConfigureJwtBearer` | Optional advanced JWT bearer configuration in JWT-capable modes. | + +The valid option combination is checked immediately during registration. +`required` properties cannot express the mode-dependent secret requirement, so +invalid combinations fail with `OptionsValidationException`. + +## License + +Apache-2.0. See [LICENSE](https://github.com/cocoar-dev/modgud/blob/develop/LICENSE). diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ServiceCollectionExtensions.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..823e1348 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ServiceCollectionExtensions.cs @@ -0,0 +1,260 @@ +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Modgud.AspNetCore.ResourceServer; + +/// Authentication registration for Modgud resource servers. +public static class ServiceCollectionExtensions +{ + /// + /// Registers the single Modgud resource-server scheme. The selected + /// determines whether + /// JWTs, reference tokens, or both are accepted. + /// + public static AuthenticationBuilder AddModgudResourceServer( + this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + if (services.Any(registration => + registration.ServiceType == typeof(ModgudResourceServerRegistrationMarker))) + { + throw new InvalidOperationException( + "AddModgudResourceServer can only be called once. Select OnlyJwt, " + + "OnlyReferenceToken, or Both through ModgudResourceServerOptions.TokenMode."); + } + + var options = new ModgudResourceServerOptions(); + configure(options); + Validate(options); + + services.AddSingleton(); + services.AddAuthorization(); + + var authentication = services.AddAuthentication( + ModgudResourceServerDefaults.AuthenticationScheme); + + switch (options.TokenMode) + { + case ModgudTokenMode.OnlyJwt: + AddJwt(authentication, ModgudResourceServerDefaults.AuthenticationScheme, options); + break; + + case ModgudTokenMode.OnlyReferenceToken: + AddIntrospection( + authentication, + ModgudResourceServerDefaults.AuthenticationScheme, + options); + break; + + case ModgudTokenMode.Both: + authentication.AddPolicyScheme( + ModgudResourceServerDefaults.AuthenticationScheme, + displayName: null, + policy => + { + policy.ForwardDefaultSelector = context => + SelectTokenScheme(context.Request.Headers.Authorization); + }); + AddJwt(authentication, ModgudSchemeNames.Jwt, options); + AddIntrospection(authentication, ModgudSchemeNames.Introspection, options); + break; + + default: + throw new InvalidOperationException("Unsupported Modgud token mode."); + } + + return authentication; + } + + internal static bool LooksLikeModgudJwt(string? authorizationHeader) + { + if (string.IsNullOrWhiteSpace(authorizationHeader) || + !AuthenticationHeaderValue.TryParse(authorizationHeader, out var header) || + string.IsNullOrWhiteSpace(header.Parameter) || + (!string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) && + !string.Equals(header.Scheme, Dpop.DpopResource.Scheme, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + var token = header.Parameter; + var firstDot = token.IndexOf('.'); + if (firstDot <= 0) return false; + var secondDot = token.IndexOf('.', firstDot + 1); + return secondDot > firstDot + 1 && + secondDot < token.Length - 1 && + token.IndexOf('.', secondDot + 1) < 0; + } + + internal static string SelectTokenScheme(string? authorizationHeader) + { + if (string.IsNullOrWhiteSpace(authorizationHeader) || + !AuthenticationHeaderValue.TryParse(authorizationHeader, out var header) || + string.IsNullOrWhiteSpace(header.Parameter) || + (!string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) && + !string.Equals(header.Scheme, Dpop.DpopResource.Scheme, StringComparison.OrdinalIgnoreCase))) + { + return ModgudSchemeNames.Jwt; + } + + return LooksLikeModgudJwt(authorizationHeader) + ? ModgudSchemeNames.Jwt + : ModgudSchemeNames.Introspection; + } + + private static void AddJwt( + AuthenticationBuilder authentication, + string scheme, + ModgudResourceServerOptions resourceServer) + { + authentication.AddJwtBearer(scheme, options => + { + resourceServer.ConfigureJwtBearer?.Invoke(options); + options.Authority = resourceServer.Authority; + options.Audience = resourceServer.Audience; + options.RequireHttpsMetadata = resourceServer.RequireHttpsMetadata; + WireJwtEvents(options, resourceServer.Audience); + }); + + authentication.Services.AddOptions(scheme) + .Validate( + options => options.EventsType is null, + "EventsType is not supported. Configure callbacks through " + + "ModgudResourceServerOptions.ConfigureJwtBearer and JwtBearerOptions.Events.") + .ValidateOnStart(); + } + + private static void AddIntrospection( + AuthenticationBuilder authentication, + string scheme, + ModgudResourceServerOptions resourceServer) + { + authentication.Services.AddHttpClient(ModgudHttpClientNames.Introspection); + authentication.AddScheme( + scheme, + options => + { + options.Authority = resourceServer.Authority; + options.Audience = resourceServer.Audience; + options.ClientId = string.IsNullOrWhiteSpace(resourceServer.IntrospectionClientId) + ? resourceServer.Audience + : resourceServer.IntrospectionClientId; + options.ClientSecret = resourceServer.IntrospectionClientSecret!; + }); + } + + private static void WireJwtEvents(JwtBearerOptions options, string audience) + { + options.Events ??= new JwtBearerEvents(); + + var existingMessageReceived = options.Events.OnMessageReceived; + options.Events.OnMessageReceived = async context => + { + if (existingMessageReceived is not null) + await existingMessageReceived(context); + + if (context.Result is null && + string.IsNullOrEmpty(context.Token) && + ModgudDpopJwtBearer.ExtractDpopSchemeToken(context.HttpContext.Request) is { } token) + { + context.Token = token; + } + }; + + var existingTokenValidated = options.Events.OnTokenValidated; + options.Events.OnTokenValidated = async context => + { + if (existingTokenValidated is not null) + await existingTokenValidated(context); + if (context.Result is not null) return; + + ModgudDpopJwtBearer.EnforceBinding(context); + if (context.Result is null) + ModgudClaimsProjector.Project(context.Principal, audience); + }; + } + + private static void Validate(ModgudResourceServerOptions options) + { + var failures = new List(); + + if (!Enum.IsDefined(options.TokenMode)) + failures.Add("TokenMode must be OnlyJwt, OnlyReferenceToken, or Both."); + if (string.IsNullOrWhiteSpace(options.Authority)) + failures.Add("Authority is required."); + else if (!IsValidAuthority(options.Authority, options.RequireHttpsMetadata)) + { + failures.Add( + "Authority must be an absolute HTTP(S) realm host root without a path, " + + "query, or fragment. HTTPS is required unless RequireHttpsMetadata=false."); + } + + if (string.IsNullOrWhiteSpace(options.Audience)) + failures.Add("Audience is required."); + + var acceptsReferenceTokens = + options.TokenMode is ModgudTokenMode.OnlyReferenceToken or ModgudTokenMode.Both; + if (acceptsReferenceTokens && + string.IsNullOrWhiteSpace(options.IntrospectionClientSecret)) + { + failures.Add( + "IntrospectionClientSecret is required when TokenMode accepts reference tokens."); + } + + if (!acceptsReferenceTokens && + (!string.IsNullOrWhiteSpace(options.IntrospectionClientId) || + !string.IsNullOrWhiteSpace(options.IntrospectionClientSecret))) + { + failures.Add( + "Introspection credentials cannot be configured when TokenMode is OnlyJwt."); + } + + if (options.TokenMode == ModgudTokenMode.OnlyReferenceToken && + options.ConfigureJwtBearer is not null) + { + failures.Add( + "ConfigureJwtBearer cannot be set when TokenMode is OnlyReferenceToken."); + } + + if (failures.Count > 0) + { + throw new OptionsValidationException( + ModgudResourceServerDefaults.AuthenticationScheme, + typeof(ModgudResourceServerOptions), + failures); + } + } + + private static bool IsValidAuthority(string authority, bool requireHttps) + { + if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri)) return false; + var isHttps = string.Equals( + uri.Scheme, + Uri.UriSchemeHttps, + StringComparison.OrdinalIgnoreCase); + var isHttp = string.Equals( + uri.Scheme, + Uri.UriSchemeHttp, + StringComparison.OrdinalIgnoreCase); + + return (isHttps || isHttp) && + (!requireHttps || isHttps) && + string.IsNullOrEmpty(uri.UserInfo) && + (string.IsNullOrEmpty(uri.AbsolutePath) || uri.AbsolutePath == "/") && + string.IsNullOrEmpty(uri.Query) && + string.IsNullOrEmpty(uri.Fragment); + } + + private sealed class ModgudResourceServerRegistrationMarker; +} + +internal static class ModgudHttpClientNames +{ + public const string Introspection = "Modgud.ResourceServer.Introspection"; +} diff --git a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs index 92af869a..913690b9 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs @@ -75,7 +75,6 @@ public static WebApplication MapAccountEndpoints(this WebApplication application IAuthSettings appSettings, IDocumentSession docSession, IQuerySession session, - ISessionService sessionService, ISecurityAuditLog securityAudit, HttpContext context) => { @@ -108,15 +107,17 @@ public static WebApplication MapAccountEndpoints(this WebApplication application // by latency. Burn an equivalent hash verify before the 401. PasswordTimingSafety.EqualizeFailure(userManager.PasswordHasher, request.Password); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.LoginFailedUnknownUser, - Level = "Warning", - Actor = LogPiiMasking.MaskUsername(request.UserName), - Ip = ip, - Status = "rejected", - Reason = "user not found or inactive", - Message = $"Login failed for {LogPiiMasking.MaskUsername(request.UserName)} — user not found or inactive", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + TargetSubjectId = user?.Id, + UnknownIdentifier = user is null ? request.UserName : null, + IpAddress = ip, + AuthenticationMethod = ModgudMeters.LoginMethod.Password, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = user is null ? "user-not-found" : "user-inactive", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Password, ModgudMeters.LoginOutcome.Failure); return Results.Json(new { Message = "Invalid credentials" }, statusCode: 401); @@ -137,15 +138,15 @@ public static WebApplication MapAccountEndpoints(this WebApplication application if (realmSettings?.SelfRegistration?.RequireEmailVerification == true && !user.EmailConfirmed) { await signInManager.SignOutAsync(); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.LoginFailed, - Level = "Warning", - Actor = LogPiiMasking.MaskUsername(request.UserName), - Ip = ip, - Status = "rejected", - Reason = "email not verified", - Message = $"Login blocked for {LogPiiMasking.MaskUsername(request.UserName)} — email not verified (realm requires verification)", + Severity = AuditSeverity.Warning, + TargetSubjectId = user.Id, + IpAddress = ip, + AuthenticationMethod = ModgudMeters.LoginMethod.Password, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "email-not-verified", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Password, ModgudMeters.LoginOutcome.Failure); return Results.Json(new @@ -161,15 +162,12 @@ public static WebApplication MapAccountEndpoints(this WebApplication application Log.Information("Login successful. UserId={UserId} IP={IP}", user.Id, ip); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Password, ModgudMeters.LoginOutcome.Success); - // Track per-user device session (best-effort). - await SessionTracker.RecordLoginAsync(sessionService, context, user.Id); - // Audit marker on the user's stream (Phase 1): the "when + by what - // method" of a successful login. No IP on the event — IP/device live - // in the Sessions feature (RecordLoginAsync above). Erasable with the - // user. Best-effort: PasswordSignInAsync has already issued the auth - // cookie, so a failed marker write must NOT turn a successful login - // into a 500 — log and continue (mirrors SessionTracker's contract). + // method" of a successful login. No IP on the event — the authoritative + // browser session created by the cookie event owns IP/device metadata. + // Erasable with the user. Best-effort: PasswordSignInAsync has already + // issued the auth cookie, so a failed marker write must NOT turn a + // successful login into a 500 — log and continue. try { docSession.Events.Append(user.Id, new Modgud.Authentication.Events.UserLoggedInEvent( @@ -262,24 +260,50 @@ public static WebApplication MapAccountEndpoints(this WebApplication application group.MapPost("logout", [Authorize] async ( HttpContext context, SignInManager signInManager, + IQuerySession session, LogoutRequest? request) => { // Capture the provider the session came from BEFORE signing out — - // we'll use it to build the IdP-side logout URL for the client. - var externalLoginProviderId = context.User.FindFirst("modgud.external.loginProviderId")?.Value; - - await signInManager.SignOutAsync(); - - // Only hand back the RP-initiated logout URL if the caller wants - // to end the IdP session too. Default (no body) keeps the existing - // "end everything" behavior for backwards compatibility. + // an upstream logout exists only for OIDC. SAML is SP-initiated + // login only in v1 and has no Single Logout endpoint. + var externalLoginProviderIdRaw = + context.User.FindFirst("modgud.external.loginProviderId")?.Value; var endIdpSession = request?.EndIdpSession ?? true; - string? externalLogoutUrl = null; - if (endIdpSession && !string.IsNullOrWhiteSpace(externalLoginProviderId)) + LoginProvider? externalLoginProvider = null; + + if (endIdpSession + && Guid.TryParse(externalLoginProviderIdRaw, out var externalLoginProviderId)) { - externalLogoutUrl = $"/api/account/external-logout/{externalLoginProviderId}"; + try + { + externalLoginProvider = + await session.LoadAsync(externalLoginProviderId); + } + catch (Exception ex) + { + // Upstream logout is optional. A provider lookup failure + // must never prevent the authoritative local logout. + Log.Warning( + ex, + "Could not resolve external login provider {LoginProviderId} during logout; continuing with local logout", + externalLoginProviderId); + } } + await signInManager.SignOutAsync(); + + // Disabled/deleted providers no longer have a registered OIDC + // scheme, so they intentionally degrade to local logout too. + var externalLogoutUrl = + externalLoginProvider is + { + Type: LoginProviderType.Oidc, + Enabled: true, + IsDeleted: false, + } + ? $"/api/account/external-logout/{externalLoginProvider.Id}" + : null; + return Results.Ok(new { Message = "Logout successful", ExternalLogoutUrl = externalLogoutUrl }); }) .WithName("Account_Logout"); @@ -350,7 +374,6 @@ public static WebApplication MapAccountEndpoints(this WebApplication application UserManager userManager, SignInManager signInManager, IUserAccessRevoker accessRevoker, - ISessionService sessionService, IAuthSettings appSettings) => { if (appSettings.AuthenticationMinimumLevel >= 2) @@ -374,15 +397,14 @@ public static WebApplication MapAccountEndpoints(this WebApplication application // session — not merely rely on the <=5-min security-stamp window. Kill // everything, then refresh the CURRENT session (reload the user first so // it carries the freshly-rotated stamp) so the password-changer stays - // signed in here, and re-record its device row so the session list stays - // accurate. + // signed in here. BrowserSessionCookieEvents creates the replacement + // authoritative row as part of the refreshed cookie. await accessRevoker.RevokeAllAccessAsync( user.Id, AccessRevocationReason.ForceSignOut, context.RequestAborted); var refreshed = await userManager.FindByIdAsync(user.Id.ToString()); if (refreshed is not null) { await signInManager.RefreshSignInAsync(refreshed); - await SessionTracker.RecordLoginAsync(sessionService, context, refreshed.Id); } Log.Information("Password changed; other sessions revoked. UserId={UserId} IP={IP}", user.Id, ip); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs index 8f8f1edd..54975754 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs @@ -43,7 +43,6 @@ public static WebApplication MapBootstrapEndpoints(this WebApplication app, stri IPendingAdminInviteService inviteService, UserManager userManager, SignInManager signInManager, - ISessionService sessionService, ISecurityAuditLog securityAudit) => { var ip = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; @@ -51,14 +50,14 @@ public static WebApplication MapBootstrapEndpoints(this WebApplication app, stri var result = await inviteService.ConsumeAsync(request.Token, request.Password); if (result.IsError) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.BootstrapInviteRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = "invalid or expired invite", - Message = "Bootstrap invite consume rejected", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + IpAddress = ip, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "invalid-or-expired-invite", }); return Results.Problem( statusCode: StatusCodes.Status400BadRequest, @@ -74,7 +73,6 @@ public static WebApplication MapBootstrapEndpoints(this WebApplication app, stri if (user is not null) { await signInManager.SignInAsync(user, isPersistent: false); - await SessionTracker.RecordLoginAsync(sessionService, http, user.Id); } Serilog.Log.Warning( diff --git a/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs index 98889f76..7862e662 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs @@ -159,7 +159,6 @@ public static WebApplication MapEmailOtpEndpoints(this WebApplication applicatio HttpContext context, SignInManager signInManager, IEmailOtpService emailOtpService, - ISessionService sessionService, CancellationToken ct) => { // Empty body / missing field — reject at the boundary instead of letting the service NRE. @@ -201,7 +200,6 @@ public static WebApplication MapEmailOtpEndpoints(this WebApplication applicatio await context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); await signInManager.SignInAsync(user, isPersistent: request.RememberMe); - await SessionTracker.RecordLoginAsync(sessionService, context, user.Id, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.EmailOtp, ModgudMeters.LoginOutcome.Success); return Results.Ok(new { Message = "Login successful" }); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs index ef5eb3d1..76203829 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs @@ -172,7 +172,6 @@ await emailService.SendTemplatedEmailAsync( IDocumentSession session, UserManager userManager, SignInManager signInManager, - ISessionService sessionService, ISecurityAuditLog securityAudit, HttpContext context) => { @@ -189,14 +188,16 @@ await emailService.SendTemplatedEmailAsync( if (challenge is null || challenge.IsExpired || challenge.IsConsumed) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.MagicLinkInvalid, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = "invalid or expired token", - Message = "Magic-link login failed — invalid or expired token", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + TargetSubjectId = request.UserId, + IpAddress = ip, + AuthenticationMethod = ModgudMeters.LoginMethod.MagicLink, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "invalid-or-expired-token", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.MagicLink, ModgudMeters.LoginOutcome.Failure); if (challenge is not null) { session.Delete(challenge); await session.SaveChangesAsync(); } @@ -212,14 +213,16 @@ await emailService.SendTemplatedEmailAsync( var user = await userManager.FindByIdAsync(request.UserId.ToString()); if (user is null || user.IsDeleted || !user.IsActive) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.LoginFailedUnknownUser, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = "user not found or inactive", - Message = "Magic-link login failed — user not found or inactive", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + TargetSubjectId = request.UserId, + IpAddress = ip, + AuthenticationMethod = ModgudMeters.LoginMethod.MagicLink, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "user-not-found-or-inactive", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.MagicLink, ModgudMeters.LoginOutcome.Failure); session.Delete(challenge); @@ -314,7 +317,6 @@ await context.SignInAsync( // Sign in — Magic Link is always persistent; user can request a new link anytime. await signInManager.SignInAsync(user, isPersistent: true); - await SessionTracker.RecordLoginAsync(sessionService, context, user.Id); Serilog.Log.Information("Magic link login successful. UserId={UserId} IP={IP}", user.Id, ip); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.MagicLink, ModgudMeters.LoginOutcome.Success); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/MfaEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/MfaEndpoints.cs index 2c13841b..841be9cc 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/MfaEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/MfaEndpoints.cs @@ -167,7 +167,6 @@ public static WebApplication MapMfaEndpoints(this WebApplication application, st group.MapPost("login", async ( SignInManager signInManager, MfaLoginRequest request, - ISessionService sessionService, HttpContext context) => { var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; @@ -202,7 +201,6 @@ public static WebApplication MapMfaEndpoints(this WebApplication application, st if (result.Succeeded) { if (twoFactorUser is not null) - await SessionTracker.RecordLoginAsync(sessionService, context, twoFactorUser.Id); Serilog.Log.Information("MFA login successful. IP={IP}", ip); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Mfa, ModgudMeters.LoginOutcome.Success); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/PasskeyEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/PasskeyEndpoints.cs index a542f9e8..9696dedb 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/PasskeyEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/PasskeyEndpoints.cs @@ -364,7 +364,6 @@ public static WebApplication MapPasskeyEndpoints(this WebApplication application IDocumentSession session, UserManager userManager, SignInManager signInManager, - ISessionService sessionService, JsonElement body, CancellationToken ct) => { @@ -444,7 +443,6 @@ public static WebApplication MapPasskeyEndpoints(this WebApplication application // Passkey login is always persistent — user can re-authenticate anytime via biometrics await signInManager.SignInAsync(user, isPersistent: true); - await SessionTracker.RecordLoginAsync(sessionService, context, user.Id); var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; Serilog.Log.Information("Passkey login successful. UserId={UserId} IP={IP}", user.Id, ip); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/SessionEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/SessionEndpoints.cs index 954b298b..6f7c7286 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/SessionEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/SessionEndpoints.cs @@ -4,6 +4,7 @@ using Modgud.Authentication.Sessions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Authentication; namespace Modgud.Authentication.Api.Account; @@ -24,12 +25,23 @@ public static WebApplication MapSessionEndpoints(this WebApplication application group.MapGet("", [Authorize] async ( HttpContext context, ISessionService svc, + IClientSessionService clientSessions, CancellationToken ct) => { var userId = context.GetUserId(); if (userId is null) return Results.Unauthorized(); - var result = await svc.GetSessionsAsync(userId.Value, currentSessionId: null, ct); + var currentSessionId = Guid.TryParse( + context.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value, + out var parsedSessionId) + ? parsedSessionId + : (Guid?)null; + var result = await svc.GetSessionsAsync(userId.Value, currentSessionId, ct); + if (!result.IsError) + { + var clients = await clientSessions.GetSessionsAsync(userId.Value, ct); + result = result.Value with { ClientSessions = clients.ToList() }; + } return result.ToResult(); }) .WithName("Auth_Sessions_List"); @@ -50,11 +62,42 @@ public static WebApplication MapSessionEndpoints(this WebApplication application var userId = context.GetUserId(); if (userId is null) return Results.Unauthorized(); + var current = context.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (Guid.TryParse(current, out var currentSessionId) && currentSessionId == id) + return Results.Conflict(new { Error = "Use normal logout to end the current browser session." }); + var result = await svc.RevokeSessionAsync(userId.Value, id, ct); return result.IsError ? result.ToResult() : Results.NoContent(); }) .WithName("Auth_Sessions_Revoke"); + group.MapDelete("client/{id:guid}", [Authorize] async ( + Guid id, + HttpContext context, + IClientSessionService clientSessions, + CancellationToken ct) => + { + var userId = context.GetUserId(); + if (userId is null) return Results.Unauthorized(); + var result = await clientSessions.RevokeAsync(userId.Value, id, ct); + return result.IsError ? result.ToResult() : Results.NoContent(); + }) + .WithName("Auth_ClientSessions_Revoke"); + + group.MapDelete("others", [Authorize] async ( + HttpContext context, + ISessionService svc, + CancellationToken ct) => + { + var userId = context.GetUserId(); + var raw = context.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (userId is null || !Guid.TryParse(raw, out var currentSessionId)) + return Results.Unauthorized(); + var result = await svc.RevokeAllSessionsAsync(userId.Value, currentSessionId, ct); + return result.IsError ? result.ToResult() : Results.NoContent(); + }) + .WithName("Auth_Sessions_RevokeOthers"); + // DELETE /api/auth/sessions — revoke all my sessions (logout everywhere). // Audit remediation #1: RevokeAllSessionsAsync alone only deleted tracking // rows — invisible to the cookie middleware, so other devices stayed signed @@ -64,27 +107,14 @@ public static WebApplication MapSessionEndpoints(this WebApplication application // THIS request so the acting device stays signed in; all others die. group.MapDelete("", [Authorize] async ( HttpContext context, - UserManager userManager, - SignInManager signInManager, IUserAccessRevoker accessRevoker, - ISessionService sessionService, CancellationToken ct) => { var userId = context.GetUserId(); if (userId is null) return Results.Unauthorized(); await accessRevoker.RevokeAllAccessAsync(userId.Value, AccessRevocationReason.ForceSignOut, ct); - var user = await userManager.FindByIdAsync(userId.Value.ToString()); - if (user is not null) - { - await signInManager.RefreshSignInAsync(user); - // RevokeAllAccessAsync deleted EVERY session row, including the acting - // device's. RefreshSignInAsync keeps this device signed in but doesn't - // re-track it — so without this the user's own "active sessions" list - // would read empty until their next fresh login. Re-record the acting - // session so the live device reappears. - await SessionTracker.RecordLoginAsync(sessionService, context, userId.Value, ct); - } + await context.SignOutAsync(IdentityConstants.ApplicationScheme); return Results.NoContent(); }) .WithName("Auth_Sessions_RevokeAll"); diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/AdminSessionEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/AdminSessionEndpoints.cs index 6cc9cc3d..cd011fcc 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/AdminSessionEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/AdminSessionEndpoints.cs @@ -23,10 +23,16 @@ public static WebApplication MapAdminSessionEndpoints(this WebApplication applic group.MapGet("{id}/sessions", async ( string id, ISessionService svc, + IClientSessionService clientSessions, CancellationToken ct) => { var userId = ShortGuid.Decode(id); var result = await svc.GetSessionsAsync(userId, currentSessionId: null, ct); + if (!result.IsError) + { + var clients = await clientSessions.GetSessionsAsync(userId, ct); + result = result.Value with { ClientSessions = clients.ToList() }; + } return result.ToResult(); }) .WithName("Admin_Sessions_List") diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/AuditEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/AuditEndpoints.cs index 588711f5..c2718148 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/AuditEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/AuditEndpoints.cs @@ -10,13 +10,11 @@ namespace Modgud.Authentication.Api.Admin; /// /// Tenant audit read surface (logging/audit redesign Track A — the GDPR-audit half). /// -/// Unlike the legacy AuthLog (cross-realm in the system DB, scoped at -/// read via ScopeToCallerRealm), lives -/// per-realm in the tenant DB. So the tenant-scoped +/// Like the realm security log, lives +/// per-realm in the tenant DB. The tenant-scoped /// returns only the caller's realm by physical isolation — no /// WHERE Realm = filter is needed and a filter bug cannot leak cross-realm. -/// Control-plane cross-realm fan-out across realm DBs is deferred; the platform-wide -/// surface is the streamless security store (Phase 3). +/// The separate Platform log contains only PII-free deployment events. /// public static class AuditEndpoints { diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs index 7291e91d..9f8009e8 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs @@ -1,50 +1,42 @@ -using System.Security.Claims; using Marten; -using Microsoft.AspNetCore.Http; +using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Marten.Projections.Users; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; namespace Modgud.Authentication.Api.Admin; /// -/// Admin Security log surface (logging/audit redesign Track A — the streamless -/// half). Reads the typed store: unknown-actor login -/// attempts, probes, rate-limits, policy rejections, and operational actions. Entries -/// live cross-realm in the system DB but are attributed to a realm via -/// . -/// -/// The read/clear scope by the CALLER'S realm so a tenant realm-admin sees and -/// clears only their own realm's tenant-visible events; the control-plane -/// realm (per TenantInfo.IsControlPlane, not a hard-coded "system" slug) sees and -/// clears the full cross-realm log including control-plane-only operational rows -/// (). This carries PR #50's scoping forward -/// and extends it with the platform-only visibility gate. -/// -/// The HTTP surface (route, shape) is carried forward from the legacy AuthLog so -/// the SPA keeps working; the backing store changed from the flat AuthLogDocument to the -/// typed SecurityAuditEntry. +/// Two deliberately separate log surfaces: +/// - /auth-log reads only the caller realm's physical database. +/// - /platform-audit reads only the PII-free Global Store and is Control-Plane only. +/// Neither surface offers arbitrary deletion; retention jobs are the only delete path. /// public static class AuthLogEndpoints { public static WebApplication MapAuthLogEndpoints(this WebApplication application, string path) + { + MapRealmSecurityLog(application, path); + MapPlatformAuditLog(application, path); + return application; + } + + private static void MapRealmSecurityLog(WebApplication application, string path) { var group = application.MapGroup($"{path}/admin/auth-log") .WithTags("Admin Security Log") .RequireAuthorization(); group.MapGet("", async ( - IDocumentStore store, - HttpContext http, + IDocumentSession session, string? category, string? eventType, - int? limit) => + int? limit, + CancellationToken ct) => { - await using var session = store.QuerySession(TenantConstants.SystemTenantId); - - var query = ScopeToCallerRealm( - session.Query(), TenantContext.Current, IsControlPlane(http)); + IQueryable query = session.Query(); if (!string.IsNullOrWhiteSpace(category)) query = query.Where(x => x.Category == category); if (!string.IsNullOrWhiteSpace(eventType)) @@ -53,89 +45,270 @@ public static WebApplication MapAuthLogEndpoints(this WebApplication application var rows = await query .OrderByDescending(x => x.Timestamp) .Take(Math.Clamp(limit ?? 200, 1, 1000)) - .ToListAsync(); + .ToListAsync(ct); + + var subjectIds = rows + .SelectMany(x => new[] { x.ActorSubjectId, x.TargetSubjectId }) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .Distinct() + .ToArray(); - // Carry-forward DTO: the legacy grid columns (Timestamp/Level/Message/ - // UserName/Ip/Realm) keep their names — Actor maps to UserName — plus the - // new EventType/Category for taxonomy-chip filtering and Status/Reason. - var dtos = rows.Select(r => new SecurityLogEntryDto( - r.Timestamp, r.Realm, r.Category, r.EventType, r.Level, - r.Actor, r.Ip, r.Status, r.Reason, r.Message)); + var users = subjectIds.Length == 0 + ? [] + : await session.Query() + .Where(x => subjectIds.Contains(x.Id)) + .ToListAsync(ct); + var names = users.ToDictionary(x => x.Id, x => x.GetDisplayLabel()); - return Results.Ok(dtos); + return Results.Ok(rows.Select(row => RealmSecurityLogDto.From(row, names))); }) .WithName("AdminAuthLog_Get") .RequiresPermission("auth-log:read"); + } - // Clearing the security log is destructive — gate behind the global app:admin - // bypass. Scoped to the caller's realm; the control-plane realm wipes the full - // log. The clear is itself audited (audit-of-the-audit): a typed - // audit.log_cleared record naming the operator is emitted AFTER the wipe, so it - // survives as the forensic trail of who cleared what, when. - group.MapDelete("", async ( - IDocumentStore store, - HttpContext http, - ClaimsPrincipal user, - ISecurityAuditLog securityAudit) => + private static void MapPlatformAuditLog(WebApplication application, string path) + { + var group = application.MapGroup($"{path}/admin/platform-audit") + .WithTags("Platform Audit Log") + .RequireAuthorization() + .AddEndpointFilter(); + + group.MapGet("", async ( + IGlobalStore store, + string? category, + string? eventType, + int? limit, + CancellationToken ct) => { - var callerRealm = TenantContext.Current; - var isControlPlane = IsControlPlane(http); - - await using var session = store.LightweightSession(TenantConstants.SystemTenantId); - if (isControlPlane) - session.DeleteWhere(x => true); - else - session.DeleteWhere(x => x.Realm == callerRealm); - await session.SaveChangesAsync(); - - var operatorName = user.Identity?.Name ?? "(unknown)"; - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.AuditLogCleared, - Level = "Warning", - Actor = operatorName, - Status = "cleared", - Reason = isControlPlane ? "all realms (control-plane)" : $"realm {callerRealm}", - Message = $"Security log cleared by {operatorName}", - }); - - return Results.Ok(new { Message = "Security log cleared" }); + await using var session = store.QuerySession(); + IQueryable query = session.Query(); + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(x => x.Category == category); + if (!string.IsNullOrWhiteSpace(eventType)) + query = query.Where(x => x.EventType == eventType); + + var rows = await query + .OrderByDescending(x => x.Timestamp) + .Take(Math.Clamp(limit ?? 200, 1, 1000)) + .ToListAsync(ct); + + return Results.Ok(rows.Select(PlatformAuditLogDto.From)); }) - .WithName("AdminAuthLog_Clear") - .RequiresPermission("realm:admin"); + .WithName("AdminPlatformAudit_Get") + .RequiresPermission("platform-audit:read", AppSlugs.ControlPlane); + } +} - return application; +public sealed record RealmSecurityLogDto( + Guid Id, + DateTimeOffset Timestamp, + string Category, + string EventType, + string Severity, + string ActorKind, + string Actor, + string? Target, + string? IpAddress, + string? UserAgent, + string? OAuthClientId, + Guid? ApplicationId, + Guid? SessionId, + Guid? LoginProviderId, + string? AuthenticationMethod, + string? CorrelationId, + string OutcomeCode, + string? ReasonCode, + string? OperationCode, + string? TargetRealmSlug, + string? KeyId, + int? Count, + int? RelatedCount, + int? RetentionDays, + DateTimeOffset? EffectiveAt, + DateTimeOffset? FirstObservedAt, + DateTimeOffset? LastObservedAt, + string Message) +{ + internal static RealmSecurityLogDto From( + RealmSecurityAuditEvent row, + IReadOnlyDictionary names) + => new( + row.Id, + row.Timestamp, + row.Category, + row.EventType, + row.Severity.ToString(), + row.ActorKind.ToString(), + RenderActor(row, names), + RenderTarget(row.TargetSubjectId, names), + row.IpAddress, + row.UserAgent, + row.OAuthClientId, + row.ApplicationId, + row.SessionId, + row.LoginProviderId, + row.AuthenticationMethod, + row.CorrelationId, + row.OutcomeCode, + row.ReasonCode, + row.OperationCode, + row.TargetRealmSlug, + row.KeyId, + row.Count, + row.RelatedCount, + row.RetentionDays, + row.EffectiveAt, + row.FirstObservedAt, + row.LastObservedAt, + AuditEventRenderer.Render(row)); + + private static string RenderActor( + RealmSecurityAuditEvent row, + IReadOnlyDictionary names) + { + if (row.ActorSubjectId is { } subject) + return names.TryGetValue(subject, out var name) ? name : "Deleted user"; + if (row.UnknownIdentifierFingerprint is { Length: > 0 } fingerprint) + return $"Unknown identifier · {fingerprint[..Math.Min(10, fingerprint.Length)]}"; + if (!string.IsNullOrWhiteSpace(row.OAuthClientId)) + return row.OAuthClientId; + + return row.ActorKind switch + { + AuditActorKind.ControlPlane => "Control Plane", + AuditActorKind.System => "System", + AuditActorKind.ServiceAccount => "Service account", + AuditActorKind.OAuthClient => "OAuth client", + _ => row.ActorKind.ToString(), + }; } - private static bool IsControlPlane(HttpContext http) => - http.Items[TenantConstants.HttpContextTenantInfoKey] is TenantInfo info && info.IsControlPlane; - - /// - /// Realm-scopes a security-log query: the control-plane realm sees the full - /// cross-realm log (including control-plane-only operational rows); every other - /// realm sees only its own realm's tenant-visible entries - /// (!PlatformOnly). Pure + provider-agnostic so it composes over either - /// Marten's IQueryable or an in-memory one (used by the unit tests). - /// - public static IQueryable ScopeToCallerRealm( - IQueryable query, string callerRealm, bool callerIsControlPlane) - => callerIsControlPlane - ? query - : query.Where(x => x.Realm == callerRealm && !x.PlatformOnly); + private static string? RenderTarget(Guid? subject, IReadOnlyDictionary names) + => subject is null + ? null + : names.TryGetValue(subject.Value, out var name) ? name : "Deleted user"; } -/// Read DTO for the Security log grid. Carries the legacy column names -/// ( = the entry's Actor) so the existing SPA keeps -/// working, plus the typed / for chip -/// filtering and / detail. -public sealed record SecurityLogEntryDto( +public sealed record PlatformAuditLogDto( + Guid Id, DateTimeOffset Timestamp, - string? Realm, string Category, string EventType, - string Level, - string? UserName, - string? Ip, - string? Status, - string? Reason, - string Message); + string Severity, + string OutcomeCode, + string? ReasonCode, + string? OperationCode, + string? TargetRealmSlug, + string? CorrelationId, + int? Count, + int? RelatedCount, + string Message) +{ + internal static PlatformAuditLogDto From(PlatformAuditEvent row) + => new( + row.Id, + row.Timestamp, + row.Category, + row.EventType, + row.Severity.ToString(), + row.OutcomeCode, + row.ReasonCode, + row.OperationCode, + row.TargetRealmSlug, + row.CorrelationId, + row.Count, + row.RelatedCount, + AuditEventRenderer.Render(row)); +} + +internal static class AuditEventRenderer +{ + public static string Render(RealmSecurityAuditEvent row) + { + var details = new List(); + Add(details, "reason", row.ReasonCode); + Add(details, "operation", row.OperationCode); + Add(details, "target-realm", row.TargetRealmSlug); + Add(details, "client", row.OAuthClientId); + Add(details, "key", row.KeyId); + Add(details, "count", row.Count); + Add(details, "related", row.RelatedCount); + Add(details, "retention-days", row.RetentionDays); + Add(details, "reminded", row.RemindedCount); + Add(details, "self-erased", row.SelfErasedCount); + Add(details, "auto-purged", row.AutoPurgedCount); + Add(details, "invite-codes-pruned", row.InviteCodesPrunedCount); + Add(details, "reused", row.ReusedCount); + Add(details, "effective-at", row.EffectiveAt); + Add(details, "first-observed-at", row.FirstObservedAt); + Add(details, "last-observed-at", row.LastObservedAt); + return Compose(row.EventType, row.OutcomeCode, details); + } + + public static string Render(PlatformAuditEvent row) + { + var details = new List(); + Add(details, "reason", row.ReasonCode); + Add(details, "operation", row.OperationCode); + Add(details, "realm", row.TargetRealmSlug); + Add(details, "domain", row.Domain); + Add(details, "previous-domain", row.PreviousDomain); + Add(details, "count", row.Count); + Add(details, "related", row.RelatedCount); + Add(details, "retention-days", row.RetentionDays); + Add(details, "effective-at", row.EffectiveAt); + return Compose(row.EventType, row.OutcomeCode, details); + } + + private static string Compose( + string eventType, + string outcome, + IReadOnlyCollection details) + { + var occurrence = eventType switch + { + AuditEvents.LoginFailed => "Login", + AuditEvents.LoginFailedUnknownUser => "Login for an unknown identifier", + AuditEvents.MagicLinkInvalid => "Invalid or expired magic link", + AuditEvents.ExternalLoginProtocolRejected => "External login protocol", + AuditEvents.ExternalLoginPolicyRejected => "External login policy", + AuditEvents.ExternalLoginConfigurationError => "External login configuration", + AuditEvents.SamlSignatureRejected => "SAML signature validation", + AuditEvents.IdentityHijackBlocked => "External identity takeover attempt", + AuditEvents.JitEmailConflict => "JIT email conflict", + AuditEvents.PrivilegeEscalationBlocked => "Federated privilege escalation", + AuditEvents.RateLimitTriggered => "Rate limit", + AuditEvents.RefreshTokenReuseDetected => "Refresh-token reuse", + AuditEvents.DcrRegistrationRejected => "Dynamic client registration", + AuditEvents.BootstrapInviteRejected => "Bootstrap invite", + AuditEvents.SecurityRetentionChanged => "Security-log retention", + AuditEvents.SigningKeyRotated => "Signing key rotation", + AuditEvents.SigningKeyPurged => "Signing-key cleanup", + AuditEvents.SamlCertRotated => "SAML certificate rotation", + AuditEvents.SamlMetadataRefreshCompleted => "SAML metadata refresh", + AuditEvents.SamlSigningCertificatesChanged => "SAML signing certificates", + AuditEvents.RecoveryCliInvoked => "Recovery CLI operation", + AuditEvents.RealmProvisioned => "Realm provisioning", + AuditEvents.RealmAdopted => "Realm adoption", + AuditEvents.ControlPlaneTransferred => "Control-Plane transfer", + AuditEvents.ControlPlaneRealmOperation => "Control-Plane realm operation", + AuditEvents.AccountLifecycleSwept => "Account lifecycle sweep", + AuditEvents.BootstrapInviteIssued => "Bootstrap invite issuance", + AuditEvents.DcrClientRegistered => "Dynamic client registration", + AuditEvents.DcrClientFirstUsed => "Dynamic client first use", + AuditEvents.DcrClientGarbageCollected => "Dynamic client cleanup", + _ => eventType, + }; + + return details.Count == 0 + ? $"{occurrence}: {outcome}" + : $"{occurrence}: {outcome} ({string.Join(", ", details)})"; + } + + private static void Add(List details, string key, object? value) + { + if (value is not null && !string.IsNullOrWhiteSpace(value.ToString())) + details.Add($"{key}={value}"); + } +} diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/CreateLoginProviderCommand.cs b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/CreateLoginProviderCommand.cs index b4618237..7bed906c 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/CreateLoginProviderCommand.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/CreateLoginProviderCommand.cs @@ -11,8 +11,9 @@ namespace Modgud.Authentication.Api.Admin.LoginProviders.Commands; /// /// Admin creates a new login provider. is set on creation -/// and immutable thereafter. Secret rotation has its own command so audit -/// trails stay clean — never set at Create. +/// and immutable thereafter. An optional initial OIDC secret is encrypted +/// before the event is appended, allowing a fully configured provider to be +/// created atomically. Later rotations retain their dedicated audit event. /// /// All fields after are optional: when omitted, the /// chosen flavor's defaults are used (legacy two-step flow). When the admin @@ -47,12 +48,14 @@ public record CreateLoginProviderCommand( string? IconName = null, string? ButtonColorHex = null, bool? TrustForAuthorization = null, - bool? AuthoritativeForProfile = null); + bool? AuthoritativeForProfile = null, + string? InitialClientSecret = null); public class CreateLoginProviderHandler( IDocumentSession session, LoginProviderFlavorRegistry oidcFlavors, SamlFlavorRegistry samlFlavors, + LoginProviderSecretStore secrets, TimeProvider clock) { public async Task> Handle(CreateLoginProviderCommand command, CancellationToken ct) @@ -110,15 +113,22 @@ public async Task> Handle(CreateLoginProviderCommand comm return Error.Validation("LoginProvider.FlavorDataInvalid", ex.Message); } - // Readiness gate parity with EnableLoginProviderHandler: an OIDC - // provider needs ClientId + ClientSecret before it can authenticate - // anyone, and Create never carries a secret (RotateClientSecret is a - // separate command for audit reasons). So Enabled=true at Create is - // structurally unsafe — refuse it. The single-modal frontend already - // hardcodes Enabled=false; this gate catches stale/scripted callers. + var initialSecret = string.IsNullOrWhiteSpace(command.InitialClientSecret) + ? null + : command.InitialClientSecret; + if (command.InitialClientSecret is not null && initialSecret is null) + return Error.Validation("LoginProvider.SecretEmpty", "Secret cannot be empty."); + + var encryptedSecret = initialSecret is null ? null : secrets.Encrypt(initialSecret); if (command.Enabled == true) - return Error.Validation("LoginProvider.SecretRequired", - "Cannot create an OIDC provider as Enabled — set the client secret first via /secret, then enable explicitly."); + { + var readinessError = LoginProviderReadiness.CheckCanEnable( + LoginProviderType.Oidc, + command.ClientId ?? string.Empty, + encryptedSecret is { Length: > 0 }, + command.FlavorData); + if (readinessError is not null) return readinessError.Value; + } var nameTaken = await session.Query() .Where(c => !c.IsDeleted && c.DisplayName == command.DisplayName) @@ -140,7 +150,7 @@ public async Task> Handle(CreateLoginProviderCommand comm IsBuiltIn: false, Enabled: command.Enabled ?? false, ClientId: command.ClientId ?? string.Empty, - ClientSecretEncrypted: null, + ClientSecretEncrypted: encryptedSecret, Scopes: command.Scopes ?? [.. flavor.DefaultScopes], UserUpdateScript: command.UserUpdateScript ?? flavor.DefaultUserUpdateScript, StoreRawClaims: command.StoreRawClaims ?? flavor.DefaultStoreRawClaims, diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/LoginProvidersEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/LoginProvidersEndpoints.cs index c80e12cb..760c3927 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/LoginProvidersEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/LoginProvidersEndpoints.cs @@ -47,6 +47,7 @@ public static void MapLoginProvidersEndpoints(this IEndpointRouteBuilder endpoin DefaultScopes = f.DefaultScopes.ToList(), DefaultUserUpdateScript = f.DefaultUserUpdateScript, DefaultStoreRawClaims = f.DefaultStoreRawClaims, + DefaultFlavorData = null, ConfigSchema = f.ConfigSchema.Select(c => new FlavorConfigFieldDto( c.Key, c.Type.ToString(), c.Label, c.Required, c.HelpText, c.Placeholder, c.Default, c.Section, @@ -54,19 +55,24 @@ public static void MapLoginProvidersEndpoints(this IEndpointRouteBuilder endpoin Type = nameof(LoginProviderType.Oidc), }); - var saml = samlFlavors.All.Select(f => new FlavorDto + var saml = samlFlavors.All.Select(f => { - Key = f.Key, - DisplayName = f.DisplayName, - DefaultIconName = f.DefaultIconName, - DefaultScopes = [], // SAML has no scopes. - DefaultUserUpdateScript = f.DefaultUserUpdateScript, - DefaultStoreRawClaims = f.DefaultStoreRawClaims, - ConfigSchema = f.ConfigSchema.Select(c => new FlavorConfigFieldDto( - c.Key, c.Type.ToString(), c.Label, c.Required, c.HelpText, c.Placeholder, c.Default, - c.Section, - c.Options?.Select(o => new FlavorConfigFieldOptionDto(o.Value, o.Label)).ToList())).ToList(), - Type = nameof(LoginProviderType.Saml), + using var defaults = f.ApplyDefaults(null).ToJson(); + return new FlavorDto + { + Key = f.Key, + DisplayName = f.DisplayName, + DefaultIconName = f.DefaultIconName, + DefaultScopes = [], // SAML has no scopes. + DefaultUserUpdateScript = f.DefaultUserUpdateScript, + DefaultStoreRawClaims = f.DefaultStoreRawClaims, + DefaultFlavorData = defaults.RootElement.Clone(), + ConfigSchema = f.ConfigSchema.Select(c => new FlavorConfigFieldDto( + c.Key, c.Type.ToString(), c.Label, c.Required, c.HelpText, c.Placeholder, c.Default, + c.Section, + c.Options?.Select(o => new FlavorConfigFieldOptionDto(o.Value, o.Label)).ToList())).ToList(), + Type = nameof(LoginProviderType.Saml), + }; }); return Results.Ok(oidc.Concat(saml).ToArray()); @@ -141,7 +147,8 @@ public static void MapLoginProvidersEndpoints(this IEndpointRouteBuilder endpoin IconName: request.IconName, ButtonColorHex: request.ButtonColorHex, TrustForAuthorization: request.TrustForAuthorization, - AuthoritativeForProfile: request.AuthoritativeForProfile); + AuthoritativeForProfile: request.AuthoritativeForProfile, + InitialClientSecret: request.InitialClientSecret); var result = await bus.InvokeAsync>(command, ct); return result.Match( v => Results.Created($"/api/admin/login-providers/{v.Id:N}", ToDto(v, publicUrl)), @@ -313,7 +320,8 @@ public record CreateLoginProviderRequest( string? IconName = null, string? ButtonColorHex = null, bool? TrustForAuthorization = null, - bool? AuthoritativeForProfile = null); + bool? AuthoritativeForProfile = null, + string? InitialClientSecret = null); // PATCH semantics: every field is Optional, so a caller can send just // the properties it wants to change (e.g. the grid sends only Enabled to diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs index effb6a96..f6aead4f 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs @@ -1,4 +1,3 @@ -using System.Security.Claims; using Modgud.Application.DTOs.RealmSettings; using Modgud.Authentication.RealmSettings; using Modgud.Authorization.AspNetCore; @@ -67,26 +66,12 @@ public static WebApplication MapRealmSettingsEndpoints(this WebApplication app, // realm-settings:write permission as the rest of this surface. group.MapPost("rotate-signing-key", async ( IRealmKeyStore keyStore, - ClaimsPrincipal user, - ISecurityAuditLog securityAudit, CancellationToken ct) => { var slug = TenantContext.Current; var creds = await keyStore.RotateAsync(slug, ct); var kid = creds.Key.KeyId; - var userName = user.Identity?.Name ?? "(unknown)"; - // Request context — leave Realm unset (ambient TenantContext is correct). - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.SigningKeyRotated, - Level = "Warning", - Actor = userName, - Status = "rotated", - Reason = $"kid {kid}", - Message = $"signing key rotated by {userName} — new kid {kid}", - }); - return Results.Ok(new RotateSigningKeyResponseDto(kid)); }) .WithName("RealmSettings_RotateSigningKey") diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCli.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCli.cs index 950127fa..8324923d 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCli.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCli.cs @@ -31,6 +31,7 @@ public static class RecoveryCli // command up by name and asks it whether it needs realm resolution. private static readonly IReadOnlyList AllCommands = [ + new InstallLinkCommand(), new ListCommand(), new Reset2FaCommand(), new SetEmailCommand(), @@ -79,23 +80,19 @@ public static async Task RunAsync( return 1; } - // Resolve the global --realm for tenant-scoped commands. It defaults to - // "system", but a misspelled --realm must fail with a clear message (not - // a deep Marten "tenant not found" crash once it enters a non-existent - // tenant), and an implicit default is announced when more than one realm - // exists so the operator never silently acts on the wrong tenant — the - // same silent-tenant class the HTTP path was hardened against. The global - // realm-management commands (RequiresRealm == false) carry their own - // --slug and are not validated here. + // Resolve --realm only for tenant-scoped commands. With one active realm + // it is unambiguous and may be omitted; with zero or multiple realms the + // operator must either install first or name the target explicitly. var explicitRealm = ParseFlag(args, "--realm"); - var realmSlug = explicitRealm ?? TenantConstants.SystemTenantId; + var realmSlug = ""; if (command.RequiresRealm) { - var realmError = await ResolveRealmAsync(services, explicitRealm, realmSlug, errorWriter); - if (realmError is not null) return realmError.Value; + var resolved = await ResolveRealmAsync(services, explicitRealm, errorWriter); + if (resolved.ErrorCode is not null) return resolved.ErrorCode.Value; + realmSlug = resolved.RealmSlug!; } - using var _tenant = TenantContext.Enter(realmSlug); + using var tenant = command.RequiresRealm ? TenantContext.Enter(realmSlug) : null; await using var scope = services.CreateAsyncScope(); var ctx = new RecoveryCliContext(scope.ServiceProvider, args, realmSlug, env, outWriter, errorWriter); @@ -112,6 +109,10 @@ Modgud Recovery CLI dotnet Modgud.Api.dll recover [args...] [--realm ] Commands: + install-link Issue a single-use first-installation URL (30 min default). + --base-url Required public URL, e.g. https://auth.example.com. + [--minutes <1..1440>] Optional lifetime in minutes. + [--json] Machine-readable token/URL output for CI automation. list List all users (UserName · Email · Active · 2FA · Passkeys). reset-2fa Disable TOTP + Email-OTP + delete all Passkeys for user. set-email Update the user's email address (appends UserUpdatedEvent @@ -128,8 +129,7 @@ a LinkedServiceAccountId (i.e. seeded or pre-2C the standard SA-managed mutation guard kicks in. Idempotent — already-linked clients are skipped; existing legacy.* SAs are re-used. - Optional --realm flag scopes to one tenant - (defaults to "system"). + Optional --realm flag scopes to one tenant. bootstrap-admin Create the first admin in a realm. Two modes: --email Email — required in both modes. [--username ] Username — defaults to the local-part of the email. @@ -140,11 +140,9 @@ in. Idempotent — already-linked clients are Without --password, an Invite-Mode magic link is generated and printed (and emailed if SMTP is set). realm-list List every active realm with its slug + domains. - Useful as a first probe after a fresh deploy to see - the system realm's seeded localhost domains. + Useful to inspect the configured realm hosts. realm-add-domain Add a domain to an active realm's Domains list. - --slug Required. Typically "system" for the first - production-hostname add after deploy. + --slug Required. --domain Required. The Host-header that should route to this realm. Stored verbatim, case-insensitive match at request time. @@ -173,15 +171,15 @@ Migration counterpart to creating a realm via the API. rotate-signing-key Rotate the realm's OpenIddict signing key. Generates a fresh RSA keypair and retires the previous active key into a 30-day verification overlap window so in-flight - tokens stay valid. Honors --realm (defaults to "system"). + tokens stay valid. Honors --realm. help Show this message. Global flag: - --realm Tenant slug to act in. Defaults to "system". Applies to + --realm Tenant slug to act in. Optional only when exactly one + active realm exists. Applies to tenant-scoped commands (bootstrap-admin, list, reset-2fa, - …). A misspelled --realm fails fast with a clear error; - an omitted --realm is announced when more than one realm - exists. The realm-* / control-plane / adopt-tenant commands + …). A misspelled --realm fails fast with a clear error. + The realm-* / control-plane / adopt-tenant commands carry their own --slug and ignore it. Exit codes: 0 on success, non-zero on any failure (validation error, @@ -221,13 +219,11 @@ All commands run against the configured database. No network access (except SMTP /// non-null exit code to short-circuit when the named /// realm doesn't exist — a misspelled --realm must fail loudly with a /// clear message instead of entering a tenant that doesn't exist (which would - /// surface as a deep Marten error). Announces the implicit system - /// default only when more than one active realm exists, so single-tenant - /// operators aren't nagged but a multi-realm operator can never silently act - /// on the wrong tenant. + /// surface as a deep Marten error). A sole active realm is unambiguous; + /// zero or multiple active realms require installation or an explicit target. /// - private static async Task ResolveRealmAsync( - IServiceProvider services, string? explicitRealm, string realmSlug, TextWriter errorWriter) + private static async Task<(int? ErrorCode, string? RealmSlug)> ResolveRealmAsync( + IServiceProvider services, string? explicitRealm, TextWriter errorWriter) { var globalStore = services.GetRequiredService(); await using var globalSession = globalStore.QuerySession(); @@ -235,24 +231,31 @@ All commands run against the configured database. No network access (except SMTP .Where(r => r.IsActive) .ToListAsync(); - // Case-sensitive match: the tenant registry is keyed by the exact slug, - // so "System" is genuinely not a realm and must error rather than enter a - // tenant that doesn't exist. - if (!activeRealms.Any(r => string.Equals(r.Slug, realmSlug, StringComparison.Ordinal))) + if (explicitRealm is null) { - errorWriter.WriteLine(explicitRealm is not null - ? $"error: Realm '{realmSlug}' not found. Run 'recover realm-list' to see available realms." - : $"error: The '{realmSlug}' realm does not exist yet — has the deployment been bootstrapped? Run 'recover realm-list'."); - return 1; + if (activeRealms.Count == 0) + { + errorWriter.WriteLine( + "error: No realm exists yet. Run 'recover install-link --base-url ' first."); + return (1, null); + } + if (activeRealms.Count > 1) + { + errorWriter.WriteLine( + $"error: {activeRealms.Count} active realms exist; pass --realm explicitly."); + return (1, null); + } + return (null, activeRealms[0].Slug); } - if (explicitRealm is null && activeRealms.Count > 1) + // Case-sensitive match: the tenant registry is keyed by the exact slug. + if (!activeRealms.Any(r => string.Equals(r.Slug, explicitRealm, StringComparison.Ordinal))) { errorWriter.WriteLine( - $"note: no --realm specified; acting on the '{realmSlug}' realm " + - $"({activeRealms.Count} active realms exist — pass --realm to target another)."); + $"error: Realm '{explicitRealm}' not found. Run 'recover realm-list' to see available realms."); + return (1, null); } - return null; + return (null, explicitRealm); } } diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCliContext.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCliContext.cs index add668b5..4b1e1d8a 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCliContext.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCliContext.cs @@ -35,7 +35,7 @@ public RecoveryCliContext( /// Full argv, with Args[0] the command name. public string[] Args { get; } - /// Resolved tenant slug (the global --realm, default system). + /// Resolved tenant slug, empty for deployment-wide commands. public string RealmSlug { get; } public IWebHostEnvironment Env { get; } diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs index da4221cb..08f0665c 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Modgud.Authentication; using Modgud.Authentication.Setup; using Modgud.Authorization.Apps; @@ -13,6 +14,7 @@ using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; +using Modgud.Infrastructure.Installation; using Marten; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; @@ -26,6 +28,59 @@ namespace Modgud.Authentication.Api.Admin; // One class per Recovery-CLI command. The dispatcher in RecoveryCli resolves the // tenant, enters the TenantContext, opens a DI scope, and calls ExecuteAsync; each // command resolves only the services it needs from ctx.Services and writes through + +// ── install-link ────────────────────────────────────────────────────────── + +/// +/// Issues the short-lived operator authorization used by both the browser +/// installation wizard and automated CI installation. +/// +internal sealed class InstallLinkCommand : IRecoveryCommand +{ + public string Name => "install-link"; + public bool RequiresRealm => false; + + public async Task ExecuteAsync(RecoveryCliContext ctx) + { + var baseUrl = ctx.Flag("--base-url"); + if (string.IsNullOrWhiteSpace(baseUrl)) + return ctx.Fail("Usage: recover install-link --base-url [--minutes 30] [--json]"); + + var minutesRaw = ctx.Flag("--minutes"); + if (minutesRaw is not null + && (!int.TryParse(minutesRaw, out var parsed) || parsed is < 1 or > 1440)) + { + return ctx.Fail("--minutes must be an integer between 1 and 1440."); + } + + var minutes = minutesRaw is null ? 30 : int.Parse(minutesRaw); + var service = ctx.Services.GetRequiredService(); + var result = await service.IssueAsync(baseUrl, TimeSpan.FromMinutes(minutes)); + if (result.IsError) + return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); + + var issued = result.Value; + if (ctx.Args.Any(a => a.Equals("--json", StringComparison.OrdinalIgnoreCase))) + { + ctx.WriteLine(JsonSerializer.Serialize(new + { + token = issued.PlaintextToken, + installUrl = issued.InstallUrl, + expiresAt = issued.ExpiresAt, + })); + return 0; + } + + ctx.WriteLine("✓ First-installation link issued:"); + ctx.WriteLine($" Expires: {issued.ExpiresAt:yyyy-MM-dd HH:mm:ss zzz}"); + ctx.WriteLine(); + ctx.WriteLine($" Link: {issued.InstallUrl}"); + ctx.WriteLine(); + ctx.WriteLine("The previous unconsumed installation link, if any, is now revoked."); + ctx.WriteLine("For CI, add --json and POST the token to /api/install/complete."); + return 0; + } +} // ctx.Out / ctx.Error. The execution model is unchanged from the original // monolith (same in-process host boot, same real events) — this is internal // modularization only. @@ -124,15 +179,17 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) await session.SaveChangesAsync(); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = user.Id.ToString(), - Status = "succeeded", - Reason = $"reset-2fa: UserId={user.Id} TOTP={wasTotpEnabled} EmailOtp={wasEmailOtpEnabled} PasskeysDeleted={passkeys.Count}", - Message = $"Recovery reset-2fa. UserId={user.Id} TOTP={wasTotpEnabled} EmailOtp={wasEmailOtpEnabled} PasskeysDeleted={passkeys.Count}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + TargetSubjectId = user.Id, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "reset-2fa", + ReasonCode = $"totp:{wasTotpEnabled};email-otp:{wasEmailOtpEnabled}", + Count = passkeys.Count, }); ctx.WriteLine($"✓ 2FA reset for {user.UserName}:"); @@ -197,15 +254,15 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) await session.SaveChangesAsync(); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = user.Id.ToString(), - Status = "succeeded", - Reason = $"set-email: UserId={user.Id} Old={LogPiiMasking.MaskEmail(oldEmail)} New={LogPiiMasking.MaskEmail(newEmail)}", - Message = $"Recovery set-email. UserId={user.Id} Old={LogPiiMasking.MaskEmail(oldEmail)} New={LogPiiMasking.MaskEmail(newEmail)}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + TargetSubjectId = user.Id, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "set-email", }); ctx.WriteLine($"✓ Email updated for {user.UserName}:"); @@ -268,15 +325,16 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) var appUrl = RealmPublicUrl.RealmPublicBaseUrl(realm, ctx.Env); var url = $"{appUrl}/magic-login?userId={user.Id}&token={Uri.EscapeDataString(token)}"; - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = user.Id.ToString(), - Status = "succeeded", - Reason = $"magic-link: UserId={user.Id} ExpiresAt={challenge.ExpiresAt:O}", - Message = $"Recovery magic-link generated. UserId={user.Id} ExpiresAt={challenge.ExpiresAt:O}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + TargetSubjectId = user.Id, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "generate-magic-link", + EffectiveAt = challenge.ExpiresAt, }); ctx.WriteLine($"✓ Magic link for {user.UserName} (expires in {expirationMinutes} min):"); @@ -293,8 +351,9 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) /// /// Rebuilds all Marten projections from event 0. Mirrors the admin rebuild /// endpoint but runs without auth — needed when a schema change leaves -/// mt_doc_principal empty so no user can claim app:admin until the -/// principal projection is replayed. +/// mt_doc_principal empty so no user's App-scoped grants or +/// realm:admin bypass can resolve until the principal projection is +/// replayed. /// internal sealed class RebuildProjectionsCommand : IRecoveryCommand { @@ -308,14 +367,14 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine("Rebuilding Marten projections..."); var securityAudit = ctx.Services.GetRequiredService(); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Status = "initiated", - Reason = "rebuild-projections", - Message = "Recovery rebuild-projections initiated", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "rebuild-projections", }); // MasterTableTenancy disables Marten's default tenant, so the no-arg @@ -331,14 +390,14 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) await daemon.RebuildProjectionAsync(timeout, CancellationToken.None); ctx.WriteLine(" OK PermissionRoleProjection (mt_doc_permissionrole)"); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Status = "succeeded", - Reason = "rebuild-projections", - Message = "Recovery rebuild-projections completed", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rebuild-projections", }); return 0; } @@ -382,29 +441,30 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) var securityAudit = ctx.Services.GetRequiredService(); if (result.IsError) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = LogPiiMasking.MaskUsername(userName), - Status = "failed", - Reason = $"bootstrap-admin: UserName={LogPiiMasking.MaskUsername(userName)} Code={result.FirstError.Code} Detail={result.FirstError.Description}", - Message = $"Recovery bootstrap-admin failed. Realm={ctx.RealmSlug} UserName={LogPiiMasking.MaskUsername(userName)} Code={result.FirstError.Code} Detail={result.FirstError.Description}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + UnknownIdentifier = userName, + OutcomeCode = AuditOutcomes.Failed, + OperationCode = "bootstrap-admin-direct", + ReasonCode = result.FirstError.Code, }); return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); } var admin = result.Value; - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = admin.UserId.ToString(), - Status = "succeeded", - Reason = $"bootstrap-admin: UserId={admin.UserId} Mode=Direct", - Message = $"Recovery bootstrap-admin succeeded. Realm={ctx.RealmSlug} UserId={admin.UserId} Mode=Direct", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + TargetSubjectId = admin.UserId, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "bootstrap-admin-direct", }); ctx.WriteLine($"✓ Admin created in realm '{ctx.RealmSlug}':"); @@ -434,15 +494,16 @@ private static async Task IssueInviteAsync( issuedBy: null, // CLI invocation — no authenticated CP-admin realm); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = LogPiiMasking.MaskUsername(userName), - Status = "initiated", - Reason = $"bootstrap-admin invite: UserName={LogPiiMasking.MaskUsername(userName)} Email={LogPiiMasking.MaskEmail(email)} ExpiresAt={invite.ExpiresAt:O}", - Message = $"Recovery bootstrap-admin issued invite. Realm={ctx.RealmSlug} UserName={LogPiiMasking.MaskUsername(userName)} Email={LogPiiMasking.MaskEmail(email)} ExpiresAt={invite.ExpiresAt:O}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + UnknownIdentifier = email, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "bootstrap-admin-invite", + EffectiveAt = invite.ExpiresAt, }); ctx.WriteLine($"✓ Bootstrap-invite issued for realm '{ctx.RealmSlug}':"); @@ -532,14 +593,17 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) await session.SaveChangesAsync(); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Status = "succeeded", - Reason = $"migrate-cc-credentials: Migrated={migrated} SaCreated={saCreated} SaReused={saReused}", - Message = $"Recovery migrate-cc-credentials completed. Realm={ctx.RealmSlug} Migrated={migrated} SaCreated={saCreated} SaReused={saReused}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "migrate-cc-credentials", + Count = migrated, + RelatedCount = saCreated, + ReusedCount = saReused, }); ctx.WriteLine(); @@ -630,14 +694,14 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine($"✓ Added '{domain}' to realm '{slug}'. Now: [{string.Join(", ", realm.Domains)}]"); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = slug, - Status = "succeeded", - Reason = $"realm-add-domain: Realm={slug} Domain={domain}", - Message = $"Recovery realm-add-domain — Realm={slug} Domain={domain}", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "realm-add-domain", + Domain = domain, }); return 0; } @@ -686,14 +750,14 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine($"✓ Removed '{domain}' from realm '{slug}'. Now: [{string.Join(", ", remaining)}]"); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = slug, - Status = "succeeded", - Reason = $"realm-remove-domain: Realm={slug} Domain={domain}", - Message = $"Recovery realm-remove-domain — Realm={slug} Domain={domain}", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "realm-remove-domain", + Domain = domain, }); return 0; } @@ -747,14 +811,15 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine(" affected users must re-register their passkeys (other login"); ctx.WriteLine(" methods are unaffected)."); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = slug, - Status = "succeeded", - Reason = $"realm-set-primary-domain: Realm={slug} Old={oldPrimary} New={domain} (passkeys invalidated)", - Message = $"Recovery realm-set-primary-domain — Realm={slug} Old={oldPrimary} New={domain}. WebAuthn RP changed; existing passkeys invalidated.", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "realm-set-primary-domain", + Domain = domain, + PreviousDomain = oldPrimary, }); return 0; } @@ -801,14 +866,13 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) if (result.IsError) return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = targetSlug, - Status = "succeeded", - Reason = $"control-plane transfer: Target={targetSlug}", - Message = $"Recovery control-plane transfer. Target={targetSlug}", + Severity = AuditSeverity.Warning, + TargetRealmSlug = targetSlug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "control-plane-transfer", }); ctx.WriteLine($"✓ Control plane transferred to realm '{targetSlug}'."); ctx.PrintRestartHint(); @@ -848,14 +912,13 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) if (result.IsError) return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = slug, - Status = "succeeded", - Reason = $"adopt-tenant: Slug={slug}", - Message = $"Recovery adopt-tenant. Slug={slug}", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "adopt-tenant", }); ctx.WriteLine($"✓ Adopted existing database as realm '{slug}'."); ctx.WriteLine($" Domains: {string.Join(", ", result.Value.Domains)}"); @@ -879,14 +942,15 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) var creds = await keyStore.RotateAsync(ctx.RealmSlug); var kid = creds.Key.KeyId; - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Status = "rotated", - Reason = $"rotate-signing-key: Realm={ctx.RealmSlug} NewKid={kid}", - Message = $"Recovery rotate-signing-key. Realm={ctx.RealmSlug} NewKid={kid}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rotate-signing-key", + KeyId = kid, }); ctx.WriteLine($" OK new active kid: {kid}"); ctx.WriteLine(" Previous key retired into the 30-day verification overlap window."); diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/DynamicOidcSchemeManager.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/DynamicOidcSchemeManager.cs index d9d79c6d..907a925b 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/DynamicOidcSchemeManager.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/DynamicOidcSchemeManager.cs @@ -29,9 +29,9 @@ namespace Modgud.Authentication.Api.ExternalAuth; /// by the auth-flow consumers. Phase 1 leaves a soft filter (we still receive /// them in from event handlers, but the missing /// flavor key sends them down the early-return path with a benign warning). -/// Phase 2 wires the explicit Type == Oidc guard in callers, plus a -/// defense-in-depth check in itself; Saml/Ldap/ -/// Kerberos types are also rejected here until their flavor surfaces land. +/// Callers apply an explicit Type == Oidc guard and +/// repeats it as defense in depth. SAML providers +/// use DynamicSamlSchemeManager; LDAP/Kerberos remain unsupported. /// /// public class DynamicOidcSchemeManager( @@ -64,9 +64,9 @@ public async Task RegisterAsync(LoginProvider config) // Type-discriminator gate. Only Oidc-typed providers run through the // OIDC scheme machinery. Internal is short-circuited (built-in form - // path); Saml/Ldap/Kerberos are not yet wired and skip silently with - // an info log so the bootstrap loop and event-handler chain don't - // raise warnings on every realm-startup. + // path). SAML uses its own manager; LDAP/Kerberos are unsupported. + // Skip every non-OIDC type quietly enough that bootstrap and event + // handling don't raise warnings on every realm startup. if (config.Type != LoginProviderType.Oidc) { logger.LogInformation( diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs index 6162bf3f..70f7abaf 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs @@ -10,10 +10,9 @@ public static class ExternalAuthEndpoints { public static void MapExternalAuthEndpoints(this IEndpointRouteBuilder endpoints, string path) { - // Public — login page needs the list to render buttons. Only returns - // enabled, non-deleted, Oidc-typed providers. Internal is rendered by - // the built-in form, not as a button on this list; Saml/Ldap/Kerberos - // are not yet wired and stay hidden until a future phase plugs them in. + // Public — login page needs the list to render buttons. Returns + // enabled, non-deleted OIDC and SAML providers. Internal is rendered by + // the built-in form; LDAP/Kerberos remain unsupported and stay hidden. endpoints.MapGet($"{path}/account/external-logins", async ([FromServices] IQuerySession session, CancellationToken ct) => { @@ -49,10 +48,10 @@ public static void MapExternalAuthEndpoints(this IEndpointRouteBuilder endpoints if (config is null || config.IsDeleted || !config.Enabled) return Results.NotFound(); - // Internal is invisible to this surface (no silent enumeration); - // Saml/Ldap/Kerberos are intentionally surfaced as "not yet - // supported" so admins/CI can tell the difference between - // "wrong id" and "type not implemented". + // This route is the OIDC entry point. SAML has its own + // SP-initiated /saml/{slug}/login route; LDAP/Kerberos remain + // unsupported. Preserve the distinction between a missing + // provider and a provider of the wrong protocol type. if (config.Type == LoginProviderType.Internal) return Results.NotFound(); if (config.Type != LoginProviderType.Oidc) @@ -91,7 +90,11 @@ public static void MapExternalAuthEndpoints(this IEndpointRouteBuilder endpoints // sends an Origin header matching the IdP's host; cross-origin // forced loads do not. endpoints.MapGet($"{path}/account/external-logout/{{loginProviderId:guid}}", - async (Guid loginProviderId, HttpContext http) => + async (Guid loginProviderId, + HttpContext http, + [FromServices] IQuerySession session, + [FromServices] IAuthenticationSchemeProvider schemeProvider, + CancellationToken ct) => { if (!IsSameSiteRequest(http)) { @@ -101,7 +104,21 @@ public static void MapExternalAuthEndpoints(this IEndpointRouteBuilder endpoints detail: "External logout must originate from the IdP's own UI."); } + var config = await session.LoadAsync(loginProviderId, ct); + if (config is null + || config.IsDeleted + || !config.Enabled + || config.Type != LoginProviderType.Oidc) + { + return Results.Redirect("/logged-out"); + } + var schemeName = DynamicOidcSchemeManager.SchemeNameFor(loginProviderId); + if (await schemeProvider.GetSchemeAsync(schemeName) is null) + { + return Results.Redirect("/logged-out"); + } + var props = new AuthenticationProperties { RedirectUri = "/logged-out" }; return Results.SignOut(props, [schemeName]); }).AllowAnonymous(); @@ -115,7 +132,6 @@ public static void MapExternalAuthEndpoints(this IEndpointRouteBuilder endpoints async (HttpContext http, [FromServices] ExternalLoginProcessor processor, [FromServices] Microsoft.AspNetCore.Identity.SignInManager signInManager, - [FromServices] Modgud.Authentication.Sessions.ISessionService sessionService, CancellationToken ct) => { var auth = await http.AuthenticateAsync(Microsoft.AspNetCore.Identity.IdentityConstants.ExternalScheme); @@ -150,8 +166,8 @@ public static void MapExternalAuthEndpoints(this IEndpointRouteBuilder endpoints return Results.Redirect($"/login?error={code}"); } - // Sign in with the app cookie. Persistent=true gives the OIDC - // path the same 30-day sliding lifetime as Passkey/Magic-Link. + // Sign in with the app cookie. Persistent=true applies the + // realm's browser-session policy, like Passkey/Magic-Link. await http.SignInAsync( Microsoft.AspNetCore.Identity.IdentityConstants.ApplicationScheme, result.Principal!, @@ -163,11 +179,6 @@ await http.SignInAsync( // the application cookie — defense against stale claim-replay. await http.SignOutAsync(Microsoft.AspNetCore.Identity.IdentityConstants.ExternalScheme); - // Track per-user device session (best-effort). - var signedInIdClaim = result.Principal!.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - if (Guid.TryParse(signedInIdClaim, out var signedInUserId)) - await Modgud.Authentication.Sessions.SessionTracker.RecordLoginAsync(sessionService, http, signedInUserId, ct); - var returnUrl = auth.Properties.Items.TryGetValue("returnUrl", out var ru) && !string.IsNullOrWhiteSpace(ru) ? ru : "/"; diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs index 6e51fabf..6702e13d 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs @@ -61,13 +61,14 @@ public async Task ProcessAsync( if (config.Type != LoginProviderType.Oidc && config.Type != LoginProviderType.Saml) { var err = LoginProviderErrors.TypeNotSupported(config.Type); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = $"misconfigured provider type {config.Type} (LoginProvider {loginProviderId})", - Message = "External login rejected — provider misconfigured (expected Oidc or Saml)", + EventType = AuditEvents.ExternalLoginConfigurationError, + Severity = AuditSeverity.Warning, + LoginProviderId = loginProviderId, + AuthenticationMethod = "external", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = $"unsupported-provider-type:{config.Type}", }); return ExternalLoginResult.Failed(err.Code, err.Description); } @@ -81,14 +82,15 @@ public async Task ProcessAsync( if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(subject)) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = "missing iss/sub", - Message = "External login rejected — identity provider returned no iss/sub", - }); + EventType = AuditEvents.ExternalLoginProtocolRejected, + Severity = AuditSeverity.Warning, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "missing-issuer-or-subject", + }, ct); return ExternalLoginResult.Failed("Idp.InvalidToken", "The identity provider did not return a subject."); } @@ -145,14 +147,17 @@ public async Task ProcessAsync( // live one is not.) if (authenticatedUserId is { } authId && authId != link.UserId) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.IdentityHijackBlocked, - Level = "Warning", - Status = "rejected", - Reason = "external subject already linked to a different user", - Message = "Link attempt rejected — external subject already linked to a different user", - }); + Severity = AuditSeverity.Warning, + ActorSubjectId = authId, + TargetSubjectId = link.UserId, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = "subject-linked-to-different-user", + }, ct); return ExternalLoginResult.Failed("Idp.LinkedToOtherUser", "This identity is already linked to another Modgud account."); } @@ -205,15 +210,16 @@ public async Task ProcessAsync( var email = scriptResult.Email.Presence == FieldPresence.Value ? scriptResult.Email.Value : null; if (!IsEmailAllowed(config, email)) { - var maskedEmail = LogPiiMasking.MaskEmail(email); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Actor = maskedEmail, - Status = "rejected", - Reason = "email not in allowlist", - Message = $"External login rejected — email '{maskedEmail}' not in allowlist", + EventType = AuditEvents.ExternalLoginPolicyRejected, + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = email, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "email-domain-not-allowed", }); return ExternalLoginResult.Failed("Idp.EmailNotAllowed", "Your email domain is not allowed for this provider."); } @@ -239,16 +245,16 @@ public async Task ProcessAsync( // specifically-configured IdP, which is the trust anchor.) if (!IsEmailLinkTrustworthy(config, rawClaims, email)) { - var maskedEmail = LogPiiMasking.MaskEmail(email); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.IdentityHijackBlocked, - Level = "Warning", - Actor = maskedEmail, - Status = "rejected", - Reason = "email-link blocked — IdP did not assert email_verified", - Message = $"External email-link rejected — IdP did not verify email '{maskedEmail}' (TrustForEmailLink requires email_verified for OIDC)", - }); + Severity = AuditSeverity.Warning, + TargetSubjectId = existing.Id, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = "email-not-verified-by-provider", + }, ct); return ExternalLoginResult.Failed("Idp.EmailNotVerified", "The identity provider did not verify this email address, so it cannot be auto-linked to an existing account."); } @@ -276,13 +282,16 @@ public async Task ProcessAsync( // 4. JIT user creation if (!config.AutoCreateUsers) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = "no existing link and JIT creation disabled", - Message = "External login rejected — no existing link and automatic user creation is disabled", + EventType = AuditEvents.ExternalLoginPolicyRejected, + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = email, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "jit-disabled", }); return ExternalLoginResult.Failed("Idp.NoUserAndAutoCreateOff", "No user is linked to this identity and automatic creation is disabled."); @@ -294,21 +303,22 @@ public async Task ProcessAsync( // Email-uniqueness on JIT: another user already owns this email → reject. var emailUpper = email.ToUpperInvariant(); - var emailTaken = await session.Query() + var emailOwnerId = await session.Query() .Where(p => !p.IsDeleted && p.NormalizedEmail == emailUpper) - .AnyAsync(ct); - if (emailTaken) + .Select(p => p.Id) + .FirstOrDefaultAsync(ct); + if (emailOwnerId != Guid.Empty) { - var maskedEmail = LogPiiMasking.MaskEmail(email); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.JitEmailConflict, - Level = "Warning", - Actor = maskedEmail, - Status = "rejected", - Reason = "email already taken (JIT create)", - Message = $"JIT creation rejected — email '{maskedEmail}' is already taken by another user", - }); + Severity = AuditSeverity.Warning, + TargetSubjectId = emailOwnerId, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "email-owned-by-existing-user", + }, ct); return ExternalLoginResult.Failed("Idp.EmailConflict", "A Modgud account with this email already exists. Please contact your administrator."); } @@ -358,16 +368,15 @@ public async Task ProcessAsync( .FirstOrDefaultAsync(ct); if (clashingUserId != Guid.Empty) { - var maskedEmail = LogPiiMasking.MaskEmail(newEmail); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.JitEmailConflict, - Level = "Warning", - Actor = maskedEmail, - Status = "rejected", - Reason = "email already taken (user-update script)", - Message = $"UserUpdateScript email conflict — '{maskedEmail}' is already taken by another user; login rejected", - }); + Severity = AuditSeverity.Warning, + ActorSubjectId = user.Id, + TargetSubjectId = clashingUserId, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "user-update-email-conflict", + }, ct); return new ApplyUpdatesError( "Idp.EmailConflict", "The identity provider reports an email that is already used by another Modgud account."); @@ -445,14 +454,15 @@ private async Task Success( // IsActive=true, so the JIT path passes this gate unaffected. if (user.IsDeleted || !user.IsActive) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Actor = user.Id.ToString(), - Status = "rejected", - Reason = "user inactive or deleted", - Message = $"External login rejected — user {user.Id} is inactive or deleted", + EventType = AuditEvents.ExternalLoginPolicyRejected, + Severity = AuditSeverity.Warning, + TargetSubjectId = user.Id, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = user.IsDeleted ? "user-deleted" : "user-inactive", }); return ExternalLoginResult.Failed("Idp.UserInactive", "This account is not active."); } @@ -512,15 +522,17 @@ private async Task Success( // lives in the Authorization layer, which cannot reach the audit store, // so it surfaces the count and we record the security event here. if (derived.DroppedRealmAdminCount > 0) - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.PrivilegeEscalationBlocked, - Level = "Warning", - Actor = user.Id.ToString(), - Status = "blocked", - Reason = $"dropped {derived.DroppedRealmAdminCount} externally-derived group(s) conferring realm:admin via provider {config.Slug}", - Message = $"Blocked {derived.DroppedRealmAdminCount} externally-derived realm:admin group(s) for user {user.Id}", - }); + Severity = AuditSeverity.Warning, + TargetSubjectId = user.Id, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = "realm-admin-group-derived-externally", + Count = derived.DroppedRealmAdminCount, + }, ct); } return new ExternalLoginResult( diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/LoginProviderEventHandlers.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/LoginProviderEventHandlers.cs index 1e1da344..ab7a733a 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/LoginProviderEventHandlers.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/LoginProviderEventHandlers.cs @@ -13,7 +13,8 @@ namespace Modgud.Authentication.Api.ExternalAuth; // type (the event stream itself is shared) but the LoginProviderReRegister // helper short-circuits non-Oidc providers before touching the scheme manager. // An Internal LoginProvider being added/updated/enabled must NOT cause OIDC -// scheme work — the same is true for Saml/Ldap/Kerberos until those land. +// scheme work. SAML events are handled by SamlLoginProviderEventHandlers; +// LDAP/Kerberos remain unsupported. public class LoginProviderOnAddedHandler( IQuerySession session, @@ -73,9 +74,9 @@ public static async Task Run(Guid id, return; } - // Type-discriminator gate. Internal/Saml/Ldap/Kerberos events skip the - // scheme-manager path — the manager defends itself too, but pre- - // filtering here keeps the warn logs out of the happy path. The + // Type-discriminator gate. Every non-OIDC event skips this OIDC + // scheme-manager path — SAML has its own parallel handlers, and the + // manager defends itself too. Pre-filtering keeps logs clean. The // unregister-on-missing branch above is unconditional on purpose: a // deleted Oidc provider whose record vanished should still drop its // scheme. diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/OidcSchemeBootstrap.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/OidcSchemeBootstrap.cs index e43f0de8..b2dc3ace 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/OidcSchemeBootstrap.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/OidcSchemeBootstrap.cs @@ -27,10 +27,11 @@ namespace Modgud.Authentication.Api.ExternalAuth; /// /// /// -/// Pre-filters on Type == LoginProviderType.Oidc so non-Oidc providers -/// (Internal, plus the not-yet-wired Saml/Ldap/Kerberos) never enter the -/// scheme-registration path. -/// double-checks defensively; the bootstrap pre-filter just keeps logs clean. +/// Pre-filters on Type == LoginProviderType.Oidc so Internal, SAML, +/// LDAP and Kerberos providers never enter the OIDC scheme-registration path. +/// SAML has its own bootstrap and manager; LDAP/Kerberos remain unsupported. +/// double-checks +/// defensively; this pre-filter keeps logs clean. /// /// public class OidcSchemeBootstrap( diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ProfileLinkEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ProfileLinkEndpoints.cs index f6bb4f6a..f10dda7f 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ProfileLinkEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ProfileLinkEndpoints.cs @@ -22,13 +22,13 @@ namespace Modgud.Authentication.Api.ExternalAuth; /// ExternalLoginProcessor.ProcessAsync(authenticatedUserId: ...) /// which creates the link. /// -/// Type-discriminator posture: only -/// providers can be linked. The gate lives in two places — /start -/// rejects non-Oidc ids before the OIDC challenge is issued, and -/// ExternalLoginProcessor rejects again on the callback. The list -/// endpoints below intentionally surface every link the user has, including -/// any that may have come from a provider whose type was later changed — -/// disconnecting a stale link must remain possible. +/// Type-discriminator posture: the self-service start route supports only +/// . SAML's cross-site ACS POST does not +/// carry the SameSite=Lax application cookie, so SAML identities +/// currently link through normal sign-in/JIT or trusted-email resolution +/// rather than this self-service flow. The list endpoints below intentionally +/// surface every OIDC or SAML link the user has; disconnecting any link must +/// remain possible. /// /// public static class ProfileLinkEndpoints diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs index 07fb67e9..d56d421f 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs @@ -228,15 +228,17 @@ public async Task RefreshMetadataAsync(Guid loginProviderId, CancellationT { var oldCount = existing.IdpMetadata?.SigningCertificatesBase64.Count ?? 0; var newCount = fresh.SigningCertificatesBase64.Count; - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { - EventType = AuditEvents.SamlMetadataRefreshed, - Realm = existing.RealmSlug, - Level = "Info", - Status = "cert_changed", - Reason = $"signing certs {oldCount}->{newCount}", - Message = $"SAML metadata refresh for provider {loginProviderId} changed signing certs ({oldCount} -> {newCount})", - }); + EventType = AuditEvents.SamlSigningCertificatesChanged, + RealmSlug = existing.RealmSlug, + ActorKind = AuditActorKind.System, + LoginProviderId = loginProviderId, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "signing-certificates-changed", + Count = newCount, + RelatedCount = oldCount, + }, ct); } return true; diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs index 0c029e35..2b5bf184 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs @@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Identity; using Modgud.Authentication.Domain; using Modgud.Authentication.Identity.LoginProviders.Saml; -using Modgud.Authentication.Sessions; using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Observability; @@ -33,9 +32,7 @@ public class SamlLoginFlow( SamlContextBuilder contextBuilder, SamlSpCertificateService spCertService, ExternalLoginProcessor processor, - SignInManager signInManager, ISamlAuthnRequestStore authnRequestStore, - ISessionService sessionService, ISecurityAuditLog securityAudit, ILogger logger) { @@ -60,13 +57,14 @@ public async Task StartLoginAsync( { if (provider.IdpMetadata is null) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = $"SAML: no IdP metadata cached (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — no IdP metadata cached", + EventType = AuditEvents.ExternalLoginConfigurationError, + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "metadata-unavailable", }); return Results.Redirect("/login?error=saml-no-metadata"); } @@ -74,13 +72,14 @@ public async Task StartLoginAsync( if (string.IsNullOrEmpty(provider.IdpMetadata.SsoRedirectUrl) && string.IsNullOrEmpty(provider.IdpMetadata.SsoPostUrl)) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = $"SAML: IdP metadata has no SSO endpoint (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — IdP metadata has no SSO endpoint", + EventType = AuditEvents.ExternalLoginConfigurationError, + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "sso-endpoint-missing", }); return Results.Redirect("/login?error=saml-no-sso"); } @@ -151,14 +150,15 @@ public async Task HandleAcsAsync( logger.LogWarning(ex, "SAML context build failed for provider {Id}", provider.LoginProviderId); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: context build failed (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — context build failed", + EventType = AuditEvents.ExternalLoginConfigurationError, + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "context-build-failed", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect("/login?error=saml-invalid"); @@ -179,30 +179,32 @@ public async Task HandleAcsAsync( logger.LogWarning(ex, "SAML response read/validate failed for provider {Id}", provider.LoginProviderId); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: response read/validate failed (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — response read/validate failed", - }); + EventType = AuditEvents.ExternalLoginProtocolRejected, + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "response-validation-failed", + }, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect("/login?error=saml-invalid"); } if (saml2Response.Status != Saml2StatusCodes.Success) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: non-success status {saml2Response.Status} (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — non-success status {saml2Response.Status}", - }); + EventType = AuditEvents.ExternalLoginProtocolRejected, + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = $"saml-status:{saml2Response.Status}", + }, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{Uri.EscapeDataString(saml2Response.Status.ToString() ?? "status")}"); } @@ -216,15 +218,16 @@ public async Task HandleAcsAsync( var sigError = CheckRequiredSignatures(saml2Response.XmlDocument, provider.FlavorData); if (sigError is not null) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.SamlSignatureRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: required-signature check failed ({sigError}) for provider {provider.LoginProviderId}", - Message = $"SAML response failed required-signature check ({sigError}) for provider {provider.Slug}", - }); + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = sigError, + }, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{Uri.EscapeDataString(sigError)}"); } @@ -258,15 +261,16 @@ public async Task HandleAcsAsync( "SAML response rejected ({Reason}) for provider {Id} — InResponseTo={InResponseTo}", reason, provider.LoginProviderId, saml2Response.InResponseToAsString); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: request correlation failed ({reason}) for provider {provider.LoginProviderId}", - Message = $"SAML response refused for provider {provider.Slug} — request correlation failed ({reason})", - }); + EventType = AuditEvents.ExternalLoginProtocolRejected, + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = $"request-correlation:{reason}", + }, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{reason}"); } @@ -310,10 +314,6 @@ await http.SignInAsync( ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Success); - var signedInIdClaim = result.Principal!.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (Guid.TryParse(signedInIdClaim, out var signedInUserId)) - await SessionTracker.RecordLoginAsync(sessionService, http, signedInUserId, ct); - var returnUrl = ExtractRelayStateReturnUrl(binding); return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl); } diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs index b9e9a605..0a4e25d1 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs @@ -84,14 +84,14 @@ private async Task TickAsync(CancellationToken ct) if (refreshed > 0 || failed > 0) { - // Platform-wide control-plane tick — leave Realm unset. - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordPlatformTelemetry(new PlatformAuditRecord { - EventType = AuditEvents.SamlMetadataRefreshed, - Level = "Info", - Status = "refreshed", - Reason = $"refreshed={refreshed} failed={failed}", - Message = $"SAML metadata refresh tick — refreshed={refreshed} failed={failed} (scanned={snapshot.Count})", + EventType = AuditEvents.SamlMetadataRefreshCompleted, + OutcomeCode = failed == 0 ? AuditOutcomes.Succeeded : AuditOutcomes.Completed, + ReasonCode = failed == 0 ? null : "partial-failure", + OperationCode = "refresh-due-providers", + Count = refreshed, + RelatedCount = failed, }); } } diff --git a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs index 040f8ba5..d4835ec4 100644 --- a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs @@ -96,6 +96,13 @@ public async Task> PatchAsync( doc.NativeGrants = r.Value; } + if (dto.ClientSessions is not null) + { + var r = MapClientSessions(dto.ClientSessions); + if (r.IsError) return r.FirstError; + doc.ClientSessions = r.Value; + } + if (dto.Dcr is not null) { var r = MapDcr(dto.Dcr); @@ -173,6 +180,9 @@ public async Task> StageNonOriginAsync( if (dto.NativeGrants is null) doc.NativeGrants = null; else { var r = MapNativeGrants(dto.NativeGrants); if (r.IsError) return r.FirstError; doc.NativeGrants = r.Value; } + if (dto.ClientSessions is null) doc.ClientSessions = null; + else { var r = MapClientSessions(dto.ClientSessions); if (r.IsError) return r.FirstError; doc.ClientSessions = r.Value; } + if (dto.Dcr is null) doc.Dcr = null; else { var r = MapDcr(dto.Dcr); if (r.IsError) return r.FirstError; doc.Dcr = r.Value; } @@ -308,6 +318,25 @@ private static ErrorOr MapNativeGrants(Applicat }; } + private static ErrorOr MapClientSessions(ApplicationClientSessionsDto dto) + { + if (dto.IdleLifetimeDays is { } idle && (idle < 1 || idle > 3650)) + return Error.Validation("ClientSessions.InvalidIdleLifetime", + "IdleLifetimeDays must be between 1 and 3650."); + if (dto.AbsoluteLifetimeDays is { } absolute && (absolute < 1 || absolute > 3650)) + return Error.Validation("ClientSessions.InvalidAbsoluteLifetime", + "AbsoluteLifetimeDays must be between 1 and 3650."); + if (dto.IdleLifetimeDays is { } i && dto.AbsoluteLifetimeDays is { } a && a < i) + return Error.Validation("ClientSessions.InvalidAbsoluteLifetime", + "AbsoluteLifetimeDays must be at least IdleLifetimeDays."); + + return new ApplicationClientSessionOverrides + { + IdleLifetime = Days(dto.IdleLifetimeDays), + AbsoluteLifetime = Days(dto.AbsoluteLifetimeDays), + }; + } + private static ErrorOr MapDcr(ApplicationDcrDto d) { if (LifetimeError("Dcr", d.AccessTokenLifetimeMinutes, d.RefreshTokenLifetimeDays) is { } e) return e; @@ -438,6 +467,11 @@ internal static ApplicationSettingsDto ToDto(ApplicationSettings? doc) AccessTokenLifetimeMinutes = doc.NativeGrants.AccessTokenLifetime is { } na ? (int)na.TotalMinutes : null, RefreshTokenLifetimeDays = doc.NativeGrants.RefreshTokenLifetime is { } nr ? (int)nr.TotalDays : null, }, + ClientSessions = doc.ClientSessions is null ? null : new ApplicationClientSessionsDto + { + IdleLifetimeDays = doc.ClientSessions.IdleLifetime is { } idle ? (int)idle.TotalDays : null, + AbsoluteLifetimeDays = doc.ClientSessions.AbsoluteLifetime is { } absolute ? (int)absolute.TotalDays : null, + }, Dcr = doc.Dcr is null ? null : new ApplicationDcrDto { Enabled = doc.Dcr.Enabled, diff --git a/src/dotnet/Modgud.Authentication/AuthLog/RealmLogEnricher.cs b/src/dotnet/Modgud.Authentication/AuthLog/RealmLogEnricher.cs index 4b51bef4..bfbc6177 100644 --- a/src/dotnet/Modgud.Authentication/AuthLog/RealmLogEnricher.cs +++ b/src/dotnet/Modgud.Authentication/AuthLog/RealmLogEnricher.cs @@ -34,6 +34,8 @@ public sealed class RealmLogEnricher : ILogEventEnricher { public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { - logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("Realm", TenantContext.Current)); + var realm = TenantContext.CurrentOrNull; + if (!string.IsNullOrEmpty(realm)) + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("Realm", realm)); } } diff --git a/src/dotnet/Modgud.Authentication/Domain/ClientSession.cs b/src/dotnet/Modgud.Authentication/Domain/ClientSession.cs new file mode 100644 index 00000000..2131a225 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Domain/ClientSession.cs @@ -0,0 +1,36 @@ +namespace Modgud.Authentication.Domain; + +/// +/// Authoritative server-side continuation state for one native OAuth +/// client/device. The associated refresh-token family is rooted in a unique +/// OpenIddict authorization so this row can be revoked independently. +/// +public class ClientSession +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string ClientId { get; set; } = string.Empty; + public string OAuthApplicationId { get; set; } = string.Empty; + public string AuthorizationId { get; set; } = string.Empty; + public string? ClientDisplayName { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public string? Browser { get; set; } + public string? BrowserVersion { get; set; } + public string? OperatingSystem { get; set; } + public string? OsVersion { get; set; } + public string? DeviceType { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastActiveAt { get; set; } + public DateTimeOffset AbsoluteExpiresAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + + public bool IsActive(DateTimeOffset now) => ExpiresAt > now && AbsoluteExpiresAt > now; + + public void Touch(DateTimeOffset now, TimeSpan idleLifetime) + { + LastActiveAt = now; + var idleExpiry = now.Add(idleLifetime); + ExpiresAt = idleExpiry <= AbsoluteExpiresAt ? idleExpiry : AbsoluteExpiresAt; + } +} diff --git a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs index 91067770..3c2e6dc7 100644 --- a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs +++ b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs @@ -11,8 +11,9 @@ namespace Modgud.Authentication.Domain.LoginProviders; /// Type discriminator: is set on creation and /// immutable thereafter. Internal-typed providers are seed-only (the realm /// seeder writes one of them), do not have a flavor and skip Client/Secret -/// validation. Oidc-typed (today) and Saml/Ldap/Kerberos-typed (future) -/// providers go through the flavor + FlavorData mechanism. +/// validation. OIDC- and SAML-typed providers use their respective flavor +/// registries plus FlavorData. LDAP/Kerberos are reserved for future +/// handlers. /// /// /// Secret handling: The client secret is never stored in clear text and @@ -118,8 +119,9 @@ public class LoginProvider /// /// Federation v1 (decision G): when true, this provider's claims may - /// drive app:admin-and-below group membership at login (gated further - /// by per-group ). Mirror of + /// drive ordinary App-scoped and <resource>:admin group + /// membership at login (gated further by per-group + /// ). Mirror of /// . realm:admin is never externally /// drivable regardless of this flag. Default false. /// diff --git a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProviderErrors.cs b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProviderErrors.cs index e019e8af..73d5f36c 100644 --- a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProviderErrors.cs +++ b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProviderErrors.cs @@ -11,9 +11,10 @@ public static class LoginProviderErrors { /// /// Returned wherever the runtime or admin layer is asked to act on a - /// LoginProvider whose is not yet wired - /// (Saml/Ldap/Kerberos today). Same code in admin and runtime paths so the - /// frontend sees a consistent shape. + /// LoginProvider whose is unsupported by + /// the requested surface. For example, the OIDC start route rejects SAML + /// because SAML uses its own SP-initiated route; LDAP/Kerberos are not + /// implemented. Admin and runtime paths share the same stable error shape. /// public static Error TypeNotSupported(LoginProviderType type) => Error.Validation( code: "LoginProvider.TypeNotSupported", diff --git a/src/dotnet/Modgud.Authentication/Domain/PendingAdminInvite.cs b/src/dotnet/Modgud.Authentication/Domain/PendingAdminInvite.cs index 5bda6b32..e0ab9cb6 100644 --- a/src/dotnet/Modgud.Authentication/Domain/PendingAdminInvite.cs +++ b/src/dotnet/Modgud.Authentication/Domain/PendingAdminInvite.cs @@ -1,10 +1,9 @@ namespace Modgud.Authentication.Domain; /// -/// One-shot bootstrap-invite for the first admin in a freshly provisioned -/// realm (C15). Stored in the tenant DB; issued by either the -/// Realm-Provisioning-Service (when the CP-admin creates the realm) or the -/// recovery CLI bootstrap-admin command (no --password flag). +/// One-shot invitation for a new realm administrator (C15). Stored in the +/// tenant DB; issued by the Control-Plane API or the recovery CLI +/// bootstrap-admin command (no --password flag). /// /// Single-use: is set when the recipient's /// password-set form succeeds. A second submit with the same token is @@ -55,7 +54,7 @@ public class PendingAdminInvite /// public string? IssuedBy { get; set; } - public const int DefaultExpirationDays = 7; + public const int DefaultExpirationHours = 24; public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; public bool IsUsed => UsedAt.HasValue; diff --git a/src/dotnet/Modgud.Authentication/Domain/UserSession.cs b/src/dotnet/Modgud.Authentication/Domain/UserSession.cs index 8ff9b1a6..f81e8c88 100644 --- a/src/dotnet/Modgud.Authentication/Domain/UserSession.cs +++ b/src/dotnet/Modgud.Authentication/Domain/UserSession.cs @@ -11,9 +11,6 @@ public class UserSession public Guid Id { get; set; } public Guid UserId { get; set; } - /// Optional correlation token (e.g. cookie/session id). - public string? SessionId { get; set; } - public string? IpAddress { get; set; } public string? UserAgent { get; set; } @@ -26,11 +23,11 @@ public class UserSession public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset LastActiveAt { get; set; } + public DateTimeOffset AbsoluteExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } public static UserSession Create( Guid userId, - string? sessionId, string? ipAddress, string? userAgent, string? browser, @@ -38,14 +35,14 @@ public static UserSession Create( string? operatingSystem, string? osVersion, string? deviceType, - TimeSpan sessionDuration) + TimeSpan idleLifetime, + TimeSpan absoluteLifetime) { var now = DateTimeOffset.UtcNow; return new UserSession { Id = Guid.NewGuid(), UserId = userId, - SessionId = sessionId, IpAddress = ipAddress, UserAgent = userAgent, Browser = browser, @@ -55,9 +52,19 @@ public static UserSession Create( DeviceType = deviceType, CreatedAt = now, LastActiveAt = now, - ExpiresAt = now.Add(sessionDuration), + AbsoluteExpiresAt = now.Add(absoluteLifetime), + ExpiresAt = Min(now.Add(idleLifetime), now.Add(absoluteLifetime)), }; } - public void Touch() => LastActiveAt = DateTimeOffset.UtcNow; + public bool IsActive(DateTimeOffset now) => ExpiresAt > now && AbsoluteExpiresAt > now; + + public void Touch(DateTimeOffset now, TimeSpan idleLifetime) + { + LastActiveAt = now; + ExpiresAt = Min(now.Add(idleLifetime), AbsoluteExpiresAt); + } + + private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => + left <= right ? left : right; } diff --git a/src/dotnet/Modgud.Authentication/Gdpr/GdprDtos.cs b/src/dotnet/Modgud.Authentication/Gdpr/GdprDtos.cs index e3417e0b..84a587c4 100644 --- a/src/dotnet/Modgud.Authentication/Gdpr/GdprDtos.cs +++ b/src/dotnet/Modgud.Authentication/Gdpr/GdprDtos.cs @@ -78,12 +78,17 @@ public record ExportSecurityDto public record ExportSessionDto { + public required string Kind { get; init; } + public string? ClientId { get; init; } + public string? ClientDisplayName { get; init; } public string? IpAddress { get; init; } public string? Browser { get; init; } public string? OperatingSystem { get; init; } public string? DeviceType { get; init; } public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset LastActiveAt { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public DateTimeOffset AbsoluteExpiresAt { get; init; } } public record ExportLoginEventDto diff --git a/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs b/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs index 63c119c2..0b09f4bd 100644 --- a/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs +++ b/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs @@ -50,6 +50,9 @@ public async Task> ExportUserDataAsync(Guid userId, C var sessions = await session.Query() .Where(s => s.UserId == userId) .ToListAsync(ct); + var clientSessions = await session.Query() + .Where(s => s.UserId == userId) + .ToListAsync(ct); var loginHistory = await GetLoginHistoryAsync(userId, 100, ct); @@ -60,7 +63,7 @@ public async Task> ExportUserDataAsync(Guid userId, C Metadata = new ExportMetadataDto { ExportedAt = DateTimeOffset.UtcNow, - FormatVersion = "1.0", + FormatVersion = "1.1", UserId = userId, }, Profile = new ExportProfileDto @@ -84,13 +87,29 @@ public async Task> ExportUserDataAsync(Guid userId, C Permissions = permissions, Sessions = sessions.Select(s => new ExportSessionDto { + Kind = "Browser", + IpAddress = s.IpAddress, + Browser = s.Browser, + OperatingSystem = s.OperatingSystem, + DeviceType = s.DeviceType, + CreatedAt = s.CreatedAt, + LastActiveAt = s.LastActiveAt, + ExpiresAt = s.ExpiresAt, + AbsoluteExpiresAt = s.AbsoluteExpiresAt, + }).Concat(clientSessions.Select(s => new ExportSessionDto + { + Kind = "OAuthClient", + ClientId = s.ClientId, + ClientDisplayName = s.ClientDisplayName, IpAddress = s.IpAddress, Browser = s.Browser, OperatingSystem = s.OperatingSystem, DeviceType = s.DeviceType, CreatedAt = s.CreatedAt, LastActiveAt = s.LastActiveAt, - }).ToList(), + ExpiresAt = s.ExpiresAt, + AbsoluteExpiresAt = s.AbsoluteExpiresAt, + })).ToList(), LoginHistory = loginHistory, }; } @@ -216,7 +235,8 @@ private async Task> PerformPermanentEraseAsync(Guid userId, Guid? // masks/archives in the correct realm DB — HttpContext is null there. var tenantId = TenantContext.CurrentOrNull ?? httpContextAccessor.HttpContext?.Items[TenantConstants.HttpContextTenantIdKey] as string - ?? TenantConstants.SystemTenantId; + ?? throw new InvalidOperationException( + "Permanent erase requires an explicit realm context."); // 0) Revoke live access (OAuth grants + sessions + security stamp) BEFORE // the user document is masked/deleted: the stamp rotation must load @@ -259,6 +279,7 @@ private async Task> PerformPermanentEraseAsync(Guid userId, Guid? // 2) Drop secondary documents (sessions + security data + change requests). session.DeleteWhere(s => s.UserId == userId); + session.DeleteWhere(s => s.UserId == userId); session.Delete(userId); // Federation v1: the per-user external-claims snapshot is a plain diff --git a/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Flavors/EntraIdFlavor.cs b/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Flavors/EntraIdFlavor.cs index e17e3252..09567d46 100644 --- a/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Flavors/EntraIdFlavor.cs +++ b/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Flavors/EntraIdFlavor.cs @@ -48,8 +48,8 @@ public class EntraIdFlavor : ILoginProviderFlavor Type: FlavorConfigFieldType.String, Label: "Tenant ID", Required: true, - HelpText: "Entra Directory (Tenant) ID, or 'common' for multi-tenant apps.", - Placeholder: "00000000-0000-0000-0000-000000000000"), + HelpText: "Entra tenant GUID, verified domain, or audience alias ('common', 'organizations', 'consumers').", + Placeholder: "contoso.onmicrosoft.com"), .. OidcAdvancedConfigFields.All, ]; diff --git a/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs b/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs index 1ddaf7f4..632c1f55 100644 --- a/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs +++ b/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs @@ -212,17 +212,16 @@ public async Task RotateAsync(CancellationToken ct = default) } _session.Store(doc); - await _session.SaveChangesAsync(ct); - - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.StoreRequired(_session, new SecurityAuditRecord { EventType = AuditEvents.SamlCertRotated, - Realm = realmSlug, - Level = "Info", - Status = "rotated", - Reason = $"thumbprint {doc.ActiveCertThumbprint}, notAfter {doc.ActiveCertNotAfter:o}", - Message = $"Rotated SAML SP cert — new thumbprint {doc.ActiveCertThumbprint}, valid until {doc.ActiveCertNotAfter:o}", + RealmSlug = realmSlug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rotate", + KeyId = doc.ActiveCertThumbprint, + EffectiveAt = doc.ActiveCertNotAfter, }); + await _session.SaveChangesAsync(ct); return newCert; } @@ -284,17 +283,16 @@ private async Task LoadOrCreateAsync(CancellationToke }; _session.Store(doc); - await _session.SaveChangesAsync(ct); - - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.StoreRequired(_session, new SecurityAuditRecord { EventType = AuditEvents.SamlCertRotated, - Realm = realmSlug, - Level = "Info", - Status = "generated", - Reason = $"initial cert, thumbprint {doc.ActiveCertThumbprint}", - Message = $"Generated initial SAML SP cert — thumbprint {doc.ActiveCertThumbprint}, valid until {doc.ActiveCertNotAfter:o}", + RealmSlug = realmSlug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "generate-initial", + KeyId = doc.ActiveCertThumbprint, + EffectiveAt = doc.ActiveCertNotAfter, }); + await _session.SaveChangesAsync(ct); return doc; } diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs index e30ccb22..81c85fac 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs @@ -4,6 +4,7 @@ using Modgud.Application.DTOs.Realms; using Modgud.Authentication.SelfRegistration.Captcha; using Modgud.Domain.Realms; +using Modgud.Infrastructure.Audit; using ErrorOr; using Marten; using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; @@ -26,7 +27,8 @@ public interface IRealmSettingsService public sealed class RealmSettingsService( IDocumentSession session, - CaptchaSecretStore captchaStore) : IRealmSettingsService + CaptchaSecretStore captchaStore, + ISecurityAuditLog? securityAudit = null) : IRealmSettingsService { public async Task LoadAsync(CancellationToken ct = default) { @@ -52,6 +54,8 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d Id = RealmSettingsDoc.SingletonId, CreatedAt = DateTimeOffset.UtcNow, }; + var previousSecurityRetentionDays = + doc.Audit?.SecurityRetentionDays ?? AuditSettings.Defaults.SecurityRetentionDays; if (dto.SelfRegistration is not null) { @@ -79,6 +83,20 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d doc.NativeGrants = native.Value; } + if (dto.BrowserSessions is not null) + { + var browserSessions = ApplyBrowserSessionPatch(doc.BrowserSessions, dto.BrowserSessions); + if (browserSessions.IsError) return browserSessions.FirstError; + doc.BrowserSessions = browserSessions.Value; + } + + if (dto.ClientSessions is not null) + { + var clientSessions = ApplyClientSessionPatch(doc.ClientSessions, dto.ClientSessions); + if (clientSessions.IsError) return clientSessions.FirstError; + doc.ClientSessions = clientSessions.Value; + } + if (dto.AuthRateLimits is not null) { var arl = ApplyAuthRateLimitsPatch(doc.AuthRateLimits, dto.AuthRateLimits); @@ -117,6 +135,24 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d if (!isCreate) doc.UpdatedAt = DateTimeOffset.UtcNow; session.Store(doc); + if (doc.Audit?.SecurityRetentionDays is { } retentionDays + && retentionDays != previousSecurityRetentionDays) + { + if (securityAudit is null) + { + throw new InvalidOperationException( + "Changing security retention requires an audit-capable RealmSettingsService."); + } + + securityAudit.StoreRequired(session, new SecurityAuditRecord + { + EventType = AuditEvents.SecurityRetentionChanged, + Severity = AuditSeverity.Warning, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "change-retention", + RetentionDays = retentionDays, + }); + } await session.SaveChangesAsync(ct); return ToDto(doc); @@ -153,6 +189,8 @@ private SelfRegistrationSettings ApplySelfRegistrationPatch( Dcr = MapDcrToDto(doc.Dcr), Cimd = MapCimdToDto(doc.Cimd), NativeGrants = MapNativeGrantsToDto(doc.NativeGrants), + BrowserSessions = MapBrowserSessionsToDto(doc.BrowserSessions), + ClientSessions = MapClientSessionsToDto(doc.ClientSessions), AuthRateLimits = MapAuthRateLimitsToDto(doc.AuthRateLimits), Branding = MapBrandingToDto(doc.Branding), RegistrationFields = MapRegistrationFieldsToDto(doc.RegistrationFields), @@ -360,17 +398,28 @@ internal static DeletionSettingsDto MapDeletionToDto(DeletionSettings? s) private static ErrorOr ApplyAuditPatch(AuditSettings? current, UpdateAuditSettingsDto patch) { var s = current ?? new AuditSettings(); - var merged = s with { VisibilityWindowDays = patch.VisibilityWindowDays ?? s.VisibilityWindowDays }; + var merged = s with + { + VisibilityWindowDays = patch.VisibilityWindowDays ?? s.VisibilityWindowDays, + SecurityRetentionDays = patch.SecurityRetentionDays ?? s.SecurityRetentionDays, + }; if (merged.VisibilityWindowDays < 1) return Error.Validation("Audit.InvalidVisibilityWindowDays", "VisibilityWindowDays must be at least 1."); + if (merged.SecurityRetentionDays is < 1 or > 365) + return Error.Validation("Audit.InvalidSecurityRetentionDays", + "SecurityRetentionDays must be between 1 and 365."); return merged; } internal static AuditSettingsDto MapAuditToDto(AuditSettings? s) { s ??= AuditSettings.Defaults; - return new AuditSettingsDto { VisibilityWindowDays = s.VisibilityWindowDays }; + return new AuditSettingsDto + { + VisibilityWindowDays = s.VisibilityWindowDays, + SecurityRetentionDays = s.SecurityRetentionDays, + }; } internal static DcrSettingsDto MapDcrToDto(DcrSettings? s) @@ -507,6 +556,61 @@ private static ErrorOr ApplyNativeGrantsPatch(NativeGrantSe return merged; } + private static ErrorOr ApplyBrowserSessionPatch( + BrowserSessionPolicy? current, + UpdateBrowserSessionPolicyDto patch) + { + var policy = current ?? BrowserSessionPolicy.Defaults; + var merged = policy with + { + IdleLifetime = patch.IdleLifetimeMinutes is { } idle + ? TimeSpan.FromMinutes(idle) + : policy.IdleLifetime, + AbsoluteLifetime = patch.AbsoluteLifetimeMinutes is { } absolute + ? TimeSpan.FromMinutes(absolute) + : policy.AbsoluteLifetime, + AllowRememberMe = patch.AllowRememberMe ?? policy.AllowRememberMe, + }; + + if (merged.IdleLifetime < TimeSpan.FromMinutes(5) || + merged.IdleLifetime > TimeSpan.FromDays(365)) + return Error.Validation("BrowserSessions.InvalidIdleLifetime", + "IdleLifetimeMinutes must be between 5 minutes and 365 days."); + if (merged.AbsoluteLifetime < merged.IdleLifetime || + merged.AbsoluteLifetime > TimeSpan.FromDays(3650)) + return Error.Validation("BrowserSessions.InvalidAbsoluteLifetime", + "AbsoluteLifetimeMinutes must be at least the idle lifetime and no more than 3650 days."); + + return merged; + } + + private static ErrorOr ApplyClientSessionPatch( + ClientSessionPolicy? current, + UpdateClientSessionPolicyDto patch) + { + var policy = current ?? ClientSessionPolicy.Defaults; + var merged = policy with + { + IdleLifetime = patch.IdleLifetimeDays is { } idle + ? TimeSpan.FromDays(idle) + : policy.IdleLifetime, + AbsoluteLifetime = patch.AbsoluteLifetimeDays is { } absolute + ? TimeSpan.FromDays(absolute) + : policy.AbsoluteLifetime, + }; + + if (merged.IdleLifetime < TimeSpan.FromDays(1) || + merged.IdleLifetime > TimeSpan.FromDays(3650)) + return Error.Validation("ClientSessions.InvalidIdleLifetime", + "IdleLifetimeDays must be between 1 and 3650."); + if (merged.AbsoluteLifetime < merged.IdleLifetime || + merged.AbsoluteLifetime > TimeSpan.FromDays(3650)) + return Error.Validation("ClientSessions.InvalidAbsoluteLifetime", + "AbsoluteLifetimeDays must be at least the idle lifetime and no more than 3650."); + + return merged; + } + internal static NativeGrantSettingsDto MapNativeGrantsToDto(NativeGrantSettings? s) { // Source the never-configured display defaults from the domain record so @@ -520,6 +624,27 @@ internal static NativeGrantSettingsDto MapNativeGrantsToDto(NativeGrantSettings? }; } + internal static BrowserSessionPolicyDto MapBrowserSessionsToDto(BrowserSessionPolicy? policy) + { + policy ??= BrowserSessionPolicy.Defaults; + return new BrowserSessionPolicyDto + { + IdleLifetimeMinutes = checked((int)policy.IdleLifetime.TotalMinutes), + AbsoluteLifetimeMinutes = checked((int)policy.AbsoluteLifetime.TotalMinutes), + AllowRememberMe = policy.AllowRememberMe, + }; + } + + internal static ClientSessionPolicyDto MapClientSessionsToDto(ClientSessionPolicy? policy) + { + policy ??= ClientSessionPolicy.Defaults; + return new ClientSessionPolicyDto + { + IdleLifetimeDays = checked((int)policy.IdleLifetime.TotalDays), + AbsoluteLifetimeDays = checked((int)policy.AbsoluteLifetime.TotalDays), + }; + } + internal static SelfRegistrationDto MapSelfRegistrationToDto(SelfRegistrationSettings? s) { if (s is null) return new SelfRegistrationDto(); diff --git a/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionConnectionRegistry.cs b/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionConnectionRegistry.cs new file mode 100644 index 00000000..9c3e3800 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionConnectionRegistry.cs @@ -0,0 +1,58 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Http; + +namespace Modgud.Authentication.Sessions; + +public interface IBrowserSessionConnectionRegistry +{ + IDisposable Register(Guid sessionId, string connectionId, HttpContext httpContext); + void Revoke(Guid sessionId); +} + +/// +/// Process-local registry used to abort already-upgraded SignalR connections +/// immediately when their authoritative browser session is revoked. In a +/// multi-node deployment the normal per-invocation validation remains the +/// cross-node backstop; a distributed disconnect notification can be added with +/// the SignalR backplane. +/// +public sealed class BrowserSessionConnectionRegistry : IBrowserSessionConnectionRegistry +{ + private readonly ConcurrentDictionary> _connections = new(); + + public IDisposable Register(Guid sessionId, string connectionId, HttpContext httpContext) + { + var perSession = _connections.GetOrAdd(sessionId, _ => new()); + perSession[connectionId] = httpContext; + return new Registration(this, sessionId, connectionId); + } + + public void Revoke(Guid sessionId) + { + if (!_connections.TryRemove(sessionId, out var connections)) return; + foreach (var http in connections.Values) + http.Abort(); + } + + private void Remove(Guid sessionId, string connectionId) + { + if (!_connections.TryGetValue(sessionId, out var connections)) return; + connections.TryRemove(connectionId, out _); + if (connections.IsEmpty) + _connections.TryRemove(new KeyValuePair>(sessionId, connections)); + } + + private sealed class Registration( + BrowserSessionConnectionRegistry owner, + Guid sessionId, + string connectionId) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + owner.Remove(sessionId, connectionId); + } + } +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionCookieEvents.cs b/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionCookieEvents.cs new file mode 100644 index 00000000..1c85e222 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionCookieEvents.cs @@ -0,0 +1,119 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Modgud.Authentication.Domain; + +namespace Modgud.Authentication.Sessions; + +/// +/// Makes authoritative for every Modgud application +/// cookie, including login paths that bypass SignInManager. +/// +public sealed class BrowserSessionCookieEvents(ISessionService sessions) : CookieAuthenticationEvents +{ + public override async Task SigningIn(CookieSigningInContext context) + { + var principal = context.Principal; + var userId = ParseUserId(principal); + if (principal is null || userId is null) + throw new InvalidOperationException("An application cookie cannot be issued without a user id."); + + UserSession? browserSession = null; + // RefreshSignInAsync rebuilds the principal and may drop custom claims. + // Fall back to the currently authenticated request so a profile/stamp + // refresh keeps the same authoritative session instead of creating a + // duplicate row. + var currentClaim = principal.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value + ?? context.HttpContext.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (Guid.TryParse(currentClaim, out var currentSessionId)) + browserSession = await sessions.ValidateSessionAsync( + userId.Value, currentSessionId, touch: false, context.HttpContext.RequestAborted); + + if (browserSession is null) + { + var created = await sessions.CreateSessionAsync( + userId.Value, + context.HttpContext.Connection.RemoteIpAddress?.ToString(), + context.HttpContext.Request.Headers.UserAgent.ToString(), + context.HttpContext.RequestAborted); + if (created.IsError) + throw new InvalidOperationException(created.FirstError.Description); + browserSession = created.Value; + } + + var identity = principal.Identities.FirstOrDefault(i => i.IsAuthenticated) + ?? throw new InvalidOperationException("An application cookie requires an authenticated identity."); + foreach (var old in principal.FindAll(SessionClaimTypes.BrowserSessionId).ToList()) + old.Subject?.RemoveClaim(old); + identity.AddClaim(new Claim(SessionClaimTypes.BrowserSessionId, browserSession.Id.ToString())); + + var policy = await sessions.GetPolicyAsync(context.HttpContext.RequestAborted); + if (!policy.AllowRememberMe) + context.Properties.IsPersistent = false; + context.Properties.IssuedUtc = DateTimeOffset.UtcNow; + context.Properties.ExpiresUtc = browserSession.ExpiresAt; + } + + public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) + { + var userId = ParseUserId(context.Principal); + var rawSessionId = context.Principal?.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (userId is null || !Guid.TryParse(rawSessionId, out var sessionId)) + { + await RejectAsync(context); + return; + } + + // Storage exceptions intentionally escape: a transient database outage + // fails the request but does not turn into a destructive cookie delete. + var session = await sessions.ValidateSessionAsync( + userId.Value, sessionId, touch: true, context.HttpContext.RequestAborted); + if (session is null) + { + await RejectAsync(context); + return; + } + + await SecurityStampValidator.ValidatePrincipalAsync(context); + } + + public override async Task SigningOut(CookieSigningOutContext context) + { + var principal = context.HttpContext.User; + var userId = ParseUserId(principal); + var rawSessionId = principal.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (userId is not null && Guid.TryParse(rawSessionId, out var sessionId)) + await sessions.RevokeSessionAsync( + userId.Value, sessionId, context.HttpContext.RequestAborted); + } + + public override Task RedirectToLogin(RedirectContext context) => + RedirectOrStatusAsync(context, StatusCodes.Status401Unauthorized); + + public override Task RedirectToAccessDenied(RedirectContext context) => + RedirectOrStatusAsync(context, StatusCodes.Status403Forbidden); + + private static Guid? ParseUserId(ClaimsPrincipal? principal) + { + var raw = principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(raw, out var id) ? id : null; + } + + private static async Task RejectAsync(CookieValidatePrincipalContext context) + { + context.RejectPrincipal(); + await context.HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme); + } + + private static Task RedirectOrStatusAsync( + RedirectContext context, + int apiStatusCode) + { + if (context.Request.Path.StartsWithSegments("/api")) + context.Response.StatusCode = apiStatusCode; + else + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + } +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/ClientSessionService.cs b/src/dotnet/Modgud.Authentication/Sessions/ClientSessionService.cs new file mode 100644 index 00000000..6208fbe9 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/ClientSessionService.cs @@ -0,0 +1,254 @@ +using System.Globalization; +using ErrorOr; +using Marten; +using JasperFx; +using Modgud.Authentication.Applications; +using Modgud.Authentication.Domain; +using Modgud.Authentication.RealmSettings; +using Modgud.Domain.Applications; +using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.OpenIddict; + +namespace Modgud.Authentication.Sessions; + +public sealed class ClientSessionService( + IDocumentSession session, + IDeviceInfoService deviceInfo, + IRealmSettingsService realmSettings, + IOAuthGrantRevoker grants) : IClientSessionService, IRefreshTokenReuseObserver +{ + private static readonly TimeSpan TouchInterval = TimeSpan.FromMinutes(5); + + public async Task ResolvePolicyAsync(string clientId, CancellationToken ct = default) + { + var realm = await realmSettings.LoadAsync(ct); + var realmPolicy = realm.ClientSessions ?? ClientSessionPolicy.Defaults; + var effective = realmPolicy; + + var client = await session.Query() + .FirstOrDefaultAsync(x => x.ClientId == clientId && !x.IsDeleted, ct); + if (client is not null && client.AppIds.Count > 0) + { + var appPolicies = new List(); + foreach (var appId in client.AppIds.Distinct()) + { + var app = await session.LoadAsync(appId, ct); + var overrides = app?.ClientSessions; + appPolicies.Add(overrides is null + ? realmPolicy + : realmPolicy with + { + IdleLifetime = overrides.IdleLifetime ?? realmPolicy.IdleLifetime, + AbsoluteLifetime = overrides.AbsoluteLifetime ?? realmPolicy.AbsoluteLifetime, + }); + } + + // A multi-App client inherits the strictest participating App until + // an explicit client override removes the ambiguity. + if (appPolicies.Count > 0) + { + effective = new ClientSessionPolicy + { + IdleLifetime = appPolicies.Min(x => x.IdleLifetime), + AbsoluteLifetime = appPolicies.Min(x => x.AbsoluteLifetime), + }; + } + } + + if (client is not null) + { + effective = effective with + { + IdleLifetime = ReadSeconds(client.Settings, OAuthApplicationSettingKeys.ClientSessionIdleLifetime) + ?? ReadSeconds(client.Settings, OAuthApplicationSettingKeys.SlidingRefreshTokenLifetime) + ?? effective.IdleLifetime, + AbsoluteLifetime = ReadSeconds(client.Settings, OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime) + ?? effective.AbsoluteLifetime, + }; + } + + var max = TimeSpan.FromDays(3650); + var absolute = Clamp(effective.AbsoluteLifetime, TimeSpan.FromDays(1), max); + var idle = Clamp(effective.IdleLifetime, TimeSpan.FromDays(1), absolute); + return new ClientSessionPolicy { IdleLifetime = idle, AbsoluteLifetime = absolute }; + } + + public async Task CreateAsync(CreateClientSessionRequest request, CancellationToken ct = default) + { + var policy = await ResolvePolicyAsync(request.ClientId, ct); + var device = deviceInfo.Parse(); + var now = DateTimeOffset.UtcNow; + var entity = new ClientSession + { + Id = Guid.CreateVersion7(), + UserId = request.UserId, + ClientId = request.ClientId, + OAuthApplicationId = request.OAuthApplicationId, + AuthorizationId = request.AuthorizationId, + ClientDisplayName = request.ClientDisplayName, + IpAddress = request.IpAddress, + UserAgent = request.UserAgent, + Browser = device.Browser, + BrowserVersion = device.BrowserVersion, + OperatingSystem = device.OperatingSystem, + OsVersion = device.OsVersion, + DeviceType = device.DeviceType, + CreatedAt = now, + LastActiveAt = now, + AbsoluteExpiresAt = now.Add(policy.AbsoluteLifetime), + }; + entity.Touch(now, policy.IdleLifetime); + session.Store(entity); + await session.SaveChangesAsync(ct); + return entity; + } + + public async Task ValidateAndTouchAsync( + Guid userId, + Guid clientSessionId, + string clientId, + string? authorizationId, + CancellationToken ct = default) + { + var entity = await session.LoadAsync(clientSessionId, ct); + var now = DateTimeOffset.UtcNow; + if (entity is null || + entity.UserId != userId || + !string.Equals(entity.ClientId, clientId, StringComparison.Ordinal) || + string.IsNullOrEmpty(authorizationId) || + !string.Equals(entity.AuthorizationId, authorizationId, StringComparison.Ordinal)) + return null; + + if (!entity.IsActive(now)) + { + await RevokeCoreAsync(entity, ct); + return null; + } + + if (entity.LastActiveAt <= now.Subtract(TouchInterval)) + { + var policy = await ResolvePolicyAsync(clientId, ct); + entity.Touch(now, policy.IdleLifetime); + session.Store(entity); + try + { + await session.SaveChangesAsync(ct); + } + catch (ConcurrencyException) + { + return null; + } + } + + return entity; + } + + public async Task> GetSessionsAsync(Guid userId, CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + var rows = await session.Query() + .Where(x => x.UserId == userId && x.ExpiresAt > now && x.AbsoluteExpiresAt > now) + .OrderByDescending(x => x.LastActiveAt) + .ToListAsync(ct); + return rows.Select(ToDto).ToList(); + } + + public async Task> RevokeAsync(Guid userId, Guid sessionId, CancellationToken ct = default) + { + var entity = await session.LoadAsync(sessionId, ct); + if (entity is null) + return Error.NotFound("ClientSession.NotFound", $"Client session {sessionId} not found."); + if (entity.UserId != userId) + return Error.Forbidden("ClientSession.NotOwner", "Caller does not own this client session."); + await RevokeCoreAsync(entity, ct); + return true; + } + + public async Task RevokeAllAsync(Guid userId, bool revokeGrants, CancellationToken ct = default) + { + var rows = await session.Query().Where(x => x.UserId == userId).ToListAsync(ct); + if (revokeGrants) + { + foreach (var row in rows) + { + await grants.RevokeTokensByAuthorizationIdAsync(row.AuthorizationId, ct); + await grants.RevokeAuthorizationByIdAsync(row.AuthorizationId, ct); + } + } + + session.DeleteWhere(x => x.UserId == userId); + await session.SaveChangesAsync(ct); + } + + public async Task PruneExpiredAsync(CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + var rows = await session.Query() + .Where(x => x.ExpiresAt <= now || x.AbsoluteExpiresAt <= now) + .ToListAsync(ct); + foreach (var row in rows) + { + await grants.RevokeTokensByAuthorizationIdAsync(row.AuthorizationId, ct); + await grants.RevokeAuthorizationByIdAsync(row.AuthorizationId, ct); + session.Delete(row); + } + if (rows.Count > 0) + await session.SaveChangesAsync(ct); + return rows.Count; + } + + public async Task OnReuseDetectedAsync( + string? subject, + string? clientId, + string? authorizationId, + CancellationToken ct) + { + if (string.IsNullOrEmpty(authorizationId)) return; + + var rows = await session.Query() + .Where(x => x.AuthorizationId == authorizationId) + .ToListAsync(ct); + if (rows.Count == 0) return; + + foreach (var row in rows) + session.Delete(row); + await session.SaveChangesAsync(ct); + } + + private async Task RevokeCoreAsync(ClientSession entity, CancellationToken ct) + { + await grants.RevokeTokensByAuthorizationIdAsync(entity.AuthorizationId, ct); + await grants.RevokeAuthorizationByIdAsync(entity.AuthorizationId, ct); + session.Delete(entity); + await session.SaveChangesAsync(ct); + } + + private static ClientSessionDto ToDto(ClientSession x) => new() + { + Id = x.Id.ToString(), + ClientId = x.ClientId, + ClientDisplayName = x.ClientDisplayName, + IpAddress = x.IpAddress, + Browser = x.Browser, + BrowserVersion = x.BrowserVersion, + OperatingSystem = x.OperatingSystem, + OsVersion = x.OsVersion, + DeviceType = x.DeviceType, + CreatedAt = x.CreatedAt, + LastActiveAt = x.LastActiveAt, + ExpiresAt = x.ExpiresAt, + AbsoluteExpiresAt = x.AbsoluteExpiresAt, + }; + + private static TimeSpan? ReadSeconds(IReadOnlyDictionary settings, string key) + { + if (!settings.TryGetValue(key, out var raw) || + !int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + return null; + return TimeSpan.FromSeconds(seconds); + } + + private static TimeSpan Clamp(TimeSpan value, TimeSpan min, TimeSpan max) => + value < min ? min : value > max ? max : value; +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/IClientSessionService.cs b/src/dotnet/Modgud.Authentication/Sessions/IClientSessionService.cs new file mode 100644 index 00000000..e8217325 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/IClientSessionService.cs @@ -0,0 +1,30 @@ +using ErrorOr; +using Modgud.Authentication.Domain; +using Modgud.Domain.Realms; + +namespace Modgud.Authentication.Sessions; + +public sealed record CreateClientSessionRequest( + Guid UserId, + string ClientId, + string OAuthApplicationId, + string AuthorizationId, + string? ClientDisplayName, + string? IpAddress, + string? UserAgent); + +public interface IClientSessionService +{ + Task ResolvePolicyAsync(string clientId, CancellationToken ct = default); + Task CreateAsync(CreateClientSessionRequest request, CancellationToken ct = default); + Task ValidateAndTouchAsync( + Guid userId, + Guid clientSessionId, + string clientId, + string? authorizationId, + CancellationToken ct = default); + Task> GetSessionsAsync(Guid userId, CancellationToken ct = default); + Task> RevokeAsync(Guid userId, Guid sessionId, CancellationToken ct = default); + Task RevokeAllAsync(Guid userId, bool revokeGrants, CancellationToken ct = default); + Task PruneExpiredAsync(CancellationToken ct = default); +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/ISessionService.cs b/src/dotnet/Modgud.Authentication/Sessions/ISessionService.cs index 79f6f76a..d167fc45 100644 --- a/src/dotnet/Modgud.Authentication/Sessions/ISessionService.cs +++ b/src/dotnet/Modgud.Authentication/Sessions/ISessionService.cs @@ -1,5 +1,6 @@ using Modgud.Authentication.Domain; using ErrorOr; +using Modgud.Domain.Realms; namespace Modgud.Authentication.Sessions; @@ -14,6 +15,12 @@ public interface ISessionService /// Task> CreateSessionAsync(Guid userId, string? ipAddress, string? userAgent, CancellationToken ct = default); + Task GetPolicyAsync(CancellationToken ct = default); + + /// Loads and validates the authoritative session. Successful + /// validation also performs a throttled sliding-idle touch. + Task ValidateSessionAsync(Guid userId, Guid sessionId, bool touch, CancellationToken ct = default); + /// Revokes a single session owned by the caller. Task> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default); @@ -25,4 +32,6 @@ public interface ISessionService /// Updates the last-active timestamp. Task TouchSessionAsync(Guid sessionId, CancellationToken ct = default); + + Task PruneExpiredAsync(CancellationToken ct = default); } diff --git a/src/dotnet/Modgud.Authentication/Sessions/SessionClaimTypes.cs b/src/dotnet/Modgud.Authentication/Sessions/SessionClaimTypes.cs new file mode 100644 index 00000000..2444762b --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/SessionClaimTypes.cs @@ -0,0 +1,7 @@ +namespace Modgud.Authentication.Sessions; + +public static class SessionClaimTypes +{ + public const string BrowserSessionId = "modgud.session_id"; + public const string ClientSessionId = "modgud.client_session_id"; +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/SessionDtos.cs b/src/dotnet/Modgud.Authentication/Sessions/SessionDtos.cs index 7e4d701d..eaf0e50b 100644 --- a/src/dotnet/Modgud.Authentication/Sessions/SessionDtos.cs +++ b/src/dotnet/Modgud.Authentication/Sessions/SessionDtos.cs @@ -17,4 +17,22 @@ public record SessionDto public record SessionListDto { public required List Sessions { get; init; } + public List ClientSessions { get; init; } = []; +} + +public record ClientSessionDto +{ + public required string Id { get; init; } + public required string ClientId { get; init; } + public string? ClientDisplayName { get; init; } + public string? IpAddress { get; init; } + public string? Browser { get; init; } + public string? BrowserVersion { get; init; } + public string? OperatingSystem { get; init; } + public string? OsVersion { get; init; } + public string? DeviceType { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastActiveAt { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public DateTimeOffset AbsoluteExpiresAt { get; init; } } diff --git a/src/dotnet/Modgud.Authentication/Sessions/SessionService.cs b/src/dotnet/Modgud.Authentication/Sessions/SessionService.cs index 760f0b4a..356edcc1 100644 --- a/src/dotnet/Modgud.Authentication/Sessions/SessionService.cs +++ b/src/dotnet/Modgud.Authentication/Sessions/SessionService.cs @@ -1,6 +1,9 @@ using Modgud.Authentication.Domain; using ErrorOr; using Marten; +using JasperFx; +using Modgud.Authentication.RealmSettings; +using Modgud.Domain.Realms; namespace Modgud.Authentication.Sessions; @@ -10,15 +13,19 @@ namespace Modgud.Authentication.Sessions; /// via TenantedSessionFactory, so a user's sessions never leak across /// realms. /// -public class SessionService(IDocumentSession session, IDeviceInfoService deviceInfo) : ISessionService +public class SessionService( + IDocumentSession session, + IDeviceInfoService deviceInfo, + IRealmSettingsService realmSettings, + IBrowserSessionConnectionRegistry connections) : ISessionService { - private static readonly TimeSpan DefaultSessionDuration = TimeSpan.FromDays(14); + private static readonly TimeSpan TouchInterval = TimeSpan.FromMinutes(5); public async Task> GetSessionsAsync(Guid userId, Guid? currentSessionId, CancellationToken ct = default) { var now = DateTimeOffset.UtcNow; var sessions = await session.Query() - .Where(s => s.UserId == userId && s.ExpiresAt > now) + .Where(s => s.UserId == userId && s.ExpiresAt > now && s.AbsoluteExpiresAt > now) .OrderByDescending(s => s.LastActiveAt) .ToListAsync(ct); @@ -41,10 +48,10 @@ public async Task> GetSessionsAsync(Guid userId, Guid? c public async Task> CreateSessionAsync(Guid userId, string? ipAddress, string? userAgent, CancellationToken ct = default) { + var policy = await GetPolicyAsync(ct); var device = deviceInfo.Parse(); var entity = UserSession.Create( userId, - sessionId: Guid.NewGuid().ToString(), ipAddress, userAgent, device.Browser, @@ -52,13 +59,56 @@ public async Task> CreateSessionAsync(Guid userId, string? device.OperatingSystem, device.OsVersion, device.DeviceType, - DefaultSessionDuration); + policy.IdleLifetime, + policy.AbsoluteLifetime); session.Store(entity); await session.SaveChangesAsync(ct); return entity; } + public async Task GetPolicyAsync(CancellationToken ct = default) + { + var settings = await realmSettings.LoadAsync(ct); + return settings.BrowserSessions ?? BrowserSessionPolicy.Defaults; + } + + public async Task ValidateSessionAsync( + Guid userId, + Guid sessionId, + bool touch, + CancellationToken ct = default) + { + var entity = await session.LoadAsync(sessionId, ct); + var now = DateTimeOffset.UtcNow; + if (entity is null || entity.UserId != userId) return null; + if (!entity.IsActive(now)) + { + session.Delete(entity); + await session.SaveChangesAsync(ct); + connections.Revoke(sessionId); + return null; + } + + if (touch && entity.LastActiveAt <= now.Subtract(TouchInterval)) + { + var policy = await GetPolicyAsync(ct); + entity.Touch(now, policy.IdleLifetime); + session.Store(entity); + try + { + await session.SaveChangesAsync(ct); + } + catch (ConcurrencyException) + { + // A concurrent targeted revoke wins. Never re-insert a deleted row. + return null; + } + } + + return entity; + } + public async Task> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default) { var entity = await session.LoadAsync(sessionId, ct); @@ -67,17 +117,29 @@ public async Task> RevokeSessionAsync(Guid userId, Guid sessionId, session.Delete(sessionId); await session.SaveChangesAsync(ct); + connections.Revoke(sessionId); return true; } public async Task> RevokeAllSessionsAsync(Guid userId, Guid? exceptSessionId, CancellationToken ct = default) { - if (exceptSessionId.HasValue) - session.DeleteWhere(s => s.UserId == userId && s.Id != exceptSessionId.Value); + var ids = exceptSessionId is { } excludedId + ? await session.Query() + .Where(s => s.UserId == userId && s.Id != excludedId) + .Select(s => s.Id) + .ToListAsync(ct) + : await session.Query() + .Where(s => s.UserId == userId) + .Select(s => s.Id) + .ToListAsync(ct); + + if (exceptSessionId is { } excludedSessionId) + session.DeleteWhere(s => s.UserId == userId && s.Id != excludedSessionId); else session.DeleteWhere(s => s.UserId == userId); await session.SaveChangesAsync(ct); + foreach (var id in ids) connections.Revoke(id); return true; } @@ -85,8 +147,31 @@ public async Task TouchSessionAsync(Guid sessionId, CancellationToken ct = defau { var entity = await session.LoadAsync(sessionId, ct); if (entity is null) return; - entity.Touch(); + var policy = await GetPolicyAsync(ct); + entity.Touch(DateTimeOffset.UtcNow, policy.IdleLifetime); session.Store(entity); + try + { + await session.SaveChangesAsync(ct); + } + catch (ConcurrencyException) + { + // Revocation won the race. + } + } + + public async Task PruneExpiredAsync(CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + var expired = await session.Query() + .Where(s => s.ExpiresAt <= now || s.AbsoluteExpiresAt <= now) + .Select(s => s.Id) + .ToListAsync(ct); + if (expired.Count == 0) return 0; + + session.DeleteWhere(s => s.ExpiresAt <= now || s.AbsoluteExpiresAt <= now); await session.SaveChangesAsync(ct); + foreach (var id in expired) connections.Revoke(id); + return expired.Count; } } diff --git a/src/dotnet/Modgud.Authentication/Sessions/SessionTracker.cs b/src/dotnet/Modgud.Authentication/Sessions/SessionTracker.cs deleted file mode 100644 index 9a369a13..00000000 --- a/src/dotnet/Modgud.Authentication/Sessions/SessionTracker.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace Modgud.Authentication.Sessions; - -/// -/// Convenience helper for sign-in handlers — captures IP + UA from the -/// current and persists a session row. Failures -/// are swallowed (logged would be added once Serilog wiring is consistent -/// across slices) so a session-tracking blip never breaks login. -/// -public static class SessionTracker -{ - public static async Task RecordLoginAsync( - ISessionService sessions, - HttpContext httpContext, - Guid userId, - CancellationToken ct = default) - { - try - { - var ip = httpContext.Connection.RemoteIpAddress?.ToString(); - var ua = httpContext.Request.Headers.UserAgent.ToString(); - await sessions.CreateSessionAsync(userId, ip, ua, ct); - } - catch - { - // Swallow — session tracking is best-effort. - } - } -} diff --git a/src/dotnet/Modgud.Authentication/Sessions/UserAccessRevoker.cs b/src/dotnet/Modgud.Authentication/Sessions/UserAccessRevoker.cs index 46968337..157114b1 100644 --- a/src/dotnet/Modgud.Authentication/Sessions/UserAccessRevoker.cs +++ b/src/dotnet/Modgud.Authentication/Sessions/UserAccessRevoker.cs @@ -15,6 +15,7 @@ namespace Modgud.Authentication.Sessions; public sealed class UserAccessRevoker( UserManager userManager, ISessionService sessionService, + IClientSessionService clientSessionService, IOAuthGrantRevoker grantRevoker, ILogger logger) : IUserAccessRevoker { @@ -36,6 +37,9 @@ public async Task RevokeAllAccessAsync(Guid userId, AccessRevocationReason reaso // 2) Device-session rows (clean device list / GDPR scrub). await sessionService.RevokeAllSessionsAsync(userId, exceptSessionId: null, ct); + // Token revocation above already cut every OAuth grant; this removes the + // user-facing native app/device rows without repeating the sweep. + await clientSessionService.RevokeAllAsync(userId, revokeGrants: false, ct); // 3) Rotate the security stamp → existing auth cookies fail at the next // SecurityStampValidator pass (<=5 min) and refresh grants fail the diff --git a/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs b/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs index 1f340674..a09f2d3d 100644 --- a/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs +++ b/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs @@ -115,8 +115,19 @@ public static StoreOptions UseModgudAuthentication(this StoreOptions options) // force-logout flow. options.Schema.For() .Identity(x => x.Id) + .UseOptimisticConcurrency(true) .Index(x => x.UserId) - .Index(x => x.ExpiresAt); + .Index(x => x.ExpiresAt) + .Index(x => x.AbsoluteExpiresAt); + + options.Schema.For() + .Identity(x => x.Id) + .UseOptimisticConcurrency(true) + .Index(x => x.UserId) + .Index(x => x.ClientId) + .Index(x => x.AuthorizationId) + .Index(x => x.ExpiresAt) + .Index(x => x.AbsoluteExpiresAt); // WebAuthn/passkey credentials (raw crypto, not event-sourced). One per // enrolled authenticator; indexed by UserId for the per-user list/login @@ -170,15 +181,14 @@ public static StoreOptions UseModgudAuthentication(this StoreOptions options) .Index(x => x.LoginProviderId) .Index(x => x.IsUnlinked); - // Streamless security/ops store (logging/audit redesign Track A, Phase 3). - // Cross-realm in the system DB; the typed successor to the personal-data- - // bearing-but-streamless portion of AuthLogDocument. Indexed for the admin - // read (Realm scope + EventType chip filter) and the retention prune. - options.Schema.For() + // Realm-owned security event store. The absence of a Realm column is + // deliberate: physical database ownership is the isolation boundary. + options.Schema.For() .Identity(x => x.Id) .Index(x => x.Timestamp) - .Index(x => x.Realm) .Index(x => x.EventType); + options.Schema.For() + .Identity(x => x.Id); // Tenant-scoped singleton config doc. One row per tenant DB, // addressed by the fixed `RealmSettings.SingletonId`. Owned by diff --git a/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs b/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs index 9453714b..d0bedbd4 100644 --- a/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs +++ b/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs @@ -14,8 +14,8 @@ namespace Modgud.Authentication.Setup; /// -/// Issues + consumes the one-shot bootstrap-invite for the first admin -/// in a realm (C15). Two issuance call-sites: +/// Issues + consumes a one-shot realm-admin invitation (C15). Issuance +/// is available from the Control-Plane API and the recovery CLI: /// /// Recovery-CLI bootstrap-admin without /// --password @@ -39,9 +39,9 @@ public interface IPendingAdminInviteService /// to print/email. The plain-text token is only available here — /// after this method returns, only the SHA-256 hash is recoverable. /// - /// If a non-used, non-expired invite already exists for the - /// same email in this realm, the old one is marked Used (revoked) - /// before a new one is issued. This is the "resend" path. + /// Every non-used invite in the realm is marked Used (revoked) + /// before a new one is issued, so only one admin invitation can be + /// active per realm. /// Task IssueAsync( string userName, @@ -103,12 +103,10 @@ public async Task IssueAsync( var normalizedUserName = userName.Trim().ToLowerInvariant(); var normalizedEmail = email.Trim(); - // Revoke any open invites for the same email in this realm. - // This makes IssueAsync the resend path too: a new call invalidates - // the previous link. (Tenant-scoped session so this only reaches - // invites in the current realm DB.) + // A realm may have exactly one open admin invite. Issuing a new one + // revokes every prior open invite, regardless of recipient. var openInvites = await session.Query() - .Where(i => i.Email == normalizedEmail && i.UsedAt == null) + .Where(i => i.UsedAt == null) .ToListAsync(ct); foreach (var open in openInvites) { @@ -128,11 +126,22 @@ public async Task IssueAsync( Firstname = firstname, Lastname = lastname, TokenHash = tokenHash, - ExpiresAt = DateTimeOffset.UtcNow.AddDays(PendingAdminInvite.DefaultExpirationDays), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(PendingAdminInvite.DefaultExpirationHours), CreatedAt = DateTimeOffset.UtcNow, IssuedBy = issuedBy, }; session.Store(invite); + securityAudit.StoreRequired(session, new SecurityAuditRecord + { + EventType = AuditEvents.BootstrapInviteIssued, + CaptureRequestContext = false, + ActorKind = issuedBy is null + ? AuditActorKind.System + : AuditActorKind.ControlPlane, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "issue", + EffectiveAt = invite.ExpiresAt, + }); await session.SaveChangesAsync(ct); var url = BuildMagicLinkUrl(realm, token); @@ -157,7 +166,7 @@ await emailService.SendTemplatedEmailAsync( ["Email"] = normalizedEmail, ["RealmDisplayName"] = realm.DisplayName, ["ActionUrl"] = url, - ["ExpirationDays"] = PendingAdminInvite.DefaultExpirationDays.ToString(), + ["ExpirationHours"] = PendingAdminInvite.DefaultExpirationHours.ToString(), }, ct); } @@ -168,16 +177,6 @@ await emailService.SendTemplatedEmailAsync( realm.Slug, LogPiiMasking.MaskEmail(normalizedEmail)); } - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.BootstrapInviteIssued, - Level = "Info", - Actor = LogPiiMasking.MaskEmail(normalizedEmail), - Status = "issued", - Reason = $"expires {invite.ExpiresAt}, issued by {issuedBy ?? "(self/CLI)"}", - Message = "Bootstrap invite issued", - }); - return new IssuedInvite(invite.Id, token, url, invite.ExpiresAt, normalizedEmail, normalizedUserName); } diff --git a/src/dotnet/Modgud.Authentication/Setup/RealmAdminBootstrapper.cs b/src/dotnet/Modgud.Authentication/Setup/RealmAdminBootstrapper.cs index 3cfd4a0e..00844a33 100644 --- a/src/dotnet/Modgud.Authentication/Setup/RealmAdminBootstrapper.cs +++ b/src/dotnet/Modgud.Authentication/Setup/RealmAdminBootstrapper.cs @@ -12,7 +12,7 @@ namespace Modgud.Authentication.Setup; /// -/// Atomic creation of the very first admin user inside a realm — used in three +/// Atomic creation of a new admin user inside a realm — used in three /// places that all need exactly the same state-write: /// /// Recovery-CLI bootstrap-admin --password (Direct mode) @@ -32,7 +32,8 @@ namespace Modgud.Authentication.Setup; /// seed rather than duplicating. /// /// The seeded structure mirrors what the legacy POST /api/setup/create-admin -/// endpoint produced, so existing realms keep the same shape. +/// endpoint produced, so existing realms keep the same shape. When the +/// administrator role/group already exists, the new user is added to it. /// /// Tenant-scoping: the resolved by DI is /// tenant-aware via TenantedSessionFactory. Callers must establish the diff --git a/src/dotnet/Modgud.Authentication/Setup/SamlSetup.cs b/src/dotnet/Modgud.Authentication/Setup/SamlSetup.cs index ca7301bc..0923524d 100644 --- a/src/dotnet/Modgud.Authentication/Setup/SamlSetup.cs +++ b/src/dotnet/Modgud.Authentication/Setup/SamlSetup.cs @@ -9,8 +9,8 @@ namespace Modgud.Authentication.Setup; /// SAML 2.0 SP federation wiring. Mirrors the OIDC external-auth setup /// () — flavor registry, /// dynamic per-realm scheme manager, and SP signing/encryption cert -/// management. Implementation lands incrementally across the SAML wave; -/// see the maintainers' saml-federation design note. +/// management. See the SAML federation documentation for the supported +/// SP-initiated surface and explicit v1 boundaries. /// public static class SamlSetup { diff --git a/src/dotnet/Modgud.Authorization/Modgud.Authorization.csproj b/src/dotnet/Modgud.Authorization/Modgud.Authorization.csproj index 8adac1fc..cbc57b26 100644 --- a/src/dotnet/Modgud.Authorization/Modgud.Authorization.csproj +++ b/src/dotnet/Modgud.Authorization/Modgud.Authorization.csproj @@ -17,9 +17,10 @@ + assembly so downstream consumers that evaluate raw grants can reuse + the logic without pulling Marten/Wolverine/JsEval transitively. The + resource-server package receives pre-expanded concrete permissions + and deliberately does not reference this assembly. --> diff --git a/src/dotnet/Modgud.Authorization/README.md b/src/dotnet/Modgud.Authorization/README.md index 84183bc4..60e835cc 100644 --- a/src/dotnet/Modgud.Authorization/README.md +++ b/src/dotnet/Modgud.Authorization/README.md @@ -248,8 +248,8 @@ Treat that as inspiration, not a drop-in. - **User profile management** — display fields, change-requests, profile self-service. Lives in `Modgud.Api/Features/Account` + `Admin`. - **Auth log / audit** — the `AuthAuditView` projection (GDPR-audit) and the - `SecurityAuditEntry` streamless security store are Modgud-internal, not part - of the slice. + realm-owned `RealmSecurityAuditEvent` plus PII-free global + `PlatformAuditEvent` stores are Modgud-internal, not part of the slice. - **Frontend** — see Step 6 above. The split is intentional: this slice owns "**who has what permission, who's diff --git a/src/dotnet/Modgud.Authorization/Roles/PermissionRole.cs b/src/dotnet/Modgud.Authorization/Roles/PermissionRole.cs index f692bfc3..bde83e89 100644 --- a/src/dotnet/Modgud.Authorization/Roles/PermissionRole.cs +++ b/src/dotnet/Modgud.Authorization/Roles/PermissionRole.cs @@ -11,16 +11,15 @@ namespace Modgud.Authorization.Roles; /// of the role's . Survive resource/action renames in /// the catalog. Roles can grant any subset of their App's catalog, /// including multiple resources within the same App. -/// — when true, the role grants -/// realm:admin regardless of . Reserved for the -/// System Admin role; bypasses every permission check across every realm. +/// — when true, the role has no +/// and grants realm:admin. Reserved for the +/// System Admin role; bypasses every permission check across every App in +/// the current realm, never across realm boundaries. /// /// -/// is nullable so that a pure-realm-admin role -/// ( = true, no catalog grants) can be modelled -/// without the operator having to pick an arbitrary App. When -/// is null, must be empty — -/// nothing to FK into. +/// These modes are mutually exclusive. An ordinary role has an +/// and optional grants from that App's catalog. A +/// realm-admin role has no App link and no catalog grants. /// public class PermissionRole { @@ -29,24 +28,24 @@ public class PermissionRole public string? Description { get; set; } /// - /// FK to App.Id. Null only for pure-realm-admin roles. When set, - /// the role's grants are interpreted within that App's catalog. + /// FK to App.Id. Required for ordinary roles and null for + /// realm-admin roles. When set, the role's grants are interpreted within + /// that App's catalog. /// public Guid? AppId { get; set; } /// - /// When true, the role grants realm:admin — the realm-wide bypass - /// recognised by Modgud.Permissions.PermissionEvaluator. Lives - /// outside any App catalog (see permission-modell.md §3 "Sonderfall - /// realm:admin"). + /// When true, the role grants realm:admin — the current-realm-wide + /// bypass recognised by Modgud.Permissions.PermissionEvaluator. + /// Lives outside every App catalog and requires and + /// to be empty. /// public bool IsRealmAdmin { get; set; } /// /// Subset of the role's App catalog this role grants. Each entry is an /// AppPermission.Id in 's App. Empty when the - /// role grants nothing through the catalog (only valid alongside - /// ). + /// role is an ordinary App role. Always empty for a realm-admin role. /// public List PermissionIds { get; set; } = new(); diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs deleted file mode 100644 index 04c4a127..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Security.Claims; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Options; - -namespace Modgud.Client.AspNetCore; - -/// -/// Pre-request claims-transformation that flattens -/// resource_access[] from -/// the principal's claims into flat and -/// "permission" claims so downstream gates work without per-endpoint -/// plumbing. -/// -/// Source of the data — preference order: the access token's own -/// embedded resource_access claim wins (federation v1.1 bakes it in -/// at issuance), with /connect/userinfo as a fallback for tokens -/// that don't carry it (see ). Both -/// paths land the claim on the identity the exact same way: JwtBearer's -/// token handler maps a JSON payload property to a claim of type -/// "resource_access" whose Value is the raw JSON text and -/// whose ValueType is -/// Microsoft.IdentityModel.JsonWebTokens.JsonClaimValueTypes.Json -/// ("JSON"); the enricher's UserInfo fallback adds a claim with the -/// same type and a raw-JSON-text value. This transformer only ever reads -/// , so it is indifferent to which path populated -/// the claim or to — it just needs valid JSON -/// text under the "resource_access" claim type. Because UserInfo -/// only ever echoes the same block the token already carries (never a wider -/// or narrower one), preferring the token claim changes nothing about what -/// ends up on the principal — it only removes a redundant HTTP round-trip -/// for tokens that already have the claim. -/// -/// Idempotent: a second pass on the same identity does not duplicate -/// claims. -/// -/// The IdP pre-expands bypass tiers before emission, so this lib -/// performs no realm:admin / <r>:admin walk — -/// just reads the -/// "permission" claims and does contains(...). -/// -public sealed class ModgudClaimsTransformation : IClaimsTransformation -{ - /// Claim type for permission strings ("<resource>:<action>"). - public const string PermissionClaimType = "permission"; - - /// - /// Claim type that USED to carry flattened group names. - /// - /// - /// Quarantined in federation v1 (hub boundary): the Modgud IdP never emits a - /// groups block in resource_access — group membership is purely - /// IdP-internal and is expanded into roles/permissions before emission. This - /// transformer therefore never produces a claim of this type. The constant is - /// retained for binary compatibility and will be removed in a future major - /// version. Gate on roles/permissions instead. - /// - [Obsolete("Hub boundary: the Modgud IdP never emits groups in resource_access, " + - "so no claim of this type is ever produced. Gate on roles/permissions instead. " + - "Retained for binary compatibility; removed in a future major version.")] - public const string GroupClaimType = "group"; - - /// The standard OIDC/Keycloak UserInfo claim that nests per-RS authz info. - public const string ResourceAccessClaimType = "resource_access"; - - private readonly ModgudOptions _options; - - public ModgudClaimsTransformation(IOptions options) - { - _options = options.Value; - if (string.IsNullOrWhiteSpace(_options.Audience)) - throw new InvalidOperationException( - "ModgudOptions.Audience must be set to the resource server's audience " + - "(same value as JwtBearerOptions.Audience). Configure it via AddModgudClient."); - } - - public Task TransformAsync(ClaimsPrincipal principal) - { - if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated) - return Task.FromResult(principal); - - var raw = identity.FindFirst(ResourceAccessClaimType)?.Value; - if (string.IsNullOrEmpty(raw)) - return Task.FromResult(principal); - - if (!TryParseJson(raw, out var resourceAccess) || - resourceAccess.ValueKind != JsonValueKind.Object) - return Task.FromResult(principal); - - if (!resourceAccess.TryGetProperty(_options.Audience, out var audienceBlock) || - audienceBlock.ValueKind != JsonValueKind.Object) - return Task.FromResult(principal); - - FlattenStringArray(identity, audienceBlock, "roles", ClaimTypes.Role); - FlattenStringArray(identity, audienceBlock, "permissions", PermissionClaimType); - // Federation v1 hub boundary: the IdP never emits a "groups" block here - // (group membership is IdP-internal, expanded into roles/permissions before - // emission), so there is nothing to flatten. The legacy group flattener was - // removed; GroupClaimType is retained [Obsolete] for binary compatibility. - - return Task.FromResult(principal); - } - - /// - /// Adds each string in [] - /// as a claim. Skips duplicates so a second - /// pipeline pass doesn't bloat the identity. - /// - private static void FlattenStringArray( - ClaimsIdentity identity, JsonElement audienceBlock, string property, string claimType) - { - if (!audienceBlock.TryGetProperty(property, out var array) || - array.ValueKind != JsonValueKind.Array) - return; - - var existing = new HashSet( - identity.FindAll(claimType).Select(c => c.Value), - StringComparer.Ordinal); - - foreach (var element in array.EnumerateArray()) - { - var value = element.GetString(); - if (string.IsNullOrEmpty(value) || !existing.Add(value)) continue; - identity.AddClaim(new Claim(claimType, value)); - } - } - - private static bool TryParseJson(string raw, out JsonElement element) - { - try - { - using var doc = JsonDocument.Parse(raw); - element = doc.RootElement.Clone(); - return true; - } - catch (JsonException) - { - element = default; - return false; - } - } -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudIntrospectionHandler.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudIntrospectionHandler.cs deleted file mode 100644 index 882af01c..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudIntrospectionHandler.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Modgud.Client.AspNetCore; - -/// -/// Validates opaque Modgud reference access tokens by calling the IdP's -/// /connect/introspect endpoint (RFC 7662) and projecting the response -/// onto a — including the per-audience -/// resource_access block, which the shared -/// then flattens into role / -/// permission claims exactly as for the JWT path. -/// -/// Fail-closed. Unlike (which -/// enriches an already-validated JWT and so fails open on a UserInfo outage), -/// introspection is the validation here. A non-2xx response, a -/// transport error, an active:false body, or an audience mismatch all -/// reject the request — a token that can't be affirmatively validated is not -/// honoured. -/// -internal sealed class ModgudIntrospectionHandler : AuthenticationHandler -{ - public ModgudIntrospectionHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : base(options, logger, encoder) - { - } - - protected override async Task HandleAuthenticateAsync() - { - var rawAuth = Request.Headers.Authorization.ToString(); - if (string.IsNullOrEmpty(rawAuth) || - !AuthenticationHeaderValue.TryParse(rawAuth, out var header) || - string.IsNullOrEmpty(header.Parameter)) - { - // No credentials → this handler has no opinion; the pipeline treats - // the request as anonymous (a 401 challenge follows only if the - // endpoint requires authorization). - return AuthenticateResult.NoResult(); - } - - var isBearer = string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase); - var isDpop = string.Equals(header.Scheme, Dpop.DpopResource.Scheme, StringComparison.OrdinalIgnoreCase); - if (!isBearer && !isDpop) - return AuthenticateResult.NoResult(); - - var principal = await ModgudTokenIntrospection.IntrospectAsync( - Options, header.Parameter!, Scheme.Name, Logger, Context.RequestAborted); - if (principal is null) - return AuthenticateResult.Fail("Modgud introspection did not affirmatively validate the token."); - - // Enforce DPoP binding (RFC 9449 §7.1): a sender-constrained token - // (cnf.jkt present) MUST be presented with the DPoP scheme AND a proof - // whose key matches; a bound token used as a plain bearer token is - // rejected. A DPoP-scheme request against an unbound token is likewise - // rejected — the client is asserting a possession the token doesn't carry. - var boundJkt = principal.FindFirst(Dpop.DpopResource.ConfirmationJktClaimType)?.Value; - if (isDpop) - { - if (string.IsNullOrEmpty(boundJkt)) - return AuthenticateResult.Fail("The DPoP scheme was used but the token is not DPoP-bound."); - - var outcome = Dpop.DpopResourceValidator.Validate( - Request, header.Parameter!, boundJkt, DateTimeOffset.UtcNow); - if (outcome != Dpop.DpopResourceResult.Valid) - return AuthenticateResult.Fail($"The DPoP proof did not validate ({outcome})."); - } - else if (!string.IsNullOrEmpty(boundJkt)) - { - return AuthenticateResult.Fail( - "This access token is DPoP-bound and must be presented with the DPoP scheme."); - } - - return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); - } -} - -/// -/// The pure introspection + claims-projection logic behind -/// , factored out so it can be unit -/// tested against a stub HTTP handler without standing up the auth pipeline. -/// -internal static class ModgudTokenIntrospection -{ - // Settable seam so unit tests can substitute a fake HttpMessageHandler and - // assert on the introspection request. Production callers never touch it. - internal static HttpClient SharedClient { get; set; } = new(); - - /// - /// Introspects and, if it is active and audience-valid, - /// returns a principal carrying the introspection claims (including the raw - /// resource_access claim the transformation reads). Returns - /// null on any failure — the caller treats that as "reject". - /// - public static async Task IntrospectAsync( - ModgudReferenceTokenOptions options, - string token, - string authenticationType, - ILogger logger, - CancellationToken ct) - { - var url = options.Authority.TrimEnd('/') + "/connect/introspect"; - // Form-body client authentication (client_secret_post). A URL-shaped - // client_id (the MCP audience case) collides with HTTP Basic, which - // splits client_id:secret on the scheme colon. - using var content = new FormUrlEncodedContent(new[] - { - new KeyValuePair("token", token), - new KeyValuePair("token_type_hint", "access_token"), - new KeyValuePair("client_id", options.ResolvedClientId), - new KeyValuePair("client_secret", options.IntrospectionClientSecret ?? string.Empty), - }); - - string body; - try - { - using var response = await SharedClient.PostAsync(url, content, ct); - if (!response.IsSuccessStatusCode) - { - logger.LogDebug( - "Modgud: /connect/introspect returned {Status}; rejecting the token.", - (int)response.StatusCode); - return null; - } - body = await response.Content.ReadAsStringAsync(ct); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) - { - // Fail-closed: introspection is the validation, so an outage means - // we cannot affirm the token — reject rather than admit it. - logger.LogWarning(ex, "Modgud: /connect/introspect call failed; rejecting the token."); - return null; - } - - return BuildPrincipal(body, options.Audience, authenticationType, logger); - } - - /// Projects an introspection response body onto a principal, or - /// returns null when the token is inactive, malformed, or not for - /// this audience. - internal static ClaimsPrincipal? BuildPrincipal( - string introspectionBody, string audience, string authenticationType, ILogger logger) - { - JsonElement root; - try - { - using var doc = JsonDocument.Parse(introspectionBody); - root = doc.RootElement.Clone(); - } - catch (JsonException ex) - { - logger.LogWarning(ex, "Modgud: /connect/introspect returned unparseable JSON; rejecting the token."); - return null; - } - - if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("active", out var active) || - active.ValueKind != JsonValueKind.True) - { - // active:false (or missing) — RFC 7662 §2.2. Nothing else is trustworthy. - return null; - } - - // Defence in depth: only honour a token that names this RS in its aud. - // (The IdP already gates this — it returns active:false to a caller that - // isn't an audience/presenter — but a misconfigured introspection client - // id must never let a foreign-audience token through.) - if (!AudienceContains(root, audience)) - { - logger.LogWarning( - "Modgud: introspected token is active but its audience does not include '{Audience}'; rejecting.", - audience); - return null; - } - - var identity = new ClaimsIdentity( - authenticationType, nameType: "name", roleType: ClaimTypes.Role); - - foreach (var property in root.EnumerateObject()) - { - switch (property.Name) - { - // The load-bearing claim: keep the raw JSON so - // ModgudClaimsTransformation can flatten resource_access[audience]. - case "resource_access" when property.Value.ValueKind == JsonValueKind.Object: - identity.AddClaim(new Claim( - ModgudClaimsTransformation.ResourceAccessClaimType, - property.Value.GetRawText(), - Microsoft.IdentityModel.JsonWebTokens.JsonClaimValueTypes.Json)); - break; - - // RFC 9449 §6 — a DPoP-bound token carries cnf={"jkt":…}. Surface - // the thumbprint so the handler can require a matching proof. - case "cnf" when property.Value.ValueKind == JsonValueKind.Object && - property.Value.TryGetProperty("jkt", out var jkt) && - jkt.ValueKind == JsonValueKind.String: - identity.AddClaim(new Claim(Dpop.DpopResource.ConfirmationJktClaimType, jkt.GetString()!)); - break; - - // Standard string scalars worth surfacing on the principal. - case "sub" when property.Value.ValueKind == JsonValueKind.String: - identity.AddClaim(new Claim("sub", property.Value.GetString()!)); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, property.Value.GetString()!)); - break; - - case "name" or "preferred_username" or "email" or "scope" or "client_id" - when property.Value.ValueKind == JsonValueKind.String: - identity.AddClaim(new Claim(property.Name, property.Value.GetString()!)); - break; - } - } - - return new ClaimsPrincipal(identity); - } - - private static bool AudienceContains(JsonElement root, string audience) - { - if (!root.TryGetProperty("aud", out var aud)) return false; - return aud.ValueKind switch - { - JsonValueKind.String => string.Equals(aud.GetString(), audience, StringComparison.Ordinal), - JsonValueKind.Array => aud.EnumerateArray().Any( - e => e.ValueKind == JsonValueKind.String && - string.Equals(e.GetString(), audience, StringComparison.Ordinal)), - _ => false, - }; - } -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudOptions.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudOptions.cs deleted file mode 100644 index fb9bb2b9..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudOptions.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace Modgud.Client.AspNetCore; - -/// -/// Configuration for the Modgud resource-server integration. -/// -/// The lib does two things on top of vanilla -/// AddJwtBearer: -/// -/// Wires a JwtBearerEvents.OnTokenValidated handler that -/// fetches {Authority}/connect/userinfo with the user's token -/// and adds the resource_access claim to the principal — pure -/// AddJwtBearer doesn't do this natively (UserInfo-fetching is -/// an AddOpenIdConnect feature). -/// Registers a ClaimsTransformation that reads -/// resource_access[] off the principal -/// and projects roles / permissions / groups onto flat -/// ClaimTypes.Role / "permission" / "group" claims -/// so endpoint filters + [Authorize(Roles=...)] work natively. -/// -/// -/// UserInfo emits permissions in their bypass-pre-expanded form -/// (the IdP already resolves realm:admin and <r>:admin -/// to concrete catalog strings), so the lib doesn't need to evaluate -/// bypass tiers itself — exact-match is sufficient. -/// -public sealed class ModgudOptions -{ - /// - /// The audience this resource server identifies as — same value the - /// JWT-bearer middleware compares the token's aud claim against - /// (options.Audience on AddJwtBearer). Used as the lookup - /// key into resource_access[…] on the principal's claims. - /// - /// Required. - /// - public string Audience { get; set; } = string.Empty; - - /// - /// IdP base URL used to construct the UserInfo URL - /// ({Authority}/connect/userinfo). Same value as - /// JwtBearerOptions.Authority. Trailing slashes are tolerated. - /// - /// Required. - /// - public string Authority { get; set; } = string.Empty; - - /// - /// Authentication scheme to attach the UserInfo-fetching handler to. - /// Defaults to "Bearer"; override if your host uses a custom - /// scheme name on AddJwtBearer(scheme, …). - /// - public string JwtBearerScheme { get; set; } = "Bearer"; -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudReferenceTokenOptions.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudReferenceTokenOptions.cs deleted file mode 100644 index 2bbf7dee..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudReferenceTokenOptions.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.AspNetCore.Authentication; - -namespace Modgud.Client.AspNetCore; - -/// Well-known scheme name for the Modgud reference-token -/// (introspection) authentication handler. -public static class ModgudReferenceTokenDefaults -{ - /// The default authentication-scheme name registered by - /// AddModgudReferenceTokenClient. - public const string AuthenticationScheme = "ModgudIntrospection"; -} - -/// -/// Options for the reference-token (opaque access token) validation mode. -/// -/// Modgud's default access-token format is a reference token — -/// an opaque handle with no self-contained claims, validated by calling the -/// IdP's /connect/introspect endpoint (RFC 7662). This mode lets a -/// resource server accept those tokens directly, instead of requiring the -/// OAuth client to be switched to JWT access tokens for the JWKS-based -/// AddModgudClient path. -/// -/// Introspection identity. The IdP only reveals a token — its -/// active status and any per-audience resource_access block — -/// to a caller that is one of the token's audiences or its authorised -/// presenter. A resource server therefore introspects with a confidential -/// client whose client_id equals its own (the -/// RFC 8707 resource= value already carried in the token's aud). -/// That single introspection call both validates the token and returns the -/// audience-scoped roles/permissions — no separate UserInfo round-trip. -/// -/// No caching, by design. Every request introspects. A reference -/// token's defining advantage is instant revocation; a TTL cache would trade -/// that away. Caching may be added later as an explicit opt-in. -/// -public sealed class ModgudReferenceTokenOptions : AuthenticationSchemeOptions -{ - /// IdP base URL, e.g. https://auth.example.com — the realm - /// host root, no realm path segment. Used to build the - /// {Authority}/connect/introspect URL. Required. - public string Authority { get; set; } = string.Empty; - - /// The resource server's audience — the same value used as the - /// RFC 8707 resource= indicator when tokens are minted for this RS - /// (an OAuthApi name in Modgud). The audience block read out of the - /// introspection response is keyed by this value. Required. - public string Audience { get; set; } = string.Empty; - - /// The client_id used to authenticate the introspection - /// call. Defaults to — the RS registers a - /// confidential client under its own audience id so the IdP treats it as - /// an authorised introspector. Override only if the introspection client - /// is registered under a different id that is nonetheless one of the - /// token's audiences. - public string? IntrospectionClientId { get; set; } - - /// The client secret for . Required. - /// Sent as a form-body credential (client_secret_post), which works - /// for both URL-shaped and plain audience ids — HTTP Basic would break on - /// the scheme colon of a URL client_id. - public string? IntrospectionClientSecret { get; set; } - - /// The effective introspection client_id: - /// if set, otherwise - /// . - public string ResolvedClientId => - string.IsNullOrEmpty(IntrospectionClientId) ? Audience : IntrospectionClientId!; -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/README.md b/src/dotnet/Modgud.Client.AspNetCore/README.md deleted file mode 100644 index a5e0e91c..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Modgud.Client.AspNetCore - -ASP.NET Core integration for resource servers that authenticate against a -[Modgud](https://github.com/cocoar-dev/modgud) identity provider. - -Whichever token format your OAuth client issues, the lib flattens the -per-audience `resource_access[]` block into native -`ClaimTypes.Role` / `"permission"` claims so `[Authorize(Roles = "...")]` -and an `.RequiresModgudPermission("...")` endpoint filter work natively. -Bypass tiers (`realm:admin`, `:admin`) are pre-expanded -**IdP-side** before emission, so the lib does pure exact-match — no -evaluator logic. - -It supports both Modgud access-token formats: - -- **JWT access tokens** — `AddModgudClient` on top of `AddJwtBearer`. - JwtBearer validates the token locally against the realm JWKS; the lib - reads `resource_access` from the token itself, falling back to - `{Authority}/connect/userinfo` only for tokens that don't carry it. -- **Reference (opaque) access tokens** — `AddModgudReferenceTokenClient`. - This is Modgud's **default** token format. Each request validates the - token via `{Authority}/connect/introspect` (RFC 7662) and reads - `resource_access` from the introspection response — one call, no - separate UserInfo round-trip. Validation is fail-closed and there is no - cache, so revocation is instant. - -## Install - -```bash -dotnet add package Modgud.Client.AspNetCore -``` - -## Quickstart — JWT access tokens - -Requires the OAuth client's **Access Token Type** to be **JWT (self-contained)**. - -```csharp -using Modgud.Client.AspNetCore; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = "https://auth.example.com"; - options.Audience = "event-tree-api"; // matches an OAuthApi in the IdP - }); - -builder.Services.AddModgudClient(o => -{ - o.Authority = "https://auth.example.com"; - o.Audience = "event-tree-api"; // same value as above -}); - -var app = builder.Build(); - -app.UseAuthentication(); -app.UseAuthorization(); - -// Role-gated — uses the standard [Authorize] attribute since -// roles are projected to ClaimTypes.Role. -app.MapGet("/admin/ping", () => "pong") - .RequireAuthorization(p => p.RequireRole("Editor")); - -// Permission-gated — bare 2-segment string. The IdP has already -// expanded realm:admin / :admin to catalog entries, so -// this is a pure contains-check. -app.MapPost("/calendars/{id}", (string id) => Results.Ok()) - .RequiresModgudPermission("calendar:write"); - -app.Run(); -``` - -## Quickstart — reference (opaque) access tokens - -Works with Modgud's **default** token format — no need to switch the client -to JWT. The endpoint gates (`RequireRole`, `RequiresModgudPermission`) are -identical to the JWT quickstart; only the authentication registration differs: - -```csharp -using Modgud.Client.AspNetCore; - -builder.Services - .AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - .AddModgudReferenceTokenClient(o => - { - o.Authority = "https://auth.example.com"; - o.Audience = "event-tree-api"; // == the introspection client_id - o.IntrospectionClientSecret = builder.Configuration["Modgud:IntrospectionSecret"]; - }); -``` - -**Setup requirement.** The resource server introspects with a confidential -OAuth client whose **`client_id` equals its `Audience`**. The IdP only -reveals a token — its `active` status and `resource_access` block — to a -caller that is one of the token's audiences (or its presenter); the audience -is the RS's own id (the RFC 8707 `resource=` value), so registering the -introspection client under that same id is what authorises it. Credentials -are sent as form-body parameters (`client_secret_post`), which also handles a -URL-shaped audience id that HTTP Basic can't (it splits on the scheme colon). - -## How the claims land on the principal - -The IdP emits permissions per audience in Keycloak shape: - -```json -"resource_access": { - "event-tree-api": { - "roles": ["Editor", "Viewer"], - "permissions": ["calendar:read", "calendar:write"] - } -} -``` - -`ModgudClaimsTransformation` projects that into flat claims: - -| Source field | Flat claim type | -| --- | --- | -| `roles` | `ClaimTypes.Role` | -| `permissions` | `"permission"` | - -> Groups are deliberately **not** emitted by the IdP (hub boundary): group -> membership is IdP-internal and is expanded into roles/permissions before -> emission. The `GroupClaimType` constant and the old `groups` flattener are -> retained only for binary compatibility and are `[Obsolete]`. - -Read them with standard claims APIs: - -```csharp -var perms = ctx.User.FindAll("permission").Select(c => c.Value); -``` - -## Configuration reference - -`AddModgudClient` (JWT mode) — `ModgudOptions`: - -| Option | Description | -| --- | --- | -| `Authority` | IdP base URL. Used to fetch `{Authority}/connect/userinfo`. Same value as `JwtBearerOptions.Authority`. | -| `Audience` | The audience this resource server identifies as — same value as `JwtBearerOptions.Audience`. Looked up against `resource_access[…]`. | -| `JwtBearerScheme` | Scheme name to attach to. Defaults to `"Bearer"`. | - -`AddModgudReferenceTokenClient` (introspection mode) — `ModgudReferenceTokenOptions`: - -| Option | Description | -| --- | --- | -| `Authority` | IdP base URL. Used to build `{Authority}/connect/introspect`. | -| `Audience` | The RS's audience — the `resource_access[…]` key, and the default introspection `client_id`. | -| `IntrospectionClientSecret` | Secret for the introspection client. Required. | -| `IntrospectionClientId` | Overrides the introspection `client_id`. Defaults to `Audience`. | - -## License - -Apache-2.0. See [LICENSE](https://github.com/cocoar-dev/modgud/blob/develop/LICENSE). diff --git a/src/dotnet/Modgud.Client.AspNetCore/RequiresModgudPermissionFilter.cs b/src/dotnet/Modgud.Client.AspNetCore/RequiresModgudPermissionFilter.cs deleted file mode 100644 index b64ebfa1..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/RequiresModgudPermissionFilter.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace Modgud.Client.AspNetCore; - -/// -/// Endpoint filter that gates a Minimal-API endpoint on a Modgud -/// permission. Reads the "permission" claims that -/// stamped on the principal -/// (flattened from resource_access[].permissions) -/// and does a pure contains-check against the requested string. -/// -/// The IdP already pre-expanded bypass tiers (realm:admin → -/// every catalog string of every reachable App; <r>:admin → -/// every <r>:<a> in the App's catalog) before emission, so -/// no PermissionEvaluator dance is needed here — the filter is a -/// straight membership test. -/// -/// Synchronous (no I/O). Returns 401 when anonymous, -/// 403 when authenticated but lacking the permission. -/// -public sealed class RequiresModgudPermissionFilter : IEndpointFilter -{ - private readonly string _permission; - - public RequiresModgudPermissionFilter(string permission) - { - ArgumentException.ThrowIfNullOrEmpty(permission); - _permission = permission; - } - - public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - var user = context.HttpContext.User; - if (user.Identity?.IsAuthenticated != true) - return ValueTask.FromResult(Results.Unauthorized()); - - var hasPermission = user - .FindAll(ModgudClaimsTransformation.PermissionClaimType) - .Any(c => string.Equals(c.Value, _permission, StringComparison.Ordinal)); - - if (!hasPermission) - return ValueTask.FromResult(Results.Forbid()); - - return next(context); - } -} - -public static class RequiresModgudPermissionExtensions -{ - /// - /// Gates the route group on the given permission. Equivalent to wiring - /// as an endpoint filter. - /// The permission is bare 2-segment ("<resource>:<action>") — - /// the App context is implicit from the audience the lib was configured - /// with. - /// - public static RouteGroupBuilder RequiresModgudPermission(this RouteGroupBuilder builder, string permission) - { - builder.AddEndpointFilter(new RequiresModgudPermissionFilter(permission)); - return builder; - } - - /// Per-endpoint variant of . - public static RouteHandlerBuilder RequiresModgudPermission(this RouteHandlerBuilder builder, string permission) - { - builder.AddEndpointFilter(new RequiresModgudPermissionFilter(permission)); - return builder; - } -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ServiceCollectionExtensions.cs b/src/dotnet/Modgud.Client.AspNetCore/ServiceCollectionExtensions.cs deleted file mode 100644 index d927adc4..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Modgud.Client.AspNetCore; - -public static class ServiceCollectionExtensions -{ - /// - /// Wires the Modgud resource-server integration into a host: - /// - /// A JwtBearerEvents.OnTokenValidated handler that - /// fetches {Authority}/connect/userinfo with the user's - /// bearer token and merges the resource_access claim onto - /// the principal. - /// The pre-request - /// that flattens resource_access[] - /// into native , - /// "permission" and "group" claims. - /// The endpoint - /// filter (consumed via the RequiresModgudPermission - /// extension). - /// - /// - /// The IdP pre-expands bypass tiers (realm:admin, - /// <r>:admin) before emission, so the lib doesn't need - /// any evaluator logic — exact-match against the "permission" - /// claims is sufficient. - /// - /// Typical usage in a resource-server Program.cs: - /// - /// services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - /// .AddJwtBearer(options => - /// { - /// options.Authority = "https://auth.example.com"; - /// options.Audience = "https://policy-api.cocoar.dev"; - /// }); - /// - /// services.AddModgudClient(o => - /// { - /// o.Authority = "https://auth.example.com"; - /// o.Audience = "https://policy-api.cocoar.dev"; - /// }); - /// - /// // Now [Authorize(Roles = "Editor")] and - /// // .RequiresModgudPermission("policy:write") just work. - /// - /// - public static IServiceCollection AddModgudClient( - this IServiceCollection services, - Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - - services.Configure(configure); - services.AddTransient(); - - // Hook UserInfo-fetching into the JwtBearer scheme — pure - // AddJwtBearer doesn't do that natively. Composable: any - // existing OnTokenValidated handler is preserved. - services.AddSingleton, ModgudJwtBearerPostConfigure>(); - - return services; - } - - /// - /// Registers the Modgud reference-token (introspection) authentication - /// scheme so a resource server can accept Modgud's default opaque access - /// tokens without switching its OAuth client to JWT. Each request validates - /// the bearer token via /connect/introspect (RFC 7662) and projects - /// the response — including the per-audience resource_access block — - /// onto the principal, where the shared - /// flattens it into role / permission claims. The - /// RequiresModgudPermission filter then works identically to the JWT - /// path. - /// - /// The resource server introspects with a confidential client whose - /// client_id equals its own - /// — see the options docs for why. Set the secret via - /// . - /// - /// Typical usage in a resource-server Program.cs: - /// - /// services.AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - /// .AddModgudReferenceTokenClient(o => - /// { - /// o.Authority = "https://auth.example.com"; - /// o.Audience = "https://mcp.acme.example"; // == introspection client_id - /// o.IntrospectionClientSecret = builder.Configuration["Modgud:IntrospectionSecret"]; - /// }); - /// - /// - public static AuthenticationBuilder AddModgudReferenceTokenClient( - this AuthenticationBuilder builder, - Action configure) - => builder.AddModgudReferenceTokenClient( - ModgudReferenceTokenDefaults.AuthenticationScheme, configure); - - /// - /// Scheme-named overload of - /// , - /// for hosts that register the introspection handler under a custom scheme - /// name (e.g. to run it alongside JwtBearer). - /// - public static AuthenticationBuilder AddModgudReferenceTokenClient( - this AuthenticationBuilder builder, - string authenticationScheme, - Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - - builder.Services.AddTransient(); - - // The shared ModgudClaimsTransformation reads ModgudOptions.Audience to - // pick the resource_access[...] block, so mirror the audience there. - builder.Services.Configure(authenticationScheme, configure); - builder.Services.AddOptions().Configure>( - (modgud, refToken) => - { - var o = refToken.Get(authenticationScheme); - modgud.Authority = o.Authority; - modgud.Audience = o.Audience; - }); - - return builder.AddScheme( - authenticationScheme, configure); - } -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/UserInfoEnricher.cs b/src/dotnet/Modgud.Client.AspNetCore/UserInfoEnricher.cs deleted file mode 100644 index d7a0d625..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/UserInfoEnricher.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Modgud.Client.AspNetCore; - -/// -/// Wires JwtBearerEvents.OnTokenValidated to make sure the validated -/// principal carries a resource_access claim, preferring the token's -/// own embedded claim and falling back to -/// {Authority}/connect/userinfo only when the token carries none. -/// -/// Preference order and why: since federation v1.1 the IdP bakes -/// resource_access straight into every access token at issuance — -/// /connect/userinfo merely echoes that same baked block back -/// verbatim (see UserInfoPerAudienceTests -/// .JwtClient_Bakes_ResourceAccess_Into_AccessToken_And_UserInfo_Echoes -/// on the IdP side). So when the JwtBearer-validated token already has the -/// claim, fetching UserInfo is a redundant round-trip that returns the exact -/// same data — this handler skips it. It only falls back to UserInfo for -/// tokens that don't carry the claim themselves (e.g. opaque/reference -/// access tokens the host validates via introspection instead of JWT -/// parsing, or older IdP versions). -/// -/// Pure AddJwtBearer only validates the token — it doesn't -/// fetch UserInfo on its own (that's an AddOpenIdConnect feature). -/// For resource servers that want the lib's claims-transformation to work -/// even when the token itself has no resource_access, the claim must -/// reach the principal somehow. This handler is the missing piece. -/// -/// Network fault tolerance: if UserInfo is unreachable or returns -/// a non-2xx, the handler logs and silently continues — the request -/// proceeds with whatever claims the bearer token already carried, and -/// downstream gates (RequiresModgudPermission, [Authorize(Roles=...)]) -/// will return 403 if those weren't enough. This is the security-positive -/// default: a transient IdP outage MUST NOT 500 the whole API. Note this -/// fail-open behaviour only applies to the fallback path — a token that -/// already carries the claim never touches the network at all. -/// -internal sealed class ModgudUserInfoEnricher -{ - // Settable (not just a readonly field) so unit tests can substitute a - // fake HttpMessageHandler and assert on call counts. Production callers - // never touch this — it defaults to a real HttpClient. - internal static HttpClient SharedClient { get; set; } = new(); - - public static async Task EnrichAsync(TokenValidatedContext context) - { - var logger = context.HttpContext.RequestServices - .GetRequiredService() - .CreateLogger("Modgud.UserInfoEnricher"); - - // Preference order: the validated token's own resource_access claim - // wins. /connect/userinfo only ever echoes the same baked block, so - // if it's already on the principal there is nothing UserInfo could - // add — skip the round-trip entirely. - if (context.Principal?.Identity is ClaimsIdentity validatedIdentity && - !string.IsNullOrEmpty(validatedIdentity - .FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType)?.Value)) - { - logger.LogDebug( - "Modgud: access token already carries a resource_access claim; " + - "skipping the /connect/userinfo round-trip."); - return; - } - - var options = context.HttpContext.RequestServices - .GetRequiredService>().Value; - - // Token was just validated → the bearer string is on the request. - var rawAuth = context.HttpContext.Request.Headers.Authorization.ToString(); - if (string.IsNullOrEmpty(rawAuth) || - !AuthenticationHeaderValue.TryParse(rawAuth, out var header) || - !string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) || - string.IsNullOrEmpty(header.Parameter)) - { - return; - } - - var url = options.Authority.TrimEnd('/') + "/connect/userinfo"; - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", header.Parameter); - - try - { - using var response = await SharedClient.SendAsync(request, - context.HttpContext.RequestAborted); - if (!response.IsSuccessStatusCode) - { - logger.LogDebug( - "Modgud: UserInfo fetch returned {Status}; continuing without enrichment.", - (int)response.StatusCode); - return; - } - - var body = await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted); - using var json = JsonDocument.Parse(body); - if (!json.RootElement.TryGetProperty("resource_access", out var resourceAccess) || - resourceAccess.ValueKind != JsonValueKind.Object) - { - return; - } - - // Add as a string-typed claim — the ClaimsTransformation will - // parse the JSON and project the configured-audience block. - if (context.Principal?.Identity is ClaimsIdentity identity) - { - identity.AddClaim(new Claim( - ModgudClaimsTransformation.ResourceAccessClaimType, - resourceAccess.GetRawText())); - } - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) - { - logger.LogWarning(ex, - "Modgud: UserInfo fetch failed; continuing without enrichment. " + - "Downstream gates will use whatever the bearer token carried."); - } - } -} - -/// -/// Hooks the lib's JwtBearer behaviour in via PostConfigure. Two composable -/// event handlers, each preserving any handler the host already set: -/// -/// OnMessageReceived — lifts a DPoP-bound token out of the -/// Authorization: DPoP … header so JwtBearer validates the JWT it -/// carries (RFC 9449, #118); a plain Bearer request is untouched. -/// OnTokenValidated — enforces the DPoP cnf.jkt binding -/// (), then, if the token was -/// accepted, ensures the principal carries resource_access -/// (). -/// -/// -internal sealed class ModgudJwtBearerPostConfigure : IPostConfigureOptions -{ - private readonly ModgudOptions _options; - - public ModgudJwtBearerPostConfigure(IOptions options) - { - _options = options.Value; - } - - public void PostConfigure(string? name, JwtBearerOptions options) - { - if (!string.Equals(name, _options.JwtBearerScheme, StringComparison.Ordinal)) return; - - if (string.IsNullOrWhiteSpace(_options.Authority)) - throw new InvalidOperationException( - "ModgudOptions.Authority must be set to the IdP base URL " + - "(e.g. \"https://auth.example.com\") so the lib can fetch /connect/userinfo. " + - "Configure it via AddModgudClient."); - - options.Events ??= new JwtBearerEvents(); - - // Accept a DPoP-scheme JWT: JwtBearer only reads `Bearer`, so lift the - // token out of the `DPoP` header for it. Only sets the token when the - // host hasn't already resolved one and the scheme is DPoP. - var existingReceived = options.Events.OnMessageReceived; - options.Events.OnMessageReceived = async ctx => - { - if (existingReceived is not null) await existingReceived(ctx); - if (string.IsNullOrEmpty(ctx.Token) && - ModgudDpopJwtBearer.ExtractDpopSchemeToken(ctx.HttpContext.Request) is { } dpopToken) - { - ctx.Token = dpopToken; - } - }; - - var existingValidated = options.Events.OnTokenValidated; - options.Events.OnTokenValidated = async ctx => - { - if (existingValidated is not null) await existingValidated(ctx); - - // Enforce the DPoP binding BEFORE enrichment: a bound token presented - // wrong (as bearer, or with an invalid/mismatched proof) must be - // rejected regardless of its claims, and there's no point fetching - // UserInfo for a request we're about to fail. - ModgudDpopJwtBearer.EnforceBinding(ctx); - if (ctx.Result is null) - await ModgudUserInfoEnricher.EnrichAsync(ctx); - }; - } -} diff --git a/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs b/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs index b562fe04..a5ce5fae 100644 --- a/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs @@ -58,6 +58,10 @@ public class ApplicationSettings /// inherit the realm native-grant settings. public ApplicationNativeGrantOverrides? NativeGrants { get; set; } + /// Per-Application defaults for native OAuth client/device + /// sessions. A concrete OAuth client may override these values. + public ApplicationClientSessionOverrides? ClientSessions { get; set; } + /// Per-Application Dynamic Client Registration overrides, merged /// field-by-field over the realm . Null = inherit. public ApplicationDcrOverrides? Dcr { get; set; } @@ -149,6 +153,14 @@ public record ApplicationNativeGrantOverrides public TimeSpan? RefreshTokenLifetime { get; init; } } +/// Nullable-field mirror of . Null +/// fields inherit the realm policy. +public record ApplicationClientSessionOverrides +{ + public TimeSpan? IdleLifetime { get; init; } + public TimeSpan? AbsoluteLifetime { get; init; } +} + /// Nullable-field mirror of . A null field /// inherits the realm value. (GcTtlDays is read by the realm-iterating GC job, /// not the per-request registration endpoint, so it stays effectively realm-level diff --git a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs index 0f71c515..6b1205b4 100644 --- a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs @@ -25,6 +25,7 @@ public sealed record EffectiveSettings public DcrSettings? Dcr { get; init; } public CimdSettings? Cimd { get; init; } public NativeGrantSettings? NativeGrants { get; init; } + public ClientSessionPolicy? ClientSessions { get; init; } public BrandingSettings? Branding { get; init; } public RegistrationFieldsSettings? RegistrationFields { get; init; } public DeletionSettings? Deletion { get; init; } @@ -50,6 +51,7 @@ public sealed record EffectiveSettings Dcr = realm.Dcr, Cimd = realm.Cimd, NativeGrants = realm.NativeGrants, + ClientSessions = realm.ClientSessions, Branding = realm.Branding, RegistrationFields = realm.RegistrationFields, Deletion = realm.Deletion, @@ -68,6 +70,7 @@ public sealed record EffectiveSettings { // Sections the App can override (field-by-field): NativeGrants = MergeNativeGrants(realm.NativeGrants, app.NativeGrants), + ClientSessions = MergeClientSessions(realm.ClientSessions, app.ClientSessions), Branding = MergeBranding(realm.Branding, app.Branding), SelfRegistration = MergeSelfRegistration(realm.SelfRegistration, app.SelfRegistration), Dcr = MergeDcr(realm.Dcr, app.Dcr), @@ -118,6 +121,19 @@ public sealed record EffectiveSettings }; } + private static ClientSessionPolicy? MergeClientSessions( + ClientSessionPolicy? realm, + ApplicationClientSessionOverrides? app) + { + if (app is null) return realm; + var policy = realm ?? ClientSessionPolicy.Defaults; + return policy with + { + IdleLifetime = app.IdleLifetime ?? policy.IdleLifetime, + AbsoluteLifetime = app.AbsoluteLifetime ?? policy.AbsoluteLifetime, + }; + } + // App override absent → realm passthrough. Present → each field is the App // value when set, else the realm value. Captcha fields stay realm-level (the // App override type doesn't carry them). diff --git a/src/dotnet/Modgud.Domain/OAuth/Apis/OAuthApiEvents.cs b/src/dotnet/Modgud.Domain/OAuth/Apis/OAuthApiEvents.cs index 3fb38a43..7f907fc5 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Apis/OAuthApiEvents.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Apis/OAuthApiEvents.cs @@ -18,9 +18,10 @@ public record OAuthApiPropertiesChanged(Guid ApiId, IReadOnlyDictionary /// Sets the App this resource-server belongs to. null = unassigned -/// (the RS exists but /connect/userinfo won't emit a per-Audience -/// resource_access block for it). Realm-admin endpoints validate -/// that the AppId resolves to a non-deleted App at append time. +/// (the RS exists but the token boundary won't emit a per-Audience +/// resource_access block for it in JWT, UserInfo or introspection). +/// Realm-admin endpoints validate that the AppId resolves to a non-deleted +/// App at append time. /// public record OAuthApiAppIdChanged(Guid ApiId, Guid? AppId); diff --git a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationAggregate.cs b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationAggregate.cs index b6d86c0d..fb202b13 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationAggregate.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationAggregate.cs @@ -22,9 +22,9 @@ public partial class OAuthApplicationAggregate public Dictionary Properties { get; private set; } = new(); /// /// n:m link to Applications. Empty = realm-wide / unassigned. One id = - /// typical app-scoped SPA. Many ids = a frontend that bundles multiple - /// resource servers (Keycloak-style resource_access in the - /// issued tokens). + /// typical app-scoped SPA. Many ids = a frontend entitled to scopes from + /// several Apps. Claim blocks remain keyed by the requested registered + /// OAuth API Audiences, not by these App ids. /// public List AppIds { get; private set; } = []; diff --git a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationEvents.cs b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationEvents.cs index 10f39d48..45528886 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationEvents.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationEvents.cs @@ -25,10 +25,12 @@ public record OAuthApplicationAppIdChanged(Guid ApplicationId, Guid? AppId); /// /// Sets the n:m link between this OAuth client and Applications. The list /// can be empty (realm-wide / unassigned), have one entry (typical web -/// SPA bound to a single app), or many (a frontend that bundles multiple -/// resource servers — Keycloak-style resource_access). Realm-admin -/// endpoints validate that every entry references a -/// non-deleted App at append time. +/// SPA bound to a single app), or many (a frontend entitled to scopes from +/// several Apps). The link does not directly create +/// resource_access blocks: requested scopes produce audiences, and +/// each registered OAuth API Audience resolves its own App context. +/// Realm-admin endpoints validate that every entry +/// references a non-deleted App at append time. /// public record OAuthApplicationAppIdsChanged(Guid ApplicationId, IReadOnlyList AppIds); diff --git a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs index f4c7c4a4..78f8b9c2 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs @@ -8,6 +8,8 @@ public static class OAuthApplicationSettingKeys public const string AccessTokenLifetime = "modgud:access_token_lifetime"; public const string AuthorizationCodeLifetime = "modgud:authorization_code_lifetime"; public const string SlidingRefreshTokenLifetime = "modgud:sliding_refresh_token_lifetime"; + public const string ClientSessionIdleLifetime = "modgud:client_session_idle_lifetime"; + public const string ClientSessionAbsoluteLifetime = "modgud:client_session_absolute_lifetime"; public const string ClientClaimsPrefix = "modgud:client_claims_prefix"; /// diff --git a/src/dotnet/Modgud.Domain/OAuth/Scopes/ScopePropertyKeys.cs b/src/dotnet/Modgud.Domain/OAuth/Scopes/ScopePropertyKeys.cs index 7d85b216..10ee2f24 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Scopes/ScopePropertyKeys.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Scopes/ScopePropertyKeys.cs @@ -46,10 +46,12 @@ public static class StandardScopes /// /// Cocoar-specific scope that gates emission of the per-audience - /// resource_access[…].permissions array in UserInfo. Not part of - /// OIDC core; modelled after the same per-scope-per-claim opt-in pattern - /// that uses for role names. Static-registered so it - /// appears in scopes_supported and is offered on the consent screen. + /// resource_access[…].permissions array on the access-token + /// principal, UserInfo and authorized introspection responses. Not part + /// of OIDC core; modelled after the same per-scope-per-claim opt-in + /// pattern that uses for role names. + /// Static-registered so it appears in scopes_supported and is + /// offered on the consent screen. /// public const string Permissions = "permissions"; diff --git a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs index c7c3a792..b6d78333 100644 --- a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs +++ b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs @@ -59,6 +59,15 @@ public class RealmSettings /// additional, separate gate. public NativeGrantSettings? NativeGrants { get; set; } + /// Realm-wide policy for the shared Modgud browser/SSO session. + /// Null = . + public BrowserSessionPolicy? BrowserSessions { get; set; } + + /// Realm fallback for native OAuth client/device sessions. + /// Applications and concrete OAuth clients may override it. Null = + /// . + public ClientSessionPolicy? ClientSessions { get; set; } + /// Per-realm overrides for the per-IP auth rate-limit ceilings /// (native-otp, magic-link, password-reset, email-otp, email-verification, /// passkey-begin, bootstrap). Null = never configured; every policy uses its diff --git a/src/dotnet/Modgud.Domain/Realms/AuditSettings.cs b/src/dotnet/Modgud.Domain/Realms/AuditSettings.cs index 9e2ba3e1..dc2e7b7a 100644 --- a/src/dotnet/Modgud.Domain/Realms/AuditSettings.cs +++ b/src/dotnet/Modgud.Domain/Realms/AuditSettings.cs @@ -6,7 +6,11 @@ namespace Modgud.Domain.Realms; /// no migration). Null on the parent = never configured; callers read it as /// . /// -/// Visibility window, NOT retention/deletion. The audit trail is a +/// The event-sourced audit trail uses a visibility window (not deletion). +/// The separate realm security-event store has a real hard-retention setting. +/// Both policies are realm-owned. +/// +/// The audit trail is a /// rebuildable projection (AuthAuditView) over event streams we keep for the /// aggregate's lifetime (masked on erase). This window only bounds what the read /// surface *shows* — it does not delete history. Named VisibilityWindowDays @@ -19,6 +23,10 @@ public record AuditSettings /// are hidden from the view (not deleted). Must be at least 1. public int VisibilityWindowDays { get; init; } = 90; + /// Hard retention for structured realm security events. Valid range + /// is 1..365 days. Defaults to seven days. + public int SecurityRetentionDays { get; init; } = 7; + /// Shared defaults used when a realm has never configured the audit /// window. Matches the property initializer above. public static AuditSettings Defaults { get; } = new(); diff --git a/src/dotnet/Modgud.Domain/Realms/SessionPolicies.cs b/src/dotnet/Modgud.Domain/Realms/SessionPolicies.cs new file mode 100644 index 00000000..e4fd01eb --- /dev/null +++ b/src/dotnet/Modgud.Domain/Realms/SessionPolicies.cs @@ -0,0 +1,32 @@ +namespace Modgud.Domain.Realms; + +/// +/// Realm-owned policy for the shared Modgud browser/SSO cookie. The cookie and +/// its authoritative UserSession row consume the same values. +/// +public record BrowserSessionPolicy +{ + public static BrowserSessionPolicy Defaults { get; } = new(); + + /// Sliding inactivity window. Default preserves the former 30-day cookie window. + public TimeSpan IdleLifetime { get; init; } = TimeSpan.FromDays(30); + + /// Hard limit measured from the interactive sign-in; activity never extends it. + public TimeSpan AbsoluteLifetime { get; init; } = TimeSpan.FromDays(180); + + /// Whether callers may request a browser-persistent cookie. + public bool AllowRememberMe { get; init; } = true; +} + +/// +/// Policy for a native OAuth client/device session. Access-token lifetime is +/// intentionally separate; this policy controls how long a rotating refresh +/// chain may continue without a full user sign-in. +/// +public record ClientSessionPolicy +{ + public static ClientSessionPolicy Defaults { get; } = new(); + + public TimeSpan IdleLifetime { get; init; } = TimeSpan.FromDays(30); + public TimeSpan AbsoluteLifetime { get; init; } = TimeSpan.FromDays(365); +} diff --git a/src/dotnet/Modgud.Infrastructure/Audit/AuditCategories.cs b/src/dotnet/Modgud.Infrastructure/Audit/AuditCategories.cs index eee8ef55..871682e0 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/AuditCategories.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/AuditCategories.cs @@ -21,14 +21,14 @@ public static class AuditCategories public const string AdminRealm = "admin-realm"; public const string DcrOAuth = "dcr-oauth"; - // ── Streamless (Track A — the security/ops store, SecurityAuditEntry) ── + // ── Streamless realm/platform security events ── /// Tenant-relevant security threats with no aggregate stream: /// unknown-actor login attempts, probes, rate-limit hits, policy rejections, /// and the audit-of-the-audit records. public const string SecurityOps = "security-ops"; /// Operational actions (key/cert rotation, recovery-CLI, realm - /// provisioning, sweeps). Some are tenant-visible, the cross-realm infra ones - /// are control-plane-only — see . + /// provisioning, sweeps). Storage scope is explicit at the call site through + /// either a realm or platform record type. public const string Operations = "operations"; } diff --git a/src/dotnet/Modgud.Infrastructure/Audit/AuditDurability.cs b/src/dotnet/Modgud.Infrastructure/Audit/AuditDurability.cs new file mode 100644 index 00000000..a7b00ecb --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Audit/AuditDurability.cs @@ -0,0 +1,84 @@ +namespace Modgud.Infrastructure.Audit; + +/// +/// Delivery and persistence contract for streamless security/operations events. +/// The class is part of the event taxonomy so a call site cannot silently choose +/// a weaker guarantee than the event requires. +/// +public enum AuditDurabilityClass +{ + /// + /// Privileged or irreversible state transition. It must be persisted, or + /// enrolled in the same transactional outbox as the state change. + /// + Required, + + /// + /// Individual takeover/tamper incident without a normal state transaction. + /// The rejecting request waits for durable persistence. + /// + Incident, + + /// + /// Potentially attacker-amplified signal. Raw occurrences may be dropped or + /// sampled, while bounded batches are persisted as count aggregates. + /// + Abuse, + + /// + /// Reconstructable operational information. Explicitly best-effort. + /// + Telemetry, +} + +public static class AuditDurability +{ + public static AuditDurabilityClass Classify(string eventType) => eventType switch + { + AuditEvents.RefreshTokenReuseDetected or + AuditEvents.AuditLogExported or + AuditEvents.SecurityRetentionChanged or + AuditEvents.SigningKeyRotated or + AuditEvents.SamlCertRotated or + AuditEvents.SamlSigningCertificatesChanged or + AuditEvents.RecoveryCliInvoked or + AuditEvents.RealmProvisioned or + AuditEvents.RealmAdopted or + AuditEvents.ControlPlaneTransferred or + AuditEvents.InstallationChallengeIssued or + AuditEvents.InstallationCompleted or + AuditEvents.ControlPlaneRealmOperation or + AuditEvents.BootstrapInviteIssued or + AuditEvents.DcrClientRegistered + => AuditDurabilityClass.Required, + + AuditEvents.ExternalLoginProtocolRejected or + AuditEvents.SamlSignatureRejected or + AuditEvents.IdentityHijackBlocked or + AuditEvents.JitEmailConflict or + AuditEvents.PrivilegeEscalationBlocked + => AuditDurabilityClass.Incident, + + AuditEvents.LoginFailed or + AuditEvents.LoginFailedUnknownUser or + AuditEvents.MagicLinkInvalid or + AuditEvents.ExternalLoginPolicyRejected or + AuditEvents.RateLimitTriggered or + AuditEvents.DcrRegistrationRejected or + AuditEvents.BootstrapInviteRejected + => AuditDurabilityClass.Abuse, + + AuditEvents.ExternalLoginConfigurationError or + AuditEvents.SigningKeyPurged or + AuditEvents.SamlMetadataRefreshCompleted or + AuditEvents.AccountLifecycleSwept or + AuditEvents.DcrClientFirstUsed or + AuditEvents.DcrClientGarbageCollected + => AuditDurabilityClass.Telemetry, + + _ => throw new ArgumentOutOfRangeException( + nameof(eventType), + eventType, + "The streamless audit event has no durability classification."), + }; +} diff --git a/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs b/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs index 1fa8fa6d..da58b2fb 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs @@ -10,10 +10,9 @@ namespace Modgud.Infrastructure.Audit; /// federation.* / admin.* codes name occurrences on the user- and config- /// aggregate streams (projected into the per-realm GDPR-audit view). The /// security.* / ops.* / audit.* codes name streamless occurrences -/// (no aggregate to attach to) routed to the cross-realm -/// SecurityAuditEntry store under a legitimate-interest basis with short -/// retention. The boundary is about whether a stream exists, not whether the data -/// is personal — see the maintainers' logging-audit-redesign design note. +/// routed either to the owning realm's RealmSecurityAuditEvent store or, +/// for genuinely deployment-wide work, to the PII-free Global Store +/// PlatformAuditEvent. /// /// PII discipline: these name occurrences, not payloads. The /// stream-backed rows store only metadata (who/when/what-kind/realm) and inherit @@ -69,7 +68,7 @@ public static class AuditEvents public const string LoginProviderDeleted = "admin.login_provider_deleted"; // ───────────────────────────────────────────────────────────────────── - // Streamless (Track A — SecurityAuditEntry, legitimate interest + retention) + // Streamless realm/platform security events // ───────────────────────────────────────────────────────────────────── // ── Security: streamless threats (tenant-visible) ──────────────── @@ -81,12 +80,23 @@ public static class AuditEvents /// Carries Ip. public const string MagicLinkInvalid = "security.magic_link_invalid"; - /// An external/federation login was rejected before any user link — - /// domain allowlist, JIT disabled, inactive user, malformed token, or a - /// misconfigured provider. Reason disambiguates. Covers the SAML - /// protocol gates (no metadata, no SSO endpoint, context-build / response-read - /// failure, non-success status) as well as the OIDC/processor rejections. - public const string ExternalLoginRejected = "security.external_login_rejected"; + /// An external/federation response was rejected because its protocol + /// shape, signature-independent validation or request correlation was invalid. + /// This is a durable security incident, not a policy decision. + public const string ExternalLoginProtocolRejected = + "security.external_login_protocol_rejected"; + + /// An otherwise valid external identity was rejected by realm policy: + /// domain allowlist, JIT disabled or inactive/deleted user. Individual attempts + /// are abuse telemetry and are durably aggregated. + public const string ExternalLoginPolicyRejected = + "security.external_login_policy_rejected"; + + /// An external login could not start because provider metadata, + /// endpoints or configuration were unavailable. Operational telemetry rather + /// than a security incident. + public const string ExternalLoginConfigurationError = + "ops.external_login_configuration_error"; /// A SAML response failed the admin-required signature check /// (response/assertion unsigned). A distinct tamper / signature-wrapping @@ -126,27 +136,32 @@ public static class AuditEvents public const string BootstrapInviteRejected = "security.bootstrap_invite_rejected"; // ── Audit-of-the-audit (tenant-visible) ────────────────────────── - /// The audit/security log was cleared by an operator. Records WHO + - /// when + realm — a forensic record of the destructive action itself. - public const string AuditLogCleared = "audit.log_cleared"; - /// The audit/security log was exported by an operator. public const string AuditLogExported = "audit.log_exported"; + /// A realm admin changed the hard retention of realm security events. + public const string SecurityRetentionChanged = "audit.security_retention_changed"; + // ── Operations: realm/platform actions ─────────────────────────── /// A realm signing key was rotated by an admin (tenant-visible). public const string SigningKeyRotated = "ops.signing_key_rotated"; - /// The signing-key janitor purged expired retired keys (platform-only). + /// The owning realm's signing-key janitor purged expired retired keys. public const string SigningKeyPurged = "ops.signing_key_purged"; /// A realm's SAML SP certificate was rotated or first generated /// (tenant-visible — a realm-relevant trust change). public const string SamlCertRotated = "ops.saml_cert_rotated"; - /// Background SAML metadata refresh tick / IdP signing-cert change - /// (platform-only). - public const string SamlMetadataRefreshed = "ops.saml_metadata_refreshed"; + /// Background SAML metadata refresh summary. Operational telemetry; + /// no trust-material change is represented by this event. + public const string SamlMetadataRefreshCompleted = + "ops.saml_metadata_refresh_completed"; + + /// The trusted IdP signing-certificate set changed after a metadata + /// refresh. This trust-boundary change requires a durable audit record. + public const string SamlSigningCertificatesChanged = + "ops.saml_signing_certificates_changed"; /// A recovery-CLI operation was invoked (filesystem-trust, control-plane /// only). Reason carries the specific operation + parameters. @@ -164,12 +179,22 @@ public static class AuditEvents /// (platform-only). public const string ControlPlaneTransferred = "ops.control_plane_transferred"; + /// A shell-authorized first-installation link was issued. + public const string InstallationChallengeIssued = "ops.installation_challenge_issued"; + + /// The first realm and its first administrator were provisioned. + public const string InstallationCompleted = "ops.installation_completed"; + + /// A Control-Plane actor changed one explicitly selected realm. + public const string ControlPlaneRealmOperation = "ops.control_plane_realm_operation"; + /// A per-realm account-lifecycle sweep ran (reminders / self-erase / - /// auto-purge counts). Platform-only operational summary. + /// auto-purge counts). Realm-owned operational summary. public const string AccountLifecycleSwept = "ops.account_lifecycle_swept"; /// A bootstrap-admin invite was issued (tenant-visible realm-init). - /// Any email is masked at the call site. + /// The recipient remains in the short-lived invite document; the durable + /// audit row deliberately carries no recipient PII. public const string BootstrapInviteIssued = "ops.bootstrap_invite_issued"; /// A DCR client was registered (tenant-visible). @@ -183,9 +208,8 @@ public static class AuditEvents public const string DcrClientGarbageCollected = "ops.dcr_client_garbage_collected"; // ───────────────────────────────────────────────────────────────────── - // Routing helpers (the taxonomy is the source of truth for category + - // visibility, so a call site passes only the EventType — it cannot mark a - // platform-only event tenant-visible by mistake). + // Taxonomy helper. Store ownership is selected through separate realm and + // platform record types, never inferred from this event code. // ───────────────────────────────────────────────────────────────────── /// The code an event type belongs to, @@ -201,24 +225,4 @@ _ when eventType.StartsWith("admin.", StringComparison.Ordinal) => AuditCategori _ => AuditCategories.Authentication, // auth.* }; - /// - /// Streamless event types that are control-plane-only — cross-realm infra - /// or platform operations a tenant realm-admin must NOT see. Everything else in - /// the streamless store is tenant-visible (a realm-admin sees their own realm's - /// rows). The read endpoint filters on the resolved flag stored on each row. - /// - private static readonly HashSet PlatformOnlyEvents = - [ - SigningKeyPurged, - SamlMetadataRefreshed, - RecoveryCliInvoked, - RealmProvisioned, - RealmAdopted, - ControlPlaneTransferred, - AccountLifecycleSwept, - ]; - - /// True if the event type is control-plane-only (see - /// ). - public static bool IsPlatformOnly(string eventType) => PlatformOnlyEvents.Contains(eventType); } diff --git a/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs b/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs index 87ae3d87..aaae65c0 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs @@ -1,69 +1,112 @@ +using Marten; + namespace Modgud.Infrastructure.Audit; /// -/// One streamless security/ops occurrence to record. The caller supplies the -/// code plus whatever context it has; the sink derives -/// the Category + control-plane visibility from the code (the taxonomy is -/// the source of truth) and stamps the realm + timestamp at emit. -/// -/// PII is the caller's responsibility to minimise. Pass an attempted -/// username / masked email / IP as only where it is the -/// security signal; never put secrets, tokens, or invite codes in any field. +/// Structured input for a realm-owned security event. is +/// routing metadata only and is never persisted in the realm document. +/// is accepted transiently so the writer can HMAC +/// it with the owning realm's key; its raw value never reaches storage. +/// is disabled for non-identifying +/// cross-realm counterpart events so actor PII stays in the actor's realm. /// public sealed record SecurityAuditRecord { - /// An streamless code (security.* / - /// ops.* / audit.*). public required string EventType { get; init; } + public string? RealmSlug { get; init; } + public bool CaptureRequestContext { get; init; } = true; + public AuditSeverity Severity { get; init; } = AuditSeverity.Info; + public AuditActorKind? ActorKind { get; init; } + public Guid? ActorSubjectId { get; init; } + public Guid? TargetSubjectId { get; init; } + public string? UnknownIdentifier { get; init; } + public string? IpAddress { get; init; } + public string? UserAgent { get; init; } + public string? OAuthClientId { get; init; } + public string? AuthorizationId { get; init; } + public Guid? ApplicationId { get; init; } + public Guid? SessionId { get; init; } + public Guid? LoginProviderId { get; init; } + public string? AuthenticationMethod { get; init; } + public string? CorrelationId { get; init; } + public string OutcomeCode { get; init; } = AuditOutcomes.Observed; + public string? ReasonCode { get; init; } + public string? OperationCode { get; init; } + public string? TargetRealmSlug { get; init; } + public string? KeyId { get; init; } + public int? Count { get; init; } + public int? RelatedCount { get; init; } + public int? RemindedCount { get; init; } + public int? SelfErasedCount { get; init; } + public int? AutoPurgedCount { get; init; } + public int? InviteCodesPrunedCount { get; init; } + public int? ReusedCount { get; init; } + public int? RetentionDays { get; init; } + public DateTimeOffset? EffectiveAt { get; init; } + public DateTimeOffset? FirstObservedAt { get; init; } + public DateTimeOffset? LastObservedAt { get; init; } +} - /// Explicit realm slug, overriding the ambient - /// TenantContext.Current. Set this from realm-iterating background - /// jobs (the signing-key janitor, DCR GC, lifecycle sweep, realm - /// provisioning) which run in the system session but emit per-realm - /// rows — exactly the case the legacy RealmLogEnricher's explicit - /// {Realm} binding handled. Leave null on the request path (the ambient - /// realm is correct there). - public string? Realm { get; init; } +/// +/// Structured deployment-wide event. The absence of subject, identifier, IP, +/// user-agent, client and session fields is an intentional compile-time privacy +/// boundary. +/// +public sealed record PlatformAuditRecord +{ + public required string EventType { get; init; } + public AuditSeverity Severity { get; init; } = AuditSeverity.Info; + public string OutcomeCode { get; init; } = AuditOutcomes.Observed; + public string? ReasonCode { get; init; } + public string? OperationCode { get; init; } + public string? TargetRealmSlug { get; init; } + public string? Domain { get; init; } + public string? PreviousDomain { get; init; } + public string? CorrelationId { get; init; } + public int? Count { get; init; } + public int? RelatedCount { get; init; } + public int? RetentionDays { get; init; } + public DateTimeOffset? EffectiveAt { get; init; } +} - /// "Info" | "Warning" | "Error" — the legacy level mapping. - public string Level { get; init; } = "Info"; +/// +/// Classified streamless audit sink. Required changes and individual incidents +/// wait for durable persistence. Abuse signals are bounded and aggregated. +/// Reconstructable operations telemetry remains explicitly best-effort. +/// +public interface ISecurityAuditLog +{ + ValueTask RecordRequiredAsync( + SecurityAuditRecord record, + CancellationToken ct = default); - /// Who/what the event is about: an attempted username, a masked email, - /// an acting admin's username, or an IP for a purely anonymous actor. A display - /// string (NOT a user-id GUID) so the cross-realm read needs no per-tenant join. - /// Null when there is no meaningful actor. - public string? Actor { get; init; } + /// + /// Adds a required realm event to an existing Marten unit of work. The + /// caller's next commits the + /// business state and audit row atomically. + /// + void StoreRequired( + IDocumentSession session, + SecurityAuditRecord record); - /// Source IP where the event carries one. Personal data under CJEU - /// Breyer — retained only for the short prune window. - public string? Ip { get; init; } + ValueTask RecordIncidentAsync( + SecurityAuditRecord record, + CancellationToken ct = default); - /// Coarse outcome, e.g. "rejected" | "succeeded" | "rotated". Optional. - public string? Status { get; init; } + void RecordAbuse(SecurityAuditRecord record); + void RecordTelemetry(SecurityAuditRecord record); - /// Disambiguating detail (e.g. the rejection reason, the recovery-CLI - /// operation). Already PII-minimised by the caller. - public string? Reason { get; init; } + ValueTask RecordPlatformRequiredAsync( + PlatformAuditRecord record, + CancellationToken ct = default); - /// Human-readable rendering for the admin grid (carried forward from the - /// legacy free-text Message column so the existing view keeps working). - public string Message { get; init; } = ""; -} + /// + /// Adds a required deployment-wide event to the caller's Global Store unit + /// of work so business state and audit row commit atomically. + /// + void StorePlatformRequired( + IDocumentSession session, + PlatformAuditRecord record); -/// -/// Best-effort sink for the streamless security/ops audit store (Track A, Phase 3). -/// Replaces the "Auth:"-message-prefix Serilog sink: call sites emit a typed -/// instead of stringly-typed log lines. -/// -/// Contract: is non-blocking and NEVER throws — a -/// failed enqueue drops the record rather than break the auth flow. The realm is -/// captured from TenantContext.Current at call time (the background writer -/// runs tenant-less). Durability is best-effort by design: this is a short-retention -/// legitimate-interest store, not the per-subject GDPR audit (which is the -/// event-sourced AuthAuditView). -/// -public interface ISecurityAuditLog -{ - /// Enqueue a streamless security/ops record. Non-blocking, never throws. - void Record(SecurityAuditRecord record); + void RecordPlatformTelemetry(PlatformAuditRecord record); } diff --git a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEntry.cs b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEntry.cs deleted file mode 100644 index caaf2de7..00000000 --- a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEntry.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Marten.Schema; - -namespace Modgud.Infrastructure.Audit; - -/// -/// A flat, typed, NON-event-sourced row in the streamless security/ops store -/// (logging/audit redesign Track A — the half that has no aggregate stream). One -/// document per occurrence; lives cross-realm in the system DB, attributed -/// to a realm via and scoped at read by the caller's realm + -/// (carrying PR #50's ScopeToCallerRealm forward). -/// -/// This is the successor to the personal-data-bearing-but-streamless portion -/// of the old AuthLogDocument: unknown-actor login attempts, probes, -/// rate-limit hits, and operational actions. Processed under Art. 6(1)(f) -/// (security / fraud detection); short hard retention is the proportionality -/// control (a Quartz prune), NOT per-subject erasure — there is no subject -/// stream to attach these to. See the maintainers' logging-audit-redesign design note -/// §A.5 + the Legitimate-Interest Assessment. -/// -[DocumentAlias("security_audit_entry")] -public class SecurityAuditEntry -{ - public Guid Id { get; init; } = Guid.NewGuid(); - - public DateTimeOffset Timestamp { get; init; } - - /// Realm slug the event was emitted in (from TenantContext.Current - /// at emit; background / no-tenant work is attributed to system). All rows - /// share the system DB; this column scopes the admin read. - public string? Realm { get; init; } - - /// code (derived from the event type). - public string Category { get; init; } = ""; - - /// code (a streamless security.* / ops.* / - /// audit.* code). - public string EventType { get; init; } = ""; - - /// "Info" | "Warning" | "Error". - public string Level { get; init; } = "Info"; - - /// True for control-plane-only events (cross-realm infra / platform ops). - /// Derived from the event type at emit () - /// and stored so the read can filter on a column: a tenant realm-admin sees only - /// PlatformOnly == false rows for their realm; the control-plane sees all. - public bool PlatformOnly { get; init; } - - /// Who/what the event is about — an attempted username, masked email, - /// acting admin, or IP. A display string, not a user-id GUID. May be personal - /// data; retained only for the prune window. Surfaced as the grid's "user" column. - public string? Actor { get; init; } - - /// Source IP where present. Personal data (CJEU Breyer) — retained - /// only for the prune window. - public string? Ip { get; init; } - - /// Coarse outcome ("rejected" | "succeeded" | "rotated" | …). Optional. - public string? Status { get; init; } - - /// Disambiguating detail (rejection reason, recovery-CLI operation, …). - public string? Reason { get; init; } - - /// Human-readable rendering for the admin grid (carry-forward of the - /// legacy Message column). - public string Message { get; init; } = ""; -} diff --git a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs new file mode 100644 index 00000000..a778024e --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs @@ -0,0 +1,117 @@ +using Marten.Schema; + +namespace Modgud.Infrastructure.Audit; + +/// +/// One structured security occurrence owned by exactly one realm. The document is +/// stored in that realm's physical database; it therefore has no Realm column. +/// Personal data is allowed only in the explicit forensic fields below and is +/// hard-deleted by the realm's configurable retention job. +/// +[DocumentAlias("realm_security_audit_event")] +public sealed class RealmSecurityAuditEvent +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public DateTimeOffset Timestamp { get; init; } + public string Category { get; init; } = ""; + public string EventType { get; init; } = ""; + public AuditSeverity Severity { get; init; } = AuditSeverity.Info; + public AuditActorKind ActorKind { get; init; } = AuditActorKind.System; + public Guid? ActorSubjectId { get; init; } + public Guid? TargetSubjectId { get; init; } + public string? UnknownIdentifierFingerprint { get; init; } + public string? IpAddress { get; init; } + public string? UserAgent { get; init; } + public string? OAuthClientId { get; init; } + public string? AuthorizationId { get; init; } + public Guid? ApplicationId { get; init; } + public Guid? SessionId { get; init; } + public Guid? LoginProviderId { get; init; } + public string? AuthenticationMethod { get; init; } + public string? CorrelationId { get; init; } + public string OutcomeCode { get; init; } = AuditOutcomes.Observed; + public string? ReasonCode { get; init; } + public string? OperationCode { get; init; } + public string? TargetRealmSlug { get; init; } + public string? KeyId { get; init; } + public int? Count { get; init; } + public int? RelatedCount { get; init; } + public int? RemindedCount { get; init; } + public int? SelfErasedCount { get; init; } + public int? AutoPurgedCount { get; init; } + public int? InviteCodesPrunedCount { get; init; } + public int? ReusedCount { get; init; } + public int? RetentionDays { get; init; } + public DateTimeOffset? EffectiveAt { get; init; } + public DateTimeOffset? FirstObservedAt { get; init; } + public DateTimeOffset? LastObservedAt { get; init; } +} + +/// +/// Deployment-wide operations event. This type deliberately has no subject, +/// identifier, IP, user-agent, client, application or session field. It lives +/// only in the non-tenanted Global Store. +/// +[DocumentAlias("platform_audit_event")] +public sealed class PlatformAuditEvent +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public DateTimeOffset Timestamp { get; init; } + public string Category { get; init; } = ""; + public string EventType { get; init; } = ""; + public AuditSeverity Severity { get; init; } = AuditSeverity.Info; + public string OutcomeCode { get; init; } = AuditOutcomes.Observed; + public string? ReasonCode { get; init; } + public string? OperationCode { get; init; } + public string? TargetRealmSlug { get; init; } + public string? Domain { get; init; } + public string? PreviousDomain { get; init; } + public string? CorrelationId { get; init; } + public int? Count { get; init; } + public int? RelatedCount { get; init; } + public int? RetentionDays { get; init; } + public DateTimeOffset? EffectiveAt { get; init; } +} + +/// +/// Per-realm secret used to turn an unresolved login/reset identifier into a +/// stable HMAC fingerprint. The raw identifier is never persisted. A separate +/// random key per physical realm prevents cross-realm correlation. +/// +[DocumentAlias("realm_audit_fingerprint_key")] +public sealed class RealmAuditFingerprintKey +{ + public const string SingletonId = "realm-security-audit-hmac-v1"; + + public string Id { get; init; } = SingletonId; + public required byte[] Key { get; init; } +} + +public enum AuditSeverity +{ + Info, + Warning, + Error, +} + +public enum AuditActorKind +{ + User, + AnonymousIdentifier, + OAuthClient, + ServiceAccount, + ControlPlane, + System, +} + +public static class AuditOutcomes +{ + public const string Observed = "observed"; + public const string Succeeded = "succeeded"; + public const string Rejected = "rejected"; + public const string Blocked = "blocked"; + public const string Failed = "failed"; + public const string Initiated = "initiated"; + public const string Completed = "completed"; + public const string Pruned = "pruned"; +} diff --git a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs index 978b5fe9..b915f405 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs @@ -1,139 +1,639 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; using System.Threading.Channels; using Marten; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; namespace Modgud.Infrastructure.Audit; /// -/// In-process implementation of : a bounded channel -/// that drains to the system DB. -/// -/// Bounded + drop-on-full (the legacy sink was an UNBOUNDED channel — -/// a memory-growth risk under a credential-stuffing storm). When the writer can't -/// keep up the oldest behaviour we want is to shed load, never to block the auth -/// path or grow without limit. Dropped counts are exposed for the writer to log. -/// -/// The realm is captured HERE, on the calling (request) thread where -/// TenantContext.Current is set — the writer runs tenant-less in a -/// background service, exactly as RealmLogEnricher captured it for the -/// legacy sink. Category + control-plane visibility are derived from the event type -/// so the row can't disagree with the taxonomy. +/// Classified streamless audit sink. Required and incident records are written +/// synchronously before the caller can report success/rejection. Abuse records +/// use a bounded aggregating buffer, and reconstructable telemetry uses the same +/// buffer with an explicit best-effort contract. /// -public sealed class SecurityAuditLog : ISecurityAuditLog +public sealed class SecurityAuditLog( + IHttpContextAccessor httpContextAccessor, + IServiceScopeFactory scopeFactory) : ISecurityAuditLog { - // Generous bound: a real burst is absorbed; a pathological flood sheds rather - // than OOMs. SingleReader because exactly one SecurityAuditWriter drains it. - private readonly Channel _channel = - Channel.CreateBounded(new BoundedChannelOptions(50_000) + private readonly Channel _channel = + Channel.CreateBounded(new BoundedChannelOptions(50_000) { - FullMode = BoundedChannelFullMode.DropWrite, + // We intentionally use TryWrite below. Wait mode makes TryWrite + // return false when full, allowing us to count the shed raw + // occurrence precisely; DropWrite reports acceptance even when it + // discards the item. + FullMode = BoundedChannelFullMode.Wait, SingleReader = true, }); private long _dropped; - internal ChannelReader Reader => _channel.Reader; - - /// Total records dropped because the channel was full (read-and-reset - /// by the writer so it can log bursts). + internal ChannelReader Reader => _channel.Reader; internal long ReadAndResetDropped() => Interlocked.Exchange(ref _dropped, 0); - public void Record(SecurityAuditRecord record) + public ValueTask RecordRequiredAsync( + SecurityAuditRecord record, + CancellationToken ct = default) + => PersistRealmNowAsync(record, AuditDurabilityClass.Required, ct); + + public void StoreRequired( + IDocumentSession session, + SecurityAuditRecord record) + { + EnsureClass(record.EventType, AuditDurabilityClass.Required); + var envelope = CaptureRealmEnvelope(record, AuditDurabilityClass.Required); + SecurityAuditPersistence.StoreRequired(session, envelope); + } + + public ValueTask RecordIncidentAsync( + SecurityAuditRecord record, + CancellationToken ct = default) + => PersistRealmNowAsync(record, AuditDurabilityClass.Incident, ct); + + public void RecordAbuse(SecurityAuditRecord record) + => EnqueueRealm(record, AuditDurabilityClass.Abuse); + + public void RecordTelemetry(SecurityAuditRecord record) + => EnqueueRealm(record, AuditDurabilityClass.Telemetry); + + public ValueTask RecordPlatformRequiredAsync( + PlatformAuditRecord record, + CancellationToken ct = default) + => PersistPlatformNowAsync(record, AuditDurabilityClass.Required, ct); + + public void StorePlatformRequired( + IDocumentSession session, + PlatformAuditRecord record) + { + EnsureClass(record.EventType, AuditDurabilityClass.Required); + SecurityAuditPersistence.StorePlatformRequired( + session, + SecurityAuditEnvelope.ForPlatform( + record with + { + CorrelationId = record.CorrelationId + ?? CurrentCorrelationId(httpContextAccessor.HttpContext), + }, + AuditDurabilityClass.Required)); + } + + public void RecordPlatformTelemetry(PlatformAuditRecord record) { - var entry = new SecurityAuditEntry + EnsureClass(record.EventType, AuditDurabilityClass.Telemetry); + try { - Timestamp = DateTimeOffset.UtcNow, - // Explicit override wins (realm-iterating background jobs), else the - // ambient realm — mirrors the legacy RealmLogEnricher dual-sourcing. - Realm = record.Realm ?? TenantContext.Current, - EventType = record.EventType, - Category = AuditEvents.CategoryOf(record.EventType), - PlatformOnly = AuditEvents.IsPlatformOnly(record.EventType), - Level = record.Level, - Actor = record.Actor, - Ip = record.Ip, - Status = record.Status, - Reason = record.Reason, - Message = record.Message, - }; + Enqueue(SecurityAuditEnvelope.ForPlatform( + record with + { + CorrelationId = record.CorrelationId + ?? CurrentCorrelationId(httpContextAccessor.HttpContext), + }, + AuditDurabilityClass.Telemetry)); + } + catch + { + Interlocked.Increment(ref _dropped); + } + } - if (!_channel.Writer.TryWrite(entry)) + private async ValueTask PersistRealmNowAsync( + SecurityAuditRecord record, + AuditDurabilityClass expected, + CancellationToken ct) + { + EnsureClass(record.EventType, expected); + var envelope = CaptureRealmEnvelope(record, expected); + using var scope = scopeFactory.CreateScope(); + await SecurityAuditPersistence.PersistAsync( + [envelope], + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + ct); + } + + private async ValueTask PersistPlatformNowAsync( + PlatformAuditRecord record, + AuditDurabilityClass expected, + CancellationToken ct) + { + EnsureClass(record.EventType, expected); + var envelope = SecurityAuditEnvelope.ForPlatform( + record with + { + CorrelationId = record.CorrelationId + ?? CurrentCorrelationId(httpContextAccessor.HttpContext), + }, + expected); + using var scope = scopeFactory.CreateScope(); + await SecurityAuditPersistence.PersistAsync( + [envelope], + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + ct); + } + + private void EnqueueRealm( + SecurityAuditRecord record, + AuditDurabilityClass expected) + { + EnsureClass(record.EventType, expected); + try + { + Enqueue(CaptureRealmEnvelope(record, expected)); + } + catch + { + Interlocked.Increment(ref _dropped); + } + } + + private SecurityAuditEnvelope CaptureRealmEnvelope( + SecurityAuditRecord record, + AuditDurabilityClass durabilityClass) + { + var http = record.CaptureRequestContext + ? httpContextAccessor.HttpContext + : null; + var subject = record.ActorSubjectId ?? + (record.ActorKind is null or AuditActorKind.User ? TryGetSubject(http) : null); + var ip = record.IpAddress ?? http?.Connection.RemoteIpAddress?.ToString(); + var requestUserAgent = http?.Request.Headers.UserAgent.ToString(); + var userAgent = record.UserAgent ?? + (string.IsNullOrWhiteSpace(requestUserAgent) ? null : requestUserAgent); + var actorKind = record.ActorKind + ?? (subject is not null + ? AuditActorKind.User + : record.UnknownIdentifier is not null + ? AuditActorKind.AnonymousIdentifier + : record.OAuthClientId is not null + ? AuditActorKind.OAuthClient + : AuditActorKind.System); + + return SecurityAuditEnvelope.ForRealm( + record.RealmSlug ?? TenantContext.Current, + record with + { + ActorSubjectId = subject, + IpAddress = ip, + UserAgent = userAgent, + CorrelationId = record.CorrelationId ?? CurrentCorrelationId(http), + ActorKind = actorKind, + }, + durabilityClass); + } + + private void Enqueue(SecurityAuditEnvelope envelope) + { + if (!_channel.Writer.TryWrite(envelope)) Interlocked.Increment(ref _dropped); } /// - /// Synchronously drain everything currently queued to the system DB. For - /// SHORT-LIVED process paths that never start the host (so - /// never runs) — notably the recovery CLI and - /// STARTUP_COMMAND. Without this, records those paths enqueue would be lost on - /// exit, which is exactly the high-value break-glass forensic trail we must keep. - /// Safe to call when the channel is empty (no-op). NOT used on the normal web - /// path, where the background writer owns the drain. + /// Drains the buffer for short-lived recovery-CLI processes which never start + /// the hosted writer. /// - public async Task FlushAsync(IDocumentStore store, CancellationToken ct = default) + public async Task FlushAsync( + IDocumentStore realmStore, + IGlobalStore globalStore, + CancellationToken ct = default) { - var batch = new List(); + var batch = new List(); while (_channel.Reader.TryRead(out var entry)) batch.Add(entry); - if (batch.Count == 0) - return; + if (batch.Count > 0) + { + var consolidated = SecurityAuditBatching.ConsolidateAbuse(batch); + await SecurityAuditPersistence.PersistAsync( + consolidated, + realmStore, + globalStore, + ct); + } + } - await using var session = store.LightweightSession(TenantConstants.SystemTenantId); - session.Store(batch.ToArray()); - await session.SaveChangesAsync(ct); + private static void EnsureClass( + string eventType, + AuditDurabilityClass expected) + { + var actual = AuditDurability.Classify(eventType); + if (actual != expected) + { + throw new InvalidOperationException( + $"Audit event '{eventType}' is classified as {actual}, not {expected}."); + } + } + + private static Guid? TryGetSubject(HttpContext? http) + { + var value = http?.User.FindFirst("sub")?.Value + ?? http?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(value, out var subject) ? subject : null; + } + + private static string? CurrentCorrelationId(HttpContext? http) + => Activity.Current?.TraceId.ToString() + ?? http?.TraceIdentifier; +} + +internal sealed record SecurityAuditEnvelope +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public DateTimeOffset CapturedAt { get; init; } = DateTimeOffset.UtcNow; + public required AuditDurabilityClass DurabilityClass { get; init; } + public required string? RealmSlug { get; init; } + public SecurityAuditRecord? RealmRecord { get; init; } + public PlatformAuditRecord? PlatformRecord { get; init; } + + public static SecurityAuditEnvelope ForRealm( + string realmSlug, + SecurityAuditRecord record, + AuditDurabilityClass durabilityClass) + => new() + { + RealmSlug = realmSlug, + RealmRecord = record, + DurabilityClass = durabilityClass, + }; + + public static SecurityAuditEnvelope ForPlatform( + PlatformAuditRecord record, + AuditDurabilityClass durabilityClass) + => new() + { + RealmSlug = null, + PlatformRecord = record, + DurabilityClass = durabilityClass, + }; +} + +internal static class SecurityAuditBatching +{ + public static IReadOnlyCollection ConsolidateAbuse( + IReadOnlyCollection batch) + { + var result = batch + .Where(x => x.DurabilityClass != AuditDurabilityClass.Abuse) + .ToList(); + + foreach (var group in batch + .Where(x => x.DurabilityClass == AuditDurabilityClass.Abuse) + .GroupBy(AbuseKey.From)) + { + var first = group.First(); + var record = first.RealmRecord!; + result.Add(SecurityAuditEnvelope.ForRealm( + first.RealmSlug!, + record with + { + Count = group.Sum(x => x.RealmRecord!.Count ?? 1), + FirstObservedAt = group.Min(x => x.CapturedAt), + LastObservedAt = group.Max(x => x.CapturedAt), + }, + AuditDurabilityClass.Abuse)); + } + + return result; + } + + private sealed record AbuseKey( + string RealmSlug, + string EventType, + string? ReasonCode, + string? OperationCode, + AuditActorKind? ActorKind, + Guid? ActorSubjectId, + Guid? TargetSubjectId, + string? UnknownIdentifier, + string? IpAddress, + string? OAuthClientId, + Guid? ApplicationId, + Guid? LoginProviderId, + string? AuthenticationMethod) + { + public static AbuseKey From(SecurityAuditEnvelope envelope) + { + var record = envelope.RealmRecord!; + return new( + envelope.RealmSlug!, + record.EventType, + record.ReasonCode, + record.OperationCode, + record.ActorKind, + record.ActorSubjectId, + record.TargetSubjectId, + record.UnknownIdentifier, + record.IpAddress, + record.OAuthClientId, + record.ApplicationId, + record.LoginProviderId, + record.AuthenticationMethod); + } + } +} + +internal static class SecurityAuditPersistence +{ + private static readonly ConcurrentDictionary FingerprintKeys = + new(StringComparer.OrdinalIgnoreCase); + + public static async Task PersistAsync( + IReadOnlyCollection batch, + IDocumentStore realmStore, + IGlobalStore globalStore, + CancellationToken ct) + { + foreach (var realmGroup in batch + .Where(x => x.RealmRecord is not null) + .GroupBy(x => x.RealmSlug!, StringComparer.OrdinalIgnoreCase)) + { + var key = realmGroup.Any(x => x.RealmRecord!.UnknownIdentifier is not null) + ? await GetOrCreateFingerprintKeyAsync(realmStore, realmGroup.Key, ct) + : null; + var events = realmGroup + .Select(x => ToRealmEvent(x, key)) + .ToArray(); + + await using var session = realmStore.LightweightSession(realmGroup.Key); + session.Store(events); + await session.SaveChangesAsync(ct); + } + + var platformEvents = batch + .Where(x => x.PlatformRecord is not null) + .Select(ToPlatformEvent) + .ToArray(); + + if (platformEvents.Length > 0) + { + await using var session = globalStore.LightweightSession(); + session.Store(platformEvents); + await session.SaveChangesAsync(ct); + } + } + + public static void StoreRequired( + IDocumentSession session, + SecurityAuditEnvelope envelope) + { + var record = envelope.RealmRecord + ?? throw new ArgumentException("A realm audit envelope is required.", nameof(envelope)); + if (record.UnknownIdentifier is not null) + { + throw new InvalidOperationException( + "Required events with an unknown identifier must use RecordRequiredAsync " + + "so the identifier can be fingerprinted with the realm-owned key."); + } + + if (!string.Equals(session.TenantId, envelope.RealmSlug, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Audit realm '{envelope.RealmSlug}' does not match the Marten session tenant '{session.TenantId}'."); + } + + session.Store(ToRealmEvent(envelope, key: null)); + } + + public static void StorePlatformRequired( + IDocumentSession session, + SecurityAuditEnvelope envelope) + { + if (envelope.PlatformRecord is null) + throw new ArgumentException("A platform audit envelope is required.", nameof(envelope)); + + session.Store(ToPlatformEvent(envelope)); + } + + private static RealmSecurityAuditEvent ToRealmEvent( + SecurityAuditEnvelope envelope, + byte[]? key) + { + var record = envelope.RealmRecord!; + return new() + { + Id = envelope.Id, + Timestamp = envelope.CapturedAt, + Category = AuditEvents.CategoryOf(record.EventType), + EventType = record.EventType, + Severity = record.Severity, + ActorKind = record.ActorKind ?? AuditActorKind.System, + ActorSubjectId = record.ActorSubjectId, + TargetSubjectId = record.TargetSubjectId, + UnknownIdentifierFingerprint = record.UnknownIdentifier is null + ? null + : Fingerprint( + record.UnknownIdentifier, + key ?? throw new InvalidOperationException( + "An audit fingerprint key is required for an unknown identifier.")), + IpAddress = record.IpAddress, + UserAgent = record.UserAgent, + OAuthClientId = record.OAuthClientId, + AuthorizationId = record.AuthorizationId, + ApplicationId = record.ApplicationId, + SessionId = record.SessionId, + LoginProviderId = record.LoginProviderId, + AuthenticationMethod = record.AuthenticationMethod, + CorrelationId = record.CorrelationId, + OutcomeCode = record.OutcomeCode, + ReasonCode = record.ReasonCode, + OperationCode = record.OperationCode, + TargetRealmSlug = record.TargetRealmSlug, + KeyId = record.KeyId, + Count = record.Count, + RelatedCount = record.RelatedCount, + RemindedCount = record.RemindedCount, + SelfErasedCount = record.SelfErasedCount, + AutoPurgedCount = record.AutoPurgedCount, + InviteCodesPrunedCount = record.InviteCodesPrunedCount, + ReusedCount = record.ReusedCount, + RetentionDays = record.RetentionDays, + EffectiveAt = record.EffectiveAt, + FirstObservedAt = record.FirstObservedAt, + LastObservedAt = record.LastObservedAt, + }; + } + + private static PlatformAuditEvent ToPlatformEvent(SecurityAuditEnvelope envelope) + { + var record = envelope.PlatformRecord!; + return new() + { + Id = envelope.Id, + Timestamp = envelope.CapturedAt, + Category = AuditEvents.CategoryOf(record.EventType), + EventType = record.EventType, + Severity = record.Severity, + OutcomeCode = record.OutcomeCode, + ReasonCode = record.ReasonCode, + OperationCode = record.OperationCode, + TargetRealmSlug = record.TargetRealmSlug, + Domain = record.Domain, + PreviousDomain = record.PreviousDomain, + CorrelationId = record.CorrelationId, + Count = record.Count, + RelatedCount = record.RelatedCount, + RetentionDays = record.RetentionDays, + EffectiveAt = record.EffectiveAt, + }; + } + + private static string Fingerprint(string identifier, byte[] key) + { + var normalized = identifier.Trim().Normalize(NormalizationForm.FormKC) + .ToLower(CultureInfo.InvariantCulture); + var digest = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexString(digest).ToLowerInvariant(); + } + + private static async Task GetOrCreateFingerprintKeyAsync( + IDocumentStore store, + string realmSlug, + CancellationToken ct) + { + if (FingerprintKeys.TryGetValue(realmSlug, out var cached)) + return cached; + + await using (var read = store.QuerySession(realmSlug)) + { + var existing = await read.LoadAsync( + RealmAuditFingerprintKey.SingletonId, ct); + if (existing is not null) + return FingerprintKeys.GetOrAdd(realmSlug, existing.Key); + } + + var candidate = new RealmAuditFingerprintKey + { + Key = RandomNumberGenerator.GetBytes(32), + }; + + try + { + await using var write = store.LightweightSession(realmSlug); + write.Insert(candidate); + await write.SaveChangesAsync(ct); + return FingerprintKeys.GetOrAdd(realmSlug, candidate.Key); + } + catch (Exception createError) when (createError is not OperationCanceledException) + { + // Another node may have created the singleton between our read and + // insert. Use the winning key; if no row exists, preserve the real + // storage failure instead of silently changing fingerprints. + await using var retry = store.QuerySession(realmSlug); + var winner = await retry.LoadAsync( + RealmAuditFingerprintKey.SingletonId, ct); + if (winner is not null) + return FingerprintKeys.GetOrAdd(realmSlug, winner.Key); + + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(createError).Throw(); + throw; + } } } -/// -/// Background service that drains into the system DB -/// in batches. Replaces the legacy AuthLogPersistenceService drain loop; the -/// retention prune that lived there is now a separate Quartz job over this store. -/// public sealed class SecurityAuditWriter( IServiceProvider services, SecurityAuditLog log, ILogger logger) : BackgroundService { - private const int MaxBatch = 256; + private const int MaxBatch = 4_096; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var reader = log.Reader; while (await reader.WaitToReadAsync(stoppingToken)) { - var batch = new List(MaxBatch); + var batch = new List(MaxBatch); while (batch.Count < MaxBatch && reader.TryRead(out var entry)) batch.Add(entry); if (batch.Count == 0) continue; + // Give attacker-amplified signals a short coalescing window. This + // turns a credential-stuffing burst into a handful of count rows + // instead of one database write per request. + await Task.Delay(TimeSpan.FromMilliseconds(250), stoppingToken); + while (batch.Count < MaxBatch && reader.TryRead(out var entry)) + batch.Add(entry); + + var consolidated = SecurityAuditBatching.ConsolidateAbuse(batch); + var abuse = consolidated + .Where(x => x.DurabilityClass == AuditDurabilityClass.Abuse) + .ToArray(); + var telemetry = consolidated + .Where(x => x.DurabilityClass == AuditDurabilityClass.Telemetry) + .ToArray(); + + if (abuse.Length > 0) + await PersistAbuseWithRetryAsync(abuse, stoppingToken); + + if (telemetry.Length > 0) + { + try + { + using var scope = services.CreateScope(); + await SecurityAuditPersistence.PersistAsync( + telemetry, + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError( + ex, + "Failed to persist {Count} best-effort audit telemetry record(s)", + telemetry.Length); + } + } + + var dropped = log.ReadAndResetDropped(); + if (dropped > 0) + { + logger.LogWarning( + "Security audit buffer shed {Dropped} abuse/telemetry occurrence(s) — channel full", + dropped); + } + } + } + + private async Task PersistAbuseWithRetryAsync( + IReadOnlyCollection batch, + CancellationToken ct) + { + var delay = TimeSpan.FromMilliseconds(250); + while (!ct.IsCancellationRequested) + { try { using var scope = services.CreateScope(); - // Runs out-of-band in a HostedService — no HttpContext to drive - // tenant resolution, so target the system tenant explicitly. The - // streamless store lives cross-realm in the system DB by design; - // each row already carries its own Realm captured at emit time. - await using var session = scope.ServiceProvider - .GetRequiredService() - .LightweightSession(TenantConstants.SystemTenantId); - - session.Store(batch.ToArray()); - await session.SaveChangesAsync(stoppingToken); + await SecurityAuditPersistence.PersistAsync( + batch, + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + ct); + return; } catch (Exception ex) when (ex is not OperationCanceledException) { - logger.LogError(ex, "Failed to persist {Count} security audit entries", batch.Count); + logger.LogError( + ex, + "Failed to persist {Count} aggregated abuse signal(s); retrying in {Delay}", + batch.Count, + delay); + await Task.Delay(delay, ct); + delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); } - - var dropped = log.ReadAndResetDropped(); - if (dropped > 0) - logger.LogWarning("Security audit store shed {Dropped} record(s) — channel full", dropped); } } } diff --git a/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs b/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs index 21cdd326..682c1fdd 100644 --- a/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs +++ b/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs @@ -41,7 +41,7 @@ private static readonly (string Resource, string[] Actions)[] ModgudCatalog = ("authorization-group", ["read", "write"]), ("permission-role", ["read", "write"]), - // Sessions + audit. auth-log:read = the streamless security/ops store; + // Sessions + audit. auth-log:read = this realm's security/ops store; // audit-log:read = the per-realm GDPR-audit (event-sourced) — two surfaces. ("session", ["read", "write"]), ("auth-log", ["read"]), @@ -89,6 +89,7 @@ private static readonly (string Resource, string[] Actions)[] ModgudCatalog = private static readonly (string Resource, string[] Actions)[] ControlPlaneCatalog = [ ("realm", ["read", "write"]), + ("platform-audit", ["read"]), ]; public static async Task SeedAsync( diff --git a/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs b/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs index 1e46aef4..44f1e16d 100644 --- a/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs +++ b/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs @@ -12,6 +12,7 @@ using Cocoar.JsEval.TypeScript; using JasperFx; using JasperFx.Events.Daemon; +using JasperFx.Resources; using Marten; using Marten.Events.Daemon; using Microsoft.Extensions.DependencyInjection; @@ -19,6 +20,7 @@ using Modgud.Application.Contracts; using Modgud.Infrastructure.Events; using Modgud.Infrastructure.Persistence.Marten.Configuration; +using Modgud.Infrastructure.Installation; using Wolverine.Marten; namespace Modgud.Infrastructure; @@ -58,8 +60,9 @@ public static IServiceCollection AddInfrastructure( }) // BuildSessionsWith installs our TenantedSessionFactory as the singleton // ISessionFactory. Every IDocumentSession / IQuerySession injection now - // resolves the tenant from HttpContext.Items["TenantId"] (set by RealmMiddleware), - // falling back to the "system" tenant when no HttpContext is available. + // resolves the tenant from HttpContext.Items["TenantId"] (set by + // RealmMiddleware) or an explicit TenantContext. Missing realm context + // fails closed; deployment-wide state belongs in IGlobalStore. // NOTE: this replaces the previous .UseLightweightSessions() call — our factory // also returns LightweightSession()-backed sessions. // Singleton lifetime: the factory is stateless — IHttpContextAccessor (Singleton) @@ -87,6 +90,29 @@ public static IServiceCollection AddInfrastructure( .Identity(x => x.Id) .Index(x => x.Slug, x => { x.IsUnique = true; x.Predicate = "((data ->> 'IsActive')::boolean = true)"; }); + opts.Schema.For().Identity(x => x.Id); + opts.Schema.For() + .Identity(x => x.Id) + .Index(x => x.TokenHash, x => x.IsUnique = true); + + // Deployment-wide scheduled jobs are controlled from whichever + // realm currently holds the Control-Plane role, but their config + // and history are platform data — never tenant/realm data. + opts.Schema.For() + .Identity(x => x.Key); + opts.Schema.For() + .Identity(x => x.Id) + .Index(x => new { x.JobKey, x.StartedAt }); + + // Deployment-wide operations are intentionally PII-free and live + // only in the non-tenanted Global Store. Realm security events are + // configured in each tenant store below. + opts.Schema.For() + .Identity(x => x.Id) + .Index(x => x.Timestamp) + .Index(x => x.EventType) + .Index(x => x.TargetRealmSlug); + // RealmSigningKey lives in the per-tenant store (configured below), // not here. Defense-in-depth: a master-DB compromise must NOT leak // every realm's private signing key — the key for realm A only sits @@ -123,15 +149,29 @@ public static IServiceCollection AddInfrastructure( // active credentials run on every token issuance. services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); + services.AddScoped(); // Required for Marten projection side effects to publish messages via Wolverine // EventForwardingToWolverine: forwards domain events as Wolverine messages on commit martenBuilder.IntegrateWithWolverine(options => { + // Database-per-realm tenancy still needs a tenant-neutral master + // database for Wolverine's node coordination. Supplying it also + // enables Wolverine's multi-tenanted message-store source. Realm + // databases registered after startup are initialized explicitly by + // IRealmMessageStorageProvisioner. + options.MainDatabaseConnectionString = connectionString; options.UseFastEventForwarding = true; }); + // Apply Wolverine/Marten resources for the master database and every + // tenant known at startup. Dynamic realms are handled during realm + // provisioning rather than relying on the first message to discover + // missing storage. + services.AddResourceSetupOnStartup(); + martenBuilder.AddAsyncDaemon(DaemonMode.Solo); // Register Event Dispatcher @@ -173,10 +213,8 @@ public static IServiceCollection AddInfrastructure( opt.RegisterResource(app, "authorization-group", "read", "write"); opt.RegisterResource(app, "permission-role", "read", "write"); - // Sessions + audit. Two distinct read surfaces (logging/audit redesign): - // auth-log:read — the streamless security/ops store (failed logins on - // unknown actors, probes, rate-limits, operational - // actions). Cross-realm in the system DB. + // Sessions + audit. Two distinct realm-owned read surfaces: + // auth-log:read — structured security events in this realm DB. // audit-log:read — the per-realm GDPR-audit (event-sourced account / // login history projected from the user streams). opt.RegisterResource(app, "session", "read", "write"); @@ -219,6 +257,7 @@ public static IServiceCollection AddInfrastructure( // into their tenant DB (see AppRealmSeeder). const string controlPlaneApp = AppSlugs.ControlPlane; opt.RegisterResource(controlPlaneApp, "realm", "read", "write"); + opt.RegisterResource(controlPlaneApp, "platform-audit", "read"); }); // OAuth admin slice services — both consume the tenant-scoped IDocumentSession diff --git a/src/dotnet/Modgud.Infrastructure/Email/EmailTemplateStore.cs b/src/dotnet/Modgud.Infrastructure/Email/EmailTemplateStore.cs index c13bce90..c82e7728 100644 --- a/src/dotnet/Modgud.Infrastructure/Email/EmailTemplateStore.cs +++ b/src/dotnet/Modgud.Infrastructure/Email/EmailTemplateStore.cs @@ -177,7 +177,7 @@ Anfrage prüfen E-Mail{{Email}}

- Dieser Link ist {{ExpirationDays}} Tage gültig und kann nur einmal verwendet werden. + Dieser Link ist {{ExpirationHours}} Stunden gültig und kann nur einmal verwendet werden.

Falls du diesen Realm nicht beantragt hast, ignoriere diese E-Mail. Solange der Link nicht eingelöst wird, bleibt der Realm leer. diff --git a/src/dotnet/Modgud.Infrastructure/Installation/InstallationChallengeService.cs b/src/dotnet/Modgud.Infrastructure/Installation/InstallationChallengeService.cs new file mode 100644 index 00000000..4471d178 --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Installation/InstallationChallengeService.cs @@ -0,0 +1,186 @@ +using System.Security.Cryptography; +using System.Text; +using ErrorOr; +using Marten; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; + +namespace Modgud.Infrastructure.Installation; + +public interface IInstallationChallengeService +{ + Task GetStatusAsync(CancellationToken ct = default); + + Task> IssueAsync( + string baseUrl, + TimeSpan lifetime, + CancellationToken ct = default); + + Task> ValidateAsync( + string plaintextToken, + CancellationToken ct = default); + + Task> CompleteAsync( + string plaintextToken, + string realmSlug, + CancellationToken ct = default); +} + +public sealed class InstallationChallengeService( + IGlobalStore globalStore, + TimeProvider clock, + ISecurityAuditLog securityAudit) : IInstallationChallengeService +{ + public async Task GetStatusAsync(CancellationToken ct = default) + { + await using var session = globalStore.QuerySession(); + var state = await session.LoadAsync(InstallationState.SingletonId, ct); + var realms = await session.Query().Where(r => r.IsActive).ToListAsync(ct); + + // Existing deployments predate InstallationState. Any active realm is + // therefore authoritative evidence that installation already happened. + var firstRealm = realms.OrderBy(r => r.CreatedAt).FirstOrDefault(); + return new InstallationStatus( + state?.IsCompleted == true || firstRealm is not null, + firstRealm is not null, + state?.RealmSlug ?? firstRealm?.Slug, + state?.CompletedAt); + } + + public async Task> IssueAsync( + string baseUrl, + TimeSpan lifetime, + CancellationToken ct = default) + { + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment)) + { + return Error.Validation( + "Installation.InvalidBaseUrl", + "Base URL must be an absolute HTTP(S) URL without query or fragment."); + } + + if (lifetime <= TimeSpan.Zero || lifetime > TimeSpan.FromHours(24)) + { + return Error.Validation( + "Installation.InvalidLifetime", + "Challenge lifetime must be greater than zero and at most 24 hours."); + } + + await using var session = globalStore.LightweightSession(); + if (await session.Query().AnyAsync(ct)) + { + return Error.Conflict( + "Installation.AlreadyInitialized", + "At least one realm already exists; first installation is no longer available."); + } + + var state = await session.LoadAsync(InstallationState.SingletonId, ct); + if (state?.IsCompleted == true) + { + return Error.Conflict( + "Installation.AlreadyInitialized", + "The deployment has already been initialized."); + } + + var now = clock.GetUtcNow(); + var openChallenges = await session.Query() + .Where(c => c.UsedAt == null && c.RevokedAt == null) + .ToListAsync(ct); + foreach (var open in openChallenges) + { + open.RevokedAt = now; + session.Store(open); + } + + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + var challenge = new InstallationChallenge + { + Id = Guid.NewGuid(), + TokenHash = Hash(token), + BaseUrl = baseUrl.TrimEnd('/'), + CreatedAt = now, + ExpiresAt = now.Add(lifetime), + }; + session.Store(challenge); + securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.InstallationChallengeIssued, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "issue-install-link", + Domain = uri.Host, + EffectiveAt = challenge.ExpiresAt, + }); + await session.SaveChangesAsync(ct); + + return new IssuedInstallationChallenge( + challenge.Id, + token, + $"{challenge.BaseUrl}/install?token={Uri.EscapeDataString(token)}", + challenge.ExpiresAt); + } + + public async Task> ValidateAsync( + string plaintextToken, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(plaintextToken)) + return Error.Validation("Installation.TokenRequired", "Installation token is required."); + + await using var session = globalStore.QuerySession(); + var challenge = await session.Query() + .FirstOrDefaultAsync(c => c.TokenHash == Hash(plaintextToken), ct); + + if (challenge is null) + return Error.Validation("Installation.TokenInvalid", "Installation token is invalid."); + if (challenge.UsedAt is not null) + return Error.Validation("Installation.TokenUsed", "Installation token has already been used."); + if (challenge.RevokedAt is not null) + return Error.Validation("Installation.TokenRevoked", "Installation token has been revoked."); + if (clock.GetUtcNow() >= challenge.ExpiresAt) + return Error.Validation("Installation.TokenExpired", "Installation token has expired."); + + return challenge; + } + + public async Task> CompleteAsync( + string plaintextToken, + string realmSlug, + CancellationToken ct = default) + { + var tokenHash = Hash(plaintextToken); + await using var session = globalStore.LightweightSession(); + var challenge = await session.Query() + .FirstOrDefaultAsync(c => c.TokenHash == tokenHash, ct); + var now = clock.GetUtcNow(); + + if (challenge is null || !challenge.IsUsable(now)) + return Error.Validation("Installation.TokenInvalid", "Installation token is invalid or no longer usable."); + + challenge.UsedAt = now; + session.Store(challenge); + session.Store(new InstallationState + { + IsCompleted = true, + RealmSlug = realmSlug, + CompletedAt = now, + UpdatedAt = now, + }); + securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.InstallationCompleted, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "complete-installation", + TargetRealmSlug = realmSlug, + }); + await session.SaveChangesAsync(ct); + return Result.Success; + } + + private static string Hash(string token) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(token))); +} diff --git a/src/dotnet/Modgud.Infrastructure/Installation/InstallationState.cs b/src/dotnet/Modgud.Infrastructure/Installation/InstallationState.cs new file mode 100644 index 00000000..fb8724c1 --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Installation/InstallationState.cs @@ -0,0 +1,51 @@ +using Marten.Schema; + +namespace Modgud.Infrastructure.Installation; + +///

+/// Deployment-wide installation marker. It lives in the Global Store because +/// no realm exists while the first installation challenge is issued. +/// +[DocumentAlias("installation_state")] +public sealed class InstallationState +{ + public const string SingletonId = "installation"; + + public string Id { get; init; } = SingletonId; + public bool IsCompleted { get; set; } + public string? RealmSlug { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} + +/// +/// Short-lived, one-shot operator authorization for the first installation. +/// Only the SHA-256 hash is persisted; the plaintext token exists solely in +/// CLI output and the URL handed to the browser or CI. +/// +[DocumentAlias("installation_challenge")] +public sealed class InstallationChallenge +{ + public Guid Id { get; init; } + public string TokenHash { get; init; } = ""; + public string BaseUrl { get; init; } = ""; + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public DateTimeOffset? UsedAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public bool IsUsable(DateTimeOffset now) => + UsedAt is null && RevokedAt is null && now < ExpiresAt; +} + +public sealed record InstallationStatus( + bool IsInitialized, + bool HasRealms, + string? RealmSlug, + DateTimeOffset? CompletedAt); + +public sealed record IssuedInstallationChallenge( + Guid Id, + string PlaintextToken, + string InstallUrl, + DateTimeOffset ExpiresAt); diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs index 7a10a0f5..497fd421 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs @@ -78,14 +78,16 @@ public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext c if (isFirstUse) { - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.RecordTelemetry(new SecurityAuditRecord { EventType = AuditEvents.DcrClientFirstUsed, - Level = "Info", - Actor = clientId, - Status = "first_used", - Reason = $"registeredAt {registeredAt ?? "(unknown)"}", - Message = $"DCR client {clientId} used for the first time", + ActorKind = AuditActorKind.OAuthClient, + OAuthClientId = clientId, + OutcomeCode = AuditOutcomes.Observed, + OperationCode = "first-use", + EffectiveAt = DateTimeOffset.TryParse(registeredAt, out var registeredAtValue) + ? registeredAtValue + : null, }); } } diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/DpopProofValidator.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/DpopProofValidator.cs index 4fcc2965..5d750b58 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/DpopProofValidator.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/DpopProofValidator.cs @@ -22,7 +22,7 @@ namespace Modgud.Infrastructure.OpenIddict.Dpop; /// jti replay detection is left to the caller (it needs a per-realm store /// and a TTL policy that live outside this crypto core). Keeping it side-effect /// free is what lets the identical file be duplicated into the dependency-light -/// Modgud.Client.AspNetCore NuGet for the resource-server side. +/// Modgud.AspNetCore.ResourceServer NuGet for the resource-server side. /// /// /// diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/JwkThumbprint.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/JwkThumbprint.cs index 201eeaea..db30b63c 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/JwkThumbprint.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/JwkThumbprint.cs @@ -30,7 +30,7 @@ namespace Modgud.Infrastructure.OpenIddict.Dpop; /// /// Kept dependency-free (BCL only: + /// ) so the exact same file can be -/// duplicated verbatim into the dependency-light Modgud.Client.AspNetCore +/// duplicated verbatim into the dependency-light Modgud.AspNetCore.ResourceServer /// NuGet for resource-server-side validation. Any change here MUST be mirrored /// there — see the "keep in sync" note on the client copy. /// diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/IOAuthGrantRevoker.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/IOAuthGrantRevoker.cs index 371e66ab..343fc08f 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/IOAuthGrantRevoker.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/IOAuthGrantRevoker.cs @@ -41,4 +41,12 @@ public interface IOAuthGrantRevoker /// must invalidate exactly that client's outstanding M2M tokens — narrower than /// a by-subject revoke, which would also kill the SA's other credentials.
Task RevokeTokensByApplicationIdAsync(string applicationId, CancellationToken ct = default); + + /// Revoke the token family attached to one authorization/client + /// session without affecting another device using the same OAuth client. + Task RevokeTokensByAuthorizationIdAsync(string authorizationId, CancellationToken ct = default); + + /// Revoke one authorization used as the server-side root of a + /// native client/device session. + Task RevokeAuthorizationByIdAsync(string authorizationId, CancellationToken ct = default); } diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/IRefreshTokenReuseObserver.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/IRefreshTokenReuseObserver.cs new file mode 100644 index 00000000..636fc6a0 --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/IRefreshTokenReuseObserver.cs @@ -0,0 +1,14 @@ +namespace Modgud.Infrastructure.OpenIddict; + +/// +/// Observes OpenIddict's confirmed refresh-token reuse signal before the +/// stock handler revokes the associated token family. +/// +public interface IRefreshTokenReuseObserver +{ + Task OnReuseDetectedAsync( + string? subject, + string? clientId, + string? authorizationId, + CancellationToken ct); +} diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/OpenIddictGrantRevoker.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/OpenIddictGrantRevoker.cs index dc9fab50..3e780204 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/OpenIddictGrantRevoker.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/OpenIddictGrantRevoker.cs @@ -52,4 +52,24 @@ public async Task RevokeTokensByApplicationIdAsync(string applicationId, Ca } return revoked; } + + public async Task RevokeTokensByAuthorizationIdAsync(string authorizationId, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(authorizationId)) return 0; + + var revoked = 0; + await foreach (var token in tokenManager.FindByAuthorizationIdAsync(authorizationId, ct)) + { + if (await tokenManager.TryRevokeAsync(token, ct)) + revoked++; + } + return revoked; + } + + public async Task RevokeAuthorizationByIdAsync(string authorizationId, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(authorizationId)) return false; + var authorization = await authorizationManager.FindByIdAsync(authorizationId, ct); + return authorization is not null && await authorizationManager.TryRevokeAsync(authorization, ct); + } } diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs index 0e7adb61..7aeb774b 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs @@ -57,17 +57,20 @@ public sealed class RefreshTokenReuseAuditHandler private readonly IOpenIddictTokenManager _tokenManager; private readonly IOpenIddictApplicationManager _applicationManager; private readonly ISecurityAuditLog _securityAudit; + private readonly IEnumerable _observers; private readonly ILogger _logger; public RefreshTokenReuseAuditHandler( IOpenIddictTokenManager tokenManager, IOpenIddictApplicationManager applicationManager, ISecurityAuditLog securityAudit, + IEnumerable observers, ILogger logger) { _tokenManager = tokenManager; _applicationManager = applicationManager; _securityAudit = securityAudit; + _observers = observers; _logger = logger; } @@ -123,14 +126,37 @@ public async ValueTask HandleAsync(ValidateTokenContext context) "Refresh token reuse detected for user {UserId}, client {ClientId}, authorization {AuthorizationId} — {FamilySize} token(s) about to be revoked", subject, clientId, authorizationId, familySize); - _securityAudit.Record(new SecurityAuditRecord + await _securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RefreshTokenReuseDetected, - Level = "Warning", - Actor = subject, - Status = "revoked", - Reason = $"clientId={clientId ?? "(unknown)"} authorizationId={authorizationId ?? "(unknown)"} revokedTokens={familySize}", - Message = $"Refresh token reuse detected for client '{clientId ?? "(unknown)"}' — revoking {familySize} token(s) and the parent authorization", - }); + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.User, + ActorSubjectId = Guid.TryParse(subject, out var subjectId) ? subjectId : null, + OAuthClientId = clientId, + AuthorizationId = authorizationId, + OutcomeCode = AuditOutcomes.Blocked, + OperationCode = "revoke-token-family", + Count = familySize, + }, context.CancellationToken); + + // Keep higher-level session models in sync with OpenIddict's imminent + // token-family teardown. Observer failures must never interrupt the + // stock security response that runs immediately after this handler. + foreach (var observer in _observers) + { + try + { + await observer.OnReuseDetectedAsync( + subject, clientId, authorizationId, context.CancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "refresh-token reuse observer {ObserverType} failed for authorization {AuthorizationId}", + observer.GetType().Name, + authorizationId); + } + } } } diff --git a/src/dotnet/Modgud.Infrastructure/Persistence/DataProtection/DataProtectionMartenExtensions.cs b/src/dotnet/Modgud.Infrastructure/Persistence/DataProtection/DataProtectionMartenExtensions.cs index 4009a46c..05cf8940 100644 --- a/src/dotnet/Modgud.Infrastructure/Persistence/DataProtection/DataProtectionMartenExtensions.cs +++ b/src/dotnet/Modgud.Infrastructure/Persistence/DataProtection/DataProtectionMartenExtensions.cs @@ -40,38 +40,41 @@ public static IServiceCollection AddTenantedDataProtection( { var store = sp.GetRequiredService(); var loggerFactory = sp.GetRequiredService(); + var httpContextAccessor = sp.GetRequiredService(); - return new TenantedDataProtectionProvider(tenantId => - { - // One mini-DI per tenant, lifetime bound to the outer - // singleton. Bounded by realm count so memory isn't a - // concern; first request per tenant is slightly slower - // (one-time provider build). - var inner = new ServiceCollection(); - inner.AddSingleton(loggerFactory); - inner.AddLogging(); - var dpBuilder = inner - .AddDataProtection() - // Defense-in-depth: even if storage isolation were - // bypassed accidentally, ApplicationName-prefixed - // payloads from one tenant wouldn't decrypt with - // another tenant's keys. - .SetApplicationName($"Modgud-{tenantId}"); + return new TenantedDataProtectionProvider( + tenantId => + { + // One mini-DI per tenant, lifetime bound to the outer + // singleton. Bounded by realm count so memory isn't a + // concern; first request per tenant is slightly slower + // (one-time provider build). + var inner = new ServiceCollection(); + inner.AddSingleton(loggerFactory); + inner.AddLogging(); + var dpBuilder = inner + .AddDataProtection() + // Defense-in-depth: even if storage isolation were + // bypassed accidentally, ApplicationName-prefixed + // payloads from one tenant wouldn't decrypt with + // another tenant's keys. + .SetApplicationName($"Modgud-{tenantId}"); - // Audit M7: encrypt the key ring at rest when an operator cert - // is configured. Mixing is safe — pre-existing unencrypted keys - // stay readable; only new keys are wrapped. - if (protectionCertificate is not null) - dpBuilder.ProtectKeysWithCertificate(protectionCertificate); + // Audit M7: encrypt the key ring at rest when an operator cert + // is configured. Mixing is safe — pre-existing unencrypted keys + // stay readable; only new keys are wrapped. + if (protectionCertificate is not null) + dpBuilder.ProtectKeysWithCertificate(protectionCertificate); - inner.Configure(opts => - { - opts.XmlRepository = new MartenXmlRepository(store, tenantId); - }); + inner.Configure(opts => + { + opts.XmlRepository = new MartenXmlRepository(store, tenantId); + }); - return inner.BuildServiceProvider() - .GetRequiredService(); - }); + return inner.BuildServiceProvider() + .GetRequiredService(); + }, + httpContextAccessor); }); services.AddSingleton(sp => diff --git a/src/dotnet/Modgud.Infrastructure/Persistence/DataProtection/TenantedDataProtectionProvider.cs b/src/dotnet/Modgud.Infrastructure/Persistence/DataProtection/TenantedDataProtectionProvider.cs index 8423de07..9798f472 100644 --- a/src/dotnet/Modgud.Infrastructure/Persistence/DataProtection/TenantedDataProtectionProvider.cs +++ b/src/dotnet/Modgud.Infrastructure/Persistence/DataProtection/TenantedDataProtectionProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Modgud.Infrastructure.Persistence.Tenancy; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.DataProtection; namespace Modgud.Infrastructure.Persistence.DataProtection; @@ -25,10 +26,14 @@ public sealed class TenantedDataProtectionProvider : IDataProtectionProvider { private readonly ConcurrentDictionary _byTenant = new(); private readonly Func _factory; + private readonly IHttpContextAccessor _httpContextAccessor; - public TenantedDataProtectionProvider(Func factory) + public TenantedDataProtectionProvider( + Func factory, + IHttpContextAccessor httpContextAccessor) { _factory = factory; + _httpContextAccessor = httpContextAccessor; } public IDataProtector CreateProtector(string purpose) @@ -39,6 +44,25 @@ public IDataProtector CreateProtector(string purpose) internal IDataProtectionProvider GetTenantProvider(string tenantId) => _byTenant.GetOrAdd(tenantId, _factory); + + internal string ResolveTenantId() + { + var ambient = TenantContext.CurrentOrNull; + if (!string.IsNullOrWhiteSpace(ambient)) + return ambient; + + // Response OnStarting callbacks can run after RealmMiddleware's + // AsyncLocal scope has unwound (notably under TestServer). The resolved + // realm remains pinned to HttpContext.Items for the request lifetime. + var requestTenant = _httpContextAccessor.HttpContext? + .Items[TenantConstants.HttpContextTenantIdKey] as string; + if (!string.IsNullOrWhiteSpace(requestTenant)) + return requestTenant; + + throw new InvalidOperationException( + "No realm context is active for DataProtection. Realm-independent " + + "paths must not create tenant cookies or session state."); + } } /// @@ -74,7 +98,7 @@ public IDataProtector CreateProtector(string purpose) private IDataProtector Resolve() { - var tenant = TenantContext.Current; + var tenant = _root.ResolveTenantId(); var provider = _root.GetTenantProvider(tenant); IDataProtector protector = provider.CreateProtector(_purposes[0]); for (var i = 1; i < _purposes.Length; i++) diff --git a/src/dotnet/Modgud.Infrastructure/Persistence/Tenancy/TenantContext.cs b/src/dotnet/Modgud.Infrastructure/Persistence/Tenancy/TenantContext.cs index 44ad402f..c6a56388 100644 --- a/src/dotnet/Modgud.Infrastructure/Persistence/Tenancy/TenantContext.cs +++ b/src/dotnet/Modgud.Infrastructure/Persistence/Tenancy/TenantContext.cs @@ -19,9 +19,9 @@ namespace Modgud.Infrastructure.Persistence.Tenancy; /// /// /// -/// Reading with no active scope returns -/// — the same fallback policy -/// TenantedSessionFactory already uses for HttpContext-less paths. +/// Reading with no active scope fails closed. Deployment- +/// wide code must use ; realm code must enter its +/// realm explicitly. /// /// public static class TenantContext @@ -29,9 +29,12 @@ public static class TenantContext private static readonly AsyncLocal _current = new(); /// - /// Currently active tenant slug, or "system" if no scope has set one. + /// Currently active tenant slug. Throws when no realm context exists. /// - public static string Current => _current.Value ?? TenantConstants.SystemTenantId; + public static string Current => _current.Value + ?? throw new InvalidOperationException( + "No realm context is active. Use IGlobalStore for deployment-wide data " + + "or enter the intended realm explicitly with TenantContext.Enter(...)."); /// /// Raw value — when no scope has set a tenant. diff --git a/src/dotnet/Modgud.Infrastructure/Persistence/Tenancy/TenantedSessionFactory.cs b/src/dotnet/Modgud.Infrastructure/Persistence/Tenancy/TenantedSessionFactory.cs index 59f09629..68facfea 100644 --- a/src/dotnet/Modgud.Infrastructure/Persistence/Tenancy/TenantedSessionFactory.cs +++ b/src/dotnet/Modgud.Infrastructure/Persistence/Tenancy/TenantedSessionFactory.cs @@ -10,12 +10,9 @@ namespace Modgud.Infrastructure.Persistence.Tenancy; /// opens a tenant-scoped session against that tenant's database. /// /// -/// When no is available (background services, hosted -/// services, tests without a request scope), it falls back to the -/// tenant, which is registered -/// against its own {master}_system database (the master DB itself is -/// pure control-plane infrastructure and holds no tenant content). So -/// single-tenant boots and infrastructure jobs work out of the box. +/// When no realm context is available it fails closed. Deployment-wide work +/// belongs in ; background realm work must enter the +/// intended realm explicitly. /// /// /// @@ -65,46 +62,16 @@ private string ResolveTenantId(bool forWrite) if (explicitTenant is not null) return explicitTenant; - // No tenant resolved. Two very different situations land here: - // - // • No HttpContext at all → a genuine background / hosted-service / - // Wolverine-handler / CLI / test path. The system fallback is - // load-bearing there (single-tenant boots and infra jobs depend on - // it) and stays SILENT — this is by design. - // - // • HttpContext present but no tenant → an in-flight HTTP request - // reached a tenant-scoped session without a resolved realm. This is - // the silent-fallback CLASS OF BUG ("I created it, got no error, and - // it isn't where I expected"): RealmMiddleware resolves (or 404s) - // every routed request, so this can only be a realm-agnostic - // skip-path (/health, /openapi, …) — a path that must NEVER perform - // a tenant-scoped write. Refuse writes loudly; warn on reads. + // No tenant resolved. Never guess a realm: doing so would make a + // Control-Plane transfer change where unrelated background data lands. var http = _httpContextAccessor.HttpContext; - if (http is not null) - { - if (forWrite) - { - _logger.LogError( - "Refusing a tenant-scoped WRITE during HTTP request to {Path}: no realm was resolved " - + "(neither TenantContext nor HttpContext.Items[\"{Key}\"] carried a tenant). Falling back " - + "to the '{System}' tenant here would silently write to the wrong database. If this is a " - + "legitimate cross-tenant or background path, enter the tenant explicitly with " - + "TenantContext.Enter(...).", - http.Request.Path, TenantConstants.HttpContextTenantIdKey, TenantConstants.SystemTenantId); - - throw new InvalidOperationException( - $"No realm/tenant resolved for the current HTTP request ({http.Request.Path}); refusing to " - + $"open a tenant-scoped write session that would silently fall back to the " - + $"'{TenantConstants.SystemTenantId}' tenant. Enter the intended tenant explicitly with " - + "TenantContext.Enter(...) if this is a deliberate cross-tenant write."); - } - - _logger.LogWarning( - "Tenant-scoped READ during HTTP request to {Path} with no resolved realm — falling back to the " - + "'{System}' tenant. Expected on realm-agnostic infra paths; unexpected on a routed endpoint.", - http.Request.Path, TenantConstants.SystemTenantId); - } - - return TenantConstants.SystemTenantId; + var location = http is null ? "outside an HTTP request" : $"during HTTP request to {http.Request.Path}"; + _logger.LogError( + "Refusing tenant-scoped {Access} {Location}: no realm was resolved. " + + "Use IGlobalStore for deployment-wide state or TenantContext.Enter(...) for realm state.", + forWrite ? "WRITE" : "READ", location); + throw new InvalidOperationException( + $"No realm/tenant resolved {location}; refusing to open a tenant-scoped " + + $"{(forWrite ? "write" : "read")} session."); } } diff --git a/src/dotnet/Modgud.Infrastructure/Realms/IRealmMessageStorageProvisioner.cs b/src/dotnet/Modgud.Infrastructure/Realms/IRealmMessageStorageProvisioner.cs new file mode 100644 index 00000000..5e5e5399 --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Realms/IRealmMessageStorageProvisioner.cs @@ -0,0 +1,36 @@ +using Wolverine.Persistence.Durability; +using Wolverine.Runtime; + +namespace Modgud.Infrastructure.Realms; + +/// +/// Provisions Wolverine's transactional inbox/outbox storage for a realm +/// database that was registered with Marten after the application started. +/// +public interface IRealmMessageStorageProvisioner +{ + Task EnsureProvisionedAsync(string realmSlug); +} + +internal sealed class RealmMessageStorageProvisioner : IRealmMessageStorageProvisioner +{ + private readonly IWolverineRuntime _runtime; + + public RealmMessageStorageProvisioner(IWolverineRuntime runtime) + { + _runtime = runtime; + } + + public async Task EnsureProvisionedAsync(string realmSlug) + { + if (_runtime.Stores.Main is not MultiTenantedMessageStore messageStore) + { + throw new InvalidOperationException( + "Wolverine's main message store is not configured for database-per-realm tenancy."); + } + + // GetDatabaseAsync discovers a Marten tenant registered at runtime and, + // when Wolverine auto-create is enabled, migrates its inbox/outbox tables. + await messageStore.GetDatabaseAsync(realmSlug); + } +} diff --git a/src/dotnet/Modgud.Infrastructure/Realms/RealmKeyStore.cs b/src/dotnet/Modgud.Infrastructure/Realms/RealmKeyStore.cs index 68da04f8..b060e342 100644 --- a/src/dotnet/Modgud.Infrastructure/Realms/RealmKeyStore.cs +++ b/src/dotnet/Modgud.Infrastructure/Realms/RealmKeyStore.cs @@ -3,6 +3,7 @@ using Modgud.Domain.Realms; using Marten; using Microsoft.IdentityModel.Tokens; +using Modgud.Infrastructure.Audit; namespace Modgud.Infrastructure.Realms; @@ -66,6 +67,7 @@ public sealed class RealmKeyStore : IRealmKeyStore private readonly IDocumentStore _store; private readonly TimeProvider _clock; + private readonly ISecurityAuditLog _securityAudit; // Active signing credentials per realm, with the kid they were built from // and when they were loaded — so a stale entry can be re-validated against @@ -102,10 +104,14 @@ private sealed record ActiveEntry(SigningCredentials Creds, string Kid, DateTime private sealed record VerificationSet(IReadOnlyList Keys, DateTimeOffset ValidUntil); - public RealmKeyStore(IDocumentStore store, TimeProvider clock) + public RealmKeyStore( + IDocumentStore store, + TimeProvider clock, + ISecurityAuditLog securityAudit) { _store = store; _clock = clock; + _securityAudit = securityAudit; } public async Task GetActiveSigningCredentialsAsync( @@ -257,6 +263,15 @@ public async Task RotateAsync( // Generate fresh key and persist. var fresh = CreateNewKeyDocument(realmSlug); session.Store(fresh); + _securityAudit.StoreRequired(session, new SecurityAuditRecord + { + EventType = AuditEvents.SigningKeyRotated, + RealmSlug = realmSlug, + Severity = AuditSeverity.Warning, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rotate", + KeyId = fresh.KeyId, + }); await session.SaveChangesAsync(ct); // Cache the fresh key as the active credentials DIRECTLY. Do NOT diff --git a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs index 7a5f1932..428a1660 100644 --- a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs +++ b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs @@ -5,6 +5,7 @@ using Modgud.Infrastructure.Authorization; using Modgud.Infrastructure.OAuth; using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Scheduling; using ErrorOr; using Marten; using Microsoft.Extensions.DependencyInjection; @@ -24,6 +25,8 @@ public interface IRealmProvisioningService Task> GetAllRealmsAsync(CancellationToken ct = default); Task GetRealmBySlugAsync(string slug, CancellationToken ct = default); Task> CreateRealmAsync(CreateRealmDto dto, CancellationToken ct = default); + Task> CreateInitialRealmAsync(CreateRealmDto dto, CancellationToken ct = default); + Task> ActivateInitialRealmAsync(string slug, CancellationToken ct = default); /// /// Patches a realm's structural metadata (DisplayName, Description, /// Domains, IsActive). Tenant-owned settings (self-registration etc.) @@ -101,8 +104,10 @@ public sealed class RealmProvisioningService : IRealmProvisioningService private readonly IDocumentStore _tenantedStore; private readonly IMasterConnectionString _masterCs; private readonly IRealmCache _realmCache; + private readonly IRealmMessageStorageProvisioner _messageStorageProvisioner; private readonly IServiceProvider _serviceProvider; private readonly ISecurityAuditLog _securityAudit; + private readonly IReadOnlyList _jobScheduleObservers; private readonly ILogger _logger; public RealmProvisioningService( @@ -110,16 +115,20 @@ public RealmProvisioningService( IDocumentStore tenantedStore, IMasterConnectionString masterCs, IRealmCache realmCache, + IRealmMessageStorageProvisioner messageStorageProvisioner, IServiceProvider serviceProvider, ISecurityAuditLog securityAudit, + IEnumerable jobScheduleObservers, ILogger logger) { _globalStore = globalStore; _tenantedStore = tenantedStore; _masterCs = masterCs; _realmCache = realmCache; + _messageStorageProvisioner = messageStorageProvisioner; _serviceProvider = serviceProvider; _securityAudit = securityAudit; + _jobScheduleObservers = jobScheduleObservers.ToList(); _logger = logger; } @@ -139,7 +148,24 @@ public async Task> GetAllRealmsAsync(CancellationToken ct = default) .FirstOrDefaultAsync(r => r.Slug == slug, ct); } - public async Task> CreateRealmAsync(CreateRealmDto dto, CancellationToken ct = default) + public Task> CreateRealmAsync(CreateRealmDto dto, CancellationToken ct = default) => + CreateRealmCoreAsync(dto, isInitialControlPlane: false, activateImmediately: true, ct); + + /// + /// Provisions the deployment's first realm. It is created inactive and + /// carries the initial Control-Plane flag; the installation coordinator + /// activates it only after the first realm administrator exists. + /// + public Task> CreateInitialRealmAsync( + CreateRealmDto dto, + CancellationToken ct = default) => + CreateRealmCoreAsync(dto, isInitialControlPlane: true, activateImmediately: false, ct); + + private async Task> CreateRealmCoreAsync( + CreateRealmDto dto, + bool isInitialControlPlane, + bool activateImmediately, + CancellationToken ct) { if (!RealmSlugRules.IsValidFormat(dto.Slug)) { @@ -153,22 +179,6 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat $"The slug '{dto.Slug}' is reserved and cannot be used."); } - // C15c — InitialAdmin is mandatory: a realm without an admin path - // is unusable, and the only way to onboard the first admin - // post-creation is via Recovery-CLI (filesystem trust). Forcing - // an Email here prevents accidentally provisioning a tenant the - // recipient can't ever activate. - if (string.IsNullOrWhiteSpace(dto.InitialAdmin?.UserName)) - { - return Error.Validation("Realm.InitialAdminUserNameRequired", - "InitialAdmin.UserName is required."); - } - if (string.IsNullOrWhiteSpace(dto.InitialAdmin.Email) || !dto.InitialAdmin.Email.Contains('@')) - { - return Error.Validation("Realm.InitialAdminEmailRequired", - "InitialAdmin.Email is required and must be a valid address."); - } - // Domains are mandatory: a realm with no domain can neither route // requests nor build outbound links / WebAuthn RP IDs. There is no // silent fallback domain anymore — the caller must name at least one. @@ -190,6 +200,13 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat } await using var session = _globalStore.LightweightSession(); + if (isInitialControlPlane && await session.Query().AnyAsync(ct)) + { + return Error.Conflict( + "Installation.RealmAlreadyExists", + "The initial realm can only be provisioned while the deployment has no realms."); + } + var existing = await session.Query() .FirstOrDefaultAsync(r => r.Slug == dto.Slug, ct); if (existing is not null) @@ -202,11 +219,8 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat var domainClash = await CheckDomainUniquenessAsync(session, dto.Domains, selfId: null, ct); if (domainClash is not null) return domainClash.Value; - // New realms are never the control plane: the IsControlPlane flag is - // stored and defaults to false, and there is no create-time switch to - // request it (CreateRealmDto carries none). The control-plane role is - // only moved via TransferControlPlaneAsync. The bootstrap realm is - // stamped once in EnsureSystemRealmExistsAsync. + // Ordinary realms never become Control Plane at creation. The sole + // exception is the installation-only path while the registry is empty. // Build the tenant database connection string var csBuilder = new NpgsqlConnectionStringBuilder(_masterCs.Value); @@ -215,6 +229,14 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat csBuilder.Database = tenantDbName; var tenantCs = csBuilder.ConnectionString; + await _securityAudit.RecordPlatformRequiredAsync(new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + TargetRealmSlug = dto.Slug, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "provision-realm", + }, ct); + // Raw SQL: create the PostgreSQL database (DDL — cannot use Marten/parameters) var bootstrapBuilder = new NpgsqlConnectionStringBuilder(_masterCs.Value) { Database = "postgres" }; await using (var bootstrapConn = new NpgsqlConnection(bootstrapBuilder.ConnectionString)) @@ -238,15 +260,6 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat #pragma warning restore CA2100 await createDbCmd.ExecuteNonQueryAsync(ct); _logger.LogInformation("Created database {DbName} for realm {Slug}", tenantDbName, dto.Slug); - _securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.RealmProvisioned, - Level = "Info", - Realm = dto.Slug, - Status = "provisioned", - Reason = $"database {tenantDbName}", - Message = $"Created database {tenantDbName} for realm {dto.Slug}", - }); } } @@ -261,6 +274,11 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat await ApplyTenantSchemaResilientlyAsync( () => newTenantDb.ApplyAllConfiguredChangesToDatabaseAsync(), dto.Slug, ct); + // Marten tenants can be registered after Wolverine's startup resource + // scan. Provision the transactional inbox/outbox before any handler or + // event-forwarding path can use this realm. + await _messageStorageProvisioner.EnsureProvisionedAsync(dto.Slug); + var realm = new Realm { Id = Guid.NewGuid(), @@ -269,12 +287,19 @@ await ApplyTenantSchemaResilientlyAsync( Description = dto.Description, Domains = dto.Domains, PrimaryDomain = primaryDomain, - IsControlPlane = false, - IsActive = true, + IsControlPlane = isInitialControlPlane, + IsActive = activateImmediately && (dto.IsActive ?? true), CreatedAt = DateTimeOffset.UtcNow, }; session.Store(realm); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + TargetRealmSlug = dto.Slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "provision-realm", + }); await session.SaveChangesAsync(ct); // Per-realm OAuth seeding — standard OIDC scopes land in the new tenant DB. @@ -305,6 +330,42 @@ await AppRealmSeeder.SeedAsync( ct); _realmCache.Invalidate(); + if (realm.IsActive) + await ReconcileJobSchedulesAsync(ct); + return realm; + } + + public async Task> ActivateInitialRealmAsync( + string slug, + CancellationToken ct = default) + { + await using var session = _globalStore.LightweightSession(); + var realm = await session.Query() + .FirstOrDefaultAsync(r => r.Slug == slug, ct); + if (realm is null) + return Error.NotFound("Realm.NotFound", $"Realm '{slug}' not found."); + if (!realm.IsControlPlane) + return Error.Validation( + "Installation.InitialRealmNotControlPlane", + "The initial realm must carry the Control-Plane flag before activation."); + + var otherActive = await session.Query() + .AnyAsync(r => r.Slug != slug && r.IsActive, ct); + if (otherActive) + return Error.Conflict( + "Installation.OtherRealmActive", + "Cannot activate an initial realm after another realm became active."); + + if (!realm.IsActive) + { + realm.IsActive = true; + realm.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(realm); + await session.SaveChangesAsync(ct); + } + + _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); return realm; } @@ -423,6 +484,7 @@ public async Task> UpdateRealmAsync( await session.SaveChangesAsync(ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); return realm; } @@ -453,6 +515,7 @@ public async Task> DeleteRealmAsync(string slug, CancellationToken await session.SaveChangesAsync(ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); return true; } @@ -478,6 +541,16 @@ public async Task> HardDeleteRealmAsync(string slug, CancellationT var mainDbName = csBuilder.Database!; var tenantDbName = $"{mainDbName}_{slug}"; + await _securityAudit.RecordPlatformRequiredAsync(new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "hard-delete", + ReasonCode = "operator-request", + }, ct); + // 1. Hand the tenant back to Marten. RemoveTenantAsync evicts it from the // tenancy's in-memory cache, disposes its Npgsql data source (gracefully // closing the pool before the drop) and deletes the registry row in @@ -513,24 +586,24 @@ public async Task> HardDeleteRealmAsync(string slug, CancellationT // 3. Remove the global Realm record and invalidate the cache so middleware // stops resolving the now-dropped realm. session.Delete(realm); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "hard-delete", + ReasonCode = "operator-request", + }); await session.SaveChangesAsync(ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); _logger.LogWarning( "Hard-deleted realm {Slug}: dropped tenant database {DbName} and removed the global Realm record. " + "Irreversible — event streams, signing keys and the OpenIddict token store are gone.", slug, tenantDbName); - _securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.RealmProvisioned, - Level = "Warning", - Realm = slug, - Status = "hard-deleted", - Reason = "operator hard-delete", - Message = $"Hard-deleted realm {slug} (tenant database {tenantDbName} dropped)", - }); - return true; } @@ -544,36 +617,37 @@ public async Task RollbackProvisionedRealmAsync(string slug, CancellationToken c if (realm is null) return; - if (realm.IsControlPlane) + if (realm.IsControlPlane && realm.IsActive) { - // Provisioning never creates a control-plane realm, so this branch - // means something is badly wrong — refuse to hard-delete the - // deployment's administration anchor. + // An active Control Plane is the deployment's administration + // anchor. The installation path deliberately creates its first + // realm inactive, so that partial realm may be compensated safely. _logger.LogError( "Refusing to roll back realm {Slug}: it holds the control-plane flag. " + - "Provisioning never creates a control-plane realm, so this indicates a logic error.", + "Only an inactive, partially installed initial realm may be rolled back.", slug); return; } session.Delete(realm); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "rollback-provisioning", + ReasonCode = "bootstrap-invite-failed", + }); await session.SaveChangesAsync(ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); _logger.LogWarning( "Rolled back partially-provisioned realm {Slug} after a post-create bootstrap failure. " + "The tenant database is left in place for idempotent reuse on retry.", slug); - _securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.RealmProvisioned, - Level = "Warning", - Realm = slug, - Status = "rolled-back", - Reason = "bootstrap-invite issuance failed after realm creation", - Message = $"Rolled back partially-provisioned realm {slug} (tenant DB retained for retry)", - }); } public async Task EnsureSystemRealmExistsAsync(CancellationToken ct = default) @@ -703,6 +777,15 @@ public async Task> TransferControlPlaneAsync( target.UpdatedAt = DateTimeOffset.UtcNow; session.Store(target); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.ControlPlaneTransferred, + Severity = AuditSeverity.Warning, + TargetRealmSlug = targetSlug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "transfer", + Count = otherHolders.Count, + }); await session.SaveChangesAsync(ct); // The flag move is committed. Invalidate the cache NOW (load-bearing — @@ -731,19 +814,11 @@ await AppRealmSeeder.SeedAsync( targetSlug); } + await ReconcileJobSchedulesAsync(ct); + _logger.LogWarning( "Control plane transferred to realm {Slug} (cleared {Count} previous holder(s))", targetSlug, otherHolders.Count); - _securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.ControlPlaneTransferred, - Level = "Warning", - Realm = targetSlug, - Status = "transferred", - Reason = $"to realm {targetSlug}, {otherHolders.Count} previous holder(s)", - Message = $"Control plane transferred to realm {targetSlug} (cleared {otherHolders.Count} previous holder(s))", - }); - return target; } @@ -805,6 +880,14 @@ public async Task> AdoptExistingDatabaseAsync( } } + await _securityAudit.RecordPlatformRequiredAsync(new PlatformAuditRecord + { + EventType = AuditEvents.RealmAdopted, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "adopt-database", + }, ct); + // Register in Marten's tenant registry + apply schema idempotently // (existing data is preserved; this only adds missing tables/indexes). var tenancy = (Marten.Storage.MasterTableTenancy)_tenantedStore.Options.Tenancy; @@ -812,6 +895,7 @@ public async Task> AdoptExistingDatabaseAsync( var adoptedDb = await tenancy.FindOrCreateDatabase(slug); await ApplyTenantSchemaResilientlyAsync( () => adoptedDb.ApplyAllConfiguredChangesToDatabaseAsync(), slug, ct); + await _messageStorageProvisioner.EnsureProvisionedAsync(slug); var realm = new Realm { @@ -825,6 +909,13 @@ await ApplyTenantSchemaResilientlyAsync( CreatedAt = DateTimeOffset.UtcNow, }; session.Store(realm); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.RealmAdopted, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "adopt-database", + }); await session.SaveChangesAsync(ct); // Idempotent catalog seeding — won't clobber existing rows in the @@ -839,16 +930,28 @@ await seederScope.ServiceProvider await AppRealmSeeder.SeedAsync(_serviceProvider, slug, isControlPlane: false, _logger, ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); _logger.LogInformation("Adopted existing database {DbName} as realm {Slug}", tenantDbName, slug); - _securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.RealmAdopted, - Level = "Info", - Realm = slug, - Status = "adopted", - Reason = $"database {tenantDbName}", - Message = $"Adopted existing database {tenantDbName} as realm {slug}", - }); return realm; } + + private async Task ReconcileJobSchedulesAsync(CancellationToken ct) + { + foreach (var observer in _jobScheduleObservers) + { + try + { + await observer.ReconcileAsync(ct); + } + catch (Exception ex) + { + // Realm mutations are already committed at every call site. + // Scheduling is in-memory and self-heals on restart, so a + // reconcile failure must not turn a successful lifecycle + // operation into a misleading HTTP/CLI failure. + _logger.LogError(ex, + "Realm lifecycle mutation committed, but Quartz schedules could not be reconciled"); + } + } + } } diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/IJobRegistry.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/IJobRegistry.cs index 84108797..a3bfee61 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/IJobRegistry.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/IJobRegistry.cs @@ -1,10 +1,10 @@ namespace Modgud.Infrastructure.Scheduling; /// -/// Startup-time catalogue of all known system jobs. Each registration ships -/// with a default cron — admin can override via the Jobs page in the UI, -/// stored as a Marten document and applied on the -/// next startup or via a live reschedule. +/// Startup-time catalogue of all known compiled jobs. Each registration ships +/// with an ownership scope and default cron. Realm overrides are tenant-owned; +/// system overrides live in the global store and are controlled by the current +/// Control Plane. /// public interface IJobRegistry { diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobConfig.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobConfig.cs index 36ad1d00..063335c4 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobConfig.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobConfig.cs @@ -6,19 +6,21 @@ namespace Modgud.Infrastructure.Scheduling; /// /// Runtime override for a job's schedule + enabled state. Marten document -/// keyed by — system jobs auto-register on startup with -/// defaults and look up a matching JobConfig to apply overrides. +/// keyed by . Realm-job documents live in the owning realm's +/// tenant database. Deployment-wide job documents live in the non-tenanted +/// global store and are merely exposed through the current Control Plane. /// -/// Phase 1 only persists overrides for system jobs. The -/// slot is reserved for future JsEval-authored jobs (Modules) — kept on the -/// same document so the storage shape doesn't change later. +/// The slot is reserved for future JsEval-authored +/// jobs (Modules) — kept on the same document so the storage shape doesn't +/// change later. /// [DocumentAlias("job_config")] public record JobConfig { /// - /// Job identifier — matches the Key a system job registered with, - /// or the unique identifier of a future script job. + /// Job identifier — matches the Key a compiled job registered with, + /// or the unique identifier of a future script job. Storage scope keeps + /// identical realm-job keys isolated. /// [Identity] public string Key { get; init; } = string.Empty; @@ -29,7 +31,7 @@ public record JobConfig public JobKind Kind { get; init; } = JobKind.System; /// - /// Cron expression. null = use the system job's default. Quartz + /// Cron expression. null = use the registered job's default. Quartz /// cron format (7 fields: sec min hour day-of-month month day-of-week year). /// public string? CronOverride { get; init; } @@ -46,7 +48,7 @@ public record JobConfig public string? ScriptSource { get; init; } /// - /// Display name (script jobs need a user-set name; system jobs default + /// Display name (script jobs need a user-set name; compiled jobs default /// to the registration's name and ignore this). /// public string? DisplayName { get; init; } diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRegistration.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRegistration.cs index e9c75f40..67073e18 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRegistration.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRegistration.cs @@ -4,10 +4,28 @@ namespace Modgud.Infrastructure.Scheduling; /// -/// Compile-time description of a system job. Registered via -/// AddSystemJob<TJob>(...) at startup. The registry walks all -/// registrations, applies any matching overrides -/// from Marten, and schedules them in Quartz. +/// Defines who owns a scheduled job. +/// +public enum JobScope +{ + /// + /// One independent Quartz job + trigger per realm. Configuration and run + /// history live in that realm's tenant database. + /// + Realm, + + /// + /// One deployment-wide Quartz job. It is visible and configurable only + /// from the realm that currently holds the Control-Plane role. + /// + System, +} + +/// +/// Compile-time description of a compiled job. Registered via +/// AddRealmJob<TJob>(...) or AddSystemJob<TJob>(...) +/// at startup. The registry applies the owning realm's matching +/// and schedules the appropriate Quartz instance(s). /// public sealed record JobRegistration { @@ -17,8 +35,17 @@ public sealed record JobRegistration /// Quartz cron expression (7 fields). Used when no override exists. public required string DefaultCron { get; init; } public JobKind Kind { get; init; } = JobKind.System; - /// The compiled job type (must implement ). Required for System jobs. + /// The compiled job type (must implement ). public required Type JobType { get; init; } + public required JobScope Scope { get; init; } + + /// + /// Realm jobs normally stop when their realm is deactivated. Set this only + /// for tenant-owned hygiene that must continue while a soft-deleted realm's + /// database still exists (for example expired private-key cleanup). + /// Ignored for . + /// + public bool RunWhenRealmInactive { get; init; } /// /// Optional factory returning the job's configurable inputs. The job is diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryEntry.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryEntry.cs index c73d022f..79cf2ac1 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryEntry.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryEntry.cs @@ -4,9 +4,9 @@ namespace Modgud.Infrastructure.Scheduling; /// -/// One execution record per job run. Written by -/// after the job's Execute returns (success or fail). Append-only — -/// admin UI shows the last N entries for each job key. +/// One execution record per job run. Realm-job history lives in the owning +/// tenant database; system-job history lives in the non-tenanted global store. +/// Written by after Execute returns. /// [DocumentAlias("job_run_history")] public record JobRunHistoryEntry diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryRetentionService.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryRetentionService.cs index af07166c..63066dc7 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryRetentionService.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryRetentionService.cs @@ -9,7 +9,20 @@ public class JobRunHistoryRetentionService( IDocumentSession session, ILogger logger) : IJobRunHistoryRetentionService { - public async Task ExecuteAsync(JobRunHistoryRetentionConfig config, CancellationToken ct = default) + public Task ExecuteAsync( + JobRunHistoryRetentionConfig config, + CancellationToken ct = default) => + ExecuteAsync(session, config, logger, ct); + + /// + /// Store-agnostic retention core. Realm jobs pass their tenant session; + /// the system retention job passes a global-store session. + /// + public static async Task ExecuteAsync( + IDocumentSession target, + JobRunHistoryRetentionConfig config, + ILogger logger, + CancellationToken ct = default) { var deletedByAge = 0; var deletedByCount = 0; @@ -20,11 +33,11 @@ public async Task ExecuteAsync(JobRunHistoryRetent // Marten maps DateTime → timestamp without time zone, so the // literal must be Kind=Unspecified to avoid Npgsql's mixed-kind error. var cutoff = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-maxAge), DateTimeKind.Unspecified); - var ids = await session.Query() + var ids = await target.Query() .Where(e => e.StartedAt < cutoff) .Select(e => e.Id) .ToListAsync(ct); - foreach (var id in ids) session.Delete(id); + foreach (var id in ids) target.Delete(id); deletedByAge = ids.Count; } @@ -33,7 +46,7 @@ public async Task ExecuteAsync(JobRunHistoryRetent // round-trip per pass is cheaper than N grouped subqueries. if (config.MaxEntriesPerJob is int maxPerJob && maxPerJob > 0) { - var all = await session.Query() + var all = await target.Query() .Select(e => new { e.Id, e.JobKey, e.StartedAt }) .ToListAsync(ct); var stale = all @@ -41,13 +54,13 @@ public async Task ExecuteAsync(JobRunHistoryRetent .SelectMany(g => g.OrderByDescending(x => x.StartedAt).Skip(maxPerJob)) .Select(x => x.Id) .ToList(); - foreach (var id in stale) session.Delete(id); + foreach (var id in stale) target.Delete(id); deletedByCount = stale.Count; } if (deletedByAge + deletedByCount > 0) { - await session.SaveChangesAsync(ct); + await target.SaveChangesAsync(ct); logger.LogInformation( "[Jobs:HistoryRetention] Deleted {ByAge} by age, {ByCount} by count", deletedByAge, deletedByCount); diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunListener.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunListener.cs index 8a781e54..6b342cc8 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunListener.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunListener.cs @@ -1,6 +1,7 @@ using Marten; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Modgud.Infrastructure.Persistence.Tenancy; using Quartz; namespace Modgud.Infrastructure.Scheduling; @@ -11,11 +12,14 @@ namespace Modgud.Infrastructure.Scheduling; /// an optional per-run ResultSummary the job can publish via /// context.Result = "...";. /// -/// Resolves a fresh DI scope each run so the IDocumentSession is short-lived -/// and not entangled with whatever the job itself uses. +/// Resolves a fresh DI scope inside the owning realm carried by the Quartz +/// job detail. Realm history stays in that tenant DB; system history stays +/// in the non-tenanted global store while notifications resolve in the current +/// Control-Plane realm. /// public class JobRunListener( IServiceScopeFactory scopeFactory, + IGlobalStore globalStore, ILogger logger) : IJobListener { public string Name => nameof(JobRunListener); @@ -39,6 +43,28 @@ public async Task JobWasExecuted( CancellationToken cancellationToken = default) { var key = context.JobDetail.Key.Name; + if (!context.JobDetail.JobDataMap.TryGetValue( + RealmJobScheduler.TenantSlugDataKey, out var rawTenant) + || rawTenant is not string tenantSlug + || string.IsNullOrWhiteSpace(tenantSlug)) + { + logger.LogError( + "[Jobs] Cannot persist run history for {Key}: Quartz job has no owning realm", + context.JobDetail.Key); + return; + } + + if (!context.JobDetail.JobDataMap.TryGetValue( + RealmJobScheduler.JobScopeDataKey, out var rawScope) + || rawScope is not string scopeName + || !Enum.TryParse(scopeName, out var jobScope)) + { + logger.LogError( + "[Jobs] Cannot persist run history for {Key}: Quartz job has no valid ownership scope", + context.JobDetail.Key); + return; + } + var startedAt = context.Get(StartTimeKey) as DateTime? ?? context.FireTimeUtc.UtcDateTime; var finishedAt = DateTime.UtcNow; var manual = context.MergedJobDataMap.TryGetValue(ManualTriggerKey, out var m) && m is true; @@ -62,17 +88,29 @@ public async Task JobWasExecuted( TriggeredByUserId = triggeredBy, }; + using var tenant = TenantContext.Enter(tenantSlug); using var scope = scopeFactory.CreateScope(); try { - var session = scope.ServiceProvider.GetRequiredService(); - session.Store(entry); - await session.SaveChangesAsync(cancellationToken); + if (jobScope == JobScope.System) + { + await using var systemSession = globalStore.LightweightSession(); + systemSession.Store(entry); + await systemSession.SaveChangesAsync(cancellationToken); + } + else + { + var realmSession = scope.ServiceProvider.GetRequiredService(); + realmSession.Store(entry); + await realmSession.SaveChangesAsync(cancellationToken); + } } catch (Exception ex) { // Persisting history must never crash the listener — log and move on. - logger.LogWarning(ex, "[Jobs] Failed to persist run history for {Key}", key); + logger.LogWarning(ex, + "[Jobs] Failed to persist run history for {Key} in realm {Realm}", + key, tenantSlug); } // Inbox-side notify: failures → admins, manual completions → trigger user. @@ -85,7 +123,9 @@ public async Task JobWasExecuted( } catch (Exception ex) { - logger.LogWarning(ex, "[Jobs] Job-run notify failed for {Key}", key); + logger.LogWarning(ex, + "[Jobs] Job-run notify failed for {Key} in realm {Realm}", + key, tenantSlug); } } diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobsService.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobsService.cs index 74f04e5c..1ce959f2 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobsService.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobsService.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging; using Quartz; using Modgud.Application.Scheduling; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.Persistence.Tenancy; namespace Modgud.Infrastructure.Scheduling; @@ -12,105 +14,141 @@ namespace Modgud.Infrastructure.Scheduling; /// run history from Marten. The single non-trivial bit is the cron-reschedule /// path — we delete and recreate the trigger to keep semantics simple. /// -public class JobsService( +internal sealed class JobsService( IJobRegistry registry, ISchedulerFactory schedulerFactory, + RealmJobScheduler jobScheduler, + IGlobalStore globalStore, IDocumentSession session, ILogger logger) : IJobsService { public async Task> GetAllAsync(CancellationToken ct = default) { - var configs = await session.Query().ToListAsync(ct); - var configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); + var realm = await GetCurrentRealmAsync(ct); + var registrations = VisibleRegistrations(realm.IsControlPlane).ToList(); + var realmKeys = registrations + .Where(r => r.Scope == JobScope.Realm) + .Select(r => r.Key) + .ToArray(); + var systemKeys = registrations + .Where(r => r.Scope == JobScope.System) + .Select(r => r.Key) + .ToArray(); - // Latest run per key, in a single query. - var registrationKeys = registry.All.Select(r => r.Key).ToList(); - var allHistory = registrationKeys.Count == 0 - ? new List() - : await session.Query() - .Where(h => h.JobKey.IsOneOf(registrationKeys.ToArray())) - .OrderByDescending(h => h.StartedAt) - .ToListAsync(ct); + var (configs, allHistory) = await LoadStateAsync(session, realmKeys, ct); + if (systemKeys.Length > 0) + { + await using var systemSession = globalStore.QuerySession(); + var (systemConfigs, systemHistory) = await LoadStateAsync( + systemSession, systemKeys, ct); + configs.AddRange(systemConfigs); + allHistory.AddRange(systemHistory); + } + + var configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); var latestByKey = allHistory .GroupBy(h => h.JobKey) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(h => h.StartedAt).First(), + StringComparer.OrdinalIgnoreCase); var scheduler = await schedulerFactory.GetScheduler(ct); - var result = new List(registry.All.Count); - foreach (var reg in registry.All) + var result = new List(registrations.Count); + foreach (var reg in registrations) { configByKey.TryGetValue(reg.Key, out var cfg); latestByKey.TryGetValue(reg.Key, out var lastRun); - result.Add(await BuildOverviewAsync(reg, cfg, lastRun, scheduler, ct)); + result.Add(await BuildOverviewAsync(reg, realm.Slug, cfg, lastRun, scheduler, ct)); } return result; } public async Task GetAsync(string key, CancellationToken ct = default) { - var reg = registry.All.FirstOrDefault(r => r.Key == key); + var realm = await GetCurrentRealmAsync(ct); + var reg = VisibleRegistrations(realm.IsControlPlane) + .FirstOrDefault(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)); if (reg is null) return null; - var cfg = await session.LoadAsync(key, ct); - var lastRun = await session.Query() - .Where(h => h.JobKey == key) - .OrderByDescending(h => h.StartedAt) - .FirstOrDefaultAsync(ct); + JobConfig? cfg; + JobRunHistoryEntry? lastRun; + if (reg.Scope == JobScope.System) + { + await using var systemSession = globalStore.QuerySession(); + cfg = await systemSession.LoadAsync(reg.Key, ct); + lastRun = await GetLastRunAsync(systemSession, reg.Key, ct); + } + else + { + cfg = await session.LoadAsync(reg.Key, ct); + lastRun = await GetLastRunAsync(session, reg.Key, ct); + } var scheduler = await schedulerFactory.GetScheduler(ct); - return await BuildOverviewAsync(reg, cfg, lastRun, scheduler, ct); + return await BuildOverviewAsync(reg, realm.Slug, cfg, lastRun, scheduler, ct); } public async Task> GetHistoryAsync(string key, int take = 50, CancellationToken ct = default) { + var realm = await GetCurrentRealmAsync(ct); + var reg = VisibleRegistrations(realm.IsControlPlane) + .FirstOrDefault(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Unknown job key '{key}'"); + if (take < 1) take = 1; if (take > 500) take = 500; - var entries = await session.Query() - .Where(h => h.JobKey == key) - .OrderByDescending(h => h.StartedAt) - .Take(take) - .ToListAsync(ct); + List entries; + if (reg.Scope == JobScope.System) + { + await using var systemSession = globalStore.QuerySession(); + entries = await GetHistoryAsync(systemSession, reg.Key, take, ct); + } + else + { + entries = await GetHistoryAsync(session, reg.Key, take, ct); + } + return entries.Select(ToDto).ToList(); } public async Task UpdateAsync(string key, JobUpdateDto update, CancellationToken ct = default) { - var reg = registry.All.FirstOrDefault(r => r.Key == key) + var realm = await GetCurrentRealmAsync(ct); + var reg = VisibleRegistrations(realm.IsControlPlane) + .FirstOrDefault(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"Unknown job key '{key}'"); - var existing = await session.LoadAsync(key, ct); - var nextParams = existing?.Parameters; - if (update.Parameters is not null) + JobConfig cfg; + if (reg.Scope == JobScope.System) { - // Drop unknown keys so a stale UI can't smuggle garbage into the doc. - var schemaKeys = reg.GetParameterSchema?.Invoke().Select(f => f.Key).ToHashSet(StringComparer.Ordinal) - ?? new HashSet(StringComparer.Ordinal); - nextParams = update.Parameters - .Where(kv => schemaKeys.Contains(kv.Key)) - .ToDictionary(kv => kv.Key, kv => kv.Value); + await using var systemSession = globalStore.LightweightSession(); + var existing = await systemSession.LoadAsync(reg.Key, ct); + cfg = BuildConfig(reg, update, existing); + systemSession.Store(cfg); + await systemSession.SaveChangesAsync(ct); } - - var cfg = (existing ?? new JobConfig { Key = key, Kind = reg.Kind, CreatedAt = DateTime.UtcNow }) with + else { - CronOverride = update.CronOverride, - Enabled = update.Enabled ?? existing?.Enabled ?? true, - Parameters = nextParams, - UpdatedAt = DateTime.UtcNow, - }; - session.Store(cfg); - await session.SaveChangesAsync(ct); + var existing = await session.LoadAsync(reg.Key, ct); + cfg = BuildConfig(reg, update, existing); + session.Store(cfg); + await session.SaveChangesAsync(ct); + } - await RescheduleAsync(reg, cfg, ct); + await jobScheduler.ApplyAsync(reg, realm.Slug, cfg, ct); } public async Task TriggerNowAsync(string key, Guid? triggeredByUserId = null, CancellationToken ct = default) { - var reg = registry.All.FirstOrDefault(r => r.Key == key) + var realm = await GetCurrentRealmAsync(ct); + var reg = VisibleRegistrations(realm.IsControlPlane) + .FirstOrDefault(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"Unknown job key '{key}'"); var scheduler = await schedulerFactory.GetScheduler(ct); - var jobKey = new JobKey(reg.Key); + var jobKey = RealmJobScheduler.GetJobKey(reg, realm.Slug); if (!await scheduler.CheckExists(jobKey, ct)) throw new InvalidOperationException($"Job '{reg.Key}' is not registered with the scheduler"); @@ -122,59 +160,111 @@ public async Task TriggerNowAsync(string key, Guid? triggeredByUserId = null, Ca if (triggeredByUserId is Guid uid && uid != Guid.Empty) data[JobRunListener.TriggeredByUserIdKey] = uid; await scheduler.TriggerJob(jobKey, data, ct); - logger.LogInformation("[Jobs] Manual trigger for {Key} by user {UserId}", - reg.Key, triggeredByUserId?.ToString() ?? "(unknown)"); + logger.LogInformation( + "[Jobs] Manual trigger for {Key} in realm {Realm} by user {UserId}", + reg.Key, realm.Slug, triggeredByUserId?.ToString() ?? "(unknown)"); } // ── helpers ───────────────────────────────────────────────────── - /// - /// Apply the (possibly new) to Quartz: re-schedule - /// with the effective cron, or unschedule if disabled. - /// - public async Task RescheduleAsync(JobRegistration reg, JobConfig? cfg, CancellationToken ct = default) + private IEnumerable VisibleRegistrations(bool isControlPlane) { - var scheduler = await schedulerFactory.GetScheduler(ct); - var jobKey = new JobKey(reg.Key); - var triggerKey = new TriggerKey($"{reg.Key}-trigger"); + return registry.All.Where(r => + r.Scope == JobScope.Realm + || (r.Scope == JobScope.System && isControlPlane)); + } - // Always make sure the job exists. - if (!await scheduler.CheckExists(jobKey, ct)) - { - var jobDetail = JobBuilder.Create(reg.JobType) - .WithIdentity(jobKey) - .WithDescription(reg.Description) - .StoreDurably() - .Build(); - await scheduler.AddJob(jobDetail, replace: false, ct); - } + private async Task GetCurrentRealmAsync(CancellationToken ct) + { + var slug = TenantContext.Current; + await using var globalSession = globalStore.QuerySession(); + return await globalSession.Query() + .FirstOrDefaultAsync(r => r.Slug == slug, ct) + ?? throw new InvalidOperationException($"Unknown current realm '{slug}'"); + } - await scheduler.UnscheduleJob(triggerKey, ct); + private static async Task<(List Configs, List History)> LoadStateAsync( + IQuerySession source, + string[] keys, + CancellationToken ct) + { + if (keys.Length == 0) + return ([], []); + + var configs = await source.Query() + .Where(c => c.Key.IsOneOf(keys)) + .ToListAsync(ct); + var history = await source.Query() + .Where(h => h.JobKey.IsOneOf(keys)) + .ToListAsync(ct); + return (configs.ToList(), history.ToList()); + } + + private static Task GetLastRunAsync( + IQuerySession source, + string key, + CancellationToken ct) => + source.Query() + .Where(h => h.JobKey == key) + .OrderByDescending(h => h.StartedAt) + .FirstOrDefaultAsync(ct); + + private static async Task> GetHistoryAsync( + IQuerySession source, + string key, + int take, + CancellationToken ct) + { + var entries = await source.Query() + .Where(h => h.JobKey == key) + .OrderByDescending(h => h.StartedAt) + .Take(take) + .ToListAsync(ct); + return entries.ToList(); + } - if (cfg is not null && !cfg.Enabled) + private static JobConfig BuildConfig( + JobRegistration registration, + JobUpdateDto update, + JobConfig? existing) + { + var nextParams = existing?.Parameters; + if (update.Parameters is not null) { - logger.LogInformation("[Jobs] {Key} is disabled — no trigger scheduled", reg.Key); - return; + // Drop unknown keys so a stale UI can't smuggle garbage into the doc. + var schemaKeys = registration.GetParameterSchema?.Invoke() + .Select(f => f.Key) + .ToHashSet(StringComparer.Ordinal) + ?? new HashSet(StringComparer.Ordinal); + nextParams = update.Parameters + .Where(kv => schemaKeys.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value); } - var cron = cfg?.CronOverride ?? reg.DefaultCron; - var trigger = TriggerBuilder.Create() - .WithIdentity(triggerKey) - .ForJob(jobKey) - .WithCronSchedule(cron) - .Build(); - await scheduler.ScheduleJob(trigger, ct); - logger.LogInformation("[Jobs] Scheduled {Key} with cron '{Cron}'", reg.Key, cron); + return (existing ?? new JobConfig + { + Key = registration.Key, + Kind = registration.Kind, + CreatedAt = DateTime.UtcNow, + }) with + { + CronOverride = update.CronOverride, + Enabled = update.Enabled ?? existing?.Enabled ?? true, + Parameters = nextParams, + UpdatedAt = DateTime.UtcNow, + }; } private static async Task BuildOverviewAsync( JobRegistration reg, + string realmSlug, JobConfig? cfg, JobRunHistoryEntry? lastRun, IScheduler scheduler, CancellationToken ct) { - var triggers = await scheduler.GetTriggersOfJob(new JobKey(reg.Key), ct); + var triggers = await scheduler.GetTriggersOfJob( + RealmJobScheduler.GetJobKey(reg, realmSlug), ct); DateTime? next = triggers .Select(t => t.GetNextFireTimeUtc()?.UtcDateTime) .Where(d => d.HasValue) @@ -190,6 +280,7 @@ private static async Task BuildOverviewAsync( Name = cfg?.DisplayName ?? reg.Name, Description = cfg?.Description ?? reg.Description, Kind = reg.Kind.ToString(), + Scope = reg.Scope.ToString(), EffectiveCron = cfg?.CronOverride ?? reg.DefaultCron, DefaultCron = reg.DefaultCron, HasOverride = !string.IsNullOrWhiteSpace(cfg?.CronOverride), diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/RealmJobScheduler.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/RealmJobScheduler.cs new file mode 100644 index 00000000..87f384d2 --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/RealmJobScheduler.cs @@ -0,0 +1,283 @@ +using Marten; +using Microsoft.Extensions.Logging; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.Persistence.Tenancy; +using Quartz; +using Quartz.Impl.Matchers; + +namespace Modgud.Infrastructure.Scheduling; + +/// +/// Optional realm-lifecycle hook. Scheduling registers an implementation; +/// hosts that use realm provisioning without Quartz simply have no observers. +/// +public interface IRealmJobScheduleObserver +{ + Task ReconcileAsync(CancellationToken ct = default); +} + +/// +/// Owns the mapping from Modgud's realm/system job model to Quartz identities. +/// Realm jobs use one Quartz group per realm. System jobs share one reserved +/// group and carry the current Control-Plane realm as their tenant context. +/// +internal sealed class RealmJobScheduler( + ISchedulerFactory schedulerFactory, + IJobRegistry registry, + IGlobalStore globalStore, + IDocumentStore tenantStore, + ILogger logger) : IRealmJobScheduleObserver +{ + internal const string TenantSlugDataKey = "__modgudTenantSlug"; + internal const string JobScopeDataKey = "__modgudJobScope"; + private const string RealmGroupPrefix = "realm:"; + private const string SystemGroup = "system"; + + private readonly SemaphoreSlim _mutationLock = new(1, 1); + + public async Task ReconcileAsync(CancellationToken ct = default) + { + await _mutationLock.WaitAsync(ct); + try + { + await using var globalSession = globalStore.QuerySession(); + var realms = await globalSession.Query() + .OrderBy(r => r.CreatedAt) + .ToListAsync(ct); + + var scheduler = await schedulerFactory.GetScheduler(ct); + await RemoveDeletedRealmGroupsAsync(scheduler, realms, ct); + + foreach (var realm in realms) + { + await ReconcileRealmJobsAsync(scheduler, realm, ct); + } + + await ReconcileSystemJobsAsync(scheduler, realms, ct); + } + finally + { + _mutationLock.Release(); + } + } + + public async Task ApplyAsync( + JobRegistration registration, + string realmSlug, + JobConfig? config, + CancellationToken ct = default) + { + await _mutationLock.WaitAsync(ct); + try + { + var scheduler = await schedulerFactory.GetScheduler(ct); + await ApplyCoreAsync(scheduler, registration, realmSlug, config, ct); + } + finally + { + _mutationLock.Release(); + } + } + + internal static JobKey GetJobKey(JobRegistration registration, string realmSlug) => + new(registration.Key, GetGroup(registration, realmSlug)); + + private static TriggerKey GetTriggerKey(JobRegistration registration, string realmSlug) => + new($"{registration.Key}-trigger", GetGroup(registration, realmSlug)); + + private static string GetGroup(JobRegistration registration, string realmSlug) => + registration.Scope == JobScope.System + ? SystemGroup + : $"{RealmGroupPrefix}{realmSlug}"; + + private async Task ReconcileRealmJobsAsync( + IScheduler scheduler, + Realm realm, + CancellationToken ct) + { + var registrations = registry.All + .Where(r => r.Scope == JobScope.Realm + && (realm.IsActive || r.RunWhenRealmInactive)) + .ToList(); + + var expectedKeys = registrations + .Select(r => GetJobKey(r, realm.Slug)) + .ToHashSet(); + + var group = $"{RealmGroupPrefix}{realm.Slug}"; + var existingKeys = await scheduler.GetJobKeys( + GroupMatcher.GroupEquals(group), ct); + var obsoleteKeys = existingKeys.Where(k => !expectedKeys.Contains(k)).ToList(); + if (obsoleteKeys.Count > 0) + await scheduler.DeleteJobs(obsoleteKeys, ct); + + if (registrations.Count == 0) + return; + + Dictionary configByKey; + try + { + await using var session = tenantStore.QuerySession(realm.Slug); + var configs = await session.Query().ToListAsync(ct); + configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, + "[Jobs] Could not load job configuration for realm {Realm}; its schedules were not reconciled", + realm.Slug); + return; + } + + foreach (var registration in registrations) + { + configByKey.TryGetValue(registration.Key, out var config); + try + { + await ApplyCoreAsync(scheduler, registration, realm.Slug, config, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, + "[Jobs] Failed to schedule {Key} for realm {Realm}", + registration.Key, realm.Slug); + } + } + } + + private async Task ReconcileSystemJobsAsync( + IScheduler scheduler, + IReadOnlyList realms, + CancellationToken ct) + { + var registrations = registry.All.Where(r => r.Scope == JobScope.System).ToList(); + var controlPlanes = realms.Where(r => r.IsControlPlane && r.IsActive).ToList(); + + if (controlPlanes.Count != 1) + { + var existing = await scheduler.GetJobKeys( + GroupMatcher.GroupEquals(SystemGroup), ct); + if (existing.Count > 0) + await scheduler.DeleteJobs(existing.ToList(), ct); + + if (realms.Count == 0) + { + logger.LogInformation( + "[Jobs] No realm exists yet; deployment-wide jobs remain unscheduled until first installation"); + } + else + { + logger.LogError( + "[Jobs] Expected exactly one active Control-Plane realm but found {Count}; system jobs are unscheduled", + controlPlanes.Count); + } + return; + } + + var controlPlane = controlPlanes[0]; + Dictionary configByKey; + try + { + // System-job configuration belongs to the deployment, not to any + // tenant database. The current Control Plane controls it, but a + // transfer must not reset or resurrect another realm's schedule. + await using var session = globalStore.QuerySession(); + var configs = await session.Query().ToListAsync(ct); + configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, + "[Jobs] Could not load system-job configuration from Control-Plane realm {Realm}", + controlPlane.Slug); + return; + } + + var expectedKeys = registrations + .Select(r => GetJobKey(r, controlPlane.Slug)) + .ToHashSet(); + var existingKeys = await scheduler.GetJobKeys( + GroupMatcher.GroupEquals(SystemGroup), ct); + var obsoleteKeys = existingKeys.Where(k => !expectedKeys.Contains(k)).ToList(); + if (obsoleteKeys.Count > 0) + await scheduler.DeleteJobs(obsoleteKeys, ct); + + foreach (var registration in registrations) + { + configByKey.TryGetValue(registration.Key, out var config); + try + { + await ApplyCoreAsync(scheduler, registration, controlPlane.Slug, config, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, + "[Jobs] Failed to schedule system job {Key} for Control-Plane realm {Realm}", + registration.Key, controlPlane.Slug); + } + } + } + + private async Task ApplyCoreAsync( + IScheduler scheduler, + JobRegistration registration, + string realmSlug, + JobConfig? config, + CancellationToken ct) + { + var jobKey = GetJobKey(registration, realmSlug); + var triggerKey = GetTriggerKey(registration, realmSlug); + var jobDetail = JobBuilder.Create(registration.JobType) + .WithIdentity(jobKey) + .WithDescription(registration.Description) + .UsingJobData(TenantSlugDataKey, realmSlug) + .UsingJobData(JobScopeDataKey, registration.Scope.ToString()) + .StoreDurably() + .Build(); + + await scheduler.AddJob(jobDetail, replace: true, ct); + await scheduler.UnscheduleJob(triggerKey, ct); + + if (config is not null && !config.Enabled) + { + logger.LogInformation( + "[Jobs] {Key} is manual-only for realm {Realm} — registered without a trigger", + registration.Key, realmSlug); + return; + } + + var cron = config?.CronOverride ?? registration.DefaultCron; + var trigger = TriggerBuilder.Create() + .WithIdentity(triggerKey) + .ForJob(jobKey) + .WithCronSchedule(cron) + .Build(); + await scheduler.ScheduleJob(trigger, ct); + + logger.LogInformation( + "[Jobs] Scheduled {Scope} job {Key} for realm {Realm} with cron '{Cron}'", + registration.Scope, registration.Key, realmSlug, cron); + } + + private async Task RemoveDeletedRealmGroupsAsync( + IScheduler scheduler, + IReadOnlyCollection realms, + CancellationToken ct) + { + var knownGroups = realms + .Select(r => $"{RealmGroupPrefix}{r.Slug}") + .ToHashSet(StringComparer.Ordinal); + var groups = await scheduler.GetJobGroupNames(ct); + + foreach (var group in groups.Where(g => + g.StartsWith(RealmGroupPrefix, StringComparison.Ordinal) + && !knownGroups.Contains(g))) + { + var keys = await scheduler.GetJobKeys( + GroupMatcher.GroupEquals(group), ct); + if (keys.Count > 0) + await scheduler.DeleteJobs(keys.ToList(), ct); + } + } + +} diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/SchedulingDependencyInjection.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/SchedulingDependencyInjection.cs index 97dfcb67..0f26f8d3 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/SchedulingDependencyInjection.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/SchedulingDependencyInjection.cs @@ -1,10 +1,9 @@ -using Marten; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using Quartz; using Quartz.Spi; using Modgud.Application.Scheduling; +using Modgud.Infrastructure.Persistence.Tenancy; namespace Modgud.Infrastructure.Scheduling; @@ -12,11 +11,9 @@ public static class SchedulingDependencyInjection { /// /// Wire Quartz.NET with an in-memory job store, register the - /// facade and the run-history listener. The - /// host that calls this is responsible for calling - /// AddSystemJob<TJob>(...) for each compiled job to register - /// it with ; everything is scheduled inside - /// a hosted bootstrap step. + /// facade and the run-history listener. Hosts + /// register every compiled job explicitly as realm-owned or system-owned; + /// a hosted bootstrap materialises the corresponding Quartz instances. /// public static IServiceCollection AddScheduling(this IServiceCollection services) { @@ -27,6 +24,9 @@ public static IServiceCollection AddScheduling(this IServiceCollection services) // this binding (Modgud.Api does so in Program.cs). services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService()); services.AddQuartz(q => { @@ -46,17 +46,39 @@ public static IServiceCollection AddScheduling(this IServiceCollection services) // jobs can pull dependencies (e.g. IDocumentStore) from scope. services.AddSingleton(); - // Boot step: after the host has started but before HTTP requests arrive, - // walk the JobRegistry, apply Marten JobConfig overrides, and schedule - // each job in Quartz. Also attach the JobRunListener at this point so - // it sees every subsequent execution. + // Boot step: after the host has started, reconcile every realm's + // independent schedule and the single Control-Plane system schedule. services.AddHostedService(); return services; } /// - /// Register a compiled job type. Call once per job at startup. + /// Register a compiled job that gets one independent Quartz job and trigger + /// per realm. + /// + public static IServiceCollection AddRealmJob( + this IServiceCollection services, + string key, + string name, + string defaultCron, + string? description = null, + Func>? getParameterSchema = null, + bool runWhenRealmInactive = false) + where TJob : class, IJob + => AddJob( + services, + key, + name, + defaultCron, + JobScope.Realm, + description, + getParameterSchema, + runWhenRealmInactive); + + /// + /// Register one deployment-wide compiled job. It is scheduled once and is + /// visible/configurable only in the current Control-Plane realm. /// public static IServiceCollection AddSystemJob( this IServiceCollection services, @@ -66,8 +88,28 @@ public static IServiceCollection AddSystemJob( string? description = null, Func>? getParameterSchema = null) where TJob : class, IJob + => AddJob( + services, + key, + name, + defaultCron, + JobScope.System, + description, + getParameterSchema, + runWhenRealmInactive: false); + + private static IServiceCollection AddJob( + IServiceCollection services, + string key, + string name, + string defaultCron, + JobScope scope, + string? description, + Func>? getParameterSchema, + bool runWhenRealmInactive) + where TJob : class, IJob { - services.AddTransient(); // resolved by MicrosoftDependencyInjectionJobFactory + services.AddTransient(); services.AddSingleton(new JobRegistration { Key = key, @@ -76,6 +118,8 @@ public static IServiceCollection AddSystemJob( DefaultCron = defaultCron, JobType = typeof(TJob), Kind = JobKind.System, + Scope = scope, + RunWhenRealmInactive = runWhenRealmInactive, GetParameterSchema = getParameterSchema, }); return services; @@ -84,97 +128,58 @@ public static IServiceCollection AddSystemJob( /// /// Quartz job factory backed by Microsoft.Extensions.DependencyInjection. -/// Creates a scope per job execution so scoped services (IDocumentSession, -/// IJobRunHistoryRetentionService) work correctly. +/// Resolves the actual job only after entering the tenant carried by the +/// Quartz job detail. This guarantees constructor-injected scoped services +/// bind to the owning realm, even though there is no HTTP request. /// internal sealed class MicrosoftDependencyInjectionJobFactory(IServiceProvider rootProvider) : IJobFactory { public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler) { - var scope = rootProvider.CreateScope(); - var job = (IJob)scope.ServiceProvider.GetRequiredService(bundle.JobDetail.JobType); - // Attach the scope so we can dispose it when the job returns. - return new ScopedJobWrapper(job, scope); - } + if (!bundle.JobDetail.JobDataMap.TryGetValue( + RealmJobScheduler.TenantSlugDataKey, out var rawTenant) + || rawTenant is not string tenantSlug + || string.IsNullOrWhiteSpace(tenantSlug)) + { + throw new InvalidOperationException( + $"Scheduled job '{bundle.JobDetail.Key}' has no owning realm."); + } - public void ReturnJob(IJob job) - { - if (job is ScopedJobWrapper wrapper) wrapper.Dispose(); + return new TenantScopedJob(rootProvider, bundle.JobDetail.JobType, tenantSlug); } - private sealed class ScopedJobWrapper(IJob inner, IServiceScope scope) : IJob, IDisposable + public void ReturnJob(IJob job) { } + + private sealed class TenantScopedJob( + IServiceProvider provider, + Type jobType, + string tenantSlug) : IJob { - public Task Execute(IJobExecutionContext context) => inner.Execute(context); - public void Dispose() => scope.Dispose(); + public async Task Execute(IJobExecutionContext context) + { + using var tenant = TenantContext.Enter(tenantSlug); + using var scope = provider.CreateScope(); + var inner = (IJob)scope.ServiceProvider.GetRequiredService(jobType); + await inner.Execute(context); + } } } /// -/// Reads + at startup, -/// schedules each enabled job, and attaches the run-history listener to the -/// scheduler. Idempotent — also handles re-registration on hot-reload of the -/// host. +/// Reconciles all realm/system job instances at startup and attaches the +/// run-history listener. /// internal sealed class SchedulingBootstrap( ISchedulerFactory schedulerFactory, - IJobRegistry registry, - IServiceScopeFactory scopeFactory, - JobRunListener listener, - ILogger logger) : IHostedService + RealmJobScheduler jobScheduler, + JobRunListener listener) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { - // Pull config overrides up-front so we only open one session. - Dictionary configByKey; - using (var scope = scopeFactory.CreateScope()) - { - var session = scope.ServiceProvider.GetRequiredService(); - var configs = await session.Query().ToListAsync(cancellationToken); - configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); - } - var scheduler = await schedulerFactory.GetScheduler(cancellationToken); scheduler.ListenerManager.AddJobListener(listener); - - foreach (var reg in registry.All) - { - configByKey.TryGetValue(reg.Key, out var cfg); - try - { - await ApplyAsync(scheduler, reg, cfg, cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "[Jobs] Failed to schedule {Key}", reg.Key); - } - } + await jobScheduler.ReconcileAsync(cancellationToken); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - private async Task ApplyAsync(IScheduler scheduler, JobRegistration reg, JobConfig? cfg, CancellationToken ct) - { - var jobKey = new JobKey(reg.Key); - var jobDetail = JobBuilder.Create(reg.JobType) - .WithIdentity(jobKey) - .WithDescription(reg.Description) - .StoreDurably() - .Build(); - await scheduler.AddJob(jobDetail, replace: true, ct); - - if (cfg is not null && !cfg.Enabled) - { - logger.LogInformation("[Jobs] {Key} is disabled — registered but unscheduled", reg.Key); - return; - } - - var cron = cfg?.CronOverride ?? reg.DefaultCron; - var trigger = TriggerBuilder.Create() - .WithIdentity($"{reg.Key}-trigger") - .ForJob(jobKey) - .WithCronSchedule(cron) - .Build(); - await scheduler.ScheduleJob(trigger, ct); - logger.LogInformation("[Jobs] Scheduled {Key} with cron '{Cron}'", reg.Key, cron); - } } diff --git a/src/dotnet/Modgud.Permissions.Abstractions/FederationClaimTypes.cs b/src/dotnet/Modgud.Permissions.Abstractions/FederationClaimTypes.cs index 814ee9cb..3cb23310 100644 --- a/src/dotnet/Modgud.Permissions.Abstractions/FederationClaimTypes.cs +++ b/src/dotnet/Modgud.Permissions.Abstractions/FederationClaimTypes.cs @@ -12,8 +12,8 @@ public static class FederationClaimTypes /// Carries a session-derived ExternallyDrivable group GUID. One claim per /// group. Set on the sign-in cookie (ExternalLoginProcessor.Success), copied /// into the OpenIddict grant with NO destination, and unioned into - /// resource_access at token/UserInfo time — NEVER emitted to the wire (the - /// hub boundary). The session is the lease (decision D/E). + /// resource_access at token-issuance/UserInfo time — NEVER itself emitted + /// to the wire (the hub boundary). The session is the lease (decision D/E). /// public const string SessionGroup = "modgud:session-group"; } diff --git a/src/dotnet/Modgud.Permissions.Abstractions/Modgud.Permissions.Abstractions.csproj b/src/dotnet/Modgud.Permissions.Abstractions/Modgud.Permissions.Abstractions.csproj index 001ae982..ca2c9db4 100644 --- a/src/dotnet/Modgud.Permissions.Abstractions/Modgud.Permissions.Abstractions.csproj +++ b/src/dotnet/Modgud.Permissions.Abstractions/Modgud.Permissions.Abstractions.csproj @@ -3,7 +3,7 @@ Modgud.Permissions Modgud.Permissions.Abstractions - Pure permission-evaluation primitives shared by the IdP server-side and external resource-server libs. No persistence / web / DI deps — reuse without transitive bloat. + Pure permission-evaluation primitives for the IdP and consumers that evaluate raw grants. No persistence / web / DI deps — reuse without transitive bloat. diff --git a/src/dotnet/Modgud.Permissions.Abstractions/PermissionEvaluator.cs b/src/dotnet/Modgud.Permissions.Abstractions/PermissionEvaluator.cs index 4da126cd..d5c2ed11 100644 --- a/src/dotnet/Modgud.Permissions.Abstractions/PermissionEvaluator.cs +++ b/src/dotnet/Modgud.Permissions.Abstractions/PermissionEvaluator.cs @@ -1,11 +1,11 @@ namespace Modgud.Permissions; /// -/// Pure permission-check logic with no I/O dependencies — the same evaluator -/// is used IdP-side (by PermissionService in the Authorization slice) -/// and RS-side (by the Modgud.Client.AspNetCore helper lib). -/// Lives in Modgud.Permissions.Abstractions so external resource -/// servers can reuse it without pulling in Marten/Wolverine/JsEval. +/// Pure permission-check logic with no I/O dependencies. It is used IdP-side +/// by PermissionService in the Authorization slice and remains available +/// to consumers that need to evaluate raw grants without pulling in +/// Marten/Wolverine/JsEval. The resource-server package does not use it: +/// distributed permissions are pre-expanded by the IdP and checked exactly. /// /// Permission strings within an App are 2-segment /// "<resource>:<action>". The App context is implicit from the diff --git a/src/dotnet/Modgud.Tests.Unit/Api/Features/Admin/RealmsEndpointsTests.cs b/src/dotnet/Modgud.Tests.Unit/Api/Features/Admin/RealmsEndpointsTests.cs index 75dcc094..6a5751a1 100644 --- a/src/dotnet/Modgud.Tests.Unit/Api/Features/Admin/RealmsEndpointsTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Api/Features/Admin/RealmsEndpointsTests.cs @@ -50,15 +50,6 @@ public void Copies_all_fields_one_to_one() Assert.Equal(created, dto.CreatedAt); } - [Fact] - public void NeedsSetup_is_always_false_in_current_etappe() - { - // Per-realm setup detection is intentionally deferred — the field is - // wired through to the SPA so it stays stable, but always false today. - // Pinning keeps the contract in lockstep with the comment in the source. - var dto = RealmsEndpoints.MapToDto(new Realm { Id = Guid.NewGuid(), Slug = "x", DisplayName = "X" }); - Assert.False(dto.NeedsSetup); - } } public class RequireControlPlaneFilterTests diff --git a/src/dotnet/Modgud.Tests.Unit/Api/TenantContextMiddlewareTests.cs b/src/dotnet/Modgud.Tests.Unit/Api/TenantContextMiddlewareTests.cs index 794ca1d1..48b6b343 100644 --- a/src/dotnet/Modgud.Tests.Unit/Api/TenantContextMiddlewareTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Api/TenantContextMiddlewareTests.cs @@ -10,8 +10,8 @@ namespace Modgud.Tests.Unit.Api; /// Pins the very small but security-critical contract of : /// every request that flows through Wolverine MUST have /// set to the tenant resolved by RealmMiddleware — never to a stale value, never to -/// a different tenant. The fallback to is what -/// keeps background services and tests working without a request scope. +/// a different tenant. Realm-independent requests leave the bus tenantless and must not +/// dispatch realm-scoped messages. /// public class TenantContextMiddlewareTests { @@ -53,7 +53,9 @@ public static (IMessageBus Bus, TenantIdRecordingBusProxy Proxy) Create() } } - private static async Task RunMiddlewareAsync(HttpContext context) + private static async Task RunMiddlewareAsync( + HttpContext context, + bool expectTenantSet = true) { var (bus, proxy) = TenantIdRecordingBusProxy.Create(); var nextCalled = false; @@ -67,7 +69,7 @@ private static async Task RunMiddlewareAsync(HttpCont await sut.InvokeAsync(context, bus); Assert.True(nextCalled, "TenantContextMiddleware must always call next."); - Assert.True(proxy.TenantIdWasSet, "TenantContextMiddleware must always set TenantId on the bus."); + Assert.Equal(expectTenantSet, proxy.TenantIdWasSet); return proxy; } @@ -83,41 +85,39 @@ public async Task Sets_bus_tenant_id_from_HttpContext_Items() } [Fact] - public async Task Falls_back_to_system_tenant_when_HttpContext_has_no_tenant() + public async Task Leaves_bus_tenantless_when_HttpContext_has_no_tenant() { - // Background services / health checks / tests often have no resolved tenant. - // The fallback keeps Wolverine routable to the master DB. var ctx = new DefaultHttpContext(); - var proxy = await RunMiddlewareAsync(ctx); + var proxy = await RunMiddlewareAsync(ctx, expectTenantSet: false); - Assert.Equal(TenantConstants.SystemTenantId, proxy.CapturedTenantId); + Assert.Null(proxy.CapturedTenantId); } [Fact] - public async Task Falls_back_to_system_tenant_when_TenantId_is_empty_string() + public async Task Leaves_bus_tenantless_when_TenantId_is_empty_string() { // Defensive: an explicitly-empty string should be treated as "no tenant" so we // never dispatch a Wolverine message with TenantId == "". var ctx = new DefaultHttpContext(); ctx.Items[TenantConstants.HttpContextTenantIdKey] = ""; - var proxy = await RunMiddlewareAsync(ctx); + var proxy = await RunMiddlewareAsync(ctx, expectTenantSet: false); - Assert.Equal(TenantConstants.SystemTenantId, proxy.CapturedTenantId); + Assert.Null(proxy.CapturedTenantId); } [Fact] - public async Task Falls_back_to_system_tenant_when_TenantId_item_is_non_string() + public async Task Leaves_bus_tenantless_when_TenantId_item_is_non_string() { // RealmMiddleware always stores a string, but the cast is `as string` so any - // foreign type silently becomes null → must still use the system fallback. + // foreign type silently becomes null and must not guess a realm. var ctx = new DefaultHttpContext(); ctx.Items[TenantConstants.HttpContextTenantIdKey] = 42; - var proxy = await RunMiddlewareAsync(ctx); + var proxy = await RunMiddlewareAsync(ctx, expectTenantSet: false); - Assert.Equal(TenantConstants.SystemTenantId, proxy.CapturedTenantId); + Assert.Null(proxy.CapturedTenantId); } [Fact] diff --git a/src/dotnet/Modgud.Tests.Unit/Application/OAuthAdminMappingTests.cs b/src/dotnet/Modgud.Tests.Unit/Application/OAuthAdminMappingTests.cs index e308d6ff..2176284f 100644 --- a/src/dotnet/Modgud.Tests.Unit/Application/OAuthAdminMappingTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Application/OAuthAdminMappingTests.cs @@ -1100,6 +1100,48 @@ public void Result_round_trips_through_DictEquals_against_unchanged_input() Assert.True(OAuthAdminMapping.DictEquals(current, merged)); } + + [Fact] + public void Client_session_lifetimes_can_be_set_and_cleared_independently() + { + var current = new Dictionary + { + [OAuthApplicationSettingKeys.ClientSessionIdleLifetime] = "2592000", + [OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime] = "31536000", + }; + + var merged = OAuthAdminMapping.MergeClientSettings(current, new UpdateOAuthClientDto + { + ClearClientSessionIdleLifetime = true, + ClientSessionAbsoluteLifetime = 315360000, + }); + + Assert.False(merged.ContainsKey(OAuthApplicationSettingKeys.ClientSessionIdleLifetime)); + Assert.Equal("315360000", merged[OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime]); + Assert.Equal("2592000", current[OAuthApplicationSettingKeys.ClientSessionIdleLifetime]); + } + } + + public class ValidateClientSessionLifetimes + { + [Fact] + public void Accepts_ten_year_absolute_lifetime() + { + Assert.Null(OAuthAdminMapping.ValidateClientSessionLifetimes( + 30 * 24 * 60 * 60, + 3650 * 24 * 60 * 60)); + } + + [Fact] + public void Rejects_absolute_lifetime_shorter_than_idle_lifetime() + { + var error = OAuthAdminMapping.ValidateClientSessionLifetimes( + 60 * 24 * 60 * 60, + 30 * 24 * 60 * 60); + + Assert.NotNull(error); + Assert.Equal("OAuthClient.InvalidClientSessionAbsoluteLifetime", error.Value.Code); + } } // ─────────────── Native token-lifetime wiring (issue #115) ───────────────── diff --git a/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs b/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs index f38e8931..5a7cae39 100644 --- a/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs @@ -23,6 +23,11 @@ public class EffectiveSettingsTests AccessTokenLifetime = TimeSpan.FromMinutes(15), RefreshTokenLifetime = TimeSpan.FromDays(14), }, + ClientSessions = new ClientSessionPolicy + { + IdleLifetime = TimeSpan.FromDays(30), + AbsoluteLifetime = TimeSpan.FromDays(365), + }, Branding = new BrandingSettings { ProductName = "RealmProduct", PrimaryColor = "#111111" }, RegistrationFields = new RegistrationFieldsSettings { @@ -48,6 +53,7 @@ public void Returns_every_realm_section_unchanged() Assert.Equal(realm.Dcr, eff.Dcr); Assert.Equal(realm.Cimd, eff.Cimd); Assert.Equal(realm.NativeGrants, eff.NativeGrants); + Assert.Equal(realm.ClientSessions, eff.ClientSessions); Assert.Equal(realm.Branding, eff.Branding); Assert.Equal(realm.RegistrationFields, eff.RegistrationFields); Assert.Equal(realm.Deletion, eff.Deletion); @@ -453,4 +459,38 @@ public void App_migration_drops_legacy_authored_schema_and_inherits() Assert.Equal("realm-login", EffectiveSettings.Merge(realm, app).Pages!["login"]); } } + + public class ClientSessionMerge + { + [Fact] + public void App_values_override_individual_realm_fields() + { + var realm = Realm(); + var app = new ApplicationSettings + { + ClientSessions = new ApplicationClientSessionOverrides + { + AbsoluteLifetime = TimeSpan.FromDays(3650), + }, + }; + + var effective = EffectiveSettings.Merge(realm, app); + + Assert.Equal(TimeSpan.FromDays(30), effective.ClientSessions!.IdleLifetime); + Assert.Equal(TimeSpan.FromDays(3650), effective.ClientSessions.AbsoluteLifetime); + } + + [Fact] + public void Empty_app_override_uses_domain_defaults_when_realm_is_unconfigured() + { + var effective = EffectiveSettings.Merge( + new RealmSettingsDoc(), + new ApplicationSettings + { + ClientSessions = new ApplicationClientSessionOverrides(), + }); + + Assert.Equal(ClientSessionPolicy.Defaults, effective.ClientSessions); + } + } } diff --git a/src/dotnet/Modgud.Tests.Unit/Architecture/PermissionsAbstractionsPurityTests.cs b/src/dotnet/Modgud.Tests.Unit/Architecture/PermissionsAbstractionsPurityTests.cs index ddc6a5de..86b96f1a 100644 --- a/src/dotnet/Modgud.Tests.Unit/Architecture/PermissionsAbstractionsPurityTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Architecture/PermissionsAbstractionsPurityTests.cs @@ -3,13 +3,12 @@ namespace Modgud.Tests.Unit.Architecture; /// -/// Modgud.Permissions.Abstractions is the one assembly external -/// resource-server consumers (via Modgud.Client.AspNetCore) link -/// against to evaluate permissions in-process. Its whole reason to exist is -/// the absence of IdP-side baggage — Marten, Wolverine, JsEval, ASP.NET -/// hosting, anything Modgud-internal. If any of those leak in, the -/// abstraction stops being reusable and downstream services drag in the -/// kitchen sink. +/// Modgud.Permissions.Abstractions is the reusable assembly for +/// consumers that evaluate raw grants in-process. Its whole reason to exist +/// is the absence of IdP-side baggage — Marten, Wolverine, JsEval, ASP.NET +/// hosting, anything Modgud-internal. The resource-server package deliberately +/// does exact checks against IdP-pre-expanded claims and does not use this +/// evaluator. /// public class PermissionsAbstractionsPurityTests { @@ -80,7 +79,7 @@ public void PermissionsAbstractions_should_not_depend_on_AspNetCore() result.IsSuccessful, TestResultFormatter.Format(result, "Modgud.Permissions.Abstractions must not depend on ASP.NET Core — " + - "the ASP.NET-aware integration helpers live in Modgud.Client.AspNetCore.")); + "the ASP.NET-aware integration helpers live in Modgud.AspNetCore.ResourceServer.")); } [Fact] @@ -95,7 +94,7 @@ public void PermissionsAbstractions_should_not_depend_on_other_Modgud_internals( "Modgud.Authorization", "Modgud.Infrastructure", "Modgud.Api", - "Modgud.Client.AspNetCore") + "Modgud.AspNetCore.ResourceServer") .GetResult(); Assert.True( diff --git a/src/dotnet/Modgud.Tests.Unit/Audit/AuditDurabilityTests.cs b/src/dotnet/Modgud.Tests.Unit/Audit/AuditDurabilityTests.cs new file mode 100644 index 00000000..ef049d36 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/Audit/AuditDurabilityTests.cs @@ -0,0 +1,60 @@ +using Modgud.Infrastructure.Audit; + +namespace Modgud.Tests.Unit.Audit; + +public class AuditDurabilityTests +{ + public static TheoryData ClassifiedEvents => new() + { + { AuditEvents.RefreshTokenReuseDetected, AuditDurabilityClass.Required }, + { AuditEvents.AuditLogExported, AuditDurabilityClass.Required }, + { AuditEvents.SecurityRetentionChanged, AuditDurabilityClass.Required }, + { AuditEvents.SigningKeyRotated, AuditDurabilityClass.Required }, + { AuditEvents.SamlCertRotated, AuditDurabilityClass.Required }, + { AuditEvents.SamlSigningCertificatesChanged, AuditDurabilityClass.Required }, + { AuditEvents.RecoveryCliInvoked, AuditDurabilityClass.Required }, + { AuditEvents.RealmProvisioned, AuditDurabilityClass.Required }, + { AuditEvents.RealmAdopted, AuditDurabilityClass.Required }, + { AuditEvents.ControlPlaneTransferred, AuditDurabilityClass.Required }, + { AuditEvents.ControlPlaneRealmOperation, AuditDurabilityClass.Required }, + { AuditEvents.BootstrapInviteIssued, AuditDurabilityClass.Required }, + { AuditEvents.DcrClientRegistered, AuditDurabilityClass.Required }, + + { AuditEvents.ExternalLoginProtocolRejected, AuditDurabilityClass.Incident }, + { AuditEvents.SamlSignatureRejected, AuditDurabilityClass.Incident }, + { AuditEvents.IdentityHijackBlocked, AuditDurabilityClass.Incident }, + { AuditEvents.JitEmailConflict, AuditDurabilityClass.Incident }, + { AuditEvents.PrivilegeEscalationBlocked, AuditDurabilityClass.Incident }, + + { AuditEvents.LoginFailed, AuditDurabilityClass.Abuse }, + { AuditEvents.LoginFailedUnknownUser, AuditDurabilityClass.Abuse }, + { AuditEvents.MagicLinkInvalid, AuditDurabilityClass.Abuse }, + { AuditEvents.ExternalLoginPolicyRejected, AuditDurabilityClass.Abuse }, + { AuditEvents.RateLimitTriggered, AuditDurabilityClass.Abuse }, + { AuditEvents.DcrRegistrationRejected, AuditDurabilityClass.Abuse }, + { AuditEvents.BootstrapInviteRejected, AuditDurabilityClass.Abuse }, + + { AuditEvents.ExternalLoginConfigurationError, AuditDurabilityClass.Telemetry }, + { AuditEvents.SigningKeyPurged, AuditDurabilityClass.Telemetry }, + { AuditEvents.SamlMetadataRefreshCompleted, AuditDurabilityClass.Telemetry }, + { AuditEvents.AccountLifecycleSwept, AuditDurabilityClass.Telemetry }, + { AuditEvents.DcrClientFirstUsed, AuditDurabilityClass.Telemetry }, + { AuditEvents.DcrClientGarbageCollected, AuditDurabilityClass.Telemetry }, + }; + + [Theory] + [MemberData(nameof(ClassifiedEvents))] + public void Streamless_events_have_an_explicit_delivery_contract( + string eventType, + AuditDurabilityClass expected) + { + Assert.Equal(expected, AuditDurability.Classify(eventType)); + } + + [Fact] + public void Unknown_event_cannot_silently_choose_a_weaker_contract() + { + Assert.Throws( + () => AuditDurability.Classify("security.unclassified")); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/AuthLog/AuthLogAttributionTests.cs b/src/dotnet/Modgud.Tests.Unit/AuthLog/AuthLogAttributionTests.cs index 863a2999..cab13cb6 100644 --- a/src/dotnet/Modgud.Tests.Unit/AuthLog/AuthLogAttributionTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/AuthLog/AuthLogAttributionTests.cs @@ -1,4 +1,3 @@ -using Modgud.Authentication.Api.Admin; using Modgud.Authentication.AuthLog; using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Persistence.Tenancy; @@ -8,95 +7,58 @@ namespace Modgud.Tests.Unit.AuthLog; -/// -/// Two deterministic seams of the security/audit logging: -/// (1) stamps the ambient realm on Serilog events at -/// emit time (kept after the "Auth:" sink was retired — it tags operational logs + -/// the Phase-4 OTel export); and (2) the realm + tenant-visibility scoping the admin -/// Security-log read applies ( over -/// the streamless store). -/// public class AuthLogAttributionTests { private static readonly MessageTemplateParser Parser = new(); - private static LogEvent AuthEvent(string template, params LogEventProperty[] props) => - new(DateTimeOffset.UtcNow, LogEventLevel.Warning, exception: null, Parser.Parse(template), props); - private sealed class TestPropertyFactory : ILogEventPropertyFactory { public LogEventProperty CreateProperty(string name, object? value, bool destructureObjects = false) => new(name, new ScalarValue(value)); } - // ── Enricher ──────────────────────────────────────────────────────── - [Fact] public void Enricher_StampsAmbientRealm() { - var evt = AuthEvent("Auth: signing key rotated"); + var evt = new LogEvent( + DateTimeOffset.UtcNow, + LogEventLevel.Warning, + null, + Parser.Parse("security operation"), + []); using (TenantContext.Enter("acme")) new RealmLogEnricher().Enrich(evt, new TestPropertyFactory()); - Assert.True(evt.Properties.TryGetValue("Realm", out var v)); - Assert.Equal("acme", ((ScalarValue)v).Value); - } - - [Fact] - public void Enricher_NoAmbientTenant_FallsBackToSystem() - { - var evt = AuthEvent("Auth: something happened"); - - // No TenantContext.Enter — Current falls back to the system tenant, so - // background / no-tenant events are attributed to "system" (not orphaned). - new RealmLogEnricher().Enrich(evt, new TestPropertyFactory()); - - Assert.True(evt.Properties.TryGetValue("Realm", out var v)); - Assert.Equal("system", ((ScalarValue)v).Value); - } - - // ── Read scoping (AuthLogEndpoints.ScopeToCallerRealm over the streamless store) ── - - private static IQueryable Rows() => new[] - { - new SecurityAuditEntry { Message = "a", Realm = "system", PlatformOnly = false }, - new SecurityAuditEntry { Message = "b", Realm = "acme", PlatformOnly = false }, - new SecurityAuditEntry { Message = "c", Realm = "globex", PlatformOnly = false }, - new SecurityAuditEntry { Message = "p", Realm = "acme", PlatformOnly = true }, - }.AsQueryable(); - - [Fact] - public void Scope_ControlPlane_SeesEveryRealm_IncludingPlatformOnly() - { - var result = AuthLogEndpoints.ScopeToCallerRealm(Rows(), "system", callerIsControlPlane: true).ToList(); - Assert.Equal(4, result.Count); // the control-plane realm sees the full cross-realm log, platform-only included - } - - [Fact] - public void Scope_TenantRealm_SeesOnlyOwnRealm_TenantVisibleOnly() - { - var result = AuthLogEndpoints.ScopeToCallerRealm(Rows(), "acme", callerIsControlPlane: false).ToList(); - Assert.Single(result); - Assert.Equal("b", result[0].Message); // own realm, tenant-visible — NOT the platform-only "p" row + Assert.Equal("acme", ((ScalarValue)evt.Properties["Realm"]).Value); } [Fact] - public void Scope_TenantRealm_NeverSeesPlatformOnly() + public void Realm_event_has_no_simulated_realm_or_free_text_fields() { - // A control-plane-only operational row in the caller's OWN realm must still - // be hidden from a tenant realm-admin. - var result = AuthLogEndpoints.ScopeToCallerRealm(Rows(), "acme", callerIsControlPlane: false).ToList(); - Assert.DoesNotContain(result, r => r.PlatformOnly); + var names = typeof(RealmSecurityAuditEvent).GetProperties() + .Select(x => x.Name) + .ToHashSet(StringComparer.Ordinal); + + Assert.DoesNotContain("Realm", names); + Assert.DoesNotContain("Actor", names); + Assert.DoesNotContain("Reason", names); + Assert.DoesNotContain("Message", names); } [Fact] - public void Scope_NonControlPlaneSystemRealm_SeesOnlyItsOwn() + public void Platform_event_type_cannot_hold_forensic_pii() { - // The leak guard: a realm named "system" that is NOT the control-plane - // holder (e.g. after a control-plane transfer) must NOT see other realms. - var result = AuthLogEndpoints.ScopeToCallerRealm(Rows(), "system", callerIsControlPlane: false).ToList(); - Assert.Single(result); - Assert.Equal("system", result[0].Realm); + var names = typeof(PlatformAuditEvent).GetProperties() + .Select(x => x.Name) + .ToHashSet(StringComparer.Ordinal); + + Assert.DoesNotContain("ActorSubjectId", names); + Assert.DoesNotContain("TargetSubjectId", names); + Assert.DoesNotContain("IpAddress", names); + Assert.DoesNotContain("UserAgent", names); + Assert.DoesNotContain("UnknownIdentifierFingerprint", names); + Assert.DoesNotContain("OAuthClientId", names); + Assert.DoesNotContain("SessionId", names); } } diff --git a/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/ClientSessionTests.cs b/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/ClientSessionTests.cs new file mode 100644 index 00000000..474635b0 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/ClientSessionTests.cs @@ -0,0 +1,31 @@ +using Modgud.Authentication.Domain; + +namespace Modgud.Tests.Unit.Authentication.Domain; + +public class ClientSessionTests +{ + [Fact] + public void Touch_slides_idle_expiry_but_caps_it_at_absolute_expiry() + { + var created = DateTimeOffset.UtcNow; + var session = new ClientSession + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + ClientId = "amzettel-ios", + OAuthApplicationId = Guid.NewGuid().ToString(), + AuthorizationId = Guid.NewGuid().ToString(), + CreatedAt = created, + LastActiveAt = created, + ExpiresAt = created.AddDays(30), + AbsoluteExpiresAt = created.AddDays(3650), + }; + + session.Touch(created.AddDays(3640), TimeSpan.FromDays(30)); + + Assert.Equal(created.AddDays(3640), session.LastActiveAt); + Assert.Equal(session.AbsoluteExpiresAt, session.ExpiresAt); + Assert.True(session.IsActive(created.AddDays(3649))); + Assert.False(session.IsActive(session.AbsoluteExpiresAt)); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/UserSessionTests.cs b/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/UserSessionTests.cs index 2887418f..86a2479a 100644 --- a/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/UserSessionTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/UserSessionTests.cs @@ -2,129 +2,85 @@ namespace Modgud.Tests.Unit.Authentication.Domain; -/// -/// Pins the factory + Touch behaviour. This is a -/// pure POCO so the tests are minimal — there is just enough logic to be worth -/// freezing (in particular: ExpiresAt = CreatedAt + sessionDuration). -/// public class UserSessionTests { - public class Create + private static UserSession CreateSession( + TimeSpan? idleLifetime = null, + TimeSpan? absoluteLifetime = null) => + UserSession.Create( + Guid.NewGuid(), + "10.0.0.1", + "Mozilla/5.0", + "Chrome", + "120.0", + "Windows", + "10", + "Desktop", + idleLifetime ?? TimeSpan.FromHours(1), + absoluteLifetime ?? TimeSpan.FromHours(8)); + + [Fact] + public void Create_sets_device_data_and_a_stable_id() { - [Fact] - public void Sets_all_provided_fields() - { - var userId = Guid.NewGuid(); - - var s = UserSession.Create( - userId: userId, - sessionId: "sess-1", - ipAddress: "10.0.0.1", - userAgent: "Mozilla/5.0", - browser: "Chrome", - browserVersion: "120.0", - operatingSystem: "Windows", - osVersion: "10", - deviceType: "Desktop", - sessionDuration: TimeSpan.FromHours(8)); - - Assert.Equal(userId, s.UserId); - Assert.Equal("sess-1", s.SessionId); - Assert.Equal("10.0.0.1", s.IpAddress); - Assert.Equal("Mozilla/5.0", s.UserAgent); - Assert.Equal("Chrome", s.Browser); - Assert.Equal("120.0", s.BrowserVersion); - Assert.Equal("Windows", s.OperatingSystem); - Assert.Equal("10", s.OsVersion); - Assert.Equal("Desktop", s.DeviceType); - } - - [Fact] - public void Generates_non_empty_id() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - - Assert.NotEqual(Guid.Empty, s.Id); - } - - [Fact] - public void Sets_created_and_last_active_to_the_same_moment() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - - Assert.Equal(s.CreatedAt, s.LastActiveAt); - } - - [Fact] - public void Sets_expires_at_to_created_plus_session_duration() - { - var duration = TimeSpan.FromMinutes(45); - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, duration); - - Assert.Equal(s.CreatedAt + duration, s.ExpiresAt); - } - - [Fact] - public void Created_at_is_close_to_now_in_utc() - { - var before = DateTimeOffset.UtcNow; - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - var after = DateTimeOffset.UtcNow; - - Assert.InRange(s.CreatedAt, before, after); - } + var session = CreateSession(); + + Assert.NotEqual(Guid.Empty, session.Id); + Assert.Equal("10.0.0.1", session.IpAddress); + Assert.Equal("Mozilla/5.0", session.UserAgent); + Assert.Equal("Chrome", session.Browser); + Assert.Equal("120.0", session.BrowserVersion); + Assert.Equal("Windows", session.OperatingSystem); + Assert.Equal("10", session.OsVersion); + Assert.Equal("Desktop", session.DeviceType); + Assert.Equal(session.CreatedAt, session.LastActiveAt); + } - [Fact] - public void Allows_all_optional_fields_null() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); + [Fact] + public void Create_caps_idle_expiry_at_absolute_expiry() + { + var session = CreateSession( + idleLifetime: TimeSpan.FromDays(10), + absoluteLifetime: TimeSpan.FromDays(2)); - Assert.Null(s.SessionId); - Assert.Null(s.IpAddress); - Assert.Null(s.UserAgent); - Assert.Null(s.Browser); - Assert.Null(s.BrowserVersion); - Assert.Null(s.OperatingSystem); - Assert.Null(s.OsVersion); - Assert.Null(s.DeviceType); - } + Assert.Equal(session.AbsoluteExpiresAt, session.ExpiresAt); + Assert.Equal(session.CreatedAt.AddDays(2), session.AbsoluteExpiresAt); } - public class Touch + [Fact] + public void Touch_slides_idle_expiry_without_moving_absolute_expiry() { - [Fact] - public void Updates_last_active_at() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - // Force a measurable gap so even on a fast clock the assertion is reliable. - var initialLastActive = s.LastActiveAt; - Thread.Sleep(2); - - s.Touch(); + var session = CreateSession( + idleLifetime: TimeSpan.FromHours(1), + absoluteLifetime: TimeSpan.FromHours(8)); + var absoluteExpiry = session.AbsoluteExpiresAt; + var now = session.CreatedAt.AddMinutes(30); - Assert.True(s.LastActiveAt >= initialLastActive); - } + session.Touch(now, TimeSpan.FromHours(1)); - [Fact] - public void Does_not_change_created_at() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - var originalCreatedAt = s.CreatedAt; + Assert.Equal(now, session.LastActiveAt); + Assert.Equal(now.AddHours(1), session.ExpiresAt); + Assert.Equal(absoluteExpiry, session.AbsoluteExpiresAt); + } - s.Touch(); + [Fact] + public void Touch_never_extends_past_absolute_expiry() + { + var session = CreateSession( + idleLifetime: TimeSpan.FromHours(1), + absoluteLifetime: TimeSpan.FromHours(2)); - Assert.Equal(originalCreatedAt, s.CreatedAt); - } + session.Touch(session.CreatedAt.AddMinutes(90), TimeSpan.FromHours(1)); - [Fact] - public void Does_not_change_expires_at() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - var originalExpiry = s.ExpiresAt; + Assert.Equal(session.AbsoluteExpiresAt, session.ExpiresAt); + } - s.Touch(); + [Fact] + public void IsActive_requires_both_idle_and_absolute_windows() + { + var session = CreateSession(); - Assert.Equal(originalExpiry, s.ExpiresAt); - } + Assert.True(session.IsActive(session.CreatedAt.AddMinutes(30))); + Assert.False(session.IsActive(session.ExpiresAt)); + Assert.False(session.IsActive(session.AbsoluteExpiresAt)); } } diff --git a/src/dotnet/Modgud.Tests.Unit/Authentication/Sessions/SessionTrackerTests.cs b/src/dotnet/Modgud.Tests.Unit/Authentication/Sessions/SessionTrackerTests.cs deleted file mode 100644 index 54348ab4..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Authentication/Sessions/SessionTrackerTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Net; -using Modgud.Authentication.Domain; -using Modgud.Authentication.Sessions; -using ErrorOr; -using Microsoft.AspNetCore.Http; - -namespace Modgud.Tests.Unit.Authentication.Sessions; - -/// -/// Pins : pulls IP + UA out of the -/// , hands them to the session service, and swallows -/// failures (a tracking blip must NEVER bring down a login). -/// -public class SessionTrackerTests -{ - private sealed class CapturingSessionService : ISessionService - { - public Guid? CapturedUserId { get; private set; } - public string? CapturedIp { get; private set; } - public string? CapturedUa { get; private set; } - public int CallCount { get; private set; } - public Func>>? Behaviour { get; set; } - - public Task> CreateSessionAsync(Guid userId, string? ipAddress, string? userAgent, CancellationToken ct = default) - { - CallCount++; - CapturedUserId = userId; - CapturedIp = ipAddress; - CapturedUa = userAgent; - return Behaviour?.Invoke() ?? Task.FromResult>(new UserSession { Id = Guid.NewGuid() }); - } - - public Task> GetSessionsAsync(Guid userId, Guid? currentSessionId, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task> RevokeAllSessionsAsync(Guid userId, Guid? exceptSessionId, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task TouchSessionAsync(Guid sessionId, CancellationToken ct = default) - => throw new NotImplementedException(); - } - - [Fact] - public async Task Forwards_user_id_ip_and_user_agent_to_session_service() - { - var svc = new CapturingSessionService(); - var ctx = new DefaultHttpContext(); - ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.1"); - ctx.Request.Headers.UserAgent = "TestAgent/1.0"; - var userId = Guid.NewGuid(); - - await SessionTracker.RecordLoginAsync(svc, ctx, userId); - - Assert.Equal(1, svc.CallCount); - Assert.Equal(userId, svc.CapturedUserId); - Assert.Equal("10.0.0.1", svc.CapturedIp); - Assert.Equal("TestAgent/1.0", svc.CapturedUa); - } - - [Fact] - public async Task Forwards_null_ip_when_remote_address_missing() - { - var svc = new CapturingSessionService(); - var ctx = new DefaultHttpContext(); - // Connection.RemoteIpAddress not set → null.toString() → null - ctx.Request.Headers.UserAgent = "ua"; - - await SessionTracker.RecordLoginAsync(svc, ctx, Guid.NewGuid()); - - Assert.Null(svc.CapturedIp); - Assert.Equal("ua", svc.CapturedUa); - } - - [Fact] - public async Task Forwards_empty_user_agent_when_header_missing() - { - var svc = new CapturingSessionService(); - var ctx = new DefaultHttpContext(); - ctx.Connection.RemoteIpAddress = IPAddress.Loopback; - - await SessionTracker.RecordLoginAsync(svc, ctx, Guid.NewGuid()); - - // StringValues.ToString() of a missing header is "" — pin so a future change - // to "null when missing" surfaces here. - Assert.Equal(string.Empty, svc.CapturedUa); - } - - [Fact] - public async Task Swallows_exceptions_thrown_by_session_service() - { - var svc = new CapturingSessionService - { - Behaviour = () => throw new InvalidOperationException("Marten down"), - }; - var ctx = new DefaultHttpContext(); - ctx.Connection.RemoteIpAddress = IPAddress.Loopback; - - // Must NOT throw — login is the caller, and a tracking failure must not - // reach the user. - await SessionTracker.RecordLoginAsync(svc, ctx, Guid.NewGuid()); - - Assert.Equal(1, svc.CallCount); - } - - [Fact] - public async Task Forwards_cancellation_token_through() - { - // The token isn't captured by the fake but we still exercise the path — - // make sure passing one doesn't trip up the helper. - var svc = new CapturingSessionService(); - var ctx = new DefaultHttpContext(); - ctx.Connection.RemoteIpAddress = IPAddress.Loopback; - using var cts = new CancellationTokenSource(); - - await SessionTracker.RecordLoginAsync(svc, ctx, Guid.NewGuid(), cts.Token); - - Assert.Equal(1, svc.CallCount); - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/IntrospectionHandlerTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/IntrospectionHandlerTests.cs deleted file mode 100644 index 90a30d7c..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/IntrospectionHandlerTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System.Net; -using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Modgud.Tests.Unit.Client.AspNetCore; - -/// -/// Pins — the reference-token -/// validation path (#132). Introspection is the validation here, so -/// the contract is fail-closed: only an active, audience-valid token -/// yields a principal; everything else (inactive, non-2xx, transport error, -/// foreign audience, malformed body) rejects. -/// -/// The IdP-side companion pin -/// (UserInfoPerAudienceTests.Introspection_Carries_ResourceAccess_Only_For_Audience_Or_Presenter_Client) -/// proves the real endpoint returns resource_access to an audience -/// client; these tests pin how the lib projects that response. -/// -public class IntrospectionHandlerTests -{ - private const string Authority = "https://auth.example.com"; - private const string Audience = "https://mcp.acme.example"; - - private sealed class StubHttpMessageHandler( - Func? respond = null) : HttpMessageHandler - { - public int CallCount { get; private set; } - public string? LastRequestBody { get; private set; } - public Uri? LastRequestUri { get; private set; } - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - CallCount++; - LastRequestUri = request.RequestUri; - if (request.Content is not null) - LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); - if (respond is null) - throw new InvalidOperationException( - "Modgud introspection made an HTTP call the test did not expect."); - return respond(request); - } - } - - private static ModgudReferenceTokenOptions Options( - string audience = Audience, string? clientId = null, string secret = "rs-secret") - => new() - { - Authority = Authority, - Audience = audience, - IntrospectionClientId = clientId, - IntrospectionClientSecret = secret, - }; - - private static async Task<(ClaimsPrincipal? Principal, StubHttpMessageHandler Handler)> IntrospectAsync( - ModgudReferenceTokenOptions options, StubHttpMessageHandler handler, string token = "opaque-ref-token") - { - var original = ModgudTokenIntrospection.SharedClient; - ModgudTokenIntrospection.SharedClient = new HttpClient(handler); - try - { - var principal = await ModgudTokenIntrospection.IntrospectAsync( - options, token, "ModgudIntrospection", NullLogger.Instance, CancellationToken.None); - return (principal, handler); - } - finally - { - ModgudTokenIntrospection.SharedClient = original; - } - } - - private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) - => new(status) { Content = new StringContent(body) }; - - [Fact] - public async Task Active_token_yields_principal_with_resource_access_and_standard_claims() - { - var body = """{"active":true,"sub":"u1","name":"Alice","scope":"openid permissions","aud":["https://mcp.acme.example","some-client"],"resource_access":{"https://mcp.acme.example":{"permissions":["policy:write"],"roles":["Editor"]}}}"""; - var (principal, _) = await IntrospectAsync(Options(), new StubHttpMessageHandler(_ => Json(body))); - - Assert.NotNull(principal); - var identity = (ClaimsIdentity)principal!.Identity!; - Assert.True(identity.IsAuthenticated); - Assert.Equal("u1", identity.FindFirst("sub")?.Value); - Assert.Equal("u1", identity.FindFirst(ClaimTypes.NameIdentifier)?.Value); - Assert.Equal("Alice", identity.Name); // nameType "name" - // The load-bearing claim survives verbatim for the transformation. - var rawResourceAccess = identity.FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType)?.Value ?? ""; - Assert.Contains("""{"permissions":["policy:write"],"roles":["Editor"]}""", rawResourceAccess); - } - - [Fact] - public async Task Resource_access_flows_through_the_shared_claims_transformation() - { - var body = """{"active":true,"sub":"u1","aud":"https://mcp.acme.example","resource_access":{"https://mcp.acme.example":{"permissions":["policy:write"],"roles":["Editor"]}}}"""; - var (principal, _) = await IntrospectAsync(Options(), new StubHttpMessageHandler(_ => Json(body))); - - var transform = new ModgudClaimsTransformation(Microsoft.Extensions.Options.Options.Create( - new ModgudOptions { Authority = Authority, Audience = Audience })); - var transformed = await transform.TransformAsync(principal!); - - Assert.Contains(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType), - c => c.Value == "policy:write"); - Assert.Contains(transformed.FindAll(ClaimTypes.Role), c => c.Value == "Editor"); - } - - [Fact] - public async Task Introspection_request_uses_form_body_client_credentials() - { - var body = $$"""{"active":true,"aud":"{{Audience}}"}"""; - var (_, handler) = await IntrospectAsync( - Options(secret: "rs-secret"), new StubHttpMessageHandler(_ => Json(body)), token: "the-token"); - - Assert.Equal($"{Authority}/connect/introspect", handler.LastRequestUri!.ToString()); - var form = handler.LastRequestBody!; - Assert.Contains("token=the-token", form); - Assert.Contains($"client_id={Uri.EscapeDataString(Audience)}", form); - Assert.Contains("client_secret=rs-secret", form); - } - - [Fact] - public async Task Client_id_defaults_to_audience_but_can_be_overridden() - { - var body = $$"""{"active":true,"aud":"{{Audience}}"}"""; - var (_, handler) = await IntrospectAsync( - Options(clientId: "custom-introspector"), new StubHttpMessageHandler(_ => Json(body))); - - Assert.Contains("client_id=custom-introspector", handler.LastRequestBody!); - } - - [Fact] - public async Task Inactive_token_is_rejected() - { - var (principal, _) = await IntrospectAsync( - Options(), new StubHttpMessageHandler(_ => Json("""{"active":false}"""))); - Assert.Null(principal); - } - - [Fact] - public async Task Active_token_for_a_different_audience_is_rejected() - { - // active:true but the token isn't for us — defence in depth against a - // misconfigured introspection client id. - var body = """{"active":true,"aud":["https://other-rs.example.com"],"resource_access":{"https://other-rs.example.com":{"permissions":["policy:write"]}}}"""; - var (principal, _) = await IntrospectAsync(Options(), new StubHttpMessageHandler(_ => Json(body))); - Assert.Null(principal); - } - - [Fact] - public async Task Non_success_status_is_rejected_fail_closed() - { - var (principal, handler) = await IntrospectAsync( - Options(), new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized))); - Assert.Null(principal); - Assert.Equal(1, handler.CallCount); - } - - [Fact] - public async Task Transport_failure_is_rejected_fail_closed() - { - var (principal, _) = await IntrospectAsync( - Options(), new StubHttpMessageHandler(_ => throw new HttpRequestException("boom"))); - Assert.Null(principal); - } - - [Fact] - public async Task Malformed_json_is_rejected() - { - var (principal, _) = await IntrospectAsync( - Options(), new StubHttpMessageHandler(_ => Json("not json"))); - Assert.Null(principal); - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs deleted file mode 100644 index b0430bc1..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs +++ /dev/null @@ -1,261 +0,0 @@ -using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; - -namespace Modgud.Tests.Unit.Client.AspNetCore; - -/// -/// Pins the resource-server-side claims-transformation: it reads the -/// resource_access claim that the JWT-bearer middleware populated -/// (from the JWT itself or via UserInfo) and projects the configured -/// audience's block onto the principal as flat ClaimTypes.Role / -/// "permission" claims. Groups are NEVER flattened — the IdP never emits -/// a groups block (hub boundary, federation v1). -/// -/// The IdP pre-expands bypass tiers, so the lib is a pure -/// claims-flattener — no HTTP, no cache, no evaluator. -/// -public class ModgudClaimsTransformationTests -{ - private const string Audience = "https://policy-api.cocoar.dev"; - - private static ModgudClaimsTransformation NewSubject(string audience = Audience) => - new(Options.Create(new ModgudOptions { Audience = audience })); - - private static ClaimsPrincipal NewAuthenticatedPrincipal(params Claim[] claims) - { - var identity = new ClaimsIdentity(claims, authenticationType: "test"); - return new ClaimsPrincipal(identity); - } - - private static Claim ResourceAccessClaim(string raw) => - new(ModgudClaimsTransformation.ResourceAccessClaimType, raw); - - public class Roles - { - [Fact] - public async Task Flattens_audience_block_roles_into_ClaimTypes_Role() - { - // Standard happy path — UserInfo emitted a per-audience block - // and we have the matching audience configured. - var resourceAccess = $$""" - { - "{{Audience}}": { - "permissions": [], - "roles": ["Editor", "Viewer"], - "groups": [] - }, - "https://other-api.example.com": { - "roles": ["ShouldNotLeak"] - } - } - """; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var roles = transformed.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); - Assert.Contains("Editor", roles); - Assert.Contains("Viewer", roles); - Assert.DoesNotContain("ShouldNotLeak", roles); - } - - [Fact] - public async Task Other_audiences_in_resource_access_do_not_leak() - { - // Defence-in-depth: only OUR audience block contributes. - var resourceAccess = """{ "https://other-api.example.com": { "roles": ["Admin"] } }"""; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - Assert.Empty(transformed.FindAll(ClaimTypes.Role)); - } - - [Fact] - public async Task Idempotent_double_run_does_not_duplicate_roles() - { - // ClaimsTransformation runs more than once per pipeline pass. - var resourceAccess = $$"""{ "{{Audience}}": { "roles": ["Editor"] } }"""; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - var subject = NewSubject(); - - await subject.TransformAsync(principal); - await subject.TransformAsync(principal); - - Assert.Single(principal.FindAll(ClaimTypes.Role)); - } - } - - public class Permissions - { - [Fact] - public async Task Flattens_audience_block_permissions_into_permission_claims() - { - var resourceAccess = $$""" - { - "{{Audience}}": { - "permissions": ["policy:read", "policy:write"], - "roles": [], - "groups": [] - } - } - """; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var permissions = transformed - .FindAll(ModgudClaimsTransformation.PermissionClaimType) - .Select(c => c.Value) - .ToList(); - Assert.Contains("policy:read", permissions); - Assert.Contains("policy:write", permissions); - } - } - - public class Groups - { - [Fact] - public async Task Groups_block_is_never_flattened_hub_boundary() - { - // Federation v1 hub boundary: the Modgud IdP never emits a "groups" - // block in resource_access (membership is IdP-internal, expanded into - // roles/permissions before emission). Even if some upstream put one - // there, the transformer must NOT surface "group" claims. - var resourceAccess = $$""" - { - "{{Audience}}": { - "permissions": [], - "roles": [], - "groups": [ - { "id": "g-1", "name": "DevOps" }, - { "id": "g-2", "name": "Mitarbeiter" } - ] - } - } - """; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - // "group" is the quarantined GroupClaimType value — assert via the - // literal so the test itself doesn't reference the [Obsolete] symbol. - Assert.Empty(transformed.FindAll("group")); - } - } - - public class ShortCircuits - { - [Fact] - public async Task Anonymous_principal_is_left_untouched() - { - var anon = new ClaimsPrincipal(new ClaimsIdentity()); - - var transformed = await NewSubject().TransformAsync(anon); - - Assert.Empty(transformed.FindAll(ClaimTypes.Role)); - Assert.Empty(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType)); - } - - [Fact] - public async Task Missing_resource_access_claim_is_a_no_op() - { - // Pure-auth tokens (no roles scope, etc.) won't have it. Bail - // gracefully rather than throwing. - var principal = NewAuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, "user-1")); - - var transformed = await NewSubject().TransformAsync(principal); - - Assert.Empty(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType)); - } - - [Fact] - public async Task Malformed_resource_access_json_is_ignored() - { - // Don't throw mid-request — that would 500 every endpoint - // for a cosmetic IDP misconfiguration. - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim("this is not json")); - - var transformed = await NewSubject().TransformAsync(principal); - - Assert.Empty(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType)); - } - - [Fact] - public async Task Configured_audience_not_in_resource_access_is_a_no_op() - { - // A token whose aud[] doesn't include this RS still authenticated - // (signature/issuer valid). It just doesn't grant any of OUR - // permissions — caller's [Authorize] / RequiresModgudPermission - // will then return 403 cleanly. - var resourceAccess = """{ "https://other-api.example.com": { "permissions": ["policy:read"] } }"""; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - Assert.Empty(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType)); - } - } - - public class Configuration - { - [Fact] - public void Constructor_throws_when_Audience_is_missing() - { - var ex = Assert.Throws(() => - new ModgudClaimsTransformation(Options.Create(new ModgudOptions { Audience = "" }))); - Assert.Contains("Audience", ex.Message); - } - } - - /// - /// Issue #116 (Option A): since the access token now carries - /// resource_access itself, the transformer must flatten it - /// identically regardless of which path put the claim on the identity. - /// ASP.NET Core's JwtBearer (JsonWebTokenHandler) maps a JSON-object JWT - /// payload property to a claim whose Value is the raw JSON text - /// and whose ValueType is — - /// confirmed empirically (CreateToken + ValidateTokenAsync round-trip) - /// rather than assumed. These tests source the claim that way instead of - /// the enricher's plain-string shape and expect the exact same output as - /// the mirrored / tests - /// above — the transformer only ever reads , so - /// ValueType must be irrelevant to it. - /// - public class TokenEmbeddedClaimShape - { - private static Claim TokenMappedResourceAccessClaim(string raw) => - new(ModgudClaimsTransformation.ResourceAccessClaimType, raw, JsonClaimValueTypes.Json); - - [Fact] - public async Task Flattens_audience_block_roles_identically_to_the_userinfo_shaped_claim() - { - var resourceAccess = $$"""{ "{{Audience}}": { "roles": ["Editor", "Viewer"] } }"""; - var principal = NewAuthenticatedPrincipal(TokenMappedResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var roles = transformed.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); - Assert.Contains("Editor", roles); - Assert.Contains("Viewer", roles); - } - - [Fact] - public async Task Flattens_audience_block_permissions_identically_to_the_userinfo_shaped_claim() - { - var resourceAccess = $$"""{ "{{Audience}}": { "permissions": ["policy:read", "policy:write"] } }"""; - var principal = NewAuthenticatedPrincipal(TokenMappedResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var permissions = transformed - .FindAll(ModgudClaimsTransformation.PermissionClaimType) - .Select(c => c.Value) - .ToList(); - Assert.Contains("policy:read", permissions); - Assert.Contains("policy:write", permissions); - } - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/RequiresModgudPermissionFilterTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/RequiresModgudPermissionFilterTests.cs deleted file mode 100644 index 3a5602fe..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/RequiresModgudPermissionFilterTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; - -namespace Modgud.Tests.Unit.Client.AspNetCore; - -/// -/// Pins the resource-server-side endpoint filter. The filter reads the -/// "permission" claims that -/// stamped on the principal -/// (flattened from resource_access[].permissions) and -/// does pure exact-match against the requested string. -/// -/// The IdP pre-expanded bypass tiers (realm:admin, -/// <r>:admin) before emission, so the filter does NOT know -/// about admin bypasses — they're already represented as concrete -/// permissions in the claim set. A user with policy:admin -/// upstream sees policy:read, policy:write, ... materialised -/// in the principal claims; the filter just checks membership. -/// -public class RequiresModgudPermissionFilterTests -{ - private static EndpointFilterInvocationContext NewContext( - ClaimsPrincipal? user = null) - { - var http = new DefaultHttpContext(); - if (user is not null) http.User = user; - return new DefaultEndpointFilterInvocationContext(http); - } - - private static ClaimsPrincipal NewPrincipalWithPermissions(params string[] permissions) - { - var identity = new ClaimsIdentity("test"); - foreach (var p in permissions) - identity.AddClaim(new Claim(ModgudClaimsTransformation.PermissionClaimType, p)); - return new ClaimsPrincipal(identity); - } - - private sealed class CapturingNext - { - public bool Called { get; private set; } - public ValueTask InvokeAsync(EndpointFilterInvocationContext _) - { - Called = true; - return ValueTask.FromResult(Results.Ok("inner-handler-result")); - } - } - - [Fact] - public async Task Anonymous_principal_returns_401() - { - var filter = new RequiresModgudPermissionFilter("policy:write"); - var anon = new ClaimsPrincipal(new ClaimsIdentity()); - var ctx = NewContext(anon); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public async Task Exact_permission_match_passes_to_next() - { - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions("policy:write")); - var next = new CapturingNext(); - - await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.True(next.Called); - } - - [Fact] - public async Task Pre_expanded_admin_bypass_passes_to_next() - { - // The IdP already expanded policy:admin upstream into policy:read, - // policy:write, policy:admin (every : in the catalog) before - // putting them in resource_access. From the filter's perspective - // it's just exact-match against the materialised list. - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions( - "policy:read", "policy:write", "policy:admin")); - var next = new CapturingNext(); - - await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.True(next.Called); - } - - [Fact] - public async Task Lone_admin_marker_does_not_grant_other_actions() - { - // If the principal somehow only has the bare "policy:admin" claim - // (e.g. because the IdP didn't pre-expand, or the test didn't - // simulate it), the filter does NOT bypass — exact-match only. - // This pins that the filter doesn't accidentally implement - // bypass semantics on top of an already-expanded source. - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions("policy:admin")); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public async Task Different_resource_does_not_leak() - { - // Holding knowledge:write must NOT cover policy:write. - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions("knowledge:write")); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public async Task Empty_permission_set_returns_403() - { - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions()); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public async Task Wrong_action_on_correct_resource_returns_403() - { - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions("policy:read")); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public void Constructor_rejects_empty_permission_string() - { - Assert.Throws(() => new RequiresModgudPermissionFilter("")); - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/UserInfoEnricherTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/UserInfoEnricherTests.cs deleted file mode 100644 index c745bbee..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/UserInfoEnricherTests.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; - -namespace Modgud.Tests.Unit.Client.AspNetCore; - -/// -/// Pins 's preference order (issue #116, -/// Option A): the access token's own embedded resource_access claim -/// wins — /connect/userinfo is called ONLY as a fallback when the -/// validated token carries none. -/// -/// Why this is safe: since federation v1.1 the IdP bakes -/// resource_access into every access token at issuance, and -/// /connect/userinfo merely echoes that same block back verbatim -/// (IdP-side UserInfoPerAudienceTests -/// .JwtClient_Bakes_ResourceAccess_Into_AccessToken_And_UserInfo_Echoes -/// pins the echo). Preferring the token claim therefore changes freshness -/// in no way — it only removes a redundant per-request HTTP round-trip for -/// tokens that already carry the claim. -/// -/// The JWT-mapped claim shape is reproduced faithfully here (claim -/// type "resource_access", raw-JSON-text Value, -/// ValueType) — see the -/// ModgudClaimsTransformation doc remarks for how that shape was -/// established empirically. -/// -public class UserInfoEnricherTests -{ - private const string Authority = "https://auth.example.com"; - - /// - /// Counts invocations and, unless a canned responder is supplied, - /// throws on any call — so an unexpected HTTP attempt fails the test - /// loudly instead of silently succeeding against a stub. - /// - private sealed class StubHttpMessageHandler( - Func? respond = null) : HttpMessageHandler - { - public int CallCount { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - CallCount++; - if (respond is null) - throw new InvalidOperationException( - "Modgud.UserInfoEnricher made an HTTP call the test did not expect."); - return Task.FromResult(respond(request)); - } - } - - private static IServiceProvider NewServices(string authority = Authority) - { - var services = new ServiceCollection(); - services.AddSingleton(NullLoggerFactory.Instance); - services.AddSingleton>( - Options.Create(new ModgudOptions { Authority = authority, Audience = "aud" })); - return services.BuildServiceProvider(); - } - - private static TokenValidatedContext NewContext( - IServiceProvider services, ClaimsPrincipal principal, string? bearerToken = "raw-access-token") - { - var httpContext = new DefaultHttpContext { RequestServices = services }; - if (bearerToken is not null) - httpContext.Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken).ToString(); - - var scheme = new AuthenticationScheme( - JwtBearerDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme, typeof(JwtBearerHandler)); - return new TokenValidatedContext(httpContext, scheme, new JwtBearerOptions()) - { - Principal = principal, - }; - } - - /// - /// Mirrors exactly how ASP.NET Core's JwtBearer (JsonWebTokenHandler) - /// maps a JSON-object JWT payload property onto the validated - /// principal: claim type = the payload key verbatim, Value = raw JSON - /// text, ValueType = . - /// - private static ClaimsPrincipal PrincipalWithTokenEmbeddedResourceAccess(string rawJson) - { - var identity = new ClaimsIdentity(authenticationType: "AuthenticationTypes.Federation"); - identity.AddClaim(new Claim( - ModgudClaimsTransformation.ResourceAccessClaimType, rawJson, - JsonClaimValueTypes.Json)); - return new ClaimsPrincipal(identity); - } - - private static ClaimsPrincipal PrincipalWithoutResourceAccess() - { - var identity = new ClaimsIdentity(authenticationType: "AuthenticationTypes.Federation"); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "user-1")); - return new ClaimsPrincipal(identity); - } - - // Both nested classes mutate the shared static HttpClient seam - // (ModgudUserInfoEnricher.SharedClient) for the duration of each test. - // xUnit parallelizes across different test classes by default, so both - // are pinned to the same collection to force sequential execution - // against each other and avoid racing on that shared mutable state. - [Collection(nameof(UserInfoEnricherTests))] - public class PrefersTokenClaim - { - [Fact] - public async Task Token_embedded_resource_access_skips_userinfo_round_trip() - { - const string rawJson = """{"aud":{"roles":["Editor"],"permissions":["policy:write"]}}"""; - var principal = PrincipalWithTokenEmbeddedResourceAccess(rawJson); - var ctx = NewContext(NewServices(), principal); - - var handler = new StubHttpMessageHandler(); // throws if invoked - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(0, handler.CallCount); - // The token's own claim is left untouched — no duplicate added. - Assert.Single(((ClaimsIdentity)ctx.Principal!.Identity!) - .FindAll(ModgudClaimsTransformation.ResourceAccessClaimType)); - } - - [Fact] - public async Task Empty_string_resource_access_claim_is_treated_as_absent() - { - // Defence-in-depth: a claim that technically exists but carries - // no data must NOT short-circuit the fallback — there'd be - // nothing for the transformation to read. - var identity = new ClaimsIdentity(authenticationType: "AuthenticationTypes.Federation"); - identity.AddClaim(new Claim(ModgudClaimsTransformation.ResourceAccessClaimType, "")); - var principal = new ClaimsPrincipal(identity); - var ctx = NewContext(NewServices(), principal); - - var handler = new StubHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"resource_access":{"aud":{"roles":[]}}}"""), - }); - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(1, handler.CallCount); - } - } - - [Collection(nameof(UserInfoEnricherTests))] - public class FallsBackToUserInfo - { - [Fact] - public async Task No_token_claim_fetches_userinfo_and_merges_resource_access() - { - var principal = PrincipalWithoutResourceAccess(); - var ctx = NewContext(NewServices(), principal); - - const string resourceAccessJson = """{"aud":{"roles":["Viewer"],"permissions":["policy:read"]}}"""; - var handler = new StubHttpMessageHandler(req => - { - Assert.Equal($"{Authority}/connect/userinfo", req.RequestUri!.ToString()); - Assert.Equal("Bearer", req.Headers.Authorization?.Scheme); - Assert.Equal("raw-access-token", req.Headers.Authorization?.Parameter); - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent($$"""{"sub":"u1","resource_access":{{resourceAccessJson}}}"""), - }; - }); - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(1, handler.CallCount); - var claim = ((ClaimsIdentity)ctx.Principal!.Identity!) - .FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType); - Assert.NotNull(claim); - Assert.Equal(resourceAccessJson, claim!.Value); - } - - [Fact] - public async Task No_token_claim_and_no_bearer_header_makes_no_http_call() - { - var principal = PrincipalWithoutResourceAccess(); - var ctx = NewContext(NewServices(), principal, bearerToken: null); - - var handler = new StubHttpMessageHandler(); // throws if invoked - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(0, handler.CallCount); - } - - [Fact] - public async Task No_token_claim_transport_failure_fails_open() - { - var principal = PrincipalWithoutResourceAccess(); - var ctx = NewContext(NewServices(), principal); - - var handler = new StubHttpMessageHandler(_ => throw new HttpRequestException("boom")); - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - // Must not throw — a transient IdP outage must not 500 the API. - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Null(((ClaimsIdentity)ctx.Principal!.Identity!) - .FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType)); - } - - [Fact] - public async Task No_token_claim_non_success_status_fails_open() - { - var principal = PrincipalWithoutResourceAccess(); - var ctx = NewContext(NewServices(), principal); - - var handler = new StubHttpMessageHandler( - _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)); - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(1, handler.CallCount); - Assert.Null(((ClaimsIdentity)ctx.Principal!.Identity!) - .FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType)); - } - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs b/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs index e6af3383..3577547f 100644 --- a/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Marten; using Microsoft.Extensions.Logging.Abstractions; using Modgud.Authentication.Api.ExternalAuth.Saml; using Modgud.Authentication.Domain.LoginProviders; @@ -36,7 +37,20 @@ private static DynamicSamlSchemeManager NewManager() => /// metadata-refresh audit record is exercised by the integration suite. private sealed class NoOpSecurityAuditLog : ISecurityAuditLog { - public void Record(SecurityAuditRecord record) { } + public ValueTask RecordRequiredAsync( + SecurityAuditRecord record, + CancellationToken ct = default) => ValueTask.CompletedTask; + public void StoreRequired(IDocumentSession session, SecurityAuditRecord record) { } + public ValueTask RecordIncidentAsync( + SecurityAuditRecord record, + CancellationToken ct = default) => ValueTask.CompletedTask; + public void RecordAbuse(SecurityAuditRecord record) { } + public void RecordTelemetry(SecurityAuditRecord record) { } + public ValueTask RecordPlatformRequiredAsync( + PlatformAuditRecord record, + CancellationToken ct = default) => ValueTask.CompletedTask; + public void StorePlatformRequired(IDocumentSession session, PlatformAuditRecord record) { } + public void RecordPlatformTelemetry(PlatformAuditRecord record) { } } /// diff --git a/src/dotnet/Modgud.Tests.Unit/Infrastructure/Persistence/Tenancy/TenantConstantsTests.cs b/src/dotnet/Modgud.Tests.Unit/Infrastructure/Persistence/Tenancy/TenantConstantsTests.cs index e4bb18d2..435572a1 100644 --- a/src/dotnet/Modgud.Tests.Unit/Infrastructure/Persistence/Tenancy/TenantConstantsTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Infrastructure/Persistence/Tenancy/TenantConstantsTests.cs @@ -4,9 +4,8 @@ namespace Modgud.Tests.Unit.Infrastructure.Persistence.Tenancy; /// /// Pin values. These constants are wire-level -/// contracts: SystemTenantId is what every background service falls -/// back to, and the HttpContext.Items keys are read by middleware all over -/// the request pipeline. Renaming them silently breaks tenant isolation. +/// compatibility contracts and HttpContext.Items keys read by middleware. +/// Runtime tenant resolution deliberately has no implicit system fallback. /// public class TenantConstantsTests { @@ -16,6 +15,14 @@ public void SystemTenantId_is_system() Assert.Equal("system", TenantConstants.SystemTenantId); } + [Fact] + public void TenantContext_without_an_explicit_realm_fails_closed() + { + Assert.Null(TenantContext.CurrentOrNull); + var error = Assert.Throws(() => TenantContext.Current); + Assert.Contains("No realm context", error.Message); + } + [Fact] public void HttpContextTenantIdKey_is_TenantId() { diff --git a/src/dotnet/Modgud.Tests.Unit/Modgud.Tests.Unit.csproj b/src/dotnet/Modgud.Tests.Unit/Modgud.Tests.Unit.csproj index b1ae156b..7dc1cb18 100644 --- a/src/dotnet/Modgud.Tests.Unit/Modgud.Tests.Unit.csproj +++ b/src/dotnet/Modgud.Tests.Unit/Modgud.Tests.Unit.csproj @@ -32,7 +32,7 @@ - + diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopJwtBearerBindingTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopJwtBearerBindingTests.cs similarity index 98% rename from src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopJwtBearerBindingTests.cs rename to src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopJwtBearerBindingTests.cs index 6eb25ac5..634ca88c 100644 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopJwtBearerBindingTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopJwtBearerBindingTests.cs @@ -3,13 +3,13 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; -using Modgud.Client.AspNetCore; +using Modgud.AspNetCore.ResourceServer; using Modgud.Tests.Unit.OAuth.Dpop; -namespace Modgud.Tests.Unit.Client.AspNetCore; +namespace Modgud.Tests.Unit.ResourceServer; /// -/// JWT-bearer path of DPoP in the client library (#118): lifting a DPoP-scheme +/// JWT-bearer path of DPoP in the resource-server package (#118): lifting a DPoP-scheme /// token into JwtBearer and enforcing the cnf.jkt binding on the validated /// principal (RFC 9449 §7.1) — the JWT twin of the introspection path covered by /// . diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopResourceValidationTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopResourceValidationTests.cs similarity index 95% rename from src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopResourceValidationTests.cs rename to src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopResourceValidationTests.cs index a50e2e5e..33e574db 100644 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopResourceValidationTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopResourceValidationTests.cs @@ -1,14 +1,14 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Modgud.Client.AspNetCore; -using Modgud.Client.AspNetCore.Dpop; +using Modgud.AspNetCore.ResourceServer; +using Modgud.AspNetCore.ResourceServer.Dpop; using Modgud.Tests.Unit.OAuth.Dpop; -namespace Modgud.Tests.Unit.Client.AspNetCore; +namespace Modgud.Tests.Unit.ResourceServer; /// -/// Resource-server side of DPoP in the client library: surfacing cnf.jkt +/// Resource-server side of DPoP in the published package: surfacing cnf.jkt /// from an introspection response and validating a request's proof against the /// bound key + the presented token (RFC 9449 §7.2). /// diff --git a/src/dotnet/Modgud.Tests.Unit/ResourceServer/IntrospectionHandlerTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/IntrospectionHandlerTests.cs new file mode 100644 index 00000000..f54925a5 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/IntrospectionHandlerTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Security.Claims; +using Microsoft.Extensions.Logging.Abstractions; +using Modgud.AspNetCore.ResourceServer; + +namespace Modgud.Tests.Unit.ResourceServer; + +public class IntrospectionHandlerTests +{ + private const string Authority = "https://auth.example.com"; + private const string Audience = "https://mcp.example.com"; + + private sealed class StubHttpMessageHandler( + Func? respond = null) : HttpMessageHandler + { + public int CallCount { get; private set; } + public string? LastRequestBody { get; private set; } + public Uri? LastRequestUri { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + CallCount++; + LastRequestUri = request.RequestUri; + if (request.Content is not null) + LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + if (respond is null) + throw new InvalidOperationException("Unexpected introspection call."); + return respond(request); + } + } + + private static ModgudIntrospectionOptions Options( + string audience = Audience, + string? clientId = null, + string secret = "rs-secret") + => new() + { + Authority = Authority, + Audience = audience, + ClientId = clientId ?? audience, + ClientSecret = secret, + }; + + private static async Task<(ClaimsPrincipal? Principal, StubHttpMessageHandler Handler)> IntrospectAsync( + ModgudIntrospectionOptions options, + StubHttpMessageHandler handler, + string token = "opaque-reference-token") + { + var principal = await ModgudTokenIntrospection.IntrospectAsync( + new HttpClient(handler), + options, + token, + "ModgudIntrospection", + NullLogger.Instance, + TestContext.Current.CancellationToken); + return (principal, handler); + } + + private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) + => new(status) { Content = new StringContent(body) }; + + [Fact] + public async Task Active_token_yields_audience_projected_principal() + { + var body = """{"active":true,"sub":"u1","name":"Alice","scope":"openid permissions","aud":["https://mcp.example.com","some-client"],"resource_access":{"https://mcp.example.com":{"permissions":["policy:write"],"roles":["Editor"]}}}"""; + + var (principal, _) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => Json(body))); + + Assert.NotNull(principal); + Assert.Equal("u1", principal!.FindFirst(ClaimTypes.NameIdentifier)?.Value); + Assert.Equal("Alice", principal.Identity!.Name); + Assert.Contains(principal.FindAll(ModgudClaimTypes.Permission), x => x.Value == "policy:write"); + Assert.Contains(principal.FindAll(ClaimTypes.Role), x => x.Value == "Editor"); + } + + [Fact] + public async Task Request_uses_form_body_credentials_and_audience_as_default_client_id() + { + var body = $$"""{"active":true,"aud":"{{Audience}}"}"""; + + var (_, handler) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => Json(body)), + token: "the-token"); + + Assert.Equal($"{Authority}/connect/introspect", handler.LastRequestUri!.ToString()); + Assert.Contains("token=the-token", handler.LastRequestBody!); + Assert.Contains($"client_id={Uri.EscapeDataString(Audience)}", handler.LastRequestBody!); + Assert.Contains("client_secret=rs-secret", handler.LastRequestBody!); + } + + [Fact] + public async Task Client_id_can_be_overridden() + { + var body = $$"""{"active":true,"aud":"{{Audience}}"}"""; + + var (_, handler) = await IntrospectAsync( + Options(clientId: "custom-introspector"), + new StubHttpMessageHandler(_ => Json(body))); + + Assert.Contains("client_id=custom-introspector", handler.LastRequestBody!); + } + + [Theory] + [InlineData("""{"active":false}""")] + [InlineData("""{"active":true,"aud":"another-api"}""")] + [InlineData("not-json")] + public async Task Inactive_foreign_or_malformed_tokens_are_rejected(string responseBody) + { + var (principal, _) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => Json(responseBody))); + + Assert.Null(principal); + } + + [Fact] + public async Task Non_success_and_transport_failures_are_rejected() + { + var (nonSuccess, _) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized))); + var (transportFailure, _) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => throw new HttpRequestException("boom"))); + + Assert.Null(nonSuccess); + Assert.Null(transportFailure); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudClaimsProjectorTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudClaimsProjectorTests.cs new file mode 100644 index 00000000..34fd3355 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudClaimsProjectorTests.cs @@ -0,0 +1,109 @@ +using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; +using Modgud.AspNetCore.ResourceServer; + +namespace Modgud.Tests.Unit.ResourceServer; + +public class ModgudClaimsProjectorTests +{ + private const string Audience = "https://policy-api.example.com"; + + private static ClaimsPrincipal Principal(string resourceAccess, bool authenticated = true) + { + var identity = new ClaimsIdentity( + authenticated ? [new Claim(ModgudClaimTypes.ResourceAccess, resourceAccess, JsonClaimValueTypes.Json)] : [], + authenticated ? "test" : null); + if (!authenticated) + identity.AddClaim(new Claim(ModgudClaimTypes.ResourceAccess, resourceAccess)); + return new ClaimsPrincipal(identity); + } + + [Fact] + public void Projects_only_the_selected_audience() + { + var principal = Principal($$""" + { + "{{Audience}}": { + "roles": ["Editor"], + "permissions": ["policy:read", "policy:write"] + }, + "https://other.example.com": { + "roles": ["ShouldNotLeak"], + "permissions": ["other:admin"] + } + } + """); + + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Contains(principal.FindAll(ClaimTypes.Role), x => x.Value == "Editor"); + Assert.DoesNotContain(principal.FindAll(ClaimTypes.Role), x => x.Value == "ShouldNotLeak"); + Assert.Contains(principal.FindAll(ModgudClaimTypes.Permission), x => x.Value == "policy:write"); + Assert.DoesNotContain(principal.FindAll(ModgudClaimTypes.Permission), x => x.Value == "other:admin"); + } + + [Fact] + public void Different_schemes_can_project_different_audiences_without_global_state() + { + const string json = """ + { + "api-a": { "permissions": ["a:read"] }, + "api-b": { "permissions": ["b:read"] } + } + """; + var schemeA = Principal(json); + var schemeB = Principal(json); + + ModgudClaimsProjector.Project(schemeA, "api-a"); + ModgudClaimsProjector.Project(schemeB, "api-b"); + + Assert.Equal(["a:read"], schemeA.FindAll(ModgudClaimTypes.Permission).Select(x => x.Value)); + Assert.Equal(["b:read"], schemeB.FindAll(ModgudClaimTypes.Permission).Select(x => x.Value)); + } + + [Fact] + public void Projection_is_idempotent() + { + var principal = Principal($$"""{ "{{Audience}}": { "roles": ["Editor"] } }"""); + + ModgudClaimsProjector.Project(principal, Audience); + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Single(principal.FindAll(ClaimTypes.Role)); + } + + [Fact] + public void Groups_are_never_projected() + { + var principal = Principal($$"""{ "{{Audience}}": { "groups": ["Internal"] } }"""); + + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Empty(principal.FindAll("group")); + } + + [Theory] + [InlineData("not-json")] + [InlineData("{}")] + [InlineData("""{"another-api":{"permissions":["x:y"]}}""")] + public void Missing_or_malformed_audience_data_is_a_no_op(string resourceAccess) + { + var principal = Principal(resourceAccess); + + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Empty(principal.FindAll(ModgudClaimTypes.Permission)); + } + + [Fact] + public void Anonymous_principals_are_not_projected() + { + var principal = Principal( + $$"""{ "{{Audience}}": { "permissions": ["policy:write"] } }""", + authenticated: false); + + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Empty(principal.FindAll(ModgudClaimTypes.Permission)); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudPermissionExtensionsTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudPermissionExtensionsTests.cs new file mode 100644 index 00000000..be5603cf --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudPermissionExtensionsTests.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Modgud.AspNetCore.ResourceServer; + +namespace Modgud.Tests.Unit.ResourceServer; + +public class ModgudPermissionExtensionsTests +{ + [Fact] + public void Policy_requires_authentication_and_the_exact_permission() + { + var policy = ModgudPermissionExtensions.BuildPolicy("policy:write"); + + Assert.Contains(policy.Requirements, x => x is DenyAnonymousAuthorizationRequirement); + var claim = Assert.Single(policy.Requirements.OfType()); + Assert.Equal(ModgudClaimTypes.Permission, claim.ClaimType); + Assert.Equal(["policy:write"], claim.AllowedValues); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Empty_permission_is_rejected(string permission) + { + Assert.Throws(() => ModgudPermissionExtensions.BuildPolicy(permission)); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/ResourceServer/ResourceServerRegistrationTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ResourceServerRegistrationTests.cs new file mode 100644 index 00000000..5d468870 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ResourceServerRegistrationTests.cs @@ -0,0 +1,232 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Modgud.AspNetCore.ResourceServer; + +namespace Modgud.Tests.Unit.ResourceServer; + +public class ResourceServerRegistrationTests +{ + [Fact] + public async Task Default_mode_registers_one_public_jwt_scheme() + { + var services = Services(); + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + }); + using var provider = services.BuildServiceProvider(); + + var schemes = provider.GetRequiredService(); + var publicScheme = await schemes.GetSchemeAsync( + ModgudResourceServerDefaults.AuthenticationScheme); + + Assert.Equal(typeof(JwtBearerHandler), publicScheme?.HandlerType); + Assert.Null(await schemes.GetSchemeAsync(ModgudSchemeNames.Introspection)); + Assert.Null(provider.GetService()); + } + + [Fact] + public async Task Reference_only_mode_registers_one_public_introspection_scheme() + { + var services = Services(); + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + options.TokenMode = ModgudTokenMode.OnlyReferenceToken; + options.IntrospectionClientSecret = "secret"; + }); + using var provider = services.BuildServiceProvider(); + + var schemes = provider.GetRequiredService(); + var publicScheme = await schemes.GetSchemeAsync( + ModgudResourceServerDefaults.AuthenticationScheme); + + Assert.Equal(typeof(ModgudIntrospectionHandler), publicScheme?.HandlerType); + Assert.Null(await schemes.GetSchemeAsync(ModgudSchemeNames.Jwt)); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public async Task Both_mode_is_one_public_policy_scheme_with_two_internal_validators() + { + var services = Services(); + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = "secret"; + }); + using var provider = services.BuildServiceProvider(); + + var schemes = provider.GetRequiredService(); + Assert.Equal( + typeof(PolicySchemeHandler), + (await schemes.GetSchemeAsync(ModgudResourceServerDefaults.AuthenticationScheme)) + ?.HandlerType); + Assert.Equal( + typeof(JwtBearerHandler), + (await schemes.GetSchemeAsync(ModgudSchemeNames.Jwt))?.HandlerType); + Assert.Equal( + typeof(ModgudIntrospectionHandler), + (await schemes.GetSchemeAsync(ModgudSchemeNames.Introspection))?.HandlerType); + + var transformations = services + .Where(x => x.ServiceType == typeof(IClaimsTransformation)) + .ToArray(); + Assert.All( + transformations, + registration => Assert.Equal( + "NoopClaimsTransformation", + registration.ImplementationType?.Name)); + } + + [Theory] + [InlineData("Bearer aaa.bbb.ccc", ModgudSchemeNames.Jwt)] + [InlineData("DPoP aaa.bbb.ccc", ModgudSchemeNames.Jwt)] + [InlineData("Bearer opaque_reference_token", ModgudSchemeNames.Introspection)] + [InlineData("DPoP opaque_reference_token", ModgudSchemeNames.Introspection)] + [InlineData("Bearer one.dot", ModgudSchemeNames.Introspection)] + [InlineData("Bearer one.two.three.four", ModgudSchemeNames.Introspection)] + [InlineData(null, ModgudSchemeNames.Jwt)] + [InlineData("Basic abc", ModgudSchemeNames.Jwt)] + public void Both_mode_routes_by_modgud_token_shape(string? header, string expectedScheme) + { + Assert.Equal(expectedScheme, ServiceCollectionExtensions.SelectTokenScheme(header)); + } + + [Fact] + public async Task Jwt_projection_uses_the_resource_servers_single_audience() + { + var services = Services(); + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api-a"; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = "secret"; + }); + await using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService>(); + const string resourceAccess = """ + { + "api-a": { "permissions": ["a:read"] }, + "api-b": { "permissions": ["b:read"] } + } + """; + var principal = Principal(resourceAccess); + + await monitor.Get(ModgudSchemeNames.Jwt).Events.OnTokenValidated( + Context(provider, principal, ModgudSchemeNames.Jwt)); + + Assert.Equal( + ["a:read"], + principal.FindAll(ModgudClaimTypes.Permission).Select(x => x.Value)); + } + + [Fact] + public void A_second_modgud_registration_is_rejected() + { + var services = Services(); + services.AddModgudResourceServer(ValidJwt); + + var error = Assert.Throws(() => + services.AddModgudResourceServer(ValidJwt)); + + Assert.Contains("only be called once", error.Message); + } + + [Theory] + [InlineData(ModgudTokenMode.OnlyReferenceToken)] + [InlineData(ModgudTokenMode.Both)] + public void Reference_accepting_modes_require_a_secret(ModgudTokenMode mode) + { + var services = Services(); + + Assert.Throws(() => + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + options.TokenMode = mode; + })); + } + + [Fact] + public void Missing_audience_realm_path_and_insecure_authority_are_rejected() + { + AssertInvalid(options => + { + options.Authority = "https://id.example.com"; + options.Audience = ""; + }); + AssertInvalid(options => + { + options.Authority = "https://id.example.com/system"; + options.Audience = "api"; + }); + AssertInvalid(options => + { + options.Authority = "http://id.example.com"; + options.Audience = "api"; + }); + } + + [Fact] + public void Only_jwt_rejects_irrelevant_introspection_credentials() + { + AssertInvalid(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + options.IntrospectionClientSecret = "unused"; + }); + } + + private static ServiceCollection Services() + { + var services = new ServiceCollection(); + services.AddLogging(); + return services; + } + + private static void ValidJwt(ModgudResourceServerOptions options) + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + } + + private static void AssertInvalid(Action configure) + { + var services = Services(); + Assert.Throws(() => + services.AddModgudResourceServer(configure)); + } + + private static ClaimsPrincipal Principal(string resourceAccess) + { + var identity = new ClaimsIdentity( + [new Claim(ModgudClaimTypes.ResourceAccess, resourceAccess)], + authenticationType: "test"); + return new ClaimsPrincipal(identity); + } + + private static TokenValidatedContext Context( + IServiceProvider services, + ClaimsPrincipal principal, + string schemeName) + { + var http = new DefaultHttpContext { RequestServices = services }; + var scheme = new AuthenticationScheme(schemeName, null, typeof(JwtBearerHandler)); + return new TokenValidatedContext(http, scheme, new JwtBearerOptions()) + { + Principal = principal, + }; + } +} diff --git a/src/dotnet/Modgud.slnx b/src/dotnet/Modgud.slnx index e976c2d4..01a13e6b 100644 --- a/src/dotnet/Modgud.slnx +++ b/src/dotnet/Modgud.slnx @@ -13,7 +13,7 @@ - + diff --git a/src/dotnet/TestApps/Modgud.TestApps.Bff/Program.cs b/src/dotnet/TestApps/Modgud.TestApps.Bff/Program.cs index fb919342..b432f368 100644 --- a/src/dotnet/TestApps/Modgud.TestApps.Bff/Program.cs +++ b/src/dotnet/TestApps/Modgud.TestApps.Bff/Program.cs @@ -73,7 +73,8 @@ // Authz-claim opt-ins per permission-modell: `roles` is the OIDC // standard convention for role names, `permissions` is Cocoar's // matching opt-in for the per-RS permission array. The BFF wants - // both so /connect/userinfo emits a full resource_access block. + // both so the token principal and /connect/userinfo expose the full + // eligible resource_access block. options.Scope.Add("roles"); options.Scope.Add("permissions"); options.Scope.Add("demo.read"); diff --git a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Modgud.TestApps.ResourceApi.csproj b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Modgud.TestApps.ResourceApi.csproj index b199b5b5..e3a6c3c3 100644 --- a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Modgud.TestApps.ResourceApi.csproj +++ b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Modgud.TestApps.ResourceApi.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs index 044458e8..f81b9fa1 100644 --- a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs +++ b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs @@ -1,7 +1,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.AspNetCore.Authentication.JwtBearer; +using Modgud.AspNetCore.ResourceServer; using Microsoft.AspNetCore.Authorization; // Disable the default JWT short→long claim translation so we see "sub", "name", @@ -20,23 +19,16 @@ // roles/permissions) // GET /scoped — token-scope based gate ("demo.read") // GET /admin — token-scope based gate ("demo.admin") -// GET /policy/read — RequiresModgudPermission("demo:read") +// GET /policy/read — RequireModgudPermission("demo:read") // — exact-match against pre-expanded permissions -// POST /policy/write — RequiresModgudPermission("demo:write") +// POST /policy/write — RequireModgudPermission("demo:write") // // What the path proves: the IdP issues a JWT with aud=, and the -// Modgud client library (Modgud.Client.AspNetCore) makes sure the -// principal ends up with a resource_access claim — preferring the token's -// own embedded claim (federation v1.1 bakes resource_access into every -// access token at issuance) and falling back to fetching -// /connect/userinfo on JwtBearer's OnTokenValidated event only when the -// token carries none. There's no GetClaimsFromUserInfoEndpoint on -// JwtBearerOptions for the fallback path — that property only exists on -// AddOpenIdConnect. Either source emits resource_access[] = +// Modgud.AspNetCore.ResourceServer validates the token and projects its +// embedded resource_access[] = // { permissions, roles } with bypass tiers (realm:admin, :admin) -// already pre-expanded to concrete strings, and the lib's -// claims-transformation flattens that block onto the principal. -// RequiresModgudPermission then does straight membership match — no HTTP, +// already pre-expanded to concrete strings directly onto the identity. +// RequireModgudPermission then does straight membership match — no HTTP, // no cache, no evaluator on the RS side. var builder = WebApplication.CreateBuilder(args); @@ -49,49 +41,49 @@ // TESTAPPS:TOKENMODE selects how this sample validates access tokens: // "jwt" (default) — self-contained JWT validated locally against the -// realm's JWKS (AddJwtBearer + AddModgudClient). +// realm's JWKS. // "reference" — Modgud's DEFAULT opaque token, validated per-request via -// /connect/introspect (AddModgudReferenceTokenClient). The RS +// /connect/introspect. The RS // introspects with a confidential client whose client_id equals // its audience; supply its secret via TESTAPPS:INTROSPECTIONSECRET. -// Everything downstream — the resource_access projection, RequiresModgudPermission, +// "both" — accepts both formats under one public Modgud scheme and +// dispatches by token shape. +// Everything downstream — the resource_access projection, RequireModgudPermission, // role gates — is identical either way; only the registration differs. -var tokenMode = (builder.Configuration["TESTAPPS:TOKENMODE"] ?? "jwt").Trim().ToLowerInvariant(); - -if (tokenMode == "reference") +var configuredTokenMode = + (builder.Configuration["TESTAPPS:TOKENMODE"] ?? "jwt").Trim().ToLowerInvariant(); +var tokenMode = configuredTokenMode switch { - builder.Services - .AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - .AddModgudReferenceTokenClient(o => - { - o.Authority = authority; - o.Audience = audience; // == the introspection client_id (an OAuthApi name) - o.IntrospectionClientSecret = builder.Configuration["TESTAPPS:INTROSPECTIONSECRET"]; - }); -} -else + "jwt" => ModgudTokenMode.OnlyJwt, + "reference" => ModgudTokenMode.OnlyReferenceToken, + "both" => ModgudTokenMode.Both, + _ => throw new InvalidOperationException( + "TESTAPPS:TOKENMODE must be 'jwt', 'reference', or 'both'."), +}; + +builder.Services.AddModgudResourceServer(options => { - builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = authority; - options.Audience = audience; - options.RequireHttpsMetadata = false; // dev only - options.MapInboundClaims = false; - options.TokenValidationParameters.NameClaimType = "name"; - options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; - }); - - // Lib hooks JwtBearer.OnTokenValidated to fetch /connect/userinfo and - // merge resource_access onto the principal, then a ClaimsTransformation - // flattens the matching audience block to ClaimTypes.Role / "permission" - // / "group" claims. Plus the RequiresModgudPermission endpoint filter. - builder.Services.AddModgudClient(o => + options.Authority = authority; + options.Audience = audience; + options.TokenMode = tokenMode; + options.RequireHttpsMetadata = false; // dev only + + if (tokenMode is ModgudTokenMode.OnlyReferenceToken or ModgudTokenMode.Both) { - o.Authority = authority; - o.Audience = audience; // must match JwtBearerOptions.Audience above - }); -} + options.IntrospectionClientSecret = + builder.Configuration["TESTAPPS:INTROSPECTIONSECRET"]; + } + + if (tokenMode is ModgudTokenMode.OnlyJwt or ModgudTokenMode.Both) + { + options.ConfigureJwtBearer = jwt => + { + jwt.MapInboundClaims = false; + jwt.TokenValidationParameters.NameClaimType = "name"; + jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; + }; + } +}); builder.Services.AddAuthorization(options => { @@ -123,13 +115,13 @@ scopes = user.FindAll("scope").Select(c => c.Value) .Concat(user.FindAll("scp").Select(c => c.Value)) .ToArray(), - // Roles + permissions come from the lib's claims-transformation, which - // reads resource_access[] off the principal. They will be empty + // Roles + permissions come from the authentication scheme's audience-local + // projection of resource_access[]. They will be empty // if the IdP hasn't emitted a block for this audience (e.g. because the // user has no grants in the linked App). Groups are never emitted by the // IdP (hub boundary, federation v1) — there is no "groups" key to read. roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(), - permissions = user.FindAll(ModgudClaimsTransformation.PermissionClaimType) + permissions = user.FindAll(ModgudClaimTypes.Permission) .Select(c => c.Value).ToArray(), claims = user.Claims.Select(c => new { c.Type, c.Value }).ToArray() })).RequireAuthorization(); @@ -145,15 +137,13 @@ .RequireAuthorization("demo.admin"); // Permission-gated endpoints — the post-Step-7-fix path. These exercise: -// incoming bearer → JwtBearer fetches UserInfo → resource_access block -// projected onto principal → filter does exact-match. +// incoming token → scheme-local resource_access projection → ASP.NET +// authorization policy does exact-match. app.MapGet("/policy/read", () => Results.Ok(new { message = "You called demo:read." })) - .RequireAuthorization() - .RequiresModgudPermission("demo:read"); + .RequireModgudPermission("demo:read"); app.MapPost("/policy/write", () => Results.Ok(new { message = "You called demo:write." })) - .RequireAuthorization() - .RequiresModgudPermission("demo:write"); + .RequireModgudPermission("demo:write"); app.Run(); diff --git a/src/frontend-vue/e2e/40-realms.spec.ts b/src/frontend-vue/e2e/40-realms.spec.ts index 654b8bee..9e670f72 100644 --- a/src/frontend-vue/e2e/40-realms.spec.ts +++ b/src/frontend-vue/e2e/40-realms.spec.ts @@ -57,7 +57,6 @@ test.describe('§14 Realms', () => { // Response shape changed in C15c: {Realm: …, InitialAdminInvite: …} expect(body.Realm.Slug).toBe(slug) expect(body.Realm.IsActive).toBe(true) - expect(typeof body.Realm.NeedsSetup).toBe('boolean') // Bootstrap-invite is included so the CP-admin can copy/share the // magic-link in SMTP-less environments. diff --git a/src/frontend-vue/package.json b/src/frontend-vue/package.json index 890b3691..f1b5d834 100644 --- a/src/frontend-vue/package.json +++ b/src/frontend-vue/package.json @@ -15,12 +15,12 @@ }, "dependencies": { "@cocoar/signalarrr": "^4.3.2", - "@cocoar/vue-data-grid": "2.17.1", - "@cocoar/vue-fragment-parser": "2.17.1", - "@cocoar/vue-localization": "2.17.1", - "@cocoar/vue-page-builder": "2.17.1", - "@cocoar/vue-script-editor": "2.17.1", - "@cocoar/vue-ui": "2.17.1", + "@cocoar/vue-data-grid": "2.18.0", + "@cocoar/vue-fragment-parser": "2.18.0", + "@cocoar/vue-localization": "2.18.0", + "@cocoar/vue-page-builder": "2.18.0", + "@cocoar/vue-script-editor": "2.18.0", + "@cocoar/vue-ui": "2.18.0", "monaco-editor": "^0.55.1", "pinia": "^3.0.4", "vue": "^3.5.39", @@ -43,7 +43,7 @@ "pnpm": { "overrides": { "dompurify": ">=3.4.7", - "postcss": ">=8.5.14", + "postcss": ">=8.5.18", "ws@>=7.0.0 <7.5.11": ">=7.5.11" } } diff --git a/src/frontend-vue/pnpm-lock.yaml b/src/frontend-vue/pnpm-lock.yaml index 549faa98..ab18e885 100644 --- a/src/frontend-vue/pnpm-lock.yaml +++ b/src/frontend-vue/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: dompurify: '>=3.4.7' - postcss: '>=8.5.14' + postcss: '>=8.5.18' ws@>=7.0.0 <7.5.11: '>=7.5.11' importers: @@ -17,23 +17,23 @@ importers: specifier: ^4.3.2 version: 4.3.2 '@cocoar/vue-data-grid': - specifier: 2.17.1 - version: 2.17.1(@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(@js-temporal/polyfill@0.5.1)(vue@3.5.39(typescript@6.0.3)) + specifier: 2.18.0 + version: 2.18.0(@cocoar/vue-localization@2.18.0(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(@js-temporal/polyfill@0.5.1)(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-fragment-parser': - specifier: 2.17.1 - version: 2.17.1(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + specifier: 2.18.0 + version: 2.18.0(@cocoar/vue-ui@2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-localization': - specifier: 2.17.1 - version: 2.17.1(vue@3.5.39(typescript@6.0.3)) + specifier: 2.18.0 + version: 2.18.0(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-page-builder': - specifier: 2.17.1 - version: 2.17.1(@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + specifier: 2.18.0 + version: 2.18.0(@cocoar/vue-localization@2.18.0(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-script-editor': - specifier: 2.17.1 - version: 2.17.1(monaco-editor@0.55.1)(vue@3.5.39(typescript@6.0.3)) + specifier: 2.18.0 + version: 2.18.0(monaco-editor@0.55.1)(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-ui': - specifier: 2.17.1 - version: 2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + specifier: 2.18.0 + version: 2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -115,18 +115,18 @@ packages: '@cocoar/signalarrr@4.3.2': resolution: {integrity: sha512-j9NpkWW0c0ZHqgAN6euJlOf0K+TJxGbqH0+/+/vIPQJ5PgMHSGmTshiATx2lyMKkWsgjNss9hAglRYUumQbDFw==} - '@cocoar/vue-data-grid@2.17.1': - resolution: {integrity: sha512-FYznr/24P8Cce6/z4g8EgOW3cCGB4SCCGOpuTRXf0/eiVWb0S/tJgvhGD71Z6l+N8+Yd0b8jT8DX1CrnkZoE8Q==} + '@cocoar/vue-data-grid@2.18.0': + resolution: {integrity: sha512-EDIU+QJxswTFArjoo+PFua5zDhKSiKfxGW0Q+ruz74L11mte8hUEPLAVXfHHmrymb7SHrYYhwF3llg0IYT+tTg==} peerDependencies: - '@cocoar/vue-localization': 2.17.1 - '@cocoar/vue-ui': 2.17.1 + '@cocoar/vue-localization': 2.18.0 + '@cocoar/vue-ui': 2.18.0 '@js-temporal/polyfill': ^0.5.1 vue: ^3.5.0 - '@cocoar/vue-fragment-parser@2.17.1': - resolution: {integrity: sha512-er/2+5BZS4xSgyQ8p29YSQZMZ0CvhNn/2jAdfldEaBbHjyQ6suu/EciXIP4rAdO8OoBfSTZLP9mnyDOmwq/Hyg==} + '@cocoar/vue-fragment-parser@2.18.0': + resolution: {integrity: sha512-hMX/j/wuM0oIB+iqhb6YD8qrnSnDO8LOvG9I6fx+yN35s1j4jH3FDGM9DfRrMgIVewquCo70y5gRHw6x2AGlmA==} peerDependencies: - '@cocoar/vue-ui': 2.17.1 + '@cocoar/vue-ui': 2.18.0 vue: ^3.5.0 vue-router: ^4.5.0 || ^5.0.0 peerDependenciesMeta: @@ -135,26 +135,26 @@ packages: vue-router: optional: true - '@cocoar/vue-localization@2.17.1': - resolution: {integrity: sha512-uPY7V4Ij0QuXOMRucd2QV9QOUnFuCMGLaFE0R1imE4OM+GqKBTQKgsIGkuB2Xn7PkgN28GL9OS6RVGvIEWOCqg==} + '@cocoar/vue-localization@2.18.0': + resolution: {integrity: sha512-b5hl1xENl2AkkwByhemJDsWuhyqapHHgxInMHF9xUALSSyt+bevrYSqHzVX15aSV2qBK1YutTYy46gFK4irwzw==} peerDependencies: vue: ^3.5.0 - '@cocoar/vue-page-builder@2.17.1': - resolution: {integrity: sha512-sOuK1NAPLZeBiwEYTi0+bq6rcoCanv2C8LhHPCSrVMUJcF+XO246QpTZXkc2rmRuIb+pAfE9W3HOF+ugBV5pNw==} + '@cocoar/vue-page-builder@2.18.0': + resolution: {integrity: sha512-rfJs9CE9ga434ChvHRiTQS7xI96gVv/U7eUbkpuZ1gdx1Q4o36uPSDmtmU86ur/xK26iPwhNpoQUyTMP95XJJg==} peerDependencies: - '@cocoar/vue-localization': 2.17.1 - '@cocoar/vue-ui': 2.17.1 + '@cocoar/vue-localization': 2.18.0 + '@cocoar/vue-ui': 2.18.0 vue: ^3.5.0 - '@cocoar/vue-script-editor@2.17.1': - resolution: {integrity: sha512-untRHzKtOnJpD57IObaE0NOk5cGtvXtjnyxKPflPS9dnfhpaVg2d1UEkj5TLbwaZgFh/Fzb/AkW1po8xIQHaPA==} + '@cocoar/vue-script-editor@2.18.0': + resolution: {integrity: sha512-xKNmaayzrTpXZPr3eWXz3AirDUiKxmjwMzXnuepbmYOP5lT/OcviF6LRdU+z8Iylv/G/ldUuDZccTTRLu0MSOw==} peerDependencies: monaco-editor: ^0.55.1 vue: ^3.5.0 - '@cocoar/vue-ui@2.17.1': - resolution: {integrity: sha512-VYOoYWTKjDfvU0cS7GtvYbXXFSutFAZLswQq4SBIwsqwytKk7iTtCgCT5+XBbnmSIpxoiPTO7nMxpJuouMppMQ==} + '@cocoar/vue-ui@2.18.0': + resolution: {integrity: sha512-2O1BVNxUnsg847SSmi6mySjm87bH+mXzem0ARcywSX406tNHHBcNekV0cBnn+BbNDBtr4v7yWwr+a/snYCTgEA==} peerDependencies: vue: ^3.5.0 vue-router: ^4.5.0 || ^5.0.0 @@ -774,13 +774,8 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -843,12 +838,8 @@ packages: engines: {node: '>=18'} hasBin: true - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} prismjs@1.30.0: @@ -1098,42 +1089,42 @@ snapshots: - encoding - utf-8-validate - '@cocoar/vue-data-grid@2.17.1(@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(@js-temporal/polyfill@0.5.1)(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-data-grid@2.18.0(@cocoar/vue-localization@2.18.0(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(@js-temporal/polyfill@0.5.1)(vue@3.5.39(typescript@6.0.3))': dependencies: - '@cocoar/vue-localization': 2.17.1(vue@3.5.39(typescript@6.0.3)) - '@cocoar/vue-ui': 2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-localization': 2.18.0(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-ui': 2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) '@js-temporal/polyfill': 0.5.1 ag-grid-community: 35.0.0 ag-grid-vue3: 35.0.0(vue@3.5.39(typescript@6.0.3)) vue: 3.5.39(typescript@6.0.3) - '@cocoar/vue-fragment-parser@2.17.1(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-fragment-parser@2.18.0(@cocoar/vue-ui@2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': dependencies: path-to-regexp: 8.4.2 vue: 3.5.39(typescript@6.0.3) optionalDependencies: - '@cocoar/vue-ui': 2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-ui': 2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) vue-router: 5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) - '@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-localization@2.18.0(vue@3.5.39(typescript@6.0.3))': dependencies: vue: 3.5.39(typescript@6.0.3) - '@cocoar/vue-page-builder@2.17.1(@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-page-builder@2.18.0(@cocoar/vue-localization@2.18.0(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': dependencies: - '@cocoar/vue-localization': 2.17.1(vue@3.5.39(typescript@6.0.3)) - '@cocoar/vue-ui': 2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-localization': 2.18.0(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-ui': 2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) '@js-temporal/polyfill': 0.5.1 vue: 3.5.39(typescript@6.0.3) - '@cocoar/vue-script-editor@2.17.1(monaco-editor@0.55.1)(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-script-editor@2.18.0(monaco-editor@0.55.1)(vue@3.5.39(typescript@6.0.3))': dependencies: monaco-editor: 0.55.1 vue: 3.5.39(typescript@6.0.3) - '@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-ui@2.18.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': dependencies: - '@cocoar/vue-localization': 2.17.1(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-localization': 2.18.0(vue@3.5.39(typescript@6.0.3)) '@fontsource/cascadia-code': 5.2.3 '@fontsource/inter': 5.2.8 '@fontsource/poppins': 5.2.7 @@ -1466,7 +1457,7 @@ snapshots: '@vue/shared': 3.5.35 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.15 + postcss: 8.5.25 source-map-js: 1.2.1 '@vue/compiler-sfc@3.5.39': @@ -1478,7 +1469,7 @@ snapshots: '@vue/shared': 3.5.39 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.16 + postcss: 8.5.25 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.35': @@ -1735,9 +1726,7 @@ snapshots: muggle-string@0.4.1: {} - nanoid@3.3.12: {} - - nanoid@3.3.15: {} + nanoid@3.3.16: {} node-fetch@2.7.0: dependencies: @@ -1793,15 +1782,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss@8.5.15: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.16: + postcss@8.5.25: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1906,7 +1889,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.25 rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index b413260f..d21c5c3c 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -1,6 +1,8 @@ { "common": { "loading": "Laden...", + "systemManagedLabel": "System", + "systemManaged": "Nicht veränderbar.", "colorPicker": "Farbe wählen", "colorInvalid": "Bitte eine gültige Farbe eingeben, z. B. #5A6478.", "create": "Erstellen", @@ -30,6 +32,10 @@ "active": "Aktiv", "enabled": "Aktiviert", "disabled": "Deaktiviert", + "statusTag": { + "active": "Aktiv", + "inactive": "Inaktiv" + }, "none": "Keine", "archive": "Archiv", "restore": "Wiederherstellen", @@ -213,7 +219,7 @@ "confirmPassword": "Passwort bestätigen", "confirmPlaceholder": "Passwort wiederholen", "failed": "Bootstrap fehlgeschlagen.", - "intro": "Setze ein Passwort, um dein Admin-Konto zu aktivieren. Dieser Link ist einmalig verwendbar und läuft in 7 Tagen ab.", + "intro": "Setze ein Passwort, um dein Admin-Konto zu aktivieren. Dieser Link ist einmalig verwendbar und läuft in 24 Stunden ab.", "invalidLink": "Ungültiger Bootstrap-Link. Bitte deinen Administrator um einen neuen Invite.", "newPassword": "Neues Passwort", "passwordMismatch": "Passwörter stimmen nicht überein.", @@ -432,6 +438,7 @@ "accountName": "Account-Name", "accountNamePlaceholder": "ci.build-agent, integrations.acme, …", "accountNameHint": "Kleinbuchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche. Wird im Audit-Log als Handle für diesen Account verwendet.", + "accountNameInvalid": "2–64 Zeichen; nur Kleinbuchstaben, Ziffern, Punkt, Bindestrich und Unterstrich.", "purpose": "Verwendungszweck", "purposePlaceholder": "CI Deployment, nächtliche Sync, …", "active": "Aktiv", @@ -444,45 +451,69 @@ "activeHint": "Inaktive Accounts können sich nicht mehr authentifizieren — bestehende Tokens laufen bis zum Ablauf, neue werden nicht ausgestellt." }, "serviceAccountCredentials": { - "sectionTitle": "Credentials", - "sectionHint": "OAuth-Clients, die diesem Service Account gehören. Jede Credential authentifiziert sich separat an /connect/token, teilt aber Permissions und Group-Memberships dieses SAs.", - "issueButton": "Credential ausstellen", - "issueTitle": "Credential ausstellen", - "editTitle": "Credential bearbeiten", - "secretTitle": "Credential ausgestellt", + "sectionTitle": "OAuth-Clients", + "sectionHintShort": "Diesem Service Account zugeordnete OAuth-Clients.", + "sectionHint": "Jeder OAuth-Client authentifiziert sich mit einer eigenen Client-ID und einem eigenen Secret an /connect/token, teilt aber Permissions und Group-Memberships dieses Service Accounts.", + "issueButton": "OAuth-Client hinzufügen", + "issueTitle": "OAuth-Client hinzufügen", + "editTitle": "OAuth-Client bearbeiten", + "editInitialButton": "OAuth-Client bearbeiten", + "initialDefaultName": "Initialer OAuth-Client", + "initialEmpty": "Noch kein initialer OAuth-Client konfiguriert.", + "outerClientHint": "Der gerade konfigurierte OAuth-Client wird diesem Service Account zugeordnet.", + "outerClientConfigured": "Scopes, Apps, Token-Einstellungen und Secret werden im übergeordneten OAuth-Client konfiguriert.", + "secretTitle": "OAuth-Client angelegt", "secretOnce": "Bitte Client Secret jetzt kopieren — es wird nicht wieder angezeigt.", "clientId": "Client ID", + "section.basics": "Basis", + "section.status": "Status", "displayName": "Anzeigename", + "displayNameHint": "Freitext, damit du diesen OAuth-Client von den anderen dieses Service Accounts unterscheiden kannst.", "displayNamePlaceholder": "CI Build Agent — staging", "scopes": "Scopes", - "scopesHint": "Welche Scopes diese Credential anfordern darf. Realm-weite OIDC-Scopes sind immer verfügbar; pro-API-Scopes brauchen einen passenden App-Link unten.", + "scopesHintShort": "Welche Scopes dieser OAuth-Client anfordern darf.", + "scopesHint": "Welche Scopes dieser OAuth-Client anfordern darf. Realm-weite OIDC-Scopes sind immer verfügbar; pro-API-Scopes brauchen einen passenden App-Link unten.", "scopesAvailable": "Verfügbar", "scopesSelected": "Erlaubt", "scopesSearch": "Scopes suchen…", + "scopeGroupRealmWide": "Realm-weit (OIDC-Standard)", + "scopeGroupApp": "App: {app}", "apps": "Apps", - "appsHint": "Apps, für die diese Credential agieren darf. Leer = realm-weit. Mehrere wenn der M2M-Backend mit verschiedenen APIs spricht.", + "appsHintShort": "Leer = realm-weit.", + "appsHint": "Apps, für die dieser OAuth-Client agieren darf. Leer = realm-weit. Mehrere, wenn das M2M-Backend mit verschiedenen APIs spricht.", "appsAvailable": "Verfügbar", "appsSelected": "Verknüpft", "appsSearch": "Apps suchen…", "appsLinked": "App(s)", + "appGroupSystem": "System-Apps", + "appGroupUser": "User-Apps", "accessTokenLifetime": "Access-Token-Lebenszeit (Sekunden)", + "accessTokenLifetimeHint": "Leer = Realm-Default (3600 s). Bei JWT-Access-Tokens kurz halten — ein JWT bleibt bis zum Ablauf gültig, auch nach einem Revoke.", "accessTokenLifetimePlaceholder": "3600 (Default)", + "accessTokenType": "Access-Token-Format", + "accessTokenTypeHint": "Reference-Tokens sind sofort widerrufbar (Deaktivieren/Löschen/Rotieren wirkt unmittelbar); der Resource Server muss introspektieren. Ein JWT validiert sich selbst, überlebt aber einen Revoke bis zum Ablauf — dann die Lebenszeit kurz halten.", + "accessTokenTypeReference": "Reference (opak, sofort widerrufbar)", + "accessTokenTypeJwt": "JWT (selbst-validierend, Revoke erst bei Ablauf)", "enabled": "Aktiv", + "enabledLabel": "OAuth-Client", + "enabledHint": "Ein inaktiver OAuth-Client kann keine Tokens mehr anfordern — bereits ausgestellte bleiben bis zum Ablauf gültig.", "disabled": "Deaktiviert", "noScopes": "Keine Scopes gesetzt", - "empty": "Noch keine Credentials. Eine ausstellen, damit sich Services als dieser Account authentifizieren können.", + "empty": "Noch keine OAuth-Clients.", "rotateButton": "Rotieren", "rotateTitle": "Secret rotieren?", "rotateConfirm": "Das alte Secret funktioniert sofort nicht mehr; das neue wird nur einmal angezeigt.", "rotated": "Secret rotiert. Bitte jetzt kopieren — es wird nicht wieder angezeigt.", "rotatedTitle": "Neues Secret für", - "deleteTitle": "Credential löschen?", + "deleteTitle": "OAuth-Client löschen?", "deleteConfirm": "Bestehende Tokens bleiben gültig bis zum Ablauf, aber es können keine neuen mehr ausgestellt werden.", - "deleted": "Credential gelöscht.", - "notFound": "Credential nicht gefunden." + "deleted": "OAuth-Client gelöscht.", + "notFound": "OAuth-Client nicht gefunden." }, "userDetails": { "createTitle": "Benutzer erstellen", + "initialPassword": "Initiales Passwort", + "initialPasswordHint": "Optional. Leer lassen für ein Konto, das sich per Magic Link, Passkey oder externem Identity Provider anmeldet.", "profileSection": "Profil", "accountSection": "Konto", "rolesSection": "Rollen", @@ -500,6 +531,7 @@ }, "twoFactorHeading": "Zwei-Faktor-Authentifizierung", "twoFactor": "2FA:", + "twoFactorAfterCreate": "Nach dem Erstellen verfügbar", "noTwoFactor": "Nicht konfiguriert", "graceHeading": "Übergangsfrist", "graceNotStarted": "Die Übergangsfrist startet beim ersten Login.", @@ -508,15 +540,17 @@ "resetGrace": "Frist verlängern (+{days}d)", "clearGrace": "Sofort erzwingen", "policyHeading": "Individuelle Richtlinie", - "policyDays": "Individuelle Frist in Tagen (leer = globaler Default)", + "policyDays": "Individuelle Grace Period", + "policyDaysHint": "Leer verwendet den globalen Standardwert.", "policyDaysPlaceholder": "{days} (Default)", "exemptBadge": "Ausgenommen — 2FA nicht erforderlich", "activeCheckbox": "Benutzer aktiv", "emailVerifiedToggle": "Mail-Adresse als bestätigt markieren", + "emailVerifiedDisabledHint": "Wird verfügbar, sobald eine E-Mail-Adresse eingetragen ist.", "emailVerifiedHint": "Forgot-Password und Self-Magic-Link sind für diesen User freigeschaltet.", "emailUnverifiedHint": "Forgot-Password und Self-Magic-Link sind blockiert, bis der User die Mail bestätigt.", - "exemptCheckbox": "2FA-Pflicht für diesen User deaktivieren", - "exemptHint": "User umgeht Grace und Enforcement komplett. Für Service-Accounts / Legacy-User.", + "exemptCheckbox": "Benutzer von der 2FA-Pflicht ausnehmen", + "exemptHint": "Umgeht Grace Period und 2FA-Enforcement vollständig. Nur für ausdrücklich genehmigte Ausnahme- oder Legacy-Konten.", "directGroups": "Direkte Mitgliedschaften", "inheritedGroups": "Vererbt über verschachtelte Gruppen", "availableGroups": "Verfügbar", @@ -537,7 +571,7 @@ "emailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben.", "inheritedGroups.hint": "Diese Gruppen sind nicht direkt zugewiesen, aber der User ist über eine andere Gruppe (die diese als Mitglied hat) darin enthalten.", "section.identity": "Identität", - "section.signin": "Anmeldung", + "section.signin": "Anmeldung & Status", "section.accountStatus": "Kontostatus", "acronym.hint": "Initialen; erscheinen im Titel als Name | Kürzel. Optional.", "email.hint": "Primäre Adresse; nötig für Passwort-Zurücksetzen und Magic-Link.", @@ -570,22 +604,31 @@ "description": "Beschreibung", "resourceType": "Resource-Typ", "permissions": "Berechtigungen", - "app": "Application", + "app": "Anwendung", + "app.placeholder": "Anwendung wählen…", + "app.realmAdminPlaceholder": "Keine Anwendung (Realm-Administrator)", "app.linkedHint": "Die Rolle vergibt die ausgewählten Berechtigungen dieser Anwendung.", - "app.none": "— Keine (realm-admin-Rolle)", - "app.noneHint": "Keine Anwendungs-Verknüpfung — nur das realm-admin-Flag unten vergibt etwas. Reserviert für die System-Admin-Rolle.", + "app.realmAdminHint": "Realm-Administratoren sind bewusst keiner Anwendung zugeordnet.", + "type.label": "Rollentyp", + "type.hint": "Anwendungsrollen vergeben ausgewählte Berechtigungen; Realm-Administratoren umgehen die Berechtigungsprüfung in diesem Realm.", + "type.application": "Anwendungsrolle", + "type.realmAdmin": "Realm-Administrator", "isRealmAdmin": "Realm Admin (umgeht jede Berechtigungsprüfung, in jedem Realm)", - "isRealmAdmin.warning": "Dieses Flag vergibt realm:admin — den globalen Bypass. Gib es nur der System-Admin-Rolle.", - "permissions.empty": "Die ausgewählte Application hat keine Einträge in ihrem Catalog. Lege zuerst über die App-Verwaltung Einträge an.", - "permissions.hint": "Subset des App-Catalogs. Diese Rolle vergibt jede angekreuzte Permission an User, die sie zugewiesen bekommen (über Direkt- oder Gruppen-Mitgliedschaft).", - "permissions.noApp": "Diese Rolle ist an keine Application gebunden — es gibt nichts zu vergeben. Wähle im Allgemein-Tab eine App, dann erscheint hier deren Catalog.", + "isRealmAdmin.warning": "Diese Rolle vergibt realm:admin und umgeht jede Berechtigungsprüfung in diesem Realm. Anwendung und einzelne Berechtigungen werden entfernt.", + "permissions.empty": "Die ausgewählte Anwendung enthält keine Berechtigungen. Lege sie zuerst in der Anwendungsverwaltung an.", + "permissions.noApp": "Wähle im Tab „Allgemein“ eine Anwendung, bevor du Berechtigungen zuweist.", + "permissions.realmAdmin": "Ein Realm-Administrator umgeht alle Anwendungsberechtigungen dieses Realms; einzelne Berechtigungen können nicht zugewiesen werden.", + "permissions.available": "Verfügbar", + "permissions.selected": "Zugewiesen", + "permissions.search": "Berechtigungen suchen…", "section.identity": "Identität", - "section.danger": "Berechtigung — Vorsicht", + "section.scope": "Geltungsbereich", "name.hint": "Anzeige-/Identifikationsname der Rolle.", "description.hint": "Optionale Notiz, die beschreibt, wofür diese Rolle gedacht ist.", - "isRealmAdmin.label": "Privilegierte Rolle", - "isRealmAdmin.toggle": "System-Administrator (realm:admin)", - "isRealmAdmin.hint": "Umgeht jede Berechtigungsprüfung in jedem Realm — nur für die System-Admin-Rolle.", + "saveError": "Die Rolle konnte nicht gespeichert werden.", + "validation.nameRequired": "Name ist erforderlich.", + "validation.appRequired": "Wähle eine Anwendung für diese Rolle.", + "validation.incomplete": "Fehlende Angaben", "tabs": { "general": "Allgemein", "permissions": "Berechtigungen" @@ -667,7 +710,8 @@ "expand": "An Mitglieder verteilen", "sharedHint": "Die Gruppe hat ein gemeinsames Postfach — Benachrichtigungen gehen an die Adresse rechts.", "sharedHelp": "Benachrichtigungen gehen an diese Adresse.", - "expandHelp": "Benachrichtigungen gehen an jedes Mitglied einzeln (rekursiv über verschachtelte Gruppen)." + "expandHelp": "Benachrichtigungen gehen an jedes Mitglied einzeln (rekursiv über verschachtelte Gruppen).", + "expandAddressHint": "Wird nicht verwendet, solange Benachrichtigungen an die Mitglieder verteilt werden." }, "emailModeLabel": "E-Mail-Modus", "section": { @@ -684,7 +728,12 @@ "boundTo.scopedHint": "Trägt nur zur Berechtigungsauflösung bei, wenn die anfragende Anwendung hier ausgewählt ist. An diese Gruppe gebundene Rollen greifen nur in diesen Anwendungen.", "boundTo.wildcardHint": "★ „Alle Anwendungen“ ausgewählt — diese Gruppe ist in jeder Anwendung des Realms aktiv. Typisch für die realm-admin-Gruppe.", "boundTo.wildcardOption": "★ Alle Anwendungen (*) — realm-weit", - "email": "E-Mail-Adresse" + "email": "E-Mail-Adresse", + "validation": { + "nameRequired": "Name ist erforderlich.", + "scriptRequired": "Für automatische Gruppen ist ein Membership-Script erforderlich.", + "incomplete": "Fehlende Angaben" + } }, "changeRequests": { "title": "Änderungsanfragen", @@ -990,21 +1039,36 @@ "webAuthnRpIdPlaceholder": "leer = Realm-Domain", "webAuthnRpIdHint": "Optional. Eigene Relying-Party-Domain für native Passkeys dieser App (z. B. app.example.com). Leer = Realm-Domain. Achtung: Eine Änderung macht alle bereits registrierten Passkeys dieser App ungültig.", "clientSecret": "Client Secret (leer = generieren)", + "clientSecretHint": "Leer lassen, um beim Erstellen ein starkes einmalig sichtbares Secret zu erzeugen.", "enabled": "Aktiv", "redirectCount": "Redirects", "grantCount": "Grants", "redirectUris": "Redirect-URIs", "postLogoutRedirectUris": "Post-Logout Redirect-URIs", + "urls": { + "navigation": "Redirect- und CORS-Listen" + }, "grantTypes": "Erlaubte Grant-Types", "corsOrigins": "CORS-Origins", "requireSecret": "Secret erforderlich", "requireConsent": "Zustimmung erforderlich", "requirePar": "Pushed Authorization Requests (PAR) erforderlich", "requireParHint": "RFC 9126 — direkte /connect/authorize-Anfragen dieses Clients werden abgelehnt; die Parameter müssen zuerst über /connect/par gepusht werden.", + "requireParCardHint": "Direkte Authorize-Aufrufe ablehnen; Parameter müssen zuerst über /connect/par übertragen werden.", "requireDpop": "DPoP erforderlich", "requireDpopHint": "RFC 9449 — Token-Anfragen dieses Clients ohne DPoP-Proof werden abgelehnt; das Access-Token wird an den Proof-Schlüssel gebunden (cnf.jkt).", + "requireDpopCardHint": "Access-Tokens an den Proof-Key binden und Anfragen ohne DPoP-Proof ablehnen.", "requireDpopNonce": "DPoP-Nonce erforderlich", "requireDpopNonceHint": "RFC 9449 — die DPoP-Proofs dieses Clients müssen eine server-ausgestellte Nonce enthalten; der erste Proof wird mit use_dpop_nonce + einem DPoP-Nonce-Header beantwortet, der Client wiederholt die Anfrage.", + "requireDpopNonceCardHint": "Im DPoP-Proof eine vom Server ausgestellte Nonce verlangen.", + "protocolParTitle": "Pushed Authorization Requests (PAR)", + "protocolParHelpAria": "Details zu PAR", + "protocolDpopHelpAria": "Details zu DPoP", + "section": { + "authentication": "Client-Authentifizierung", + "protocolSecurity": "Protokollabsicherung", + "tokenFormat": "Token-Format" + }, "rememberConsent": "Zustimmung speichern", "tokensInBrowser": "Token im Browser erlaubt", "localLogin": "Lokaler Login erlaubt", @@ -1013,27 +1077,51 @@ "secretOnce": "Bitte Client Secret jetzt kopieren — es wird nicht wieder angezeigt.", "loadFailed": "Client konnte nicht geladen werden.", "validation": { - "noGrants": "Mindestens einen Grant Type wählen (Tab „Grants“) — ohne Grant kann der Client keine Tokens ausstellen.", - "noRedirect": "authorization_code braucht mindestens eine Redirect-URI (Tab „URLs“)." + "noGrants": "Mindestens einen Grant Type wählen (Tab „Flows“) — ohne Grant kann der Client keine Tokens ausstellen.", + "noRedirect": "authorization_code benötigt mindestens eine Redirect-URI (Tab „Redirects & CORS“).", + "noFlows": "Mindestens einen Grant Type wählen — ohne Grant kann der Client keine Tokens ausstellen.", + "mixedGrantModes": "client_credentials kann nicht mit Benutzer-Flows kombiniert werden. Lege dafür einen eigenen Machine-Client an.", + "noServiceAccount": "client_credentials benötigt einen Service Account.", + "newServiceAccountNameRequired": "Für den neuen Service Account ist ein Account-Name erforderlich.", + "newServiceAccountNameInvalid": "Der Account-Name des neuen Service Accounts ist ungültig.", + "noAuthorizationCodeRedirect": "authorization_code benötigt mindestens eine Redirect-URI." }, "tabs": { "general": "Allgemein", + "loginAndConsent": "Login & Zustimmung", "apps": "Apps", "scopes": "Scopes", "grants": "Grants", + "flows": "Flows", "urls": "URLs", + "redirectsAndCors": "Redirects & CORS", "lifetimes": "Token-Laufzeiten", + "tokensAndSessions": "Tokens & Sessions", + "security": "Sicherheit", "dcr": "Registrierungs-Info" }, "lifetimesHint": "Werte in Sekunden. Leer = Default des IdP.", + "lifetimesHelpAria": "Details zu Token-Laufzeiten", + "clientSessionsHelpAria": "Details zu Client-Sessions", + "clientSessionsHint": "Client-Sessions begrenzen die Nutzung von Refresh-Tokens. Leere Werte erben die Richtlinie der verknüpften App und danach des Realms.", "identityTokenLifetime": "Identity-Token", "accessTokenLifetime": "Access-Token", "authCodeLifetime": "Authorization-Code", "slidingRefreshLifetime": "Sliding Refresh-Token", "accessTokenType": "Access-Token-Typ", - "accessTokenType.hint": "JWT: Das Token ist selbsttragend, der Resource Server validiert es lokal über die Signatur. Reference: Das Token ist opak, der RS muss bei jeder Anfrage /connect/introspect aufrufen. JWT ist die richtige Wahl für RS auf Basis von AddJwtBearer.", + "accessTokenType.hint": "Reference: opakes Token, das der Resource Server über /connect/introspect auflöst. JWT: selbsttragendes Token, das lokal anhand der Signatur validiert wird.", "apps": { + "assignment": "App-Zuordnung", "available": "Verfügbare Apps", + "helpAria": "Details zur App-Zuordnung", + "helpTitle": "App-Zuordnung", + "helpScopeTitle": "Gültigkeitsbereich", + "helpEmpty": "Keine App verknüpft: Der Client gilt realm-weit und kann nur Standard-OIDC-Scopes verwenden.", + "helpMultiple": "Mehrere Apps verknüpft: Der Client kann app-übergreifend agieren.", + "helpSelectionTitle": "Mehrfachauswahl", + "helpMulti": "Strg/Cmd + Klick wählt einzelne Einträge.", + "helpRange": "Shift + Klick wählt einen Bereich.", + "helpDrag": "Einträge können auch zwischen den Spalten gezogen werden.", "hint": "Apps, in denen dieser Client agieren darf. Leer = realm-weit (nur Standard-OIDC-Scopes). Mehrere Apps = App-übergreifender Client.", "searchPlaceholder": "Apps suchen…", "selected": "Verknüpft" @@ -1041,6 +1129,7 @@ "corsOrigin": { "placeholder": "https://app.example.com" }, + "corsOriginsHint": "Erlaubte Browser-Origins aus Schema, Host und optionalem Port — ohne Pfad.", "dcr": { "lastUsedAt": "Letzte erfolgreiche Token-Ausstellung", "registeredAt": "Registriert am (UTC)", @@ -1050,20 +1139,66 @@ "dcrOnly": "Nur DCR", "dcrOnly.help": "Nur Clients anzeigen, die über /connect/register (RFC 7591) erzeugt wurden. Nützlich, um von Agents registrierte Clients von admin-erstellten zu unterscheiden.", "grantTypes.available": "Verfügbare Grant-Types", + "grantTypes.assignment": "Flow-Auswahl", + "grantTypes.helpAria": "Details zur Flow-Auswahl", + "grantTypes.helpTitle": "Flow-Auswahl", + "grantTypes.helpPrincipleTitle": "Grundsatz", + "grantTypes.helpPrinciple": "Aktiviere nur die Flows, die der Client tatsächlich benötigt. Es gibt keine stillen Defaults.", + "grantTypes.helpCombinationsTitle": "Typische Kombinationen", + "grantTypes.helpSpa": "SPA / Mobile", + "grantTypes.helpMachine": "Server-zu-Server", + "grantTypes.helpDevice": "TV / CLI / Gerät ohne Browser", + "grantTypes.helpSelectionTitle": "Mehrfachauswahl", + "grantTypes.helpMulti": "Strg/Cmd + Klick wählt einzelne Einträge.", + "grantTypes.helpRange": "Shift + Klick wählt einen Bereich.", + "grantTypes.helpDrag": "Einträge können auch zwischen den Spalten gezogen werden.", + "grantTypes.authorizationCodeDescription": "Interaktiver Benutzer-Flow mit PKCE", + "grantTypes.refreshTokenDescription": "Langlebige Sitzung; erneuert Access-Tokens ohne erneuten Login", + "grantTypes.clientCredentialsDescription": "Machine-to-Machine ohne Benutzer", + "grantTypes.deviceCodeDescription": "Für TVs, CLIs und Geräte ohne eigenen Browser", + "grantTypes.otpDescription": "Einmalcode per E-Mail ohne Browser-Redirect", + "grantTypes.magicDescription": "Magic-Link-Token ohne Browser-Redirect", + "grantTypes.passkeyDescription": "WebAuthn-Assertion ohne Browser-Redirect", "grantTypes.hint": "Keine stillen Defaults: Bleibt dies leer, entsteht ein Client, der keine Tokens ausstellen kann. SPAs / Mobile-Apps: authorization_code + refresh_token. Server-zu-Server: client_credentials. Wähle, was der Client tatsächlich braucht.", "grantTypes.nativeHint": "Native passwortlose Grants (urn:cocoar:otp / :magic / :passkey) sind für diesen Realm aktiviert und unten verfügbar. Füge hier einen hinzu, um diesem Client die passende gt:urn:cocoar:*-Permission zu geben — erst dann kann er einen passwortlosen Nachweis an /connect/token eintauschen.", "grantTypes.nativeDisabledWarning": "Dieser Client hat einen nativen passwortlosen Grant (urn:cocoar:otp / :magic / :passkey) ausgewählt, aber native Grants sind für diesen Realm DEAKTIVIERT — er funktioniert also nicht: Der Token-Endpoint weist den Grant ab und der OTP-Request-Endpoint liefert einen Fehler, statt einen Code zu mailen. Aktiviere sie unter Realm-Einstellungen → Native passwortlose Grants.", "grantTypes.searchPlaceholder": "Suchen…", "grantTypes.selected": "Aktiviert", + "newServiceAccount.button": "Neu anlegen", + "newServiceAccount.title": "Neuen Service Account anlegen", + "newServiceAccount.apply": "Übernehmen", + "newServiceAccount.discard": "Verwerfen", + "newServiceAccount.noPurpose": "Kein Verwendungszweck angegeben", + "newServiceAccount.useExisting": "Vorhandenen auswählen", + "newServiceAccount.invalidName": "2–64 Zeichen; nur Kleinbuchstaben, Ziffern, Punkt, Bindestrich und Unterstrich.", "postLogoutRedirectUri": { "placeholder": "https://app.example.com/signout-callback-oidc" }, + "postLogoutRedirectUrisHint": "Erlaubte Rücksprungziele nach dem Abmelden.", "redirectUri": { "placeholder": "https://app.example.com/signin-oidc" }, + "redirectUrisHint": "Erlaubte Rücksprungziele nach Login; URI wird exakt abgeglichen.", "scopes": { + "assignment": "Scope-Auswahl", "available": "Verfügbare Scopes", - "hint": "OpenIddict weist /connect/authorize- und /connect/token-Anfragen für jeden hier nicht gelisteten Scope ab. Füge für OIDC-Clients mindestens openid + roles hinzu.", + "helpAria": "Details zur Scope-Auswahl", + "helpTitle": "Scope-Auswahl", + "helpModgudTitle": "App-Zuordnung", + "helpApp": "App-spezifische Scopes können nur angefordert werden, wenn der Client mit der zugehörigen App verknüpft ist.", + "helpSelectionTitle": "Mehrfachauswahl", + "helpMulti": "Strg/Cmd + Klick wählt einzelne Einträge.", + "helpRange": "Shift + Klick wählt einen Bereich.", + "helpDrag": "Einträge können auch zwischen den Spalten gezogen werden.", + "openidDescription": "Aktiviert OpenID Connect und ID-Tokens", + "profileDescription": "Basisprofil wie Name und Anzeigename", + "emailDescription": "E-Mail-Adresse und Verifizierungsstatus", + "rolesDescription": "Rollen des Principals im Token", + "permissionsDescription": "Aufgelöste Berechtigungen im Token", + "offlineAccessDescription": "Erlaubt die Ausgabe von Refresh-Tokens", + "groupStandard": "Realm-weit (OIDC-Standard)", + "groupApp": "App: {app}", + "groupRealm": "Realm-weit", "searchPlaceholder": "Scopes suchen…", "selected": "Erlaubt" } @@ -1072,37 +1207,48 @@ "title": "OAuth-Scopes", "emptyHint": "Ein Scope ist eine benannte Berechtigung, die ein Client beim Login anfordern kann (z. B. Lesezugriff auf eine API). Definiere hier eigene Scopes über die Standard-OIDC-Scopes hinaus.", "createTitle": "Scope erstellen", - "name": "Name", - "displayName": "Display Name", + "createBannerLabel": "Neuer Scope", + "createBanner": "Der Scope-Name ist die Protokollkennung, die Clients anfordern, und kann später nicht mehr geändert werden.", + "standardManaged": "Dieser Standard-OIDC-Scope wird vom IdP verwaltet und kann nicht geändert werden.", + "cannotDeleteStandard": "Standard-OIDC-Scopes können nicht gelöscht werden.", + "confirmDelete": "Diesen Scope wirklich löschen?", + "name": "Scope-Name", + "displayName": "Anzeigename", "description": "Beschreibung", - "resources": "Resources (API-Audiences)", - "userClaims": "User Claims", - "required": "Pflicht", - "emphasize": "Hervorheben", - "showInDiscovery": "In Discovery anzeigen", + "resources": "API-Audiences", + "userClaims": "User-Claims", + "enabled": "Aktiv", + "required": "Im Consent verpflichtend", + "emphasize": "Im Consent hervorheben", + "showInDiscovery": "In Discovery veröffentlichen", "loadFailed": "Scope konnte nicht geladen werden.", "allowDcr": "Dynamic Client Registration", - "allowDcr.toggle": "DCR-Clients dürfen diesen Scope anfordern", - "allowDcr.hint": "Per DCR registrierte Clients dürfen diesen Scope nur anfordern, wenn dies aktiviert ist.", + "allowDcr.toggle": "Dynamisch registrierte Clients zulassen", + "allowDcr.hint": "DCR-Clients dürfen diesen Scope nur anfordern, wenn Realm, Ziel-API und dieser Scope die Verwendung erlauben.", "allowDcr.help": "Capability-Eingrenzung für die Dynamic Client Registration. Standardmäßig aus: per DCR registrierte Clients können diesen Scope nicht anfordern, solange es hier nicht ausdrücklich erlaubt ist.", - "app": "Application", - "app.global": "— Global (app-übergreifend, OIDC-Standard)", - "app.globalHint": "App-übergreifender Scope (z. B. Standard-OIDC-Scopes). Jeder Client darf ihn anfordern.", + "app": "Anwendung", + "app.global": "— Realm-weit (app-übergreifend)", + "app.globalHint": "App-übergreifender Scope. Jeder Client, der ihn in seiner Allow-Liste führt, darf ihn anfordern.", "app.scopedHint": "Nur OAuth-Clients, die mit dieser App verknüpft sind, dürfen diesen Scope anfordern.", + "tabs.general": "Allgemein", + "tabs.content": "Token-Inhalt", + "tabs.behavior": "Verhalten", "section.identity": "Identität", - "section.target": "Ziel & Inhalt", - "section.options": "Optionen", - "name.hint": "Maschinen-Identifier, den Clients im scope-Parameter senden (z. B. read:events). Nach dem Anlegen unveränderlich.", + "section.assignment": "Scope-Zuordnung", + "section.content": "Token- und UserInfo-Inhalt", + "section.behavior": "Verfügbarkeit und Consent", + "name.hint": "Technischer Name im OAuth-Protokoll.\n\nBeispiel: acme.read\nNach dem Anlegen unveränderlich.", + "name.placeholder": "acme.read", "displayName.hint": "Lesbarer Name auf dem Zustimmungs-Bildschirm.", "description.hint": "Optionale Erläuterung, die Nutzern auf dem Zustimmungs-Bildschirm angezeigt wird.", - "resources.hint": "API-Audiences, für die ein Token mit diesem Scope gültig ist.", - "userClaims.hint": "OIDC-Claim-Namen, die mit diesem Scope ins Token/UserInfo aufgenommen werden.", - "enabled.hint": "(Standard: an) Scope ist anforderbar.", - "required.hint": "Auf dem Zustimmungs-Bildschirm nicht abwählbar.", - "emphasize.hint": "Auf dem Zustimmungs-Bildschirm als sicherheitsrelevant hervorheben.", - "showInDiscovery.hint": "Im öffentlichen OIDC-Discovery-Dokument sichtbar. (Standard: an)", + "resources.hint": "aud-Werte, für die ein Token mit diesem Scope gültig ist. Einfache Identifier und absolute URIs werden unterstützt.", + "userClaims.hint": "OIDC-Claim-Namen, die mit diesem Scope ins Token oder UserInfo aufgenommen werden.", + "enabled.hint": "Nur aktive Scopes können von Clients angefordert werden.", + "required.hint": "Nutzer können diesen Scope im Zustimmungsdialog nicht abwählen.", + "emphasize.hint": "Hebt den Scope im Zustimmungsdialog als sicherheitsrelevant hervor.", + "showInDiscovery.hint": "Listet den Scope öffentlich unter scopes_supported im OIDC-Discovery-Dokument.", "resource": { - "placeholder": "event-tree-api" + "placeholder": "acme-api" }, "userClaim": { "placeholder": "email" @@ -1112,6 +1258,9 @@ "title": "OAuth-APIs", "emptyHint": "Eine API ist eine geschützte Backend-Ressource, für die Clients Tokens anfordern. Sie besitzt die Scopes, die ein Client für den Zugriff anfragen darf. Definiere hier deine erste API.", "createTitle": "API erstellen", + "createBannerLabel": "Neue API", + "createBanner": "Die Audience identifiziert diesen Resource Server in ausgestellten Tokens und kann später nicht mehr geändert werden.", + "confirmDelete": "Diese API wirklich löschen?", "name": "Name", "displayName": "Anzeigename", "description": "Beschreibung", @@ -1132,13 +1281,13 @@ "created": "Erstellt:", "expires": "Läuft ab:", "loadFailed": "API konnte nicht geladen werden.", - "allowDcr": "DCR-Clients dürfen diese API anfragen", + "allowDcr": "Dynamisch registrierte Clients zulassen", "allowDcr.label": "Dynamische Client-Registrierung (DCR)", - "allowDcr.help": "Standardmäßig aus: dynamisch registrierte Clients können keine Tokens für diese API anfragen, solange dies nicht hier erlaubt wird.", - "app": "Application", + "allowDcr.help": "DCR-Clients dürfen diese API nur adressieren, wenn Realm, API und angeforderter Scope die Verwendung erlauben.", + "app": "Anwendung", "app.linkedHint": "Wenn der IdP ein Token ausstellt, dessen aud zu diesem Resource Server passt, gibt /connect/userinfo einen resource_access-Block aus, der die Berechtigungen des Benutzers über den Catalog der verknüpften App auflöst.", "app.unassigned": "— Nicht zugewiesen (keine UserInfo-Ausgabe)", - "app.unassignedHint": "Ohne App-Verknüpfung hat der IdP keinen Catalog zum Auflösen, daher gibt /connect/userinfo für diese Audience keinen resource_access-Block aus. Nur für Legacy-/Standalone-Setups verwenden.", + "app.unassignedHint": "Ohne App-Verknüpfung hat der IdP keinen Katalog zum Auflösen, daher gibt /connect/userinfo für diese Audience keinen resource_access-Block aus. Nur für Legacy-/Standalone-Setups verwenden.", "implicitScope": { "button": "Scope anlegen", "hint": "Clients brauchen einen Scope, um diese API anzufragen. Erstellt einen Scope mit gleichem Namen (Resources = Audience, nicht im Discovery sichtbar).", @@ -1149,14 +1298,14 @@ "permissionsHint": "Welche Berechtigungen des Katalogs diese API absichert. UserInfo gibt nur die Schnittmenge aus dieser Auswahl und den Berechtigungen des Nutzers zurück.", "section.identity": "Identität", "section.linkage": "Verknüpfung", - "section.surface": "OAuth-Oberfläche", - "section.options": "Optionen", - "section.config": "OAuth & Optionen", + "section.surface": "Token-Inhalt", + "section.options": "Verhalten", + "section.config": "Token & Verhalten", "section.review": "Überprüfen", "audience": "Audience (aud)", - "audience.hint": "Der aud-Wert dieser geschützten Ressource — genau dieser Wert landet im Token (aud) und ist der resource=-Wert, den ein Client anfragt. Nach dem Anlegen unveränderlich (Tokens, Scopes und Clients referenzieren ihn).", + "audience.hint": "Identifier im aud-Claim des Tokens. Einfache Bezeichner und absolute URIs werden unterstützt.\n\nBeispiel: acme-api\nNach dem Anlegen unveränderlich.", "audience.immutable": "unveränderlich", - "audience.placeholder": "https://event-tree.api", + "audience.placeholder": "acme-api", "review.hint": "Prüfe die Angaben und lege die API an. Die Audience ist danach unveränderlich.", "review.permissions": "Berechtigungen", "name.hint": "Audience (aud) der geschützten Ressource; das Token eines Clients muss auf diesen Namen zielen. Nach dem Anlegen unveränderlich.", @@ -1165,9 +1314,10 @@ "app.hint": "Verknüpft diese API mit dem Berechtigungs-Katalog einer Anwendung; UserInfo löst die Berechtigungen des Nutzers darüber auf.", "scopes.hint": "Scopes, die ein Client anfragen darf, um Tokens für diese API zu erhalten.", "userClaims.hint": "Nutzer-Claims, die in Access-Tokens dieser API aufgenommen werden.", - "enabled.hint": "Deaktivierte APIs nehmen keine Tokens mehr an.", + "enabled": "Aktiv", + "enabled.hint": "Nur aktive APIs können von neu ausgestellten Tokens adressiert werden.", "scope": { - "placeholder": "event-tree.api" + "placeholder": "acme.read" }, "userClaim": { "placeholder": "email" @@ -1182,10 +1332,15 @@ "displayName": "Display Name", "slug": "Slug", "slugPlaceholder": "z. B. acme-entra", - "slugHintCreate": "Erscheint in den Provider-URLs (z. B. /signin-oidc/). Nach dem Anlegen nicht mehr änderbar.", - "slugHintEdit": "Nicht änderbar — ein anderer Slug bedeutet löschen + neu anlegen.", + "slugHintCreate": "Erscheint in den Provider-URLs. Nach dem Anlegen nicht mehr änderbar.", + "slugHintCreateOidc": "Teil der OIDC-Callback-URL „/signin-oidc/“. Nach dem Anlegen nicht mehr änderbar.", + "slugHintCreateSaml": "Teil der SAML-Endpunkte, z. B. „/saml//acs“. Nach dem Anlegen nicht mehr änderbar.", + "slugHintEdit": "URL-Kennung des Providers. Zum Ändern muss der Provider neu angelegt werden.", "slugInvalid": "Slug muss 3-64 Zeichen lang sein: Kleinbuchstaben, Ziffern, Bindestriche; Beginn mit Buchstabe, Ende alphanumerisch.", "flavor": "Flavor", + "flavor.hint": "Legt Protokoll, Felder und sinnvolle Standardwerte fest.", + "active": "Aktiv", + "active.hint": "Aktive Provider erscheinen sofort auf der Login-Seite. Die Verbindung muss dafür vollständig konfiguriert sein.", "enabled": "Aktiviert", "disabled": "Deaktiviert", "enable": "Aktivieren", @@ -1200,35 +1355,62 @@ "tabGeneral": "Allgemein", "tabConnection": "Verbindung", "tabUserUpdate": "User-Update-Script", - "tabLinking": "Verknüpfung & Richtlinien", + "tabLinking": "Benutzer & Vertrauen", "redirectUri": "Redirect URI", "samlSpMetadataUrl": "SP-Metadata-URL", "samlAcsUrl": "ACS-URL / Reply-URL", "iconName": "Button-Icon", "buttonColorHex": "Button-Farbe", + "preview": "Vorschau", + "previewFallback": "Mit Provider anmelden", + "section.provider": "Provider", "section.identity": "Identität", "section.appearance": "Erscheinungsbild", "section.idpIntegration": "IdP-Integration", + "section.connection": "Provider-Verbindung", + "section.credentials": "Client-Zugangsdaten", + "section.oidcProtocol": "OIDC-Verhalten", + "section.oidcRequest": "Anmeldeanforderung", + "section.oidcClaimsTokens": "Claims & Tokens", + "section.samlProtection": "Signaturen & Verschlüsselung", + "section.samlProtocol": "Protokoll & Metadaten", + "section.provisioning": "Provisionierung", + "section.accountLinking": "Kontoverknüpfung", + "section.trust": "Profil & Autorisierung", + "section.diagnostics": "Diagnose", "displayName.hint": "Erscheint auf dem Login-Button; setzt den Slug vor.", "description.hint": "Optionale Notiz zur internen Beschreibung dieses Providers.", "iconName.hint": "Name eines Lucide-Icons (z. B. microsoft, key, building). Siehe lucide.dev.", "buttonColorHex.hint": "Hex-Farbe des Login-Buttons (optional, z. B. #0078D4).", "idpReadOnlyHint": "Schreibgeschützt — in die App-Registrierung des externen IdP eintragen.", - "scopes": "Scopes (leer- oder komma-getrennt)", + "clientId.hint": "Client-ID aus der App-Registrierung des externen IdP.", + "scopes": "Scopes", + "scopes.hint": "Leer- oder komma-getrennte OIDC-Scopes.", "clientSecret": "Client-Secret", "secretSet": "Secret ist konfiguriert. Neuen Wert eingeben, um zu rotieren.", "secretMissing": "Kein Secret gesetzt — setze eines, bevor du den Provider aktivierst.", "rotateSecret": "Rotieren", "setSecret": "Setzen", - "autoCreate": "Neue User beim ersten Login automatisch anlegen (JIT)", - "allowLinking": "User dürfen diesen Provider im Profil verknüpfen", - "trustForEmailLink": "Email-basierte Auto-Verknüpfung — bei gleicher Email an bestehenden lokalen User binden (GEFÄHRLICH: nur bei tenant-eigenen Providern)", - "trustForAuthorization": "Für Autorisierung vertrauen — Logins über diesen Provider dürfen session-weise Gruppenzugehörigkeit ableiten (nur „extern treibbare\" Gruppen; niemals realm:admin). Standard: aus.", - "authoritativeForProfile": "Profil-autoritativ — dieser Provider darf die Profilfelder (Vorname/Nachname/Email/Kürzel) bei jedem Login schreiben. Standard: aus (der anlegende Provider ist per Default autoritativ).", - "allowedEmailDomains": "Erlaubte Email-Domänen (komma-getrennt, leer = kein Filter)", - "storeRawClaims": "Roh-Claim-Snapshots pro Login speichern (für Debugging)", - "rawRetentionDays": "Aufbewahrung der Rohclaims (Tage, leer = unbegrenzt)", + "autoCreate": "Benutzer automatisch anlegen (JIT)", + "autoCreate.hint": "Legt unbekannte Benutzer bei der ersten erfolgreichen Anmeldung lokal an.", + "allowLinking": "Verknüpfung im Benutzerprofil erlauben", + "allowLinking.hint": "Benutzer dürfen diesen Provider mit ihrem bestehenden Konto verbinden.", + "trustForEmailLink": "Automatisch über E-Mail verknüpfen", + "trustForEmailLink.hint": "Bindet externe Identitäten bei gleicher E-Mail-Adresse automatisch an bestehende Konten. Nur bei vollständig kontrollierten, tenant-eigenen Providern verwenden.", + "trustForEmailLink.warning": "E-Mail-Auto-Verknüpfung ist sicherheitskritisch und darf nur für tenant-eigene Provider aktiviert werden.", + "trustForAuthorization": "Für Autorisierung vertrauen", + "trustForAuthorization.hint": "Darf sitzungsbezogene Mitgliedschaften in extern steuerbaren Gruppen ableiten; niemals realm:admin.", + "authoritativeForProfile": "Profil-autoritativ", + "authoritativeForProfile.hint": "Darf Vorname, Nachname, E-Mail und Kürzel bei jeder Anmeldung aktualisieren.", + "allowedEmailDomains": "Erlaubte E-Mail-Domänen", + "allowedEmailDomains.hint": "Komma- oder leerzeichengetrennt. Leer bedeutet keine Einschränkung.", + "storeRawClaims": "Roh-Claims speichern", + "storeRawClaims.hint": "Speichert den vom IdP gelieferten Claim-Snapshot pro Anmeldung für Diagnosezwecke.", + "rawRetentionDays": "Aufbewahrung in Tagen", + "rawRetentionDays.hint": "Leer bedeutet unbegrenzt.", "userUpdateScript": "User-Update-Script", + "testScript": "User-Update-Script testen", + "testScriptAction": "Script testen", "testPanel": "Test", "loadLastClaims": "Letzter Login", "runTest": "Ausführen", @@ -1249,8 +1431,7 @@ } }, "builtIn": { - "badge": "System", - "banner": "Dies ist der eingebaute interne Login-Provider — die Konfiguration wird vom System verwaltet und kann hier nicht geändert werden." + "badge": "System" }, "errors": { "typeNotSupported": "Login-Provider-Typ {type} wird derzeit nicht unterstützt.", @@ -1258,6 +1439,9 @@ "internalNotEditable": "Der eingebaute interne Login-Provider kann nicht bearbeitet werden." }, "addMapping": "Mapping hinzufügen", + "mappingNavigation": "Claim-Mapping auswählen", + "attributeMapShort": "Attribut-Mapping", + "amrMappingShort": "AMR-Mapping", "amrClassRef": "AuthnContextClassRef-URI", "amrMapping": "AMR-Mapping (AuthnContextClassRef → AMR)", "amrValues": "AMR-Werte (komma-getrennt)", @@ -1265,10 +1449,20 @@ "claimLogicalName": "Claim (z. B. email, given_name)", "claimUris": "SAML-Attribut-URIs (komma-getrennt)", "flavorRequired": "Flavor auswählen", - "secretInitial": "Initiales Secret (optional; kann später unter Verbindung gesetzt werden).", + "secretInitial": "Wird beim Erstellen verschlüsselt gespeichert und nicht wieder angezeigt.", "secretRotationFailed": "Provider angelegt, aber Secret konnte nicht gesetzt werden — bitte unter „Verbindung“ erneut versuchen.", - "tabAdvanced": "Erweitert", - "tabClaimMapping": "Claim-Mapping" + "tabAdvanced": "Protokoll & Sicherheit", + "tabClaimMapping": "Claim-Mapping", + "validation": { + "displayName": "Display Name ist erforderlich.", + "slug": "Ein gültiger Slug ist erforderlich.", + "incomplete": "Fehlende Angaben", + "requiredField": "{field} ist erforderlich.", + "clientId": "Client-ID fehlt.", + "clientSecret": "Client-Secret fehlt.", + "samlMetadata": "IdP-Metadaten fehlen.", + "notReady": "Der Provider kann noch nicht aktiviert werden: {issues}" + } }, "apps": { "title": "Anwendungen", @@ -1291,19 +1485,26 @@ }, "catalogBlocked": "Einige Catalog-Einträge sind noch in Verwendung.", "confirmDelete": "App wirklich löschen?", + "createBannerLabel": "Neue App", "createHint": "Eine neue App registriert sich für die Permission-Resolution. Der Slug ist nach dem Erstellen unveränderbar.", - "createTitle": "Application erstellen", - "displayName": "Display Name", - "loadFailed": "Application konnte nicht geladen werden.", + "createTitle": "Anwendung erstellen", + "displayName": "Anzeigename", + "displayName.hint": "Menschenlesbarer Name, der in Listen und Auswahlelementen angezeigt wird.", + "description.hint": "Optionale Notiz zum Produkt oder System, das diese Anwendung repräsentiert.", + "loadFailed": "Anwendung konnte nicht geladen werden.", "permissionsHint": "Resource und Action je 1+ Kleinbuchstaben/Ziffern/Bindestriche. Ids bleiben über Umbenennungen stabil — Role-Grants und RS-Subsets folgen automatisch.", "permissionsHintSystem": "Permission-Catalog der System-App — schreibgeschützt. Diese Einträge entsprechen 1:1 den RequiresPermission-Aufrufen im Backend-Code.", - "slug": "Slug (unveränderbar)", + "slug": "Slug", + "slug.hint": "Dauerhafte URL- und API-Kennung in Kebab-Case.\n\nBeispiel: acme-portal\nNach dem Anlegen unveränderlich.", "slugPlaceholder": "kebab-case-slug", - "systemHint": "Dies ist eine System-App des IdP. Slug, Display Name und Permission-Catalog sind im Backend fest hinterlegt — der Catalog hier ist schreibgeschützt und dient nur zur Einsicht. Änderungen an den Strings würden die RequiresPermission-Aufrufe im Backend brechen.", "tabs": { "catalog": "Permission-Catalog", "general": "Allgemein", "settings": "Einstellungen" + }, + "validation": { + "displayName": "Anzeigename ist erforderlich.", + "slug": "Gib einen gültigen Slug mit 3–63 Kleinbuchstaben, Ziffern oder Bindestrichen ein." } }, "realms": { @@ -1315,15 +1516,27 @@ "slugPlaceholder": "kebab-case-slug", "displayName": "Anzeigename", "section.identity": "Identität", + "section.routing": "Routing", "section.status": "Status", + "tabs": { + "general": "Allgemein", + "domains": "Domains", + "domainsHint": "Mindestens eine Domain ist erforderlich. Die Primär-Domain wird für Links in E-Mails und für Passkeys verwendet." + }, "slug.hint": "Dauerhafte URL-/API-Kennung in Kebab-Case. Nach dem Anlegen unveränderlich.", "displayName.hint": "Menschenlesbarer Name, der im Realm-Umschalter und in Kopfzeilen angezeigt wird.", "description.hint": "Optionale Notiz, die den Zweck dieses Realms beschreibt.", - "initialAdminEmailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben.", "isActive.hint": "Inaktive Realms können sich nicht anmelden und nicht zur Control-Plane werden.", + "isActive.controlPlaneHint": "Der Control-Plane-Realm kann nicht deaktiviert werden.", + "validation": { + "slug": "Gib einen gültigen Slug mit 3–63 Kleinbuchstaben, Ziffern oder Bindestrichen ein.", + "displayName": "Anzeigename ist erforderlich.", + "domains": "Füge mindestens eine Domain hinzu." + }, "domains": "Domains", "primaryDomain": "Primär-Domain", "primaryBadge": "Primär", + "makePrimary": "Als Primär-Domain festlegen", "newDomainRadioLabel": "Neue Domain", "addDomain": "Domain hinzufügen", "domainsEmpty": "Noch keine Domains — füge mindestens eine hinzu. Die erste wird automatisch zur Primär-Domain.", @@ -1344,26 +1557,39 @@ "domain": { "placeholder": "auth.example.com" }, - "initialAdminEmail": "E-Mail", - "initialAdminFirstname": "Vorname", - "initialAdminHint": "Wird per Magic-Link zum Aktivieren eingeladen — die empfangende Person setzt ihr Passwort selbst. Pflichtfelder: Benutzername und E-Mail.", - "initialAdminLastname": "Nachname", - "initialAdminTitle": "Erster Admin", - "initialAdminUserName": "Benutzername", - "inviteEmail": "E-Mail", - "inviteExpiresAt": "Gültig bis", - "inviteIssuedHint": "Diese Magic-Link-URL wird genau einmal angezeigt. Falls die E-Mail nicht zugestellt wird (z. B. lokale Entwicklung ohne SMTP), kopieren Sie sie jetzt — danach geht es nur noch über „Resend“.", - "inviteIssuedTitle": "Realm angelegt — Bootstrap-Invite ausgestellt.", - "inviteLink": "Magic-Link", - "inviteResentTitle": "Bootstrap-Invite neu ausgestellt — der alte Token wurde widerrufen.", - "inviteUserName": "Benutzername", - "resendHint": "Bootstrap-Invite erneut ausstellen (z. B. wenn der Token abgelaufen ist oder die E-Mail nie zugestellt wurde).", - "resendInvite": "Invite erneut senden" + "adminInvite": { + "action": "Realm-Admin einladen", + "title": "Realm-Admin einladen", + "submit": "Einladung erstellen", + "singleActive": "Pro Realm kann nur eine Einladung aktiv sein. Eine neue Einladung widerruft den bisherigen Link und ist 24 Stunden gültig.", + "issued": "Die Einladung wurde erstellt. Wenn der E-Mail-Versand eingerichtet ist, wurde sie auch versendet.", + "linkOnceShort": "Der Magic-Link wird nur jetzt angezeigt.", + "linkOnce": "Kopiere den Link jetzt, falls lokal kein E-Mail-Versand eingerichtet ist. Eine neue Einladung macht diesen Link sofort ungültig.", + "userName": "Benutzername", + "email": "E-Mail", + "emailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben.", + "firstname": "Vorname", + "lastname": "Nachname", + "expiresAt": "Gültig bis", + "link": "Magic-Link" + } }, "realmSettings": { "title": "Realm-Einstellungen", "saved": "Gespeichert.", + "unsaved": "Ungespeicherte Änderungen in diesem Bereich.", + "noUnsaved": "Keine ungespeicherten Änderungen.", + "saveArea": "Bereich speichern", + "unsavedLeave": "Es gibt ungespeicherte Realm-Einstellungen. Seite verlassen und Änderungen verwerfen?", + "field": "Feld", + "requirement": "Anforderung", "tabs": { + "registration": "Registrierung", + "sessions": "Sessions", + "oauthCapabilities": "OAuth & Clients", + "security": "Sicherheit", + "dataRetention": "Daten & Aufbewahrung", + "pages": "Anmeldeseiten", "selfRegistration": "Self-Registration", "dcr": "Dynamic Client Registration", "cimd": "Client-ID-Metadaten (CIMD)", @@ -1374,6 +1600,18 @@ "signingKeys": "Signing-Schlüssel", "registrationFields": "Pflichtfelder" }, + "sections": { + "selfRegistration": "Self-Registration", + "registrationFields": "Registrierungsfelder", + "dcr": "Dynamic Client Registration", + "cimd": "Client-ID-Metadaten (CIMD)", + "nativeGrants": "Native passwortlose Grants", + "rateLimits": "Authentifizierungs-Rate-Limits", + "signingKeys": "Signing-Schlüssel", + "auditRetention": "Protokoll-Aufbewahrung", + "accountDeletion": "Konto-Löschung", + "pages": "Aktive Anmeldeseiten" + }, "signingKeys": { "hint": "Dieser Realm signiert seine OpenIddict Access- und ID-Tokens mit einem realm-eigenen RSA-Schlüssel. Beim Rotieren wird ein frischer Schlüssel für neue Tokens erzeugt; der bisherige wird stillgelegt, bleibt aber 30 Tage lang im JWKS, damit bereits ausgestellte Tokens gültig bleiben. Abgelaufene stillgelegte Schlüssel werden automatisch entfernt.", "warning": "Nur rotieren, wenn es einen Grund gibt (Verdacht auf Schlüssel-Kompromittierung, geplante Hygiene). Resource-Server, die das JWKS aggressiv cachen, können neue Tokens kurzzeitig ablehnen, bis sie aktualisieren.", @@ -1407,6 +1645,8 @@ "requireEmailVerification": "E-Mail-Verifizierung verlangen", "requireAdminApproval": "Admin-Freigabe verlangen", "allowedDomains": "Erlaubte E-Mail-Domains (leer = alle)", + "allowedDomainsHint": "Leer lassen, um jede E-Mail-Domain zu erlauben.", + "addDomain": "Domain hinzufügen", "allowedDomains.placeholder": "example.com", "defaultGroups": "Default-Gruppen (Auto-Mitgliedschaft nach Verifikation)", "defaultGroups.placeholder": "Gruppen wählen…", @@ -1433,6 +1673,7 @@ "reservedNames": "Reservierte Client-Namen (Teilstring-Match, NFKC + Groß-/Kleinschreibung egal)", "reservedNames.help": "Verhindert client_name-Impersonation. Alles, was einen dieser Strings enthält, wird bei der Registrierung abgewiesen. Jeder Eintrag wird vor dem Vergleich NFKC-normalisiert und in Kleinbuchstaben umgewandelt.", "reservedNames.placeholder": "Cocoar", + "addReservedName": "Namen hinzufügen", "tripleOptInWarning": "Dreifaches Opt-in: Hier registrierte Clients können Access-Tokens nur für OAuth-APIs mit aktiviertem AllowDynamicRegistration UND für Scopes mit aktiviertem AllowDynamicRegistrationClients anfordern. Solange du nicht mindestens eine API und einen Scope freigibst, können DCR-Clients keine nutzbaren Tokens ausstellen." }, "cimd": { @@ -1451,6 +1692,7 @@ }, "authRateLimits": { "hint": "Pro-IP-Anfrage-Obergrenzen für die Auth-Endpoints dieses Realms: höchstens „Max. Anfragen“ pro „Fenster“ (Minuten) von einer Quell-IP. Die Defaults sind die sichere Produktions-Vorgabe — erhöhe sie nur für Test-Realms, Dev oder legitim stoßweise Consumer; senke sie zum Verschärfen. Jeder Wert gilt pro Realm.", + "flow": "Authentifizierungsablauf", "permitLimit": "Max. Anfragen", "windowMinutes": "Fenster (Minuten)", "nativeOtp": "Native-OTP-Anfrage (passwortloser Login-Code)", @@ -1461,6 +1703,31 @@ "passkeyBegin": "Passkey-Ceremony Begin / Enroll", "bootstrap": "First-Admin-Bootstrap" }, + "sessions": { + "browser": { + "title": "Browser- und SSO-Sessions", + "hint": "Diese Sessions liegen dem signierten Anwendungs-Cookie zugrunde. Die Idle-Lebensdauer verlängert sich bei Nutzung; die absolute Lebensdauer nie.", + "idle": "Idle-Lebensdauer (Minuten)", + "absolute": "Absolute Lebensdauer (Minuten)", + "remember": "Dauerhafte „Angemeldet bleiben“-Cookies erlauben" + }, + "client": { + "title": "Native App- und OAuth-Client-Sessions", + "hint": "Realm-Standard für Refresh-Token-basierte Sessions. Anwendungen und einzelne OAuth-Clients können ihn überschreiben.", + "idle": "Idle-Lebensdauer (Tage)", + "absolute": "Absolute Lebensdauer (Tage)" + } + }, + "audit": { + "hint": "Security-Events gehören zu diesem Realm und werden nach der Aufbewahrungsfrist endgültig gelöscht. Die Event-Sourcing-Audit-Historie verwendet ein separates Sichtbarkeitsfenster.", + "securityRetentionDays": "Security-Event-Aufbewahrung (Tage)", + "securityRetentionHelp": "Erlaubter Bereich: 1–365 Tage. Der Prune-Job löscht ausschließlich abgelaufene Events.", + "visibilityWindowDays": "Sichtbarkeit der Audit-Historie (Tage)", + "visibilityHelp": "Blendet ältere Audit-Einträge aus, ohne deren aggregierte Historie zu löschen." + }, + "pages": { + "hint": "Lege fest, welche Authentifizierungsseite für diesen Realm aktiv ist. Änderungen werden sofort angewendet." + }, "regFields": { "hint": "Welche Identitätsfelder bei der Kontoerstellung gefordert sind (Admin-Anlage, Selbstregistrierung, native passwortlose Registrierung). E-Mail ist immer Pflicht. Pro Application überschreibbar." } @@ -1743,7 +2010,7 @@ "localOnlyHint": "Deine {idp}-Sitzung bleibt aktiv", "title": "Abmelden" }, - "admin.apps.renamedWarning": "${renamedCount} Eintrag/Einträge wurden umbenannt. Die String-Form ändert sich (z.B. in UserInfo), aber Role-Grants und RS-Subsets folgen automatisch über die stabile Id.", + "admin.apps.renamedWarning": "{count} Eintrag/Einträge wurden umbenannt. Die String-Form ändert sich (z.B. in UserInfo), aber Role-Grants und RS-Subsets folgen automatisch über die stabile Id.", "admin.appSettings.inherit": "(vom Realm erben)", "admin.appSettings.posture.off": "Off — keine Selbstregistrierung", "admin.appSettings.hint": "Diese Einstellungen überschreiben die Realm-Defaults nur für diese App. Ein deaktivierter Abschnitt erbt vom Realm.", @@ -1777,5 +2044,24 @@ "admin.changeRequests.fieldLabels.email": "E-Mail", "admin.groupDetails.via": "über", "admin.appContext.showAll": "Alle anzeigen", - "admin.appContext.realmWide": "Realm-weit (Global)" + "admin.appContext.realmWide": "Realm-weit (Global)", + "admin.realmSettings.dcr.tripleOptInWarningShort": "Dreifaches Opt-in nötig, bevor DCR-Clients nutzbare Tokens ausstellen können.", + "admin.realmSettings.cimd.optInWarningShort": "Opt-in nötig; der Server ruft die Metadaten-URL des Clients ab.", + "admin.realmSettings.nativeGrants.optInWarningShort": "Opt-in pro Client weiterhin nötig — dieser Realm-Schalter allein genügt nicht.", + "admin.realmSettings.audit.hintShort": "Security-Events werden nach der konfigurierten Aufbewahrung hart gelöscht.", + "admin.realmSettings.signingKeys.warningShort": "Schlüssel nur mit gutem Grund rotieren — gecachte JWKS können neue Tokens kurz ablehnen.", + "admin.oauthClients.grantTypes.nativeHintShort": "Passwortlose Grants sind für diesen Realm aktiv — füge einen hinzu, um ihn diesem Client zu erlauben.", + "admin.oauthClients.grantTypes.nativeDisabledWarningShort": "Ein nativer Grant ist gewählt, aber für diesen Realm deaktiviert — er funktioniert nicht.", + "admin.oauthClients.clientSessionsHintShort": "Client-Sessions steuern die Refresh-Token-Nutzung — Idle-Lebensdauer gleitet, absolute ist fix.", + "admin.realms.inviteIssuedHintShort": "Die Magic-Link-URL wird nur einmal angezeigt — jetzt kopieren, falls keine E-Mail eingerichtet ist.", + "admin.realms.transferDoneHintShort": "Die Control-Plane-Administration liegt jetzt auf den Domain(s) des Ziel-Realms.", + "admin.realms.primaryChangedWarningShort": "Das Ändern der Primär-Domain macht bestehende Passkeys dieses Realms ungültig.", + "admin.realms.isControlPlaneNoteShort": "Dieser Realm hostet als Control Plane die realm-übergreifende Administration.", + "admin.apps.systemHintShort": "System-App — der Catalog ist schreibgeschützt und im Backend fest hinterlegt.", + "admin.apps.renamedWarningShort": "{count} Eintrag/Einträge umbenannt — die String-Form ändert sich, die Id bleibt stabil.", + "admin.appSettings.regFields.hintShort": "Welche Identitätsfelder bei der Registrierung Pflicht sind. E-Mail ist immer Pflicht.", + "admin.appSettings.sessions.hintShort": "Überschreibt den Realm-Default für die Refresh-Token-Sessions dieser App.", + "admin.appSettings.pages.hintV3Short": "Wähle je Slot die Seiten-Variante dieser App; „erben“ folgt dem Realm.", + "consent.cimdWarningShort": "Diese App wird durch die Domain {host} identifiziert — stelle sicher, dass du ihr vertraust.", + "consent.dcrWarningShort": "Diese App hat sich selbst registriert; ihr Name ist nicht verifiziert." } diff --git a/src/frontend-vue/public/i18n/en.json b/src/frontend-vue/public/i18n/en.json index 9e26dfee..f409cc92 100644 --- a/src/frontend-vue/public/i18n/en.json +++ b/src/frontend-vue/public/i18n/en.json @@ -1 +1,8 @@ -{} \ No newline at end of file +{ + "common": { + "statusTag": { + "active": "Active", + "inactive": "Inactive" + } + } +} diff --git a/src/frontend-vue/src/assets/styles/main.css b/src/frontend-vue/src/assets/styles/main.css index 09f41427..49b079ec 100644 --- a/src/frontend-vue/src/assets/styles/main.css +++ b/src/frontend-vue/src/assets/styles/main.css @@ -213,6 +213,11 @@ body { margin-top: 0.25rem; } +/* Form-field help may deliberately contain short paragraphs or examples. */ +.coar-form-field__status-section--hint .coar-form-field__status-section-body > p { + white-space: pre-line; +} + /* Scrollbar */ ::-webkit-scrollbar { width: 6px; diff --git a/src/frontend-vue/src/components/AssetPicker.vue b/src/frontend-vue/src/components/AssetPicker.vue index dc9a0ad6..e865afa9 100644 --- a/src/frontend-vue/src/components/AssetPicker.vue +++ b/src/frontend-vue/src/components/AssetPicker.vue @@ -1,6 +1,6 @@ diff --git a/src/frontend-vue/src/views/admin/AdminView.vue b/src/frontend-vue/src/views/admin/AdminView.vue index 7a08b9e0..728d2f19 100644 --- a/src/frontend-vue/src/views/admin/AdminView.vue +++ b/src/frontend-vue/src/views/admin/AdminView.vue @@ -24,8 +24,9 @@ interface NavItemDef { to: string /** * Resource permissions that grant visibility. Matches if the user holds - * any of these. `app:admin` is a global bypass and applied implicitly by - * `authStore.hasPermission`. + * any of these. `realm:admin` is the current-realm bypass and + * `:admin` is the resource-wide bypass; both are applied + * implicitly by `authStore.hasPermission`. */ requirePermissions: string[] /** @@ -87,7 +88,7 @@ const sections = computed(() => [ { label: 'admin.apps.title', labelEn: 'Applications', icon: 'layout-grid', to: '/admin/apps', requirePermissions: ['app:read'] }, { label: 'admin.realms.title', labelEn: 'Realms', icon: 'globe', to: '/admin/realms', requirePermissions: ['realm:read'] }, { label: 'admin.realmSettings.title', labelEn: 'Realm Settings', icon: 'sliders-horizontal', to: '/admin/realm-settings', requirePermissions: ['realm-settings:read'] }, - { label: 'admin.logs.title', labelEn: 'Logs', icon: 'scroll-text', to: '/admin/logs', requirePermissions: ['auth-log:read', 'audit-log:read'] }, + { label: 'admin.logs.title', labelEn: 'Logs', icon: 'scroll-text', to: '/admin/logs', requirePermissions: ['auth-log:read', 'audit-log:read', 'platform-audit:read'] }, { label: 'admin.scheduledJobs.title', labelEn: 'Scheduled Jobs', icon: 'clock', to: '/admin/scheduled-jobs', requirePermissions: ['scheduled-job:read'] }, { label: 'admin.changeRequests.title', labelEn: 'Change Requests', icon: 'inbox', to: '/admin/change-requests', requirePermissions: ['user:write'] }, ], diff --git a/src/frontend-vue/src/views/admin/AuthLogView.vue b/src/frontend-vue/src/views/admin/AuthLogView.vue index e4319a30..69086987 100644 --- a/src/frontend-vue/src/views/admin/AuthLogView.vue +++ b/src/frontend-vue/src/views/admin/AuthLogView.vue @@ -13,21 +13,28 @@ const { t } = useI18n() const { searchPlaceholder, applyListGridDefaults } = useGridLocale() const http = useHttpClient('/api/admin/auth-log') -// Streamless security/ops store (logging/audit redesign Track A — the half with no -// aggregate stream): unknown-actor login attempts, probes, rate-limits, policy -// rejections, and operational actions. Cross-realm in the system DB; a tenant -// realm-admin sees their own realm's tenant-visible rows, the control-plane realm -// sees the full cross-realm log including platform-only operational rows. +// Realm-owned structured security events. This endpoint reads the current +// realm's physical database only, including when the current realm is the +// Control Plane. interface SecurityLogEntry { + Id: string Timestamp: string - Realm: string | null Category: string EventType: string - Level: string - UserName: string | null - Ip: string | null - Status: string | null - Reason: string | null + Severity: string + ActorKind: string + Actor: string + Target: string | null + IpAddress: string | null + UserAgent: string | null + OAuthClientId: string | null + AuthenticationMethod: string | null + CorrelationId: string | null + OutcomeCode: string + ReasonCode: string | null + TargetRealmSlug: string | null + FirstObservedAt: string | null + LastObservedAt: string | null Message: string } @@ -53,12 +60,6 @@ async function loadEntries() { finally { loading.value = false } } -async function clearLog() { - // Clearing is itself audited (audit.log_cleared) on the server. - await http.delete() - entries.value = [] -} - onMounted(() => { loadEntries() pollInterval = setInterval(loadEntries, 5_000) @@ -77,22 +78,23 @@ const gridBuilder = applyListGridDefaults(CoarGridBuilder.create p.data?.Level === 'Warning', - 'security-log-error': (p) => p.data?.Level === 'Error', + 'security-log-warning': (p) => p.data?.Severity === 'Warning', + 'security-log-error': (p) => p.data?.Severity === 'Error', }) .columns([ (col) => col.date('Timestamp', { includeTime: true }).header('Time', 'admin.securityLog.time').width(180), (col) => col.field('Category').header('Category', 'admin.securityLog.category').width(140), (col) => col.field('EventType').header('Event', 'admin.securityLog.event').width(220), (col) => col.field('Message').header('Detail', 'admin.securityLog.detail').flex(1), - (col) => col.field('UserName').header('Actor', 'admin.securityLog.actor').width(160), - (col) => col.field('Ip').header('IP', 'admin.securityLog.ip').width(140), - (col) => col.tag('Level', { + (col) => col.field('Actor').header('Actor', 'admin.securityLog.actor').width(170), + (col) => col.field('Target').header('Target', 'admin.securityLog.target').width(170), + (col) => col.field('TargetRealmSlug').header('Target realm', 'admin.platformLog.targetRealm').width(140), + (col) => col.field('IpAddress').header('IP', 'admin.securityLog.ip').width(140), + (col) => col.field('AuthenticationMethod').header('Method', 'admin.securityLog.method').width(110), + (col) => col.field('OAuthClientId').header('Client', 'admin.securityLog.client').width(160), + (col) => col.tag('Severity', { variantMap: { Info: 'neutral', Warning: 'warning', Error: 'error' }, }).header('Level', 'admin.securityLog.level').width(100), - // Realm attribution — constant for a tenant admin (their own realm), varies - // for the control-plane (system) realm which sees the full cross-realm log. - (col) => col.field('Realm').header('Realm', 'admin.securityLog.realm').width(120), ]) @@ -122,9 +124,6 @@ const gridBuilder = applyListGridDefaults(CoarGridBuilder.create {{ t('admin.securityLog.refresh', {}, 'Refresh') }} - - {{ t('admin.securityLog.clear', {}, 'Clear') }} - diff --git a/src/frontend-vue/src/views/admin/ChangeRequestsView.vue b/src/frontend-vue/src/views/admin/ChangeRequestsView.vue index c4f665c4..7cd10b61 100644 --- a/src/frontend-vue/src/views/admin/ChangeRequestsView.vue +++ b/src/frontend-vue/src/views/admin/ChangeRequestsView.vue @@ -4,7 +4,7 @@ import { useHttpClient } from '@/composables/useHttpClient' import { useUI } from '@/composables/useUI' import { useI18n } from '@cocoar/vue-localization' import { CoarDataGrid, CoarGridBuilder } from '@cocoar/vue-data-grid' -import { CoarButton, CoarCheckbox, CoarTextInput, CoarFormField, CoarNote } from '@cocoar/vue-ui' +import { CoarNotice, CoarButton, CoarCheckbox, CoarTextInput, CoarFormField } from '@cocoar/vue-ui' import { useGridLocale } from '@/composables/useGridLocale' import GridEmptyState from '@/components/GridEmptyState.vue' import ModalLayout from '@/components/ModalLayout.vue' @@ -193,7 +193,7 @@ const gridBuilder = applyListGridDefaults(CoarGridBuilder.create( - {{ actionError }} + {{ actionError }}
( {{ t('admin.changeRequests.approve', {}, 'Approve') }}
- + {{ t('admin.changeRequests.waitingForVerify', {}, 'The user has not yet confirmed the new address via email. Approval is only possible once ownership has been proven.') }} - + diff --git a/src/frontend-vue/src/views/admin/PlatformAuditLogView.vue b/src/frontend-vue/src/views/admin/PlatformAuditLogView.vue new file mode 100644 index 00000000..657d176e --- /dev/null +++ b/src/frontend-vue/src/views/admin/PlatformAuditLogView.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/frontend-vue/src/views/admin/RealmSettingsView.vue b/src/frontend-vue/src/views/admin/RealmSettingsView.vue index a68308e0..35f50b03 100644 --- a/src/frontend-vue/src/views/admin/RealmSettingsView.vue +++ b/src/frontend-vue/src/views/admin/RealmSettingsView.vue @@ -1,17 +1,18 @@