diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md index 0a27def77..cd35609eb 100644 --- a/.github/RELEASE_NOTICE.md +++ b/.github/RELEASE_NOTICE.md @@ -95,3 +95,15 @@ When `server_edition.enabled` is true, `/mcp` now behaves as if `require_mcp_aut - **Refusals are uniform**: every denied login renders one generic `403` page ("Sign-in was not permitted") with a reference id; the reason (`email_unverified`, `subject_mismatch`, `state_invalid`, …) reaches only the server log and the `auth_event` line under that id. IdP-side failures (`discovery_failed`, `provider_error`) and proxy-side failures after verification (`internal_error`) render a `503` "Sign-in is temporarily unavailable" instead, so an outage is never shown as "not permitted" (FR-024). - **Login page label and edition probe**: public `GET /api/v1/auth/provider` returns only `{"display_name": "…"}` — never the issuer, client id, tenant, scopes or domains — so the Web UI labels the sign-in button and detects the edition before login; the personal build answers `404` (FR-030). - Guide: [multi-user authentication](https://docs.mcpproxy.app/development/server-edition-multiuser-auth/). + +## Server edition: group-based server access, and credential minting is now session-cookie-only + +A tenant — anyone who signs in through the team's IdP rather than through the API key or the local socket — now sees, uses, mints tokens for, connects to and diagnoses exactly the servers their IdP group grants (spec 107, FR-004/FR-009), on the REST API, the Web UI and every agent token they own. Administrators (API key, socket, and any `admin_email` user) are unaffected. + +- **New config**: `server_edition.access.group_servers` maps an IdP group name to a list of shared server names (or `["*"]` for every shared server); `access.default_servers` covers a user whose groups match no key. A user's grant is the union of their groups' entries; a server must be **both shared and granted** to appear for them — sharing alone is not enough once the map is active. **The `access` block itself must be present to change anything**: an *absent* block keeps today's `Shared`-only behaviour (every shared server visible to every tenant, unchanged); a *present* block — even `{}`, with no `group_servers`/`default_servers` entries — is deny-all for every tenant that matches none of it. Add the block to narrow access; there is no config that widens it beyond `Shared`. +- **Non-disclosing**: a server outside a tenant's grant does not exist for them on any surface — not its name, tools, prompts, counts, status, logs or activity. This applies to core REST (`/api/v1/*`, `/events`), the Web UI and agent tokens a tenant owns. +- **Agent tokens narrow on every use, not just at mint**: an owned token's effective scope is recomputed from the user's *current* groups and the *current* `access` map on every authentication (one `GetUser` per call) — so **enabling, editing or removing an `access` entry takes effect on the token's very next call**, without rotating or re-minting it. The same is true for a tenant's session on core REST and its SSE stream (the per-frame refresher re-resolves the principal, so un-sharing narrows the next frame). Widening a user's access (the IdP adds them to a group, or the map is edited to include them) only takes effect once they sign in again — groups themselves refresh only at login. +- **Credential-minting doors are session-cookie-only**: `POST /api/v1/auth/token` (JWT renewal), `POST /api/v1/user/tokens` and `POST /api/v1/user/tokens/{name}/regenerate` now accept only the browser session cookie — a bearer JWT or an agent token gets `401`. A derived credential can no longer mint another credential, closing the chain where a JWT could renew itself indefinitely and then mint a 30-day agent token in its last second. `POST /api/v1/user/tokens` also now caps `expires_in` at 365 days (previously unbounded), matching core `/api/v1/tokens`. The practical freshness bound is now **session TTL + the longer of JWT TTL and the longest-lived owned agent token (≤ 365 days)**; an administrator `disable` still takes effect immediately. +- **Subject-rebind procedure for a re-created IdP account**: if your IdP re-creates a user's account (new subject, same email), the login is refused (`subject_mismatch`) rather than silently taking over the existing record. An administrator re-arms the binding by disabling the user and then re-enabling them — this arms a single-use, persisted rebind window — and the user's *next successful login* accepts the new subject and rebinds automatically. No other action is needed and no record is deleted. +- **Tenant Web UI**: a signed-in tenant now gets a working dashboard, server list and activity view built entirely from the session cookie and the tenant-allowed routes — no `?apikey=`, no calls to administrator-only or global-state endpoints (`/info`, `/routing`, `/docker/status`, `/connect`, `/stats/tokens`, `/security/overview`, `/onboarding/state`, core `/activity*`, core `/config`); those cards, chips and pages are hidden rather than issued-and-403'd — Settings stays an administrator-only page (its own personal-server and token management live under `/my/servers`, `/my/tokens`). Diagnostics and history use the tenant-scoped `/user/diagnostics` and `/user/activity` endpoints. The `access` map is edited by an administrator through Settings' Raw JSON tab and shown read-only as group chips on the admin server page and on `AdminUsers`. +- No action needed if you do not set `server_edition.access` at all: every tenant keeps seeing every `Shared` server exactly as before this release, on group grants alone. To start restricting tenants by IdP group, add the `access` block — from that point on, only a matching group entry (or `default_servers`) grants a shared server; a present-but-empty block denies every tenant until you populate it. Administrators are unaffected either way. diff --git a/ROADMAP.md b/ROADMAP.md index 24ca150cd..a0d997a8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -426,16 +426,16 @@ graph LR classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff; classDef in_progress fill:#1f6feb,stroke:#0b3d91,color:#ffffff; classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; - class sso_pr_a_freeze_cut done; - class sso_pr_b_oidc_front_door in_progress; - class sso_pr_c_group_allowlist,sso_pr_d_audit_line todo; + class sso_pr_a_freeze_cut,sso_pr_b_oidc_front_door done; + class sso_pr_c_group_allowlist in_progress; + class sso_pr_d_audit_line todo; ``` | Task | Status | Refs | | --- | --- | --- | | PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) | 🟢 Done | #1287 | -| PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) | 🔵 In progress | #1292 | -| PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4) | ⚪ Todo | — | +| PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) | 🟢 Done | #1292 | +| PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4) | 🔵 In progress | #1293 | | PR-D attributable JSONL audit line + auth_event + config/doctor/metrics (US3) | ⚪ Todo | — | @@ -896,7 +896,7 @@ graph LR | Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | | | MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 19/81 (23%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | | | Planning/docs truth automation | In progress | P2 | — | | | -| Spec 107 server edition SSO front door hardened for real IdPs | In progress | P2 | 69/126 (55%) | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | | +| Spec 107 server edition SSO front door hardened for real IdPs | In progress | P2 | 100/126 (79%) | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | | | Discovery-quality eval harness (Spec 065 second half) | In progress | P3 | — | [065-evaluation-foundation](./specs/065-evaluation-foundation/) | | | tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | — | [101-tpa-db](./specs/101-tpa-db/) | | | Auto routing mode: budget-fitted tool surface per session (spec 104) | Todo | P1 | — | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | | @@ -1038,4 +1038,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — | | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 12/109 (11%) | | [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) | -| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 69/126 (55%) | +| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 100/126 (79%) | diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index 5a04dc879..2423fe2c9 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -314,6 +314,15 @@ strings emitted at boot, by `PATCH /api/v1/config` and by `/config/apply` | `oauth.groups_claim` | string | `"groups"` | Restart | `oidc` only. Name of the ID-token (then userinfo) claim carrying group memberships. Accepted shapes: a flat JSON array of strings or a single string; anything else is treated as absent. Compared as exact strings by the group → server map | | `oauth.email_verified_policy` | `refuse_false` \| `require_true` \| `ignore` | `refuse_false` | Restart | `server_edition.oauth.email_verified_policy must be one of: refuse_false, require_true, ignore`. See the cost note below | | `oauth.display_name` | string | provider family name | Restart | Login-button label; at most 64 characters (`server_edition.oauth.display_name must be at most 64 characters`). It is the **only** field returned by the public `GET /api/v1/auth/provider` probe (never the issuer, client id, tenant, scopes or domains) | +| `access` | object | absent (Shared-only semantics) | **Live** | Absent = today's behaviour, unchanged: every tenant sees every `shared` server. **Present = active**, with no silent allow-all: a tenant sees a shared server only through a group grant or `default_servers`; a user whose groups match no key and who has no default grant sees none. Read live through the config provider on every entitlement decision, so it hot-reloads (see [Group access map](/development/server-edition-multiuser-auth#group-access-map-server_editionaccess-and-entitlement-spec-107-pr-c)) | +| `access.group_servers` | map[string]string[] | `{}` | Live | Group value (compared exactly, case-sensitive) → admin-config server names, or `"*"` for every shared server. Non-empty only with `oauth.provider: "oidc"` — `server_edition.access.group_servers requires oauth.provider "oidc" (legacy providers yield no groups)`. A group with no map entry contributes nothing (silent, not an error) | +| `access.default_servers` | string[] | `[]` (no default grant) | Live | The grant for a user whose stored groups match no `group_servers` key. Absent, `null` and `[]` all mean "no default"; `"*"` is honoured here too | + +An access-map entry (in `group_servers` or `default_servers`) that names no +configured server is accepted, not refused — it may be written ahead of the +server it names — but `mcpproxy doctor` reports it: `server_edition.access +names N server(s) that match no configured server (…): those entries grant +nothing until a server with that exact name exists`. #### `email_verified_policy` — what each value costs @@ -372,7 +381,7 @@ See [Upstream Servers](/configuration/upstream-servers) for detailed server conf MCPProxy watches the configuration file for changes and automatically reloads when modifications are detected. No restart is required for most configuration changes. -Exceptions that require a restart include `listen`, `data_dir`, `api_key`, the TLS block, the three `http_*_timeout` options, and — in the Server edition — every `server_edition` key except `admin_emails` (see [Server Edition](#server-edition)). `trusted_proxies` is live. +Exceptions that require a restart include `listen`, `data_dir`, `api_key`, the TLS block, the three `http_*_timeout` options, and — in the Server edition — every `server_edition` key except `admin_emails` and `access` (see [Server Edition](#server-edition)). `trusted_proxies` is live. ## Environment Variable Overrides diff --git a/docs/development/server-edition-multiuser-auth.md b/docs/development/server-edition-multiuser-auth.md index bde34e50f..22b77245f 100644 --- a/docs/development/server-edition-multiuser-auth.md +++ b/docs/development/server-edition-multiuser-auth.md @@ -179,22 +179,29 @@ ignored (one deprecation warning when `true`); see | `GET /api/v1/auth/login` | Public | Initiate OAuth login flow (PKCE S256, `state`, `nonce`; `?redirect_uri=` must be a same-origin path) | | `GET /api/v1/auth/callback` | Public | OAuth callback (verifies the ID token for `oidc`, creates session; one generic refusal page) | | `GET /api/v1/auth/me` | Session/JWT | Get current user profile | -| `POST /api/v1/auth/token` | Session | Mint a user JWT for the REST API and CLI (`/api/v1/user/*`); a JWT is **never** an MCP credential — `/mcp` accepts only agent tokens, the API key and the socket | +| `POST /api/v1/auth/token` | **Session only** | Mint a user JWT for the REST API and CLI (`/api/v1/user/*`); a JWT is **never** an MCP credential — `/mcp` accepts only agent tokens, the API key and the socket | | `POST /api/v1/auth/logout` | Session | Invalidate session | -| `GET /api/v1/user/servers` | Session/JWT | List user's servers (personal + shared) | +| `GET /api/v1/user/servers` | Session/JWT | List user's servers (personal + shared, filtered by the [group entitlement](#group-access-map-server_editionaccess-and-entitlement-spec-107-pr-c) when `access` is configured) | | `POST /api/v1/user/servers` | Session/JWT | Add personal upstream server | | `GET /api/v1/user/activity` | Session/JWT | User's activity log | | `GET /api/v1/user/diagnostics` | Session/JWT | Server health for user's servers | -| `GET /api/v1/admin/users` | Admin | List all users | -| `POST /api/v1/admin/users/{id}/disable` | Admin | Disable a user | +| `GET /api/v1/user/tokens` | Session/JWT | List the caller's own agent tokens | +| `POST /api/v1/user/tokens` | **Session only** | Mint an agent token owned by the caller; `allowed_servers` narrowed to the entitlement set, `expires_in` capped at 365 days | +| `POST /api/v1/user/tokens/{name}/regenerate` | **Session only** | Rotate an owned token's secret, re-narrowing `allowed_servers` to the current entitlement | +| `DELETE /api/v1/user/tokens/{name}` | Session/JWT | Revoke/delete an owned token; unlike mint and rotate, revoke keeps accepting a bearer JWT (it extends nothing) | +| `GET /api/v1/admin/users` | Admin | List all users (now includes `groups`, `groups_updated_at`, `subject_rebind_armed_at`) | +| `POST /api/v1/admin/users/{id}/disable` | Admin | Disable a user (revokes their sessions and owned tokens) | +| `POST /api/v1/admin/users/{id}/enable` | Admin | Re-enable a disabled user; on a real `disabled: true → false` transition, arms a single-use subject rebind (`subject_rebind_armed_at`) consumed by the user's next successful login | | `GET /api/v1/admin/activity` | Admin | All users' activity logs | | `GET /api/v1/admin/sessions` | Admin | List active sessions | +**"Session only"** means the session cookie exclusively: a bearer user JWT (or an agent token) presented to `POST /auth/token`, `POST /user/tokens` or `POST /user/tokens/{name}/regenerate` is refused with `401` — a derived credential never mints or renews another credential (FR-011). This closes what was previously an indefinite chain: a JWT could renew itself forever through `/auth/token`, and a JWT minted in a session's last second could still mint a 30-day agent token through `/user/tokens` in its own last second. See [Freshness bound](#freshness-bound-and-session-cookie-only-minting-doors-fr-011) below for the resulting staleness guarantee. + ## Server Architecture - **Auth flow**: OAuth 2.0 + PKCE (+ nonce and a JWKS-verified ID token for `oidc`) → Session cookie (`HttpOnly; SameSite=Lax`; `Secure` per `session_cookie_secure`) for the Web UI + JWT bearer (REST API / CLI only). Neither is accepted on `/mcp`; a user reaches tools only through an agent token they own. - **Server types**: Shared (config file) + Personal (DB rows a user adds through `POST /api/v1/user/servers`). Every upstream connection is the process's single shared connection — there is no per-user connection, per-user workspace or per-user credential on the tool-call path. -- **Isolation**: REST listing scope (users see only shared + own personal servers), agent-token `allowed_servers` scope narrowed on every authentication, and user-scoped activity logs. +- **Isolation**: REST listing scope (users see only shared + own personal servers, further narrowed by the group entitlement below when `server_edition.access` is configured), agent-token `allowed_servers` scope narrowed on every authentication, a tenant session/JWT principal restricted to an explicit allowlist of core REST routes and filtered by the same entitlement (Spec 107 PR-C, [below](#tenant-session-principal-on-core-rest-spec-107-pr-c)), and user-scoped activity logs. - **Admin**: Identified by `admin_emails` config. Sees all activity, manages users. - **Build tag**: All server code behind `//go:build server`. Personal edition unaffected. @@ -377,6 +384,115 @@ tidy audits them straight from storage — every record in the `agent_tokens` bucket whose `allowed_servers` contains `"*"` and whose `user_id` is non-empty — and asks the owner to rotate, or revokes them. +### Freshness bound and session-cookie-only minting doors (FR-011) + +Groups (and admin-role membership) refresh only at login — there is no +background IdP re-query. That makes staleness a real, documented bound rather +than "however long the IdP takes to notice": + +``` +bound = session_ttl + max(bearer_token_ttl, longest owned agent-token expiry ≤ 365 days) +``` + +A live session or a bearer JWT is narrowed on its **next request**, and every +owned agent token on its **next authentication** — both *before* the holder +re-logs in, because the scope resolver reads the stored `User.Groups` / +entitlement set fresh on every request rather than trusting anything cached in +the JWT. `admin_emails` users are exempt from the group map, so this bound is +about tenants; the administrator role itself already re-derives from +`admin_emails` on every request ([Role freshness](#role-freshness-issue-1169)). + +The bound only holds because the three credential-minting doors — +`POST /auth/token`, `POST /user/tokens`, `POST /user/tokens/{name}/regenerate` +— accept a session cookie only (previous section): without that, a JWT or +token minted in a session's last second could keep re-minting itself past the +session's expiry, making the bound unbounded. `expires_in` on `/user/tokens` is +capped at 365 days by the same `auth.ParseTokenExpiry` rule as core +`/api/v1/tokens` (`9000h` → `400 expires_in must be at most 365 days`). + +An administrator `disable` is the immediate remedy — it is not subject to this +bound because it revokes the user's sessions and owned tokens directly through +the owner gate ([Agent tokens and tenant identity](#agent-tokens-and-tenant-identity-issue-1168)), +rather than waiting for staleness to expire. + +### Group access map (`server_edition.access`) and entitlement (Spec 107 PR-C) + +`server_edition.access` (FR-006/FR-007/FR-009) turns "the IdP put someone in a +group" into server entitlement. It is **absent by default** — today's +Shared-only semantics, unchanged — and becomes **active** the moment the block +is present, with no silent allow-all: `"*"` is the only way to grant every +shared server, and a user whose stored groups match no key and who has no +`default_servers` grant is entitled to **no shared server at all** +(deny-all for non-administrators). See the [config reference](../configuration/config-file.md#server-edition) +for the key table and the per-IdP groups-claim table. + +``` +grant(u) = ⋃ access.group_servers[g] for g ∈ u.Groups ∪ (access.default_servers if no g matches any key) + where "*" expands to every shared server +entitled(u) = personal(u) ∪ { s ∈ shared : access == nil ∨ s ∈ grant(u) } +``` + +`entitledServerNamesFor` (`internal/serveredition/api/user_handlers.go`) is the +**one** function every tenant-facing door consults — REST listing/by-name +doors, the owned-token narrowing on every authentication, and the tenant +session principal below. Administrators keep today's whole-configuration view +(SC-006 parity); the "*" literal survives only for them. A group-excluded +server is indistinguishable from a nonexistent one on every surface (FR-010) +except the still-open Spec 105 items named in [Agent Tokens](../features/agent-tokens.md#server-edition-incident-response). + +#### Upgrade-state table + +What happens to an existing deployment the moment an operator adds an `access` +block to a config that previously had none: + +| State | Outcome | +|-------|---------| +| A pre-upgrade user record (no stored groups) | Decodes with `groups == nil`, which matches no `group_servers` key — the user falls straight to `default_servers` (or deny-all if that is empty too) | +| A live session or bearer JWT the user is already holding | Narrowed to the new (default) grant on its **very next request** — no wait for expiry, no re-login required to lose access | +| Every agent token that user owns | Narrowed to the new grant on its **next authentication** — same immediacy, independent of the request path above | +| An `admin_emails` user | **Unaffected** — administrators are exempt from the group map by construction (FR-009) | +| Groups themselves (as opposed to the grant computed from them) | Refresh only at the user's **next login** — enabling `access` narrows immediately using whatever groups are already stored; it does not requery the IdP | +| Provider changed for the same email (e.g. migrating IdPs) | Automatic rebind on the first login with the new provider, flagged `provider_rebound` | +| Same provider, but the IdP-side subject (`sub`) changed (e.g. account re-created) | Refused as `subject_mismatch` until an administrator runs `disable` (which revokes the user's sessions and tokens) → `enable` (which arms a single-use `subject_rebind_armed_at`) → the user's next successful login consumes it | + +### Tenant session principal on core REST (Spec 107 PR-C) + +Before this spec, a session cookie or user JWT was accepted only on +`/api/v1/auth/*`, `/api/v1/user/*` and `/api/v1/admin/*`. PR-C additionally +accepts a `user`-typed session/JWT principal on **core** `/api/v1` and `/events` +(the routes the Web UI and CLI otherwise reach only with the admin API key), +gated by a fixed allowlist and the entitlement above: + +| Method | Route | Filter | +|---|---|---| +| GET | `/api/v1/status` | `CanEnumerateServer` | +| GET | `/api/v1/servers` | `visibleServers` | +| GET | `/api/v1/servers/{id}/**` (except `/tool-calls` and the static `/servers/import/paths`) | `scopedServerSubtree` (404 parity with a nonexistent server) | +| GET | `/api/v1/tools`, `/api/v1/index/search` | scoped (search is filtered *before* the ranked cut, so a hidden high-ranker can never displace an entitled hit) | +| POST | `/api/v1/preflight` | `ResolveScope` with `Restricted=true` | +| GET | `/api/v1/profiles`, `/api/v1/profiles/active` | tenant projection — profiles with an empty entitled intersection are omitted | +| GET/HEAD | `/events` | `eventVisibleToCaller`; the principal is re-resolved before every frame | + +**Every other method+route** under `/api/v1` — `/tools/call`, `/code/exec`, +`/config`, `/servers` add/remove/enable, `/quarantine/*`, `/tokens*`, +`/secrets*`, `/sessions`, `/registries*`, `/telemetry/*`, `/activity*` and +everything else, including routes added later — answers `403` before the +handler runs (so before any body parse), with the same fixed body core REST +already used for a scoped caller: +`{"error":"forbidden","message":"this credential is not permitted to access this resource","request_id":"…"}`. + +An `admin_user` session or JWT is an administrator everywhere on core REST +except `CanRevealSecrets`, which stays API-key/socket-only — `GET +/config?reveal=…` and `/info`'s `web_ui_url` return masked values even for an +administrator's session. This is the one place a session/JWT principal is +deliberately weaker than the API key, and it is why the Web UI login alone is +enough for an **administrator** session to use the Configuration and Servers +pages, but not enough to read a raw secret. A **tenant** session is a +different, narrower principal: `/config` (GET and PATCH) is on the core +must-refuse list above regardless of session kind, so a tenant's Web UI never +has a Configuration page to show, and the Servers page it does have is the +scoped `/user/servers*` surface (see the table above), not core `/servers`. + ## Key Directories | Directory | Purpose | diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 066862838..efddedc84 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -547,6 +547,49 @@ mcpproxy serve --require-mcp-auth # Enforce /mcp authentication | `--expires` | No | `30d` | Expiry duration (e.g., `7d`, `90d`, `365d`) | | `--profile-pin` | No | — | Pin the token to a single profile (see [Profile Pinning](#profile-pinning)) | +### Documented invariant (Spec 107 FR-046) + +A person who signs in through the team's IdP — directly through their session +on the REST API and Web UI, or through any agent token they mint — can see and +use exactly the servers their group grants, and cannot learn about or act on +any other server through proxy-produced data; every tool-call authorization +decision about them is recorded on the audit line with the real server name, +which is never echoed to them. + +- **Covered surfaces.** The server-edition REST routes (`/api/v1/auth/*`, + `/user/*`, `/admin/*`); the [core REST API](../development/server-edition-multiuser-auth.md#tenant-session-principal-on-core-rest-spec-107-pr-c) + and `/events` for the tenant session principal; the HTTP MCP surfaces + (`/mcp`) for agent tokens a tenant owns, scoped exactly as described + throughout this page; and the Web UI, which reaches nothing a tenant's own + session and owned tokens could not already reach. `/mcp` never accepts a + session cookie or user JWT — a tenant reaches tools only through an agent + token they own. +- **Staleness bound (FR-011), including the closed JWT self-renewal.** Groups + refresh only at login: `session_ttl + max(bearer_token_ttl, longest owned + token expiry ≤ 365 days)`. This bound holds specifically because a bearer + JWT can no longer renew itself through `POST /auth/token`, nor mint or + rotate an agent token through `POST /user/tokens(/…/regenerate)` — those + three doors accept only a live session cookie (see + [Freshness bound](../development/server-edition-multiuser-auth.md#freshness-bound-and-session-cookie-only-minting-doors-fr-011)). + An administrator `disable` is immediate and is not subject to this bound. +- **Retained Spec 105 effects.** Everything Spec 105 already scopes for an + agent token — [server scoping](#server-scoping), [administrative denial](#administrative-operations-are-admin-only), + and [`read_cache`](#server-scoping) authorization-stamped entries — applies + identically whether a server is excluded by group (this spec) or by token + scope (Spec 105); a group-excluded server is indistinguishable from a + nonexistent one on every one of those surfaces. +- **Still-open Spec 105 items.** Three surfaces on `main` still leak the + *existence* (not the content) of an excluded server, whether excluded by + group or by token: `retrieve_tools`'s `usage_summary`/`session_risk` + statistics, the "Available servers" error text, and the scope-denial text. + This spec adds nothing new to that leak and closes it the moment the + corresponding Spec 105 item merges — it is not something a server-edition + deployment can configure around today. +- **Single-replica assumption.** Pending OAuth login state, the SSE + per-frame principal re-resolution and the entitlement computation above all + run in-process with no shared cross-replica store; a second replica of the + server edition is unsupported. + ### Server-edition incident response Administrators authenticated through a server-edition session or bearer JWT can list safe metadata for all owners with `GET /api/v1/admin/tokens`. Each entry includes `user_id`, `name`, scope, permissions, timestamps, prefix, profile pin, and revocation state. Raw credentials and token hashes are never listed. diff --git a/docs/features/settings-page.md b/docs/features/settings-page.md index 2842be98c..2834c3e9c 100644 --- a/docs/features/settings-page.md +++ b/docs/features/settings-page.md @@ -19,7 +19,7 @@ friendly, prioritized form sections instead of raw JSON: isolation, sensitive-data detection, output validation, output sanitisation, activity retention, logging, TLS, …). - **Raw JSON** — the full Monaco editor, kept as an escape hatch. -- **Server Edition** — server edition only. +- **Server Edition** — server edition only: `admin_emails`, front-door keys, OAuth provider settings and the `access` group-to-server map that decides tenant entitlement (see [Server Edition](../configuration/config-file.md#server-edition)). ## How saving works @@ -30,11 +30,16 @@ changes, unrelated values and masked secrets (API key, secret headers) are never overwritten. Fields that need a restart (`listen`, `api_key`, `routing_mode`, `tls.*`, -`code_execution_pool_size`, and `server_edition.enabled` on the server edition) -show a **restart** badge — the two response-detail modes deliberately carry no -badge, because they hot-reload; sensitive changes (reveal secret headers, disabling -quarantine/management, binding to a non-loopback address) require an explicit -confirmation before they apply. +`code_execution_pool_size`, and most of `server_edition` — `enabled`, `oauth.*`, +`public_url`, `session_cookie_secure`, `session_ttl`, `bearer_token_ttl`, +`credential_encryption_key`) show a **restart** badge — the two response-detail +modes deliberately carry no badge, because they hot-reload; likewise +`server_edition.admin_emails` and `server_edition.access` (the group-to-server +map) apply on the next request with no restart and no badge, so editing who is +an administrator or which group sees which server never needs a bounce. +Sensitive changes (reveal secret headers, disabling quarantine/management, +binding to a non-loopback address) require an explicit confirmation before they +apply. ```bash # Equivalent API call — change one field, everything else preserved diff --git a/e2e/playwright/server-edition-tenant.spec.ts b/e2e/playwright/server-edition-tenant.spec.ts new file mode 100644 index 000000000..517c9e048 --- /dev/null +++ b/e2e/playwright/server-edition-tenant.spec.ts @@ -0,0 +1,306 @@ +import { test, expect, type Page, type Request as PWRequest } from '@playwright/test'; + +/** + * Spec 107 PR-C, T089. Drives a real server-edition instance + fake OIDC IdP + * (quickstart.md "Playwright (US4, PR-C)"): fresh context -> the public + * probe -> /login -> the fake IdP's login form -> tenant dashboard -> the + * entitled server only -> mint a token -> activity served by + * GET /user/activity, then walks the FR-045 refused-route list asserting + * non-disclosing 403s. + * + * Requires a rig already running (quickstart.md steps 0-3): export + * MCPPROXY_RIG_URL=http://127.0.0.1:$PORT before running Playwright. Without + * it every test here is skipped, so CI (which never sets it) stays green — + * this suite only runs against a locally started rig, the same convention + * oauth-login.spec.ts uses for OAUTH_SERVER_URL. + * + * Fixture (quickstart.md §2): access.group_servers = {eng:[a], ops:[a,b]}, + * default_servers = []; alice@example.com is in "eng" (entitled to "a" + * only); "b" and "a__b" exist but must never appear for her. + */ + +const RIG_URL = process.env.MCPPROXY_RIG_URL; +const ALICE_EMAIL = process.env.MCPPROXY_RIG_TENANT_EMAIL || 'alice@example.com'; +const ALICE_PASSWORD = process.env.MCPPROXY_RIG_TENANT_PASSWORD || 'pass'; +const ENTITLED_SERVER = 'a'; +const HIDDEN_SERVERS = ['b', 'a__b']; + +// FR-045 (rest-endpoints.md §"Core /api/v1 — tenant-session allowlist"): +// every one of these, for a session-cookie tenant, must 403 with the fixed +// scoped-caller body below — before any body parse (a malformed body must +// not turn a 403 into a 400). +// NOT the exhaustive production route table: internal/httpapi's Go suite +// (TestTenantSessionAllowlistWalk, tenant_allowlist_walk_test.go) chi.Walks +// every route this binary actually serves and is the binding, exhaustive +// FR-045 gate; this list is a browser-level smoke check over a +// representative subset (widened in cross-review round 1 to also cover the +// named must-refuse examples spec.md's US4 Independent Test calls out — +// /servers/{id}/tool-calls, the dispatch/mutation/telemetry/onboarding/ +// feedback/diagnostics/doctor/telemetry-payload/annotations/config-patch/ +// personal-tokens rows — that were previously exercised only in Go). +const REFUSED_ROUTES: Array<{ method: 'GET' | 'POST' | 'PATCH'; path: string; body?: unknown }> = [ + { method: 'GET', path: '/api/v1/config' }, + { method: 'GET', path: '/api/v1/info' }, + { method: 'GET', path: '/api/v1/routing' }, + { method: 'GET', path: '/api/v1/docker/status' }, + { method: 'GET', path: '/api/v1/stats/tokens' }, + { method: 'GET', path: '/api/v1/sessions' }, + { method: 'GET', path: '/api/v1/security/overview' }, + { method: 'GET', path: '/api/v1/activity' }, + { method: 'GET', path: '/api/v1/activity/summary' }, + { method: 'GET', path: '/api/v1/tool-calls' }, + { method: 'GET', path: `/api/v1/servers/${ENTITLED_SERVER}/tool-calls` }, + { method: 'GET', path: '/api/v1/connect' }, + { method: 'GET', path: '/api/v1/onboarding/state' }, + { method: 'GET', path: '/api/v1/doctor' }, + { method: 'GET', path: '/api/v1/diagnostics' }, + { method: 'GET', path: '/api/v1/code/scripts' }, + { method: 'GET', path: '/api/v1/telemetry/payload' }, + { method: 'GET', path: '/api/v1/annotations/coverage' }, + // Deliberately malformed body: still 403, never 400 (scoped-caller check + // runs before the handler touches the body). + { method: 'POST', path: '/api/v1/tools/call', body: '{not json' }, + { method: 'POST', path: '/api/v1/code/exec', body: {} }, + { method: 'POST', path: '/api/v1/servers', body: {} }, + { method: 'POST', path: '/api/v1/quarantine/approve', body: {} }, + { method: 'POST', path: '/api/v1/tool-calls/some-id/replay', body: {} }, + { method: 'POST', path: '/api/v1/registries/some-id/refresh', body: {} }, + { method: 'POST', path: '/api/v1/telemetry/update-failure', body: {} }, + { method: 'POST', path: '/api/v1/onboarding/mark', body: {} }, + { method: 'POST', path: '/api/v1/feedback', body: {} }, + { method: 'POST', path: '/api/v1/tokens', body: {} }, + { method: 'PATCH', path: '/api/v1/config', body: {} }, +]; + +const FIXED_403_BODY = { + error: 'forbidden', + message: 'this credential is not permitted to access this resource', +}; + +function requireRigUrl(): string { + if (!RIG_URL) throw new Error('MCPPROXY_RIG_URL not set'); + return RIG_URL.replace(/\/$/, ''); +} + +/** Track every XHR/fetch this page issues, for the "no ?apikey=, all 2xx" assertions below. */ +function trackRequests(page: Page): PWRequest[] { + const seen: PWRequest[] = []; + page.on('request', (req) => { + if (['xhr', 'fetch'].includes(req.resourceType())) seen.push(req); + }); + return seen; +} + +async function assertNoApiKeyLeakage(requests: PWRequest[]) { + for (const req of requests) { + expect(req.url(), `XHR must never carry ?apikey=: ${req.url()}`).not.toContain('apikey='); + const headers = await req.allHeaders(); + expect( + Object.keys(headers).some((h) => h.toLowerCase() === 'x-api-key'), + `XHR must never carry X-API-Key for a session principal: ${req.url()}` + ).toBe(false); + } +} + +async function assertAll2xx(requests: PWRequest[]) { + for (const req of requests) { + const res = await req.response(); + if (!res) continue; // request still in flight / aborted by navigation — not this test's concern + expect(res.ok(), `${req.method()} ${req.url()} => ${res.status()}`).toBe(true); + } +} + +test.describe('Server edition: tenant session (Spec 107 T089)', () => { + test.skip(() => !RIG_URL, 'MCPPROXY_RIG_URL not set — run against a local rig (quickstart.md)'); + + test('fresh context -> login -> dashboard -> entitled servers only -> activity via /user/activity', async ({ page, context }) => { + const base = requireRigUrl(); + const requests = trackRequests(page); + + // Probe: the public provider probe names the login button, nothing else + // (FR-030 — no issuer/client id/scopes leak to an unauthenticated caller). + await page.goto(`${base}/ui/`, { waitUntil: 'domcontentloaded' }); + await expect(page).toHaveURL(/\/login$/); + const loginButton = page.getByRole('button', { name: /Example Corp/i }); + await expect(loginButton).toBeVisible(); + await loginButton.click(); + + // Fake IdP login form (tests/oauthserver): #username, #password, #consent, "Approve". + await expect(page.locator('h1')).toContainText(/OAuth Test Server|Sign in/i, { timeout: 10_000 }); + await page.fill('#username', ALICE_EMAIL); + await page.fill('#password', ALICE_PASSWORD); + const consent = page.locator('#consent'); + if ((await consent.count()) > 0 && !(await consent.isChecked())) { + await consent.check(); + } + await page.click('button:has-text("Approve")'); + + // Lands back on the tenant dashboard with nothing but the session cookie. + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 10_000 }); + const cookies = await context.cookies(); + const sessionCookie = cookies.find((c) => c.name === 'mcpproxy_session'); + expect(sessionCookie, 'session cookie must be set after login').toBeTruthy(); + expect(sessionCookie?.httpOnly).toBe(true); + + // No API key ever lands in localStorage for a session principal (T088). + const localStorageApiKey = await page.evaluate(() => { + try { + return Object.keys(localStorage).some((k) => /api.?key/i.test(k) && !!localStorage.getItem(k)); + } catch { + return false; + } + }); + expect(localStorageApiKey, 'no API key in localStorage for a session principal').toBe(false); + + // Servers: entitled set only ("a"); "b"/"a__b" must never appear. + await page.goto(`${base}/ui/servers`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByText(ENTITLED_SERVER, { exact: false }).first()).toBeVisible({ timeout: 10_000 }); + for (const hidden of HIDDEN_SERVERS) { + await expect(page.getByText(new RegExp(`\\b${hidden}\\b`))).toHaveCount(0); + } + + // Mint an agent token from the tenant's own token page. + await page.goto(`${base}/ui/my/tokens`, { waitUntil: 'domcontentloaded' }); + const nameField = page.locator('input[type="text"]').first(); + if ((await nameField.count()) > 0) { + await nameField.fill(`t-${Date.now()}`); + const createButton = page.getByRole('button', { name: /create|generate|mint/i }).first(); + if ((await createButton.count()) > 0) await createButton.click(); + } + + // Activity: tenant view is served by GET /user/activity, never core /activity. + const activityRequests: string[] = []; + page.on('request', (req) => { + if (req.url().includes('/api/v1/activity') || req.url().includes('/api/v1/user/activity')) { + activityRequests.push(req.url()); + } + }); + await page.goto(`${base}/ui/activity`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(500); // let the mounted fetch fire + expect(activityRequests.some((u) => u.includes('/api/v1/user/activity'))).toBe(true); + expect(activityRequests.some((u) => /\/api\/v1\/activity(\?|$)/.test(u))).toBe(false); + + await assertNoApiKeyLeakage(requests); + await assertAll2xx(requests); + }); + + test('refused-route allowlist: every non-allowlisted door 403s, non-disclosing', async ({ browser }) => { + const base = requireRigUrl(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${base}/ui/`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: /Example Corp/i }).click(); + await page.fill('#username', ALICE_EMAIL); + await page.fill('#password', ALICE_PASSWORD); + const consent = page.locator('#consent'); + if ((await consent.count()) > 0 && !(await consent.isChecked())) await consent.check(); + await page.click('button:has-text("Approve")'); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 10_000 }); + + for (const route of REFUSED_ROUTES) { + const res = await context.request.fetch(`${base}${route.path}`, { + method: route.method, + data: route.body, + headers: typeof route.body === 'string' ? { 'Content-Type': 'application/json' } : undefined, + failOnStatusCode: false, + }); + expect(res.status(), `${route.method} ${route.path}`).toBe(403); + const json = await res.json().catch(() => null); + expect(json?.error, `${route.method} ${route.path} body`).toBe(FIXED_403_BODY.error); + expect(json?.message, `${route.method} ${route.path} body`).toBe(FIXED_403_BODY.message); + expect(json?.request_id, `${route.method} ${route.path} carries a request_id`).toBeTruthy(); + } + + await context.close(); + }); + + test('wrong X-API-Key alongside a valid session cookie -> 401 (FR-001 precedence)', async ({ browser }) => { + const base = requireRigUrl(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${base}/ui/`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: /Example Corp/i }).click(); + await page.fill('#username', ALICE_EMAIL); + await page.fill('#password', ALICE_PASSWORD); + const consent = page.locator('#consent'); + if ((await consent.count()) > 0 && !(await consent.isChecked())) await consent.check(); + await page.click('button:has-text("Approve")'); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 10_000 }); + + const res = await context.request.get(`${base}/api/v1/status`, { + headers: { 'X-API-Key': 'wrong-key-entirely' }, + failOnStatusCode: false, + }); + expect(res.status()).toBe(401); + + await context.close(); + }); + + test('cross-site POST with the session cookie is refused (SameSite=Lax)', async ({ browser }) => { + const base = requireRigUrl(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${base}/ui/`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: /Example Corp/i }).click(); + await page.fill('#username', ALICE_EMAIL); + await page.fill('#password', ALICE_PASSWORD); + const consent = page.locator('#consent'); + if ((await consent.count()) > 0 && !(await consent.isChecked())) await consent.check(); + await page.click('button:has-text("Approve")'); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 10_000 }); + + // Navigate to a distinct site so the next same-origin fetch is a genuine + // cross-site request from the browser's point of view; SameSite=Lax + // withholds the cookie on a cross-site POST (only a top-level GET + // navigation carries it cross-site). + await page.goto('https://example.org/', { waitUntil: 'domcontentloaded' }); + const status = await page.evaluate(async (url) => { + try { + const res = await fetch(url, { method: 'POST', credentials: 'include', body: '{}' }); + return res.status; + } catch { + // A network-level block (CORS preflight rejected, connection refused + // from a sandboxed egress) is an acceptable pass too: either way the + // session cookie never reached mcpproxy on behalf of this call. + return 0; + } + }, `${base}/api/v1/user/tokens`); + expect(status === 401 || status === 403 || status === 0).toBe(true); + + await context.close(); + }); + + test('admin_user saves Settings and sees masked secrets', async ({ browser }) => { + const base = requireRigUrl(); + const adminEmail = process.env.MCPPROXY_RIG_ADMIN_EMAIL || 'dana@example.com'; + const adminPassword = process.env.MCPPROXY_RIG_ADMIN_PASSWORD || 'pass'; + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${base}/ui/`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: /Example Corp/i }).click(); + await page.fill('#username', adminEmail); + await page.fill('#password', adminPassword); + const consent = page.locator('#consent'); + if ((await consent.count()) > 0 && !(await consent.isChecked())) await consent.check(); + await page.click('button:has-text("Approve")'); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 10_000 }); + + await page.goto(`${base}/ui/settings`, { waitUntil: 'domcontentloaded' }); + // Secrets render masked (e.g. "***" / a redaction marker), never the raw + // client secret value, for an administrator viewing server_edition.oauth. + const bodyText = await page.textContent('body'); + expect(bodyText || '').not.toMatch(/OIDC_CLIENT_SECRET_VALUE_SENTINEL/); + + const saveButton = page.getByRole('button', { name: /save/i }).first(); + if ((await saveButton.count()) > 0) { + await saveButton.click(); + await page.waitForTimeout(500); + } + + await context.close(); + }); +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b32c7cf9a..a8bd6cf50 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -42,7 +42,7 @@ import { onMounted, onUnmounted, reactive, ref } from 'vue' +import { useRouter } from 'vue-router' import SidebarNav from '@/components/SidebarNav.vue' import TopHeader from '@/components/TopHeader.vue' import AppFooter from '@/components/AppFooter.vue' @@ -68,6 +69,7 @@ import api, { type APIAuthEvent } from '@/services/api' const systemStore = useSystemStore() const serversStore = useServersStore() const authStore = useAuthStore() +const router = useRouter() // Authentication modal state const authModal = reactive({ @@ -130,6 +132,16 @@ function handleAuthModalRefresh(verified: boolean) { // Handle API authentication errors function handleAuthError(event: APIAuthEvent) { console.log('Global auth error received:', event) + + // Spec 107 FR-041 / T088: a session principal (tenant or admin, no local + // API key) never holds an API key to type into the modal below — a 401 + // there means the cookie/JWT went stale, and the fix is to sign in again + // via the IdP, not to prompt for a credential that does not exist. + if (authStore.isTeamsEdition && !api.hasAPIKey()) { + void router.push('/login') + return + } + authModal.lastError = event.error authModal.show = true // Audit F28: one cause, one message. The modal now suppresses the reconnect @@ -144,17 +156,25 @@ onMounted(async () => { // Set up API error listener removeAPIListener = api.addEventListener(handleAuthError) - // Connect to real-time updates - systemStore.connectEventSource() + // Spec 107 FR-041 / T088: these are all admin-only core doors (server + // status/version, routing mode, the full server list, the SSE event + // stream). A tenant principal is entitled to a narrow, per-user surface + // instead (their own servers/activity, fetched by the routed tenant + // views) — issuing these here would either 403 pointlessly or, worse, + // leak fleet-wide state into a tenant's browser. + if (authStore.principalKind !== 'tenant') { + // Connect to real-time updates + systemStore.connectEventSource() - // Initial data load - serversStore.fetchServers() + // Initial data load + serversStore.fetchServers() - // Fetch version info - systemStore.fetchInfo() + // Fetch version info + systemStore.fetchInfo() - // Fetch routing mode info - systemStore.fetchRouting() + // Fetch routing mode info + systemStore.fetchRouting() + } }) onUnmounted(() => { diff --git a/frontend/src/components/AuthErrorModal.vue b/frontend/src/components/AuthErrorModal.vue index 1702729cc..578b06d35 100644 --- a/frontend/src/components/AuthErrorModal.vue +++ b/frontend/src/components/AuthErrorModal.vue @@ -112,7 +112,10 @@ import { ref, computed, onMounted } from 'vue' import api from '@/services/api' interface Props { - show: boolean + // Spec 107 T088 (App.vue): bound as `authModal.show || undefined` so the + // stubbed component in tests never renders a literal `show="false"` + // attribute — accept the omitted case here too. + show?: boolean canClose?: boolean lastError?: string } diff --git a/frontend/src/components/ModeSwitcher.vue b/frontend/src/components/ModeSwitcher.vue index 93d27cf6c..c2b8ff19c 100644 --- a/frontend/src/components/ModeSwitcher.vue +++ b/frontend/src/components/ModeSwitcher.vue @@ -251,6 +251,7 @@ import { ref, computed, onBeforeUnmount, watch } from 'vue' import { RouterLink } from 'vue-router' import { useSystemStore } from '@/stores/system' +import { useAuthStore } from '@/stores/auth' import SerializationAxis from './SerializationAxis.vue' import type { RoutingModeMeta } from '@/utils/routingMode' import { @@ -271,6 +272,7 @@ import { } from '@/utils/routingMode' const systemStore = useSystemStore() +const authStore = useAuthStore() const open = ref(false) const busy = ref(false) @@ -371,8 +373,11 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown)) function toggle() { open.value = !open.value // Refresh on open: another surface (Settings, the tray, the config file) may - // have changed any of these three since the last fetch. - if (open.value) void systemStore.fetchRouting() + // have changed any of these three since the last fetch. Spec 107 FR-041: + // /routing is an admin-only core door; a tenant principal never has this + // panel available (see the App.vue mount gate), but guard the fetch too in + // case that ever changes. + if (open.value && authStore.principalKind !== 'tenant') void systemStore.fetchRouting() } async function applyField(field: string, value: string) { diff --git a/frontend/src/components/TopHeader.vue b/frontend/src/components/TopHeader.vue index 6ca72f3a4..df7005d25 100644 --- a/frontend/src/components/TopHeader.vue +++ b/frontend/src/components/TopHeader.vue @@ -46,8 +46,19 @@ - + - -