From 9a079e2961bbd38eb0239dc1f1468a683e9f8cba Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 10:44:49 +0300 Subject: [PATCH 01/35] =?UTF-8?q?docs(specs):=20Spec=20107=20=E2=80=94=20s?= =?UTF-8?q?erver=20edition=20SSO=20front=20door=20hardened=20for=20real=20?= =?UTF-8?q?IdPs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic OIDC provider, IdP-group β†’ server allowlist, attributable JSONL audit line, and a freeze/cut of the latent multiuser/credential-injection code, split into four PRs (A freeze/cut, B OIDC + front door + telemetry, C entitlement predicate + tenant session, D audit line). Judge-panel drafted; cross-model reviewed over nine rounds (codex gpt-5.6-sol), ~130 findings resolved. Un-parks roadmap epic sso. --- ROADMAP.md | 45 +- roadmap.yaml | 27 +- .../checklists/requirements.md | 45 ++ .../contracts/audit-line-events.md | 89 ++++ .../contracts/audit-line.schema.json | 182 ++++++++ .../contracts/config-keys.md | 74 +++ .../contracts/entitlement-predicate.md | 68 +++ .../contracts/rest-endpoints.md | 111 +++++ .../contracts/telemetry-v13.md | 24 + .../data-model.md | 109 +++++ .../107-server-edition-sso-hardening/plan.md | 238 ++++++++++ .../quickstart.md | 168 +++++++ .../research.md | 90 ++++ .../107-server-edition-sso-hardening/spec.md | 420 ++++++++++++++++++ .../107-server-edition-sso-hardening/tasks.md | 313 +++++++++++++ .../verification.md | 35 ++ specs/README.md | 1 + 17 files changed, 2025 insertions(+), 14 deletions(-) create mode 100644 specs/107-server-edition-sso-hardening/checklists/requirements.md create mode 100644 specs/107-server-edition-sso-hardening/contracts/audit-line-events.md create mode 100644 specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json create mode 100644 specs/107-server-edition-sso-hardening/contracts/config-keys.md create mode 100644 specs/107-server-edition-sso-hardening/contracts/entitlement-predicate.md create mode 100644 specs/107-server-edition-sso-hardening/contracts/rest-endpoints.md create mode 100644 specs/107-server-edition-sso-hardening/contracts/telemetry-v13.md create mode 100644 specs/107-server-edition-sso-hardening/data-model.md create mode 100644 specs/107-server-edition-sso-hardening/plan.md create mode 100644 specs/107-server-edition-sso-hardening/quickstart.md create mode 100644 specs/107-server-edition-sso-hardening/research.md create mode 100644 specs/107-server-edition-sso-hardening/spec.md create mode 100644 specs/107-server-edition-sso-hardening/tasks.md create mode 100644 specs/107-server-edition-sso-hardening/verification.md diff --git a/ROADMAP.md b/ROADMAP.md index 679967a98..03b1ca4f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,13 +80,13 @@ graph LR - πŸ”΅ **Release qualification gate (auto-QA matrix blocks the tag)** β€” In progress Β· P0 - πŸ”΅ **MCP protocol upgrade to 2026-07-28 revision** β€” In progress Β· P1 - πŸ”΅ **Planning/docs truth automation** β€” In progress Β· P2 +- πŸ”΅ **Spec 107 server edition SSO front door hardened for real IdPs** β€” In progress Β· P2 - πŸ”΅ **Discovery-quality eval harness (Spec 065 second half)** β€” In progress Β· P3 - βšͺ **Windows native tray app** β€” Todo Β· P2 - ⚫ **Server marketplace** β€” Todo Β· P3 Β· parked - ⚫ **Audit SIEM integration** β€” Todo Β· P3 Β· parked - ⚫ **Paid-tier MVP (billing / seats / license)** β€” Todo Β· P3 Β· parked - ⚫ **SDK v1 migration** β€” Todo Β· P3 Β· parked -- ⚫ **SSO (server edition)** β€” Todo Β· P3 Β· parked - βšͺ **Security gateway Tracks C/D (per-arg least-privilege + signature provenance)** β€” Todo Β· P3 - 🟒 **Upgrade awareness & guided update** β€” Done Β· P0 - 🟒 **Connect step trust: preview, visible backup, one-click undo** β€” Done Β· P0 @@ -405,6 +405,39 @@ graph LR +
+πŸ”΅ Spec 107 server edition SSO front door hardened for real IdPs β€” In progress Β· P2 + +> Generic OIDC, IdP-group -> server allowlist, attributable JSONL audit line; freeze the latent multiuser/credential-injection code. Research: docs/research/server-edition-2026-09-14 (#1281). + +Spec: [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) + +```mermaid +graph LR + sso_pr_a_freeze_cut["PR-A freeze/cut latent code + config normalis…"] + sso_pr_b_oidc_front_door["PR-B generic OIDC provider + front door behin…"] + sso_pr_c_group_allowlist["PR-C one entitlement predicate, group grants,…"] + sso_pr_d_audit_line["PR-D attributable JSONL audit line + auth_eve…"] + + sso_pr_a_freeze_cut --> sso_pr_b_oidc_front_door + sso_pr_b_oidc_front_door --> sso_pr_c_group_allowlist + sso_pr_c_group_allowlist --> sso_pr_d_audit_line + + classDef in_progress fill:#1f6feb,stroke:#0b3d91,color:#ffffff; + classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; + class sso_pr_a_freeze_cut in_progress; + class sso_pr_b_oidc_front_door,sso_pr_c_group_allowlist,sso_pr_d_audit_line todo; +``` + +| Task | Status | Refs | +| --- | --- | --- | +| PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) | πŸ”΅ In progress | β€” | +| PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) | βšͺ Todo | β€” | +| PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4) | βšͺ Todo | β€” | +| PR-D attributable JSONL audit line + auth_event + config/doctor/metrics (US3) | βšͺ Todo | β€” | + +
+
πŸ”΅ Discovery-quality eval harness (Spec 065 second half) β€” In progress Β· P3 @@ -600,13 +633,6 @@ Spec: [054-mcp-security-gateway](./specs/054-mcp-security-gateway/)
-
-⚫ SSO (server edition) β€” Todo Β· parked Β· P3 - -> PARKED. Single sign-on for the multi-user server edition. - -
-
🟒 Upgrade awareness & guided update β€” Done Β· P0 @@ -868,6 +894,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 | 4/126 (3%) | [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/) | | @@ -879,7 +906,6 @@ graph LR | Audit SIEM integration `MCP-39` | Todo (parked) | P3 | β€” | | | | Paid-tier MVP (billing / seats / license) `MCP-40` | Todo (parked) | P3 | β€” | | | | SDK v1 migration | Todo (parked) | P3 | β€” | | | -| SSO (server edition) | Todo (parked) | P3 | β€” | | | | Upgrade awareness & guided update | Done | P0 | β€” | [079-upgrade-nudge](./specs/079-upgrade-nudge/) | | | Connect step trust: preview, visible backup, one-click undo | Done | P0 | β€” | [078-connect-trust-preview](./specs/078-connect-trust-preview/) | | | Non-Docker sandbox isolation (Landlock) `MCP-34` | Done | P1 | β€” | | | @@ -1010,3 +1036,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/) | β€” | β€” | | [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` | 4/126 (3%) | diff --git a/roadmap.yaml b/roadmap.yaml index d9c3b24ae..f4be67c1f 100644 --- a/roadmap.yaml +++ b/roadmap.yaml @@ -801,12 +801,29 @@ epics: note: "PARKED. Migrate to the v1 MCP Go SDK surface." - id: sso - title: SSO (server edition) - status: todo - parked: true - priority: P3 + title: Spec 107 server edition SSO front door hardened for real IdPs + status: in_progress + priority: P2 + spec: specs/107-server-edition-sso-hardening depends_on: [] - note: "PARKED. Single sign-on for the multi-user server edition." + note: "Generic OIDC, IdP-group -> server allowlist, attributable JSONL audit line; freeze the latent multiuser/credential-injection code. Research: docs/research/server-edition-2026-09-14 (#1281)." + tasks: + - id: sso-pr-a-freeze-cut + title: PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) + status: in_progress + depends_on: [] + - id: sso-pr-b-oidc-front-door + title: PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) + status: todo + depends_on: [sso-pr-a-freeze-cut] + - id: sso-pr-c-group-allowlist + title: PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4) + status: todo + depends_on: [sso-pr-b-oidc-front-door] + - id: sso-pr-d-audit-line + title: PR-D attributable JSONL audit line + auth_event + config/doctor/metrics (US3) + status: todo + depends_on: [sso-pr-c-group-allowlist] # ── MERGED-BUT-UNIMPLEMENTED specs (cross-spec audit 2026-07-01) ─────────── # These specs are checked into specs/ but materially absent from code. Most diff --git a/specs/107-server-edition-sso-hardening/checklists/requirements.md b/specs/107-server-edition-sso-hardening/checklists/requirements.md new file mode 100644 index 000000000..b613a7ed2 --- /dev/null +++ b/specs/107-server-edition-sso-hardening/checklists/requirements.md @@ -0,0 +1,45 @@ +# Specification Quality Checklist: Server Edition β€” SSO Front Door Hardened for Real IdPs + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-09-15 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details beyond named seams β€” surfaces, config keys, funnels and predicates are named with `file:line` anchors so the planner can verify them; no new packages or types are prescribed (the hook names are placeholders) +- [x] Focused on user value and business needs (four maintainer-decided deliverables F1–F4 plus the prerequisites that make them true in a container) +- [x] Written for a security reviewer and an operator as well as the maintainer +- [x] All mandatory sections completed (User Scenarios with priorities, independent tests and acceptance scenarios; Edge Cases; Functional Requirements; Key Entities; Success Criteria; Assumptions; Out of Scope; Commit conventions) + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous (every FR names the invariant it closes or the fixture that proves it) +- [x] Success criteria are measurable (fixture-pair equality, line counts under bus saturation, exit codes, schema pins, perf bounds in Spec 105 FR-011 shape) +- [x] Success criteria are verifiable at merge time (no "four weeks after release" criteria; the deploy walkthrough is verified by loading its config in a test, not by running Kubernetes in CI) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified (pre-upgrade users, empty-set semantics, round-trip of `default_servers`, case-sensitivity convergence, Type-keyed MCP gates, CSRF posture, crash window, limiter reasons, distroless, `trusted_hosts` inactivity) +- [x] Scope is clearly bounded (administrator exception list defined once in SC-006; personal-edition parity in SC-007; deferral table with a consequence per row) +- [x] Dependencies and assumptions identified (PR #1279 / Spec 105 delivery state and the two-fixture oracle not existing on `main`; single replica; lenient loader; zero-caller criterion for cut vs freeze) + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows (tenant visibility everywhere; IdP login behind an ingress; audit; Web UI with a session alone; honest docs; per-owner cap; telemetry) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] Consistency with Specs 105/106/028: same effective-authorization definition extended by one term; non-disclosing refusal reused; no new enumeration or existence oracle; administrator exception list honoured; per-request narrow-only entitlement preserved +- [x] Container/ingress prerequisites decided in scope (public URL, Secure cookie, trusted proxies, relative-only redirect, forced MCP auth, provider label/probe) vs deferred to Spec 089 (AS role, DCR, listener, path filtering, rate limits) with consequences stated + +## Notes + +- Origin: synthesised 2026-09-15 from three parallel drafts (winner: the minimal-diff draft, judge totals 106/96/117) and three judges' defect lists, each defect re-verified against the worktree (origin/main `b39800a89`) before being applied or rejected. Evidence base: seven lane maps (auth, authorization scope, dead code, audit, remote/container, wiring, frontend/CLI/docs) and a critic pass resolving contradictions C1–C14 and gaps G1–G11. +- Verified defects fixed vs the winner: no subject binding (added FR-023, closes critic C4); `email_verified:false` refused with no knob (added `email_verified_policy`, FR-020/FR-022); `public_url` unset left Host-header injection silent (FR-025 warns + doctor finding; `trusted_proxies` in scope, FR-027); critic G3/G4 deferred (both now in scope, FR-039/FR-040 β€” one-line changes the same PR touches); audit off by default in the server edition (now on-to-stdout when absent, FR-014); SSE refresher claimed inherited (now explicit new code, FR-005); `idp_subject_token.go:95` attributed to the wrong FR (now FR-033); edition probe named a side-effecting route (now the public `GET /api/v1/auth/provider`, FR-030); `AdminUserContext` on core REST satisfied `CanRevealSecrets` (masked for session principals, FR-002); four `SchemaVersion` pins named, not one (FR-038); `trusted_hosts` advice for containers dropped (Edge Cases); SC-006 exception list extended with the per-owner cap, dropped modes and `DetectConfigChanges`; `group_servers` "last key deleted = allow all" closed by making the `access` block the switch with a fail-closed empty map (FR-007); `-tags server` race job given the `-skip` regex (FR-047); `deploying-for-a-team` walkthrough made verifiable (SC-005); release notice required (FR-042); Path B kept frozen rather than cut (FR-034, zero-caller criterion); removed modes load with a warning rather than failing boot (FR-032). +- Judge defects rejected after verification: "Draft 0 US4 `/config` shows entitled servers" β€” confirmed wrong (`requireAdminRead` at `httpapi/server.go:5069` β†’ 403), so the matrix (FR-045) says 403; "cookie GET/HEAD only" β€” rejected because the Web UI saves Settings via cookie-authenticated PATCH and mints no user JWT, and the server-edition group already accepts cookie mutations; the CSRF posture is stated instead (Edge Cases, Out of Scope). +- Open decisions for the maintainer are listed in the synthesis summary, not in the spec. +- Validation pass 1 (2026-09-15): all items pass; zero clarification markers. +- Cross-model review round 1 (2026-09-15, codex `gpt-5.6-sol`, static): 15 findings, 14 accepted after verification against the worktree, 1 rejected. Fixed: FR-002 now names the one door change (`POST /api/v1/tools/call` has no scope/tier gate and `CallToolDirect` routes built-ins untiered β€” an explicit fixed-403 user-principal gate is required, not "no door code changes"); FR-012 gained binding audit phases (pre-dispatch `authz`, per-dispatch `tool_call`; post-dispatch sanitisation/schema blocks are `tool_call outcome: blocked`, never a second `authz`) and a `jsruntime.checkDispatchGates` observer because nested script refusals never reach the `mcp_code_execution.go` emitters; FR-010 states the Spec 105 open-item carve-out inline (consistent with Assumptions/SC-001) instead of an unqualified "every surface"; SC-006 defines its baseline as the PR merge-base and inherits 105 SC-005; FR-026 refuses `session_cookie_secure: false` with an https `public_url`/TLS listener; FR-020 requires https discovered endpoints and a non-redirect-following back-channel client (+ US2 fixtures); FR-023 replaces "disable clears the binding" (it does not β€” `admin_handlers.go:220-276`) with an administrator disableβ†’enable single-use rebind; `token_event` dropped as beyond F3 (Out of Scope row added), `auth_event` kept with its F1 rationale, SIEM recipes reduced to one vendor-neutral example; US3/SC-003 caller-kind matrix corrected to seven kinds Γ— situations; FR-015 adopts RFC 8785 canonicalisation with vectors; "latent code" defined by reachability from production roots (`broker.ConnectionKey`); FR-014 validates that an enabled sink has a target; SC-007 says three telemetry fields. Rejected: "tenant `*` materialisation violates Spec 028 dynamic wildcard" β€” materialisation at mint and on every authentication is today's shipped Spec 106 behaviour (`user_handlers.go:940-948`, `narrowScopeToEntitled`), 028 FR-016 governs the literal administrator/personal `*`, and the spec says "as today". +- Cross-model review round 2 (2026-09-15, codex `gpt-5.6-sol`, static): 16 findings, 16 accepted after verification against the worktree (one, #8, partially β€” the REST direct path legitimately reuses the transport id as the activity id, `mcp_routing.go:412-414`, so that half became a clarification rather than a contradiction). Fixed: FR-002 replaced the "one door change" premise with a default-deny **tenant-session allowlist** on the core `/api/v1` group (session admission is group-wide, `httpapi/server.go:749-755`; `/code/exec`, `/tool-calls/{id}/replay`, `/registries/{id}/refresh`, `/telemetry/*`, `/onboarding/*`, `/feedback`, `/code/scripts`, `/diagnostics` were all ungated) with the FR-045 matrix, US4 and FR-043(f) walking the production route table; FR-001 now states a binding credential precedence (socket β†’ X-API-Key β†’ bearer β†’ ?apikey= β†’ cookie only when none present; any presented credential that fails is terminal β€” the server-edition middleware's cookie-first order is explicitly not inherited); FR-011's staleness bound was unbounded because `POST /auth/token` renews a JWT from a JWT (`auth_endpoints.go:105-153`) β€” it now accepts only a session-cookie principal and the bound is session TTL + max(JWT TTL, token TTL) (SC-006 (i), FR-036, FR-042, FR-043(i)); FR-008/FR-022 require the userinfo `sub` to equal the verified ID token's `sub` (OIDC Core Β§5.3.2, new reason `userinfo_subject_mismatch`); FR-020's insecure-issuer exception made uniform (loopback AND `allow_insecure_issuer`); FR-023's rebind window is persisted `subject_rebind_armed_at` set on a real disableβ†’enable transition, consumed atomically by the first successful login, no enable timestamp on the line (SC-006 (j)); FR-012 limiter sheds are `tool_call outcome: rejected` (admission runs after every gate inside `managed.Client.CallTool`, `managed/client.go:815-836`), the count invariant is `tool_call` = `authz allow`, and an immutable audit-attempt record on the context replaces gate-local variables; FR-004 collapses the owner gate and scope resolver into one owner resolution that also yields the ephemeral email/provider/role FR-013 needs; FR-017 is one line per attempt with a singular terminal `reason` plus a closed `flags` array (`provider_rebound|redirect_rejected|groups_claim_missing`) and identity omitted when nothing verified; FR-012 scopes F3 to upstream tool dispatch and lists built-in invocations in Out of Scope with the consequence; the client-auth method is chosen once from `token_endpoint_auth_methods_supported` (no code retry); US2.10/FR-020 say "provider-specific behaviour unchanged" and enumerate the provider-neutral FRs that apply to the legacy three; FR-032/FR-040 state the build split for nested deprecated keys; FR-038/US7/SC-010 list all ten v12 pins with a grep guard; FR-021 bounds `pendingStates` (10,000, oldest evicted) β€” it was unbounded, not "as today". Rejected: none. +- Cross-model review round 3 (2026-09-15, codex `gpt-5.6-sol`, static): 15 findings, 15 accepted after verification against the worktree. Fixed: core `/activity*`, `/tool-calls*` and `/servers/{id}/tool-calls` removed from the FR-002 tenant-session allowlist β€” they are server-scoped only (`httpapi/activity.go:147-150`, `activity_models.go:385-390`, `server.go:4762-4778`; `ToolCallRecord` has no owner) and would show every caller's arguments/responses on a shared server; `/user/activity` (today wired `nil` β†’ empty, `setup.go:196-198`) MUST be wired through `multiuser.ActivityFilter` with `user_id` = principal AND server ∈ entitlement (FR-002, FR-043(k), FR-045 rows, US1.2, Out of Scope row for the token door); `session_user` is unreachable for dispatch, so the US3/SC-003 matrix is six kinds and `session_user` is `auth_event`-only (FR-013, FR-043(j)); `authz.reason` excludes `output_sanitisation|output_schema` and `outcome` is `tool_call`-only (FR-013, US3.2); US3.6 no longer implies a wrapper audit line (`parent_id` is an activity correlation key); FR-042 says session cookie only (an agent token never mints a JWT); SC-006 gained (k) additive fields (`/auth/me`, `/admin/users`, `/auth/provider`, telemetry v13, config keys, doctor findings); FR-045/FR-004/Definitions keep the administrator's `/user/*` shared projection unchanged (`user_handlers.go:343-352` selects on `Shared` regardless of role β€” collapsing onto the predicate must not widen it); FR-032/FR-040: the personal build's `AuthBrokerConfig` is also a `struct{}` stub (`auth_broker_stub.go:5-13`) that writes back `{}`, so normalisation/warnings/refusal are server-build-only and FR-040's raw carrier covers both blocks with **semantic** (RFC 8785) equality, not source bytes (`loader.go:392-393` and PATCH `server.go:5367-5400` re-serialise); FR-017 counts terminal attempts only; FR-004's guard permits the predicate's own `Shared` read (`:842` = the eighth site); FR-041 names the unconditional UI calls (`App.vue` `/info`+`/routing`, `Dashboard.vue` `/docker/status`+`/stats/tokens`+`/config`+`/sessions`+`/connect`, `Servers.vue`/`ServerCard.vue` `/security/overview`, `onboarding.ts` `/onboarding/state`) that must be gated on principal kind β€” `/info` embeds the API key for `CanRevealSecrets` callers (`server.go:1501-1525`); audit `source` is `api` for every REST line because `reqcontext.GetRequestSource` is rewritten from the caller-asserted `X-MCPProxy-Client` header (`concurrency_shed.go:12-22`); FR-038 = eleven test pins + the production constant `telemetry.go:138`, grep guard extended; FR-031/US5 = six retired tests, named. Rejected: none. +- Cross-model review round 4 (2026-09-15, codex `gpt-5.6-sol`, static): 9 findings, 9 accepted after verification against the worktree. Fixed: FR-011's bound was still serial β€” the server-edition middleware admits a bearer JWT on `/user/*` (`middleware.go:100-108`) and `POST /user/tokens` needs only `IsUser()` (`user_handlers.go:1071-1076`) with an uncapped `time.ParseDuration` (`:1120-1126`), so session β†’ JWT β†’ token chained to S + J + T; the three credential-minting doors (`/auth/token`, `/user/tokens`, `/user/tokens/{name}/regenerate`) are now session-cookie-only and `expires_in` reuses the core 365-day `parseExpiry` (US1.7, FR-011, FR-036, FR-042, FR-043(i), FR-045, SC-006(i), SC-008); `GET /profiles` lists every profile name for scoped callers (`httpapi/profiles.go:61-85`) and `/profiles/active` ignores the caller (`:104-108`) β€” FR-002 now defines a tenant projection (hidden-only profiles omitted, active `""` when hidden), US1.2 fixture, FR-045 row, Out of Scope row for the token door; FR-012 no longer claims `status` keeps its pinned index β€” `statusArgIndex` moves 5 β†’ 6 (`activity_result_status_test.go:120-124`); US3.1 no longer puts `duration_ms` on the `authz` line and FR-013 forbids `duration_ms`/`error_class`/`request_bytes`/`response_bytes` there; `auth_event` gained `internal_error` for the three proxy-side terminal branches (`oauth_handler.go:242-289`) rendered as the 503 page (FR-013, FR-017, FR-024); the redaction criterion is structural + high-entropy sentinels, not byte-absence of arbitrary values (US3 test, FR-015, SC-003); the F4 guard exempts the compatibility literals FR-032/FR-033 require (`store_idp_tokens` decoder, normaliser warnings) (US5, FR-035, SC-005); FR-040 requires `UseNumber` on both PATCH decode sides (`server.go:5332`, `:5376`) and a comparator independent of the FR-015 canonicaliser, with large-integer/precise-decimal fixtures; a failed userinfo fetch is `provider_error` β†’ 503, never "claim absent" (US2.3, US2.9, FR-022, Edge Cases). Rejected: none. +- Cross-model review round 5 (2026-09-15, codex `gpt-5.6-sol`, static, combined artifact set β€” spec, plan, research, data-model, tasks, contracts, quickstart): 6 findings, 6 accepted after verification against the worktree (one, #5, partially). Fixed: T075a's scoped `/index/search` no longer caps the widening window at 10,000 hits β€” it pages `From`/`Size` exhaustively until `limit` in-scope hits or `From >= Total` (Spec 105 "Ranking under scope" = top-K of an exhaustive filtered search; a cap is a hidden-population oracle), T070a gained the beyond-one-page hidden-prefix case, and the three `ServerController` test doubles are named so PR-C compiles; the owner-resolution identity now has an implementable carrier β€” `ValidateAgentToken` returns the token (`agent_tokens.go:943`) and every caller builds the context with `AgentToken.AuthContext()` (`httpapi/server.go:580`, `server/server.go:427`, `sse_scope.go:43`), so `auth.AgentToken` gains three `json:"-"` `Owner*` fields that storage stamps and `AuthContext()` copies (FR-013, data-model Β§3/Β§4/Β§6, entitlement contract Β§2/Β§5, T071/T076), and the predicate splits into `entitledServerNamesFor(user)` + a `userID` wrapper so the groups read is not a second `GetUser` (FR-004, contract Β§1, T075, T068 guard); FR-014/US3.10 now carry the native-stdio exception the plan, data model, config contract and T108 already defined (absent block β†’ disabled + WARN; explicit stdout-only β†’ exit 4; explicit path honoured); `provider_error` identity is stage-dependent (FR-013, US3.7, events contract, schema rules + a sixth example, T106): `email_hash` when raised by the userinfo fetch after a verified ID token, never `user_id`; FR-016 states the sanitiser exception the contract documented, and `server`/`tool` on a refused dispatch are sanitised per field like the other caller-controlled strings (FR-015, T097 nine sentinels); T076 names the five test files that call the two retired setters. Rejected (part of #5): applying the generic high-entropy rule to `server`/`tool` β€” it would mask legitimate long identifiers and break FR-016's attribution, and the activity log already records caller-supplied names raw, so there is no regression to close. +- Cross-model review round 6 (2026-09-15, codex `gpt-5.6-sol`, static, combined artifact set): 3 findings, 3 accepted after verification against the worktree (severity re-graded: #2 P1, #1 and #3 P2). Fixed: `OwnerResolution.Entitled` is now the *narrowed* grant (`narrowScopeToEntitled(granted, entitledServerNamesFor(user, isAdmin), isAdmin)`) rather than the raw whole-configuration list β€” `intersectAllowedServers` treats a stored `"*"` as "take every entry of `proposed`" (`agent_tokens.go:697-738`), so the previous wording would have frozen an administrator's literal `["*"]` into a configuration snapshot against FR-009 / Definitions (contract Β§2, data-model Β§6, T076; T071 pins Dana's `["*"]` surviving); T067 / `rest-endpoints.md` no longer claim corpus-independent top-K membership unconditionally β€” `total` is always equal across fixtures (boolean matching), membership only while entitled matches ≀ `limit`, beyond that Spec 105's per-fixture derivation applies (T070a gained the over-limit case); the audit schema's `auth_event` branch now encodes the per-reason identity rules (`ok|logout|subject_mismatch|user_disabled` β‡’ session kind + `user_id`; `domain_not_allowed|userinfo_subject_mismatch` β‡’ `anonymous` + `email_hash`) that the events contract already stated, with matching T097 negative fixtures. diff --git a/specs/107-server-edition-sso-hardening/contracts/audit-line-events.md b/specs/107-server-edition-sso-hardening/contracts/audit-line-events.md new file mode 100644 index 000000000..3631eaada --- /dev/null +++ b/specs/107-server-edition-sso-hardening/contracts/audit-line-events.md @@ -0,0 +1,89 @@ +# Audit line β€” per-event contract (schema_version 1) + +Machine-readable form: [`audit-line.schema.json`](audit-line.schema.json) (draft 2020-12; its `examples` validate and the forbidden-key cases reject β€” checked in PR-D by `internal/audit/schema_test.go`, which loads this file and the US3 fixture lines). PR-D copies the schema to `docs/schemas/audit-line-v1.schema.json` and `docs/features/audit-log.md` embeds this table. + +## Common keys (every event) + +| Key | Req | Source | +|---|---|---| +| `schema_version` | βœ” | const `1` | +| `ts` | βœ” | `time.Now().UTC()` formatted with the fixed layout `2006-01-02T15:04:05.000000000Z` (nine fractional digits, always `Z`; **not** `RFC3339Nano`, which trims trailing zeros) | +| `event` | βœ” | `authz` \| `tool_call` \| `auth_event` | +| `request_id` | βœ” | `audit.Attempt.RequestID` (activity id, `mcp.go:837` on `/mcp`; transport id on REST direct dispatch, `mcp_routing.go:412-414`); for `auth_event` the request id shown on the refusal page | +| `origin` | βœ” | `transport.GetConnectionSource(ctx)`: `tcp`β†’`local`, `tray`β†’`socket`, `stdio`β†’`local`; `remote` reserved | +| `source` | βœ” | mount point: `/mcp*`β†’`mcp`, `/api/v1/*`β†’`api`, proxy-originatedβ†’`internal`. **Never** `reqcontext.GetRequestSource` (rewritten from `X-MCPProxy-Client`, `concurrency_shed.go:12-22`) | +| `caller` | βœ” | `auth.AuthContextFromContext(ctx)` + `CredentialKind` + `Anonymous` bit; see kinds below | +| `client` | opt | `name`/`version` caller-asserted (MCP `clientInfo` / `X-MCPProxy-Client`), `ip` via `config.ForwardedHeaders(r, trusted_proxies)` | +| `transport_request_id` | opt | REST only | +| `session_id`, `work_session_id` | opt | activity resolver values (`activity_service.go:168`) | + +`caller.kind` derivation (FR-013): `AuthContext.Type==admin && Anonymous` β†’ `anonymous`; `Type==admin && source==tray` β†’ `socket`; `Type==admin && source==stdio` β†’ `stdio` (the native stdio transport installs `auth.AdminContext()` with no listener, `server.go:1039`; `stdioAuthContext` tags the new `transport.ConnectionSourceStdio` β€” without it `GetConnectionSource` defaults to TCP, `transport/context.go:25`, and the line would say `api_key`); `Type==admin` (API key) β†’ `api_key`; `Type==agent` β†’ `agent_token` (+ `user_id`, `user_email`, `role`, `provider` from the owner resolution when owned; `token_name`, `token_prefix`); `Type==admin_user` with `CredentialKind∈{cookie,bearer_jwt}` β†’ `session_admin`; `Type==user` β†’ `session_user` (auth_event only β€” no dispatch door admits it, FR-002/FR-003); proxy-originated β†’ `internal`. + +## `authz` β€” one per pre-dispatch decision + +| Key | Req | Forbidden | Notes | +|---|---|---|---| +| `surface` | βœ” | | `call_tool_read|write|destructive` \| `direct` \| `code_execution` (nested child) \| `rest` | +| `server`, `tool`, `operation` | βœ” | | canonical pair; recorded even when the refusal was non-disclosing | +| `decision` | βœ” | | `allow` written after the last gate and before the upstream call; `deny` at the refusing gate | +| `reason` | βœ” | | `none` iff `allow`; else one pre-dispatch gate: `intent_invalid`, `intent_rejected`, `profile_scope`, `token_scope`, `token_permission`, `server_quarantined`, `tool_pending_approval`, `tool_changed_approval`, `tool_not_callable`, `other` (= `telemetry.BlockReason*` minus `output_sanitisation|output_schema`) | +| `disclosed` | βœ” when `deny` | | `false` for non-disclosing refusals (Spec 105 FR-010 shapes) | +| `args_sha256`, `args_bytes` | βœ” | | from the attempt record | +| `parent_id` | opt | | nested children only | +| `profile`, `caller.profile_pin` | opt | | | +| β€” | | `outcome`, `error_class`, `duration_ms`, `request_bytes`, `response_bytes`, `flags`, `caller.email_hash`, `caller.kind: session_user` | | + +Gate β†’ reason mapping (all in `handleCallToolVariant` unless noted; anchors shift after PR #1279): intent invalid `mcp.go:2211` β†’ `intent_invalid`; intent rejected `:2227` β†’ `intent_rejected`; profile `:2271` β†’ `profile_scope`; `CanAccessServer` `:2286-2291` β†’ `token_scope` (`disclosed:false`); variant/tier permission `:2305/:2320` β†’ `token_permission`; quarantine `:2396` β†’ `server_quarantined`; approval `:2408/:2417` β†’ `tool_pending_approval|tool_changed_approval`; callability `:2425` β†’ `tool_not_callable`; direct-name dispatch `mcp_routing.go:412-469` same map; nested `jsruntime.checkDispatchGates` (`runtime.go:384`) β†’ `token_scope|token_permission` via the new observer. + +## `tool_call` β€” one per `authz allow`, at completion + +| Key | Req | Forbidden | Notes | +|---|---|---|---| +| `surface`, `server`, `tool`, `operation`, `args_sha256`, `args_bytes` | βœ” | | same values as the paired `authz` line | +| `outcome` | βœ” | | `success` \| `error` \| `blocked` (post-dispatch output sanitisation/schema) \| `rejected` (limiter shed, `*limiter.LimitError` returned to the completion path) | +| `reason` | βœ” iff `blocked`/`rejected` | forbidden on `success`/`error` | `output_sanitisation` \| `output_schema` \| `limiter_queue_full` \| `limiter_queue_timeout` | +| `error_class` | βœ” iff `error` | forbidden on every other outcome (`success`, `blocked`, `rejected`) | `upstream_error` \| `upstream_timeout` \| `upstream_unavailable` \| `validation` \| `sanitisation` \| `internal` \| `cancelled` β€” a bounded class, never message text | +| `duration_ms` | βœ” | | | +| `request_bytes`, `response_bytes` | opt | | as measured by the completion path (`RequestBytes` pre-truncation) | +| `parent_id` | opt | | nested children | +| β€” | | `decision`, `disclosed`, `flags` | | + +## `auth_event` β€” one per terminal login attempt observed by the proxy, one per logout + +| Key | Req | Forbidden | Notes | +|---|---|---|---| +| `surface` | βœ” | | `login` \| `logout` | +| `reason` | βœ” | | `ok`, `logout`, `authorization_denied` (IdP `error=` response on the callback with a valid state β€” `access_denied` or any RFC 6749 Β§4.1.2.1 code; 403 page), `id_token_invalid`, `nonce_mismatch`, `audience_mismatch`, `issuer_mismatch`, `token_expired`, `email_missing`, `email_unverified`, `domain_not_allowed`, `subject_mismatch`, `userinfo_subject_mismatch`, `user_disabled`, `state_invalid`, `provider_error`, `discovery_failed`, `internal_error` | +| `flags` | opt | | `provider_rebound`, `redirect_rejected`, `groups_claim_missing`; omitted when empty | +| `caller.user_id` | when the attempt reached the user store and a record exists (`ok`, `logout`, `subject_mismatch`, `user_disabled`, `internal_error`) | forbidden on `provider_error` (the store is never consulted on that path) | | +| `caller.email_hash` | only when the email is verified (verified ID token for `oidc`; back-channel userinfo for legacy providers) **and** the store was not yet consulted: `domain_not_allowed`, `userinfo_subject_mismatch`, `provider_error` raised by the userinfo fetch after a verified ID token (US2.3) | forbidden beside `user_id`/`user_email` | SHA-256 of the normalised email | +| `caller.kind` | βœ” | | `session_user` \| `session_admin` (after role derivation) \| `anonymous` (refused before identity) | +| `client.ip` | opt | | via `trusted_proxies` | +| β€” | | `server`, `tool`, `operation`, `decision`, `disclosed`, `outcome`, `error_class`, `duration_ms`, `request_bytes`, `response_bytes`, `args_sha256`, `args_bytes`, `parent_id`, `profile` | | + +Identity is **stage-dependent, not reason-dependent** (FR-013). Pre-identity refusals (`state_invalid`, `authorization_denied`, `discovery_failed`, `provider_error` from discovery, JWKS or the token exchange, `id_token_invalid`, `nonce_mismatch`, `audience_mismatch`, `issuer_mismatch`, `token_expired`, `email_missing`, `email_unverified`) carry neither `user_id` nor `email_hash` β€” an unverified claim is never hashed. `provider_error` is the one reason that occurs at two stages: raised by the userinfo fetch **after** a verified ID token it carries `email_hash` of the verified email and never `user_id` (the schema forbids `user_id` on every `provider_error` line and admits `email_hash` only on `auth_event` lines whose reason is `domain_not_allowed`, `userinfo_subject_mismatch` or `provider_error`; T106 covers both stages). An abandoned redirect (pending state never returns) writes no line. + +Callback branch β†’ reason (FR-013/FR-024): state lookup fails `oauth_handler.go:152ff` β†’ `state_invalid`; `?error=…` with a valid state (state consumed first; today the missing `code` returns 400 before the state is read, `:163-167`) β†’ `authorization_denied`; discovery/JWKS/token/userinfo transport, 3xx, non-200 or non-JSON β†’ `provider_error` (503 page) / bad discovery document β†’ `discovery_failed` (503 page); verification β†’ `id_token_invalid|nonce_mismatch|audience_mismatch|issuer_mismatch|token_expired`; `:226` no email β†’ `email_missing`; policy β†’ `email_unverified`; `isDomainAllowed` `:362-379` β†’ `domain_not_allowed`; subject check β†’ `subject_mismatch`; userinfo `sub` β‰  token `sub` β†’ `userinfo_subject_mismatch`; `user.Disabled` β†’ `user_disabled`; upsert `:242-247`, JWT `:260-270`, session `:276-289` failures β†’ `internal_error` (503 page); success β†’ `ok`. + +## Caller identity rules (encoded in the schema, negative fixtures in T097) + +The per-*reason* rules of the `auth_event` table above are encoded too (schema `auth_event` branch `allOf`): `ok|logout|subject_mismatch|user_disabled` β‡’ `caller.kind ∈ {session_user, session_admin}` + `user_id`; `domain_not_allowed|userinfo_subject_mismatch` β‡’ `caller.kind: anonymous` + `email_hash`; the pre-identity reasons β‡’ neither `user_id` nor `email_hash`; `provider_error` β‡’ never `user_id`; `internal_error` is stage-dependent and constrained only by the per-kind rules below. + +| `caller.kind` | Required | Forbidden | +|---|---|---| +| `api_key`, `socket`, `stdio`, `anonymous`, `internal` | β€” | `user_id`, `user_email`, `email_hash`, `role`, `provider`, `token_name`, `token_prefix` β€” except `anonymous` on an `auth_event` refused **after** a verified email is known and before the store was consulted (`domain_not_allowed`, `userinfo_subject_mismatch`, `provider_error` from the userinfo fetch; `email_unverified` has no verified email by definition, and `subject_mismatch`/`user_disabled` have a record β†’ `user_id`), which may carry `email_hash` only | +| `agent_token` | `token_name`, `token_prefix`; when owned (`user_id` present) **all** of `user_id`, `user_email`, `role`, `provider` (all-or-none, encoded as `user_id` β‡’ the other three) | `email_hash`; ownerless (no `user_id`) also forbids `user_email`, `role`, `provider` | +| `session_user` | `user_id`, `role: user` | `email_hash`, `token_name`, `token_prefix`; `user_email` on `auth_event` | +| `session_admin` | `user_id`, `role: admin` | `email_hash`, `token_name`, `token_prefix`; `user_email` on `auth_event` | + +## Count invariants (SC-003, tested under bus saturation) + +`#authz == #pre-dispatch decisions`; `#tool_call == #authz(decision=allow)`; no line for a built-in invocation as such; no `authz`/`tool_call` line with `caller.kind: session_user`; no `authz` line with `outcome` or an `output_*` reason; `#auth_event(surface=login) == #terminal login attempts`. + +## Redaction (FR-015) + +Structural: the line builder's inputs are the `audit.Attempt`, the `AuthContext`, the decision/reason/outcome enums, the typed error class and integers β€” there is no parameter through which an argument value, response fragment, error message, cookie, token or `_auth_*` member can arrive. The line does carry caller/operator-controlled strings β€” `client.name`/`client.version` (MCP `clientInfo` / `X-MCPProxy-Client`), `caller.token_name`, `profile`, and on a **refused** dispatch `server`/`tool` (the caller may name any `server:tool` pair; on an allowed dispatch they are configured names) β€” and each is sanitised **per field** at build time (the fixed-prefix secret patterns masked, length-capped; never the high-entropy rule) with the schema validated *after* sanitisation. Defence in depth: every serialised line additionally passes through the **fixed-prefix** credential patterns via `logs.NewStringSanitizer(logs.WithoutHighEntropy())` (the zap-core `NewSecretSanitizer` is not an `io.Writer`) before the write β€” the generic `high_entropy` rule (`sanitizer.go:104`, any quoted 32+ char `[A-Za-z0-9+/]` run) is excluded because it would mask every `args_sha256` and `email_hash` and break the schema after validation; a fixture proves that `args_sha256`, `email_hash` and a 40-char alphanumeric `server` name survive the pass byte-identical while a planted `AKIA…` in `client.name` and a caller-supplied `AKIA…:ghp_…` target on a refused dispatch are masked **by the per-field pass at build time**, so the writer pass is the **identity on every constructor output** (asserted byte-for-byte on every fixture line). Because every caller- or operator-controlled string is sanitised per field, the writer pass can fire only on a builder bug; a hit is counted in `Sink.SanitizerHits()` (always-on, mirrored to `mcpproxy_audit_sanitizer_hits_total`, surfaced by `doctor`) and logged once per minute, and the masked line is still written. Both passes together are the one documented exception to FR-016's real-name rule (spec FR-016; `docs/features/audit-log.md`): a credential-shaped `server`/`tool`/`token_name` is recorded masked, a configured name that is not credential-shaped is always recorded verbatim. Test: nine distinct high-entropy sentinels β€” arguments, response, error text, a caller-supplied `_auth_user_email`, `client.name`, `token_name`, `profile`, and a caller-supplied `server` and `tool` on a refused dispatch β€” are byte-absent from every line. + +## Versioning + +`schema_version` is bumped only for a removed/renamed key or a narrowed vocabulary (FR-013); **a bump advances three things together** β€” the `schema_version` const, the `$id` (`…/audit-line-v.schema.json`) and the published filename `docs/schemas/audit-line-v.schema.json` (the v file stays published for old consumers) β€” pinned by the schema-identity sync test of T097. Adding a key or an enum value is a minor change: it ships as an updated copy of the **same** schema file (`$id` unchanged, `docs/schemas/audit-line-v1.schema.json`) with a change-log entry in `docs/features/audit-log.md`. The **published** schema (`docs/schemas/audit-line-v1.schema.json`, identical to the checked-in contract) is **consumer-tolerant**: `additionalProperties: true` at the root and inside `caller`/`client`, so a strict consumer holding the v1 document keeps validating after a minor additive change under the same `$id`. The **exact key set is a producer property proven by test**, not by the published file: `internal/audit/schema_test.go` loads the document, flips every `additionalProperties` to `false` in memory, and validates every constructor's output against that strict variant. Identity rules (which `caller` keys each `kind` requires or forbids) are encoded in the schema itself (see the `allOf` blocks) and covered by negative fixtures. diff --git a/specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json b/specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json new file mode 100644 index 000000000..70d4ec19c --- /dev/null +++ b/specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json @@ -0,0 +1,182 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docs.mcpproxy.app/schemas/audit-line/v1.json", + "title": "MCPProxy audit line (schema_version 1)", + "description": "One JSON object per line written by the audit sink (Spec 107 FR-013). Three event kinds share one closed key set; per-event required/forbidden keys and the caller identity rules are enforced by the allOf/if/then blocks. Absent optional keys are omitted, never null. Adding a key is a minor change; removing or renaming a key or narrowing a vocabulary bumps schema_version. This published document is CONSUMER-TOLERANT (additionalProperties: true at every object level) so a strict consumer keeps validating across a minor additive change under the same $id; the exact key set is a producer property proven by internal/audit/schema_test.go, which flips additionalProperties to false in memory.", + "type": "object", + "additionalProperties": true, + "required": ["schema_version", "ts", "event", "origin", "source", "caller"], + "properties": { + "schema_version": { "const": 1 }, + "ts": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{9}Z$", "description": "RFC 3339, fixed nine fractional digits, UTC (Z) β€” Go layout 2006-01-02T15:04:05.000000000Z (not RFC3339Nano, which trims trailing zeros); format alone would admit offsets and other precisions" }, + "event": { "enum": ["authz", "tool_call", "auth_event"] }, + "request_id": { "type": "string", "minLength": 1, "description": "Activity request id (locally minted on /mcp, mcp.go:837; transport id on REST-originated direct dispatch). Join key to the activity record." }, + "transport_request_id": { "type": "string", "minLength": 1, "description": "REST only: X-Request-Id. Equals request_id on REST-originated direct dispatch." }, + "parent_id": { "type": "string", "minLength": 1, "description": "code_execution children: the wrapper's activity request id (a correlation key into the activity log β€” the wrapper writes no line)." }, + "session_id": { "type": "string" }, + "work_session_id": { "type": "string" }, + "origin": { "enum": ["local", "socket", "remote"], "description": "Listener-derived (transport.ConnectionSource). remote is reserved for Spec 089 FR-010 and never emitted by Spec 107." }, + "source": { "enum": ["mcp", "api", "internal"], "description": "Mount point, never the X-MCPProxy-Client header. api for every REST-originated line." }, + "surface": { "enum": ["call_tool_read", "call_tool_write", "call_tool_destructive", "direct", "code_execution", "rest", "login", "logout"] }, + "caller": { + "type": "object", + "additionalProperties": true, + "required": ["kind"], + "properties": { + "kind": { "enum": ["api_key", "socket", "stdio", "anonymous", "agent_token", "session_user", "session_admin", "internal"], "description": "stdio = the native stdio transport (administrator context, no listener; tagged as its own connection source)." }, + "user_id": { "type": "string", "minLength": 1 }, + "user_email": { "type": "string", "format": "email" }, + "email_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "SHA-256 of the normalised (lower-cased, trimmed) email. auth_event only, only when a verified email is known and no user record exists." }, + "role": { "enum": ["admin", "user"] }, + "provider": { "enum": ["google", "github", "microsoft", "oidc"] }, + "token_name": { "type": "string", "minLength": 1 }, + "token_prefix": { "type": "string", "minLength": 8, "maxLength": 16 }, + "profile_pin": { "type": "string" } + } + }, + "client": { + "type": "object", + "additionalProperties": true, + "properties": { + "name": { "type": "string", "description": "Caller-asserted (MCP clientInfo or X-MCPProxy-Client). Untrusted." }, + "version": { "type": "string", "description": "Caller-asserted. Untrusted." }, + "ip": { "type": "string", "description": "RemoteAddr, or the right-most untrusted X-Forwarded-For hop when the peer is in trusted_proxies (FR-027)." } + } + }, + "profile": { "type": "string" }, + "server": { "type": "string", "minLength": 1, "description": "Canonical server name. Recorded even on a non-disclosing refusal (FR-016)." }, + "tool": { "type": "string", "minLength": 1, "description": "Raw upstream tool name (Spec 105 FR-009 registration identity)." }, + "operation": { "enum": ["read", "write", "destructive", "unknown"] }, + "decision": { "enum": ["allow", "deny"] }, + "reason": { + "type": "string", + "description": "Event-specific closed vocabulary; see the per-event blocks below. Never prose." + }, + "disclosed": { "type": "boolean", "description": "false when the refusal was non-disclosing to the caller." }, + "flags": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["provider_rebound", "redirect_rejected", "groups_claim_missing"] }, + "description": "auth_event only. Non-terminal facts of one attempt; omitted when empty." + }, + "outcome": { "enum": ["success", "error", "blocked", "rejected"] }, + "error_class": { "enum": ["upstream_error", "upstream_timeout", "upstream_unavailable", "validation", "sanitisation", "internal", "cancelled"] }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "request_bytes": { "type": "integer", "minimum": 0 }, + "response_bytes": { "type": "integer", "minimum": 0 }, + "args_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "SHA-256 over the RFC 8785 serialisation of security.StripInternalArgs(args), computed before masking/truncation (FR-015)." }, + "args_bytes": { "type": "integer", "minimum": 0, "description": "Length of the canonical serialisation the hash was computed over." } + }, + "allOf": [ + { + "if": { "properties": { "event": { "const": "authz" } } }, + "then": { + "required": ["request_id", "surface", "server", "tool", "operation", "decision", "reason", "args_sha256", "args_bytes"], + "properties": { + "surface": { "enum": ["call_tool_read", "call_tool_write", "call_tool_destructive", "direct", "code_execution", "rest"] }, + "reason": { "enum": ["none", "intent_invalid", "intent_rejected", "profile_scope", "token_scope", "token_permission", "server_quarantined", "tool_pending_approval", "tool_changed_approval", "tool_not_callable", "other"] }, + "caller": { "properties": { "kind": { "enum": ["api_key", "socket", "stdio", "anonymous", "agent_token", "session_admin", "internal"] } }, "not": { "required": ["email_hash"] } } + }, + "not": { "anyOf": [ + { "required": ["outcome"] }, { "required": ["error_class"] }, { "required": ["duration_ms"] }, + { "required": ["request_bytes"] }, { "required": ["response_bytes"] }, { "required": ["flags"] } + ] }, + "allOf": [ + { "if": { "properties": { "decision": { "const": "allow" } } }, "then": { "properties": { "reason": { "const": "none" } } } }, + { "if": { "properties": { "decision": { "const": "deny" } } }, "then": { "properties": { "reason": { "not": { "const": "none" } } }, "required": ["disclosed"] } } + ] + } + }, + { + "if": { "properties": { "event": { "const": "tool_call" } } }, + "then": { + "required": ["request_id", "surface", "server", "tool", "operation", "outcome", "duration_ms", "args_sha256", "args_bytes"], + "properties": { + "surface": { "enum": ["call_tool_read", "call_tool_write", "call_tool_destructive", "direct", "code_execution", "rest"] }, + "reason": { "enum": ["output_sanitisation", "output_schema", "limiter_queue_full", "limiter_queue_timeout"] }, + "caller": { "properties": { "kind": { "enum": ["api_key", "socket", "stdio", "anonymous", "agent_token", "session_admin", "internal"] } }, "not": { "required": ["email_hash"] } } + }, + "not": { "anyOf": [ { "required": ["decision"] }, { "required": ["disclosed"] }, { "required": ["flags"] } ] }, + "allOf": [ + { "if": { "properties": { "outcome": { "const": "blocked" } } }, "then": { "required": ["reason"], "properties": { "reason": { "enum": ["output_sanitisation", "output_schema"] } } } }, + { "if": { "properties": { "outcome": { "const": "rejected" } } }, "then": { "required": ["reason"], "properties": { "reason": { "enum": ["limiter_queue_full", "limiter_queue_timeout"] } } } }, + { "if": { "properties": { "outcome": { "enum": ["success", "error"] } } }, "then": { "not": { "required": ["reason"] } } }, + { "if": { "properties": { "outcome": { "const": "error" } } }, "then": { "required": ["error_class"] } }, + { "if": { "properties": { "outcome": { "not": { "const": "error" } } } }, "then": { "not": { "required": ["error_class"] } } } + ] + } + }, + { + "if": { "properties": { "event": { "const": "auth_event" } } }, + "then": { + "required": ["request_id", "surface", "reason"], + "properties": { + "surface": { "enum": ["login", "logout"] }, + "reason": { "enum": ["ok", "logout", "authorization_denied", "id_token_invalid", "nonce_mismatch", "audience_mismatch", "issuer_mismatch", "token_expired", "email_missing", "email_unverified", "domain_not_allowed", "subject_mismatch", "userinfo_subject_mismatch", "user_disabled", "state_invalid", "provider_error", "discovery_failed", "internal_error"] }, + "caller": { "properties": { "kind": { "enum": ["session_user", "session_admin", "anonymous"] } }, "not": { "required": ["user_email"] }, "description": "Identity rule (FR-013/FR-017): user_id, else email_hash only for a verified email, else neither β€” never the raw email." } + }, + "not": { "anyOf": [ + { "required": ["server"] }, { "required": ["tool"] }, { "required": ["operation"] }, { "required": ["decision"] }, + { "required": ["disclosed"] }, { "required": ["outcome"] }, { "required": ["error_class"] }, { "required": ["duration_ms"] }, + { "required": ["request_bytes"] }, { "required": ["response_bytes"] }, { "required": ["args_sha256"] }, { "required": ["args_bytes"] }, + { "required": ["parent_id"] }, { "required": ["profile"] } + ] }, + "allOf": [ + { "description": "Pre-identity refusals carry neither user_id nor email_hash (FR-013). provider_error is stage-dependent and handled by the next two rules.", + "if": { "properties": { "reason": { "enum": ["state_invalid", "authorization_denied", "discovery_failed", "id_token_invalid", "nonce_mismatch", "audience_mismatch", "issuer_mismatch", "token_expired", "email_missing", "email_unverified"] } } }, + "then": { "properties": { "caller": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["email_hash"] }, { "required": ["user_email"] } ] } } } } }, + { "description": "provider_error never reaches the user store: no user_id at either stage; email_hash only when it was raised by the userinfo fetch after a verified ID token (US2.3).", + "if": { "properties": { "reason": { "const": "provider_error" } } }, + "then": { "properties": { "caller": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] } ] } } } } }, + { "description": "Post-identity results (the store was reached and a record exists): a session caller kind carrying user_id (FR-013/FR-017; audit-line-events.md auth_event table). internal_error is stage-dependent and stays unconstrained beyond the per-kind rules.", + "if": { "properties": { "reason": { "enum": ["ok", "logout", "subject_mismatch", "user_disabled"] } } }, + "then": { "properties": { "caller": { "required": ["user_id"], "properties": { "kind": { "enum": ["session_user", "session_admin"] } } } } } }, + { "description": "Verified email known, store not yet consulted: an anonymous caller carrying email_hash (never user_id).", + "if": { "properties": { "reason": { "enum": ["domain_not_allowed", "userinfo_subject_mismatch"] } } }, + "then": { "properties": { "caller": { "required": ["email_hash"], "properties": { "kind": { "const": "anonymous" } } } } } }, + { "if": { "properties": { "surface": { "const": "logout" } } }, "then": { "properties": { "reason": { "const": "logout" } } } }, + { "if": { "properties": { "reason": { "const": "logout" } } }, "then": { "properties": { "surface": { "const": "logout" } } } } + ] + } + }, + { + "description": "caller.user_email and caller.email_hash are mutually exclusive; email_hash never appears beside a user_id.", + "properties": { "caller": { "not": { "anyOf": [ { "required": ["user_email", "email_hash"] }, { "required": ["user_id", "email_hash"] } ] } } } + }, + { + "description": "Caller identity rules per kind (contracts/audit-line-events.md 'Caller identity rules'): impersonal kinds carry no identity; agent tokens carry token_name/token_prefix; session kinds carry user_id and their matching role and never token fields.", + "properties": { "caller": { "allOf": [ + { "if": { "properties": { "kind": { "enum": ["api_key", "socket", "stdio", "internal"] } } }, + "then": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] }, { "required": ["email_hash"] }, { "required": ["role"] }, { "required": ["provider"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } }, + { "if": { "properties": { "kind": { "const": "anonymous" } } }, + "then": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] }, { "required": ["role"] }, { "required": ["provider"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } }, + { "if": { "properties": { "kind": { "const": "agent_token" } } }, + "then": { "required": ["token_name", "token_prefix"], "not": { "required": ["email_hash"] } } }, + { "description": "Owned agent token: user_id implies user_email, role and provider (all-or-none).", + "if": { "properties": { "kind": { "const": "agent_token" } }, "required": ["user_id"] }, + "then": { "required": ["user_email", "role", "provider"] } }, + { "description": "Ownerless agent token: no user identity at all.", + "if": { "properties": { "kind": { "const": "agent_token" } }, "not": { "required": ["user_id"] } }, + "then": { "not": { "anyOf": [ { "required": ["user_email"] }, { "required": ["role"] }, { "required": ["provider"] } ] } } }, + { "if": { "properties": { "kind": { "const": "session_user" } } }, + "then": { "required": ["user_id", "role"], "properties": { "role": { "const": "user" } }, "not": { "anyOf": [ { "required": ["email_hash"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } }, + { "if": { "properties": { "kind": { "const": "session_admin" } } }, + "then": { "required": ["user_id", "role"], "properties": { "role": { "const": "admin" } }, "not": { "anyOf": [ { "required": ["email_hash"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } } + ] } } + }, + { + "description": "An anonymous caller may carry email_hash only on an auth_event refused after a verified email is known and before the user store was consulted (domain_not_allowed, userinfo_subject_mismatch, or provider_error raised by the userinfo fetch).", + "if": { "properties": { "caller": { "properties": { "kind": { "const": "anonymous" } }, "required": ["email_hash"] } } }, + "then": { "properties": { "event": { "const": "auth_event" }, "reason": { "enum": ["domain_not_allowed", "userinfo_subject_mismatch", "provider_error"] } } } + } + ], + "examples": [ + { "schema_version": 1, "ts": "2026-09-15T10:00:00.000000001Z", "event": "authz", "request_id": "1757930400000000001-jira-create_issue-7", "session_id": "s-1", "origin": "local", "source": "mcp", "surface": "call_tool_write", "caller": { "kind": "agent_token", "user_id": "01J...", "user_email": "alice@example.com", "role": "user", "provider": "oidc", "token_name": "t1", "token_prefix": "mcp_agt_ab12" }, "client": { "name": "claude-code", "version": "2.1", "ip": "10.0.0.7" }, "server": "jira", "tool": "create_issue", "operation": "write", "decision": "allow", "reason": "none", "args_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "args_bytes": 2 }, + { "schema_version": 1, "ts": "2026-09-15T10:00:00.250000000Z", "event": "tool_call", "request_id": "1757930400000000001-jira-create_issue-7", "session_id": "s-1", "origin": "local", "source": "mcp", "surface": "call_tool_write", "caller": { "kind": "agent_token", "user_id": "01J...", "user_email": "alice@example.com", "role": "user", "provider": "oidc", "token_name": "t1", "token_prefix": "mcp_agt_ab12" }, "server": "jira", "tool": "create_issue", "operation": "write", "outcome": "success", "duration_ms": 248, "request_bytes": 2, "response_bytes": 512, "args_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "args_bytes": 2 }, + { "schema_version": 1, "ts": "2026-09-15T10:00:01.000000000Z", "event": "authz", "request_id": "1757930401000000000-prod-db-query-8", "session_id": "s-1", "origin": "local", "source": "mcp", "surface": "call_tool_read", "caller": { "kind": "agent_token", "user_id": "01J...", "user_email": "alice@example.com", "role": "user", "provider": "oidc", "token_name": "t1", "token_prefix": "mcp_agt_ab12" }, "server": "prod-db", "tool": "query", "operation": "read", "decision": "deny", "reason": "token_scope", "disclosed": false, "args_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "args_bytes": 2 }, + { "schema_version": 1, "ts": "2026-09-15T09:59:00.000000000Z", "event": "auth_event", "request_id": "req-4f2a", "origin": "local", "source": "api", "surface": "login", "caller": { "kind": "session_user", "user_id": "01J...", "role": "user", "provider": "oidc" }, "client": { "ip": "10.0.0.7" }, "reason": "ok", "flags": ["groups_claim_missing"] }, + { "schema_version": 1, "ts": "2026-09-15T09:59:30.000000000Z", "event": "auth_event", "request_id": "req-9c01", "origin": "local", "source": "api", "surface": "login", "caller": { "kind": "anonymous" }, "client": { "ip": "203.0.113.9" }, "reason": "nonce_mismatch" }, + { "schema_version": 1, "ts": "2026-09-15T09:59:45.000000000Z", "event": "auth_event", "request_id": "req-b7d2", "origin": "local", "source": "api", "surface": "login", "caller": { "kind": "anonymous", "email_hash": "b36a83701f1c3191e19722d6f90274bc1b5501fe69ebf33313e440fe4b0fe210" }, "client": { "ip": "10.0.0.7" }, "reason": "provider_error" } + ] +} diff --git a/specs/107-server-edition-sso-hardening/contracts/config-keys.md b/specs/107-server-edition-sso-hardening/contracts/config-keys.md new file mode 100644 index 000000000..11a6421e4 --- /dev/null +++ b/specs/107-server-edition-sso-hardening/contracts/config-keys.md @@ -0,0 +1,74 @@ +# Configuration key contracts + +Every key this spec adds, removes or re-specifies. "Live" = takes effect on file reload with no restart; "Restart" = reported by `DetectConfigChanges` with `RequiresRestart=true` and the stated reason. Validation messages are the exact strings both boot and `PATCH /api/v1/config`/`/config/apply` emit (FR-039). Edition: **S** = `//go:build server` only (personal build carries the block as opaque JSON, FR-040); **N** = edition-neutral. PR = where the key lands. + +## `server_edition` (S; `swaggerignore` β€” documented in `docs/configuration/config-file.md`, not OAS) + +Settings-catalogue disposition (checklist item 8; the `frontend/src/views/settings/fields.ts` row set is asserted exactly by `settings-server-edition-wording.spec.ts`): **row** β€” `enabled` (exists), `oauth.provider` (exists; `oidc` added), `oauth.issuer_url`, `oauth.scopes`, `oauth.groups_claim`, `oauth.email_verified_policy`, `oauth.display_name`, `public_url`, `session_cookie_secure`; **Raw-JSON-only by decision** β€” `access.*` (no map control), `oauth.allow_insecure_issuer` (loopback-only development toggle), and the retained keys that have no row today: `admin_emails`, `session_ttl`, `bearer_token_ttl`, `oauth.client_id`, `oauth.tenant_id`, `oauth.allowed_domains` (SC-007: no UI widening); **never a row** β€” `oauth.client_secret` and `credential_encryption_key` (secrets; `${env:}`/`MCPPROXY_CRED_KEY`), `store_idp_tokens` (deprecated no-op), the removed keys. + +| Key | Type | Default | Live/Restart | Validation (message) | PR | +|---|---|---|---|---|---| +| `enabled` | bool | `false` | Restart ("server_edition.enabled requires a restart") | β€” | (exists) | +| `admin_emails` | []string | β€” (required when enabled) | Live (read through `ServerEditionConfigProvider` by the middleware **and** by the login callback's role derivation β€” today the callback reads the boot pointer, `oauth_handler.go:254`) | `server_edition.admin_emails must contain at least one admin email` | (exists) | +| `public_url` | string | `""` (env `MCPPROXY_PUBLIC_URL` overrides the file value β€” the one nested key with an env alias, applied by the build-tagged `applyServerEditionEnvOverrides`; the personal build ignores the variable) | Restart ("server_edition.public_url is used at login handler construction") | absolute `http(s)://host[:port]`, no path/query/fragment: `server_edition.public_url must be an absolute origin (scheme://host[:port]) with no path` ; unset + non-loopback listener β†’ boot warning + `doctor` finding, never an error | B | +| `session_cookie_secure` | `auto`\|`true`\|`false` | `auto` | Restart | `false` with `https` `public_url` or in-process TLS: `server_edition.session_cookie_secure=false cannot be combined with an https public_url or tls.enabled` (the `tls.enabled` half needs the top-level `Config`, so the rule lives in the `*Config`-level bridge `validateServerEditionConfig`, reached from `Config.Validate`/`ValidateDetailed`) ; explicit `false` otherwise β†’ boot warning + `doctor` finding | B | +| `session_ttl`, `bearer_token_ttl` | duration | `24h` | Restart | positive: `server_edition.session_ttl must be positive` | (exists; defaults move to `ApplyDefaults`, A) | +| `store_idp_tokens` | bool | `false` | β€” | retained decoder; `true` β†’ one boot warning `server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it` | A | +| `credential_encryption_key` | string | env `MCPPROXY_CRED_KEY` | Restart | unchanged (fallback moves to `ApplyDefaults`) | A | +| ~~`max_user_servers`~~ | β€” | β€” | never reported | **removed**; server-build normaliser drops it from the raw map before the typed decode and records a `LoadDiagnostic` (the loader has no logger; `config.LogLoadDiagnostics` emits them once after the logger exists β€” `main.go` and the reload path) with `server_edition.max_user_servers is no longer supported and was ignored`; PATCH/`/config/apply` refuse with the same text **from the raw-document check `ValidateRemovedKeys`** run on the generic map before typed decoding (`json.Unmarshal` into `Config` drops unknown keys, so `Config.Validate` never sees them) | A | +| ~~`workspace_idle_timeout`~~ | β€” | β€” | never reported | **removed**; `server_edition.workspace_idle_timeout is no longer supported and was ignored` | A | +| `oauth.provider` | `google`\|`github`\|`microsoft`\|`oidc` | β€” | Restart ("server_edition.oauth.* is bound at login handler construction") | `server_edition.oauth.provider must be one of: google, github, microsoft, oidc (got: %s)` | B | +| `oauth.client_id`, `oauth.client_secret` | string (`${env:}`) | β€” | Restart | required; secret masked by `oauth.RedactedConfig` | (exists) | +| `oauth.tenant_id` | string | `common` (microsoft) | Restart | microsoft only | (exists) | +| `oauth.allowed_domains` | []string | `[]` = allow all | Restart | case-insensitive match (unchanged) | (exists) | +| `oauth.issuer_url` | string | β€” | Restart | required for `oidc`: `server_edition.oauth.issuer_url is required when provider is oidc` ; must be `https`, or `http` only when host is loopback **and** `allow_insecure_issuer: true`: `server_edition.oauth.issuer_url must use https (http is allowed only for a loopback host with allow_insecure_issuer: true)` ; byte-compared with the discovery `issuer` at login (`discovery_failed` on mismatch) | B | +| `oauth.allow_insecure_issuer` | bool | `false` | Restart | see above; non-loopback `http` refused regardless. **Raw-JSON-only** (no Settings row by decision β€” a loopback-only development toggle) | B | +| `oauth.scopes` | []string | `["openid","profile","email"]` | Restart | `openid` appended if missing (ApplyDefaults) | B | +| `oauth.groups_claim` | string | `"groups"` | Restart | non-empty for `oidc` | B | +| `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` | B | +| `oauth.display_name` | string | provider family name | Restart | ≀ 64 chars; returned by `GET /api/v1/auth/provider` | B | +| `access` | object \| absent | absent (= today's `Shared`-only semantics) | **Live** (`access.*` read through `ServerEditionConfigProvider` on every authentication; `DetectConfigChanges` reports `server_edition.access`) | present with empty `group_servers` β†’ every tenant gets only `default_servers` (fail closed) | C | +| `access.group_servers` | map[string][]string | `{}` | Live | non-empty map requires `oauth.provider: oidc`: `server_edition.access.group_servers requires oauth.provider "oidc" (legacy providers yield no groups)` ; each value a valid server name or `"*"`: `server_edition.access.group_servers[%q] contains an invalid server name %q` ; unknown names β†’ boot warning + `doctor` finding only | C | +| `access.default_servers` | []string | absent/`null`/`[]` = no default grant | Live | same name rule; nil-vs-`[]` round trip is semantics-neutral by definition | C | + +## Top-level (N) + +| Key | Type | Default | Env | Live/Restart | Validation | PR | +|---|---|---|---|---|---|---| +| `trusted_proxies` | []string (CIDR or IP) | `[]` (trust nobody) | `MCPPROXY_TRUSTED_PROXIES` (comma list) | Live (`slices.Equal` clause; `ChangedFields: trusted_proxies`; every reader β€” session store, callback, connector base URL, swagger, request-metadata tag β€” evaluates a `func() []string` provider per request, never a captured slice) | each entry parses as CIDR or IP: `trusted_proxies[%d] %q is not a valid CIDR or IP address` β€” `validateTrustedProxies` reached from `Config.Validate`/`ValidateDetailed` (boot, PATCH, apply) | B | +| `audit_log` | object | personal: `{enabled:false}`; server with block absent: `{enabled:true, stdout:true, path:""}` (+ one startup line) β€” **under the native stdio transport** the stdout sink is never used (stdout carries JSON-RPC): with the block **absent** the server-edition default resolves to `{enabled:false}` with one WARN `audit_log.stdout is ignored under the stdio transport; set audit_log.path` (only the default is suppressed); an **explicit** `enabled: true, stdout: true` with no `path` is a sink-construction failure, `StartupError` exit 4 `audit_log.stdout cannot be used under the stdio transport (stdout carries JSON-RPC); set audit_log.path` (FR-014: an explicit value always wins β€” it is refused, never silently disabled); an explicit `path` is honoured and a `stdout: true` beside it is dropped with the WARN | β€” | Restart ("audit_log is bound at sink construction"; `jsonEqual` clause) | `enabled` with neither `stdout` nor `path`: `audit_log is enabled but has no sink (set stdout: true or a path)` β€” `validateAuditLog` reached from `Config.Validate`/`ValidateDetailed` (boot, PATCH, apply) | D | +| `audit_log.enabled` | bool | see above | `MCPPROXY_AUDIT_LOG_ENABLED` | Restart | explicit `false` under the server edition β†’ startup warning "audit attribution is off" | D | +| `audit_log.path` | string | `""` | `MCPPROXY_AUDIT_LOG_PATH` | Restart | uncreatable at boot β†’ exit code 4: `audit_log.path %q cannot be opened for append: %v` (a typed `config.StartupError{ExitCode: 4}` matched by `errors.As` in `classifyError` before its string heuristics β€” a bare `permission denied` message would otherwise classify as exit 5, `main.go:869-873`) | D | +| `audit_log.stdout` | bool | server: `true` when block absent | `MCPPROXY_AUDIT_LOG_STDOUT` | Restart | β€” | D | +| `audit_log.max_size_mb` | int | `50` | β€” | Restart | > 0 when a path is set: `audit_log.max_size_mb must be positive` | D | +| `audit_log.max_backups` | int | `10` | β€” | Restart | > 0 when a path is set | D | +| `audit_log.max_age_days` | int | `90` | β€” | Restart | > 0 when a path is set | D | +| `audit_log.compress` | bool | `true` | β€” | Restart | β€” (wire type `*bool` so an omitted value takes the default and an explicit `false` wins; every `audit_log.max_*` is `*int` for the same reason β€” explicit `0` is a validation error, omitted is the default) | D | +| `require_mcp_auth` | bool | `false` | (exists) | Live | under the server edition the *effective* value is `true` regardless (`config.EffectiveRequireMCPAuth`); explicit `false` β†’ boot notice `require_mcp_auth: false is overridden to true because server_edition.enabled is true` + `doctor` finding; not an error | B | + +## Per-server `auth_broker` (S) + +| Key | Change | PR | +|---|---|---| +| `mode` | accepted set becomes `{oauth_connect}`; `token_exchange`/`entra_obo` β†’ normaliser drops the **whole** `auth_broker` block of that server with `auth_broker.mode %q was never implemented; the auth_broker block for server %q was ignored`; PATCH/`/config/apply` refuse with the same text from the raw-document check (see `max_user_servers`) | A | +| `header`, `header_format` | removed; normaliser drops with `auth_broker.header is no longer supported and was ignored` (same for `header_format`) | A | +| `authorization_endpoint`, `token_endpoint`, `client_id`, `client_secret`, `scopes`, `resource` | unchanged (live connect flow) | β€” | + +## Personal build (N behaviour of S blocks) + +`server_edition` and each `auth_broker` are `json.RawMessage` carriers: preserved key-for-key and value-for-value through load β†’ save β†’ PATCH (with `UseNumber`) β†’ save; no warning, no normalisation, no validation (FR-040). Fixture comparator: independent `UseNumber` structural equality with `9007199254740993` and `0.1000000000000000055511151231257827` planted. + +## `DetectConfigChanges` clauses to add (`internal/runtime/config_hotreload.go`) + +| Clause | Comparison | Result | PR | +|---|---|---|---| +| `server_edition.enabled`, `oauth.*`, `public_url`, `session_cookie_secure`, `session_ttl`, `bearer_token_ttl`, `credential_encryption_key` | `jsonEqual` on a projection struct (server build; the personal build compares the raw bytes and reports `server_edition` as changed-and-restart-pinned when they differ) | `RequiresRestart=true`, `RestartReason="server_edition settings are bound at startup"`, `ChangedFields+="server_edition"` | B | +| `server_edition.admin_emails` | `slices.Equal` | `ChangedFields+="server_edition.admin_emails"` (live) | B | +| `server_edition.access` | `jsonEqual` | `ChangedFields+="server_edition.access"` (live) | C | +| `trusted_proxies` | `slices.Equal` | `ChangedFields+="trusted_proxies"` (live) | B | +| `audit_log` | `jsonEqual` | `RequiresRestart=true`, `RestartReason="audit_log is bound at sink construction"` | D | +| dropped keys (`max_user_servers`, …) | never compared | not reported | A | + +## Settings catalogue rows (`frontend/src/views/settings/fields.ts`) + +`SERVER_EDITION_FIELDS` (no row for `oauth.allow_insecure_issuer` β€” Raw-JSON-only): remove `server_edition.max_user_servers`; `oauth.provider` options `['', 'google','github','microsoft','oidc']`; add `oauth.issuer_url` (text, `valueKind:'url'`, restart), `oauth.scopes` (textarea comma list, restart), `oauth.groups_claim` (text, restart), `oauth.email_verified_policy` (select, restart), `oauth.display_name` (text, restart), `public_url` (text, url, restart), `session_cookie_secure` (select `auto|true|false`, restart); never `client_secret`. New `AUDIT_LOG_FIELDS` accordion: `audit_log.enabled` (toggle, restart), `audit_log.stdout` (toggle, restart), `audit_log.path` (text, restart), `audit_log.max_size_mb|max_backups|max_age_days` (number, restart), `audit_log.compress` (toggle, restart). `trusted_proxies` (textarea, one CIDR per line, live) in the Security section. The `access` map is Raw-JSON only. Swift `SettingsCatalog.swift` gains read-only rows for `audit_log.*` and `trusted_proxies` (parity check, research D12). diff --git a/specs/107-server-edition-sso-hardening/contracts/entitlement-predicate.md b/specs/107-server-edition-sso-hardening/contracts/entitlement-predicate.md new file mode 100644 index 000000000..b0cfd82c8 --- /dev/null +++ b/specs/107-server-edition-sso-hardening/contracts/entitlement-predicate.md @@ -0,0 +1,68 @@ +# Entitlement predicate, owner resolution and session-principal hook + +## 1. `entitledServerNamesFor(user *users.User, isAdmin bool) ([]string, error)` + wrapper `entitledServerNames(userID, isAdmin)` β€” `internal/serveredition/api/user_handlers.go:808` (PR-C) + +The **only** function that answers "may this user see, use, mint against, connect to or diagnose server *N*" for a tenant (FR-004). Non-nil result always (empty = deny-all). Today `entitledServerNames(userID, isAdmin)` never loads the user record (`ListUserServers(userID)` + the live admin servers, `:809-849`); the group term (FR-009) needs `User.Groups`, so the core becomes **`entitledServerNamesFor(user, isAdmin)`**, which takes the already-loaded record, and `entitledServerNames(userID, isAdmin)` is the wrapper that performs exactly one `GetUser` and calls it β€” the REST door handlers use the wrapper (one load per request), the owner resolver (Β§2) calls the core with the record it already loaded, so an agent-token authentication stays at **one** `GetUser`. + +``` +personal(u) = names of u's personal records minus collidesWithAdminConfig (unchanged, :853-869) +shared = { s ∈ live.Servers : s.Shared } # live via AdminServersProvider +grant(u) = ⋃ access.group_servers[g] for g ∈ u.Groups βˆͺ (access.default_servers if no g matches any key) + where "*" expands to every s ∈ shared +entitled(u) = personal(u) βˆͺ { s ∈ shared : access == nil ∨ s ∈ grant(u) } # tenants +entitled(admin) = whole configuration (unchanged) β€” used by mint/rotate and the literal "*" only +``` + +Rules: `access` read live through `ServerEditionConfigProvider`; group values compared exactly (case-sensitive); server names compared exactly on the bare name; a group with no map entry contributes nothing; `u.Groups == nil` (pre-upgrade record) = matches no key; errors from the user store or a nil live config β†’ error (fail closed β†’ `ErrAgentTokenScopeUnavailable` / 503 on REST); a nil `user` handed to the core β†’ error (never an empty grant that could be mistaken for "no groups"). + +Helper `tenantEntitled(r, userID) (set map[string]struct{}, isAdmin bool, err error)` wraps it for the door handlers. **Guard test** (`user_handlers_shared_guard_test.go`): walks the non-test AST of `internal/serveredition/api` and fails on any selector `.Shared` outside `entitledServerNamesFor` (the core; the `entitledServerNames` wrapper reads nothing itself), the administrator projection helper `adminSharedProjection` (the one permitted second reader, used only for `admin_user` list doors β€” FR-004 "administrator projection unchanged") and the administrator-only file `admin_handlers.go` (exempted by file name: the share toggle `:551-597` writes `found.Shared = req.Shared` and the `/admin/servers` enrichment `:637` reads `sc.Shared` β€” `/admin/servers` is the whole-config surface, not a tenant door; the walk is untyped, so the file exemption also covers `req.Shared` on the request struct). The guard is behaviour-red on HEAD (seven tenant-door sites trip it). + +## 2. `storage.SetAgentTokenOwnerResolver` β€” replaces `SetAgentTokenOwnerGate` + `SetAgentTokenScopeResolver` (`internal/storage/agent_tokens.go:846,865`) (PR-C) + +```go +type OwnerResolution struct { + Active bool + UserID, Email string + Provider, Role string // role derived live from admin_emails + Entitled []string // non-nil; the NARROWED grant = narrowScopeToEntitled(granted, entitledServerNamesFor(user, isAdmin), isAdmin) + // (`user_handlers.go:978ff`, today's NarrowTokenServerScope body): ["*"] stays literal for an administrator, + // is materialised into the entitlement set for a tenant, [] when nothing survives +} +type AgentTokenOwnerResolver func(userID string, granted []string) (OwnerResolution, error) +func (m *Manager) SetAgentTokenOwnerResolver(r AgentTokenOwnerResolver) +``` + +`ValidateAgentToken` (today `:911-939`): when `token.UserID != ""` and a resolver is installed β€” one call; `err != nil` β†’ `ErrAgentTokenScopeUnavailable` (unless the resolver reports the owner missing/disabled, which β†’ `ErrAgentTokenOwnerInactive`); `!Active` β†’ `ErrAgentTokenOwnerInactive`; else `token.AllowedServers = intersectAllowedServers(granted, res.Entitled)` β€” `res.Entitled` is already the narrowed grant, so the intersection is the storage-side narrow-only fence exactly as today (`intersect(["*"], ["*"]) = ["*"]`: an administrator's literal star survives, FR-009 / spec Definitions "literal for administrators"; `res.Entitled` MUST NOT be the raw whole-configuration list, which the wildcard rule of `intersectAllowedServers` (`agent_tokens.go:697-738`) would materialise into a frozen snapshot β€” round-6 finding) **and** `token.OwnerEmail/OwnerProvider/OwnerRole = res.Email/Provider/Role` β€” three new `json:"-"` fields on `auth.AgentToken` (`data-model.md` Β§3; never written to BBolt) that `AgentToken.AuthContext()` copies into `AuthContext.Email/Provider/Role`. This is the only implementable carrier: `ValidateAgentToken` returns the token (`:943`) and every caller builds the context from it (`httpapi/server.go:580`, `server/server.go:427`, `sse_scope.go:43`). The resolver, built in `setupMultiUserOAuth`, does exactly one `userStore.GetUser(userID)`, computes the entitlement set with `entitledServerNamesFor(user, isAdmin)` (Β§1) on that record and returns `Entitled = narrowScopeToEntitled(granted, thatSet, isAdmin)`. Installed **first** in `setupMultiUserOAuth` (before any fallible step β€” the existing fail-closed ordering comment at `setup.go:45-56` still applies). Ownerless tokens are untouched. + +`intersectAllowedServers(current, proposed)` returns `[]string{}` (non-nil) when the result is empty or `proposed` is empty (`:707-709`, `:735-737` today return nil). + +## 3. Restricted marker β€” `internal/preflight.ScopeInputs` (PR-C) + +`ScopeInputs.Restricted bool`: set by `internal/httpapi/preflight.go:139-146` whenever `authCtx != nil && !authCtx.IsAdmin()`. `normalizeTokenServers` (`preflight/scope.go:150-162`): `Restricted && len(servers)==0` β†’ deny-all (every id out of scope); unrestricted callers keep "empty = no restriction". `ResolveScope` gains no other change; MCP in-band preflight already enumerates through `serverInScope` (`preflight_glue.go:200-233`). + +## 4. Session-principal hook β€” `internal/httpapi` (PR-C) + +```go +// internal/auth/context.go β€” the type lives in auth, not httpapi: httpapi imports auth, +// so an AuthContext field of an httpapi type would be an import cycle. Defined by T076 +// (PR-C phase C.2) because T078 in the same phase records it; T083 (C.3) only consumes it. +type CredentialKind string // socket|api_key|bearer_jwt|agent_token|cookie|anonymous + +// internal/httpapi/session_principal.go +type SessionPrincipalResolver func(r *http.Request, kind auth.CredentialKind, value string) (*auth.AuthContext, error) +func (s *Server) SetSessionPrincipalResolver(f SessionPrincipalResolver) // nil in the personal build +``` + +Presence of a credential source is header/query **membership** (`r.Header.Values("X-API-Key")`, `r.URL.Query().Has("apikey")`), so an empty `X-API-Key:` or `Authorization: Bearer ` is a present, failing credential (401) and never falls through to the cookie. + +Called by `apiKeyAuthMiddleware` in exactly two places: (3) a bearer that is neither `mcp_agt_` nor the global key β†’ `kind=bearer_jwt`; (5) no `X-API-Key`/bearer/`?apikey=` present and the `mcpproxy_session` cookie is β†’ `kind=cookie`. Returns `UserContext` (tenant: `AllowedServers` = entitlement set, materialised) or `AdminUserContext`, both with `CredentialKind` set; `(nil, nil)` = not a principal β†’ 401. The resolver is built in `internal/serveredition/setup.go` from the same `userStore.GetUser` + `ValidateBearerToken` + live `IsAdminEmail` the server-edition middleware uses (`middleware.go:138-214`, `:228-246`); it never reads the cookie when asked for a bearer and vice versa. + +Immediately after a `user`-typed principal is installed, `tenantSessionAllowlist.Allows(method, path)` decides; on `false` the fixed 403 body is written and the handler never runs. The SSE refresher (`sse_scope.go:17-43`) calls the same resolver with the original `(kind, value)` before each frame; `(nil, nil)` or an error ends the stream. + +## 5. `auth.AuthContext` additions (PR-C/PR-D) + +`CredentialKind auth.CredentialKind` (defined in this package); `IsSessionPrincipal() bool` (`cookie|bearer_jwt`); `auth.ParseTokenExpiry(expiresIn string, now time.Time) (time.Time, error)` β€” the expiry rule shared by core `/tokens` and `/user/tokens` (positive, ≀ 365 d; `httpapi.parseExpiry` becomes a wrapper); `CanRevealSecrets()` returns `false` for session principals; `UserContext(...)` takes the entitlement set (never nil). `AgentToken.AuthContext()` **changes** (T076, `internal/auth/agent_token.go`): it additionally copies the token's non-persisted `OwnerEmail/OwnerProvider/OwnerRole` into `Email/Provider/Role`, which storage stamped on the validated token value (Β§2). + +## 6. Freshness bound (FR-011, documented in `docs/features/agent-tokens.md` and the deploy guide) + +`session_ttl + max(bearer_token_ttl, longest owned token expiry ≀ 365 d)`; administrator `disable` is immediate through the owner resolution; a JWT can neither renew itself nor mint/rotate tokens (session-cookie-only doors). diff --git a/specs/107-server-edition-sso-hardening/contracts/rest-endpoints.md b/specs/107-server-edition-sso-hardening/contracts/rest-endpoints.md new file mode 100644 index 000000000..e1907b82c --- /dev/null +++ b/specs/107-server-edition-sso-hardening/contracts/rest-endpoints.md @@ -0,0 +1,111 @@ +# REST contracts β€” new and changed routes + +Server-edition routes are `//go:build server` and therefore invisible to `swag`, `oas/swagger.yaml`, `scripts/verify-oas-coverage.sh` (greps only `internal/httpapi/server.go`) and CI `verify-oas` β€” the OAS 3.1 snippets below are the hand-maintained contract, mirrored into `docs/development/server-edition-multiuser-auth.md`'s route table. Edition-neutral config changes (`audit_log`, `trusted_proxies`) do reach `oas/swagger.yaml` via `make swagger` (the `Config` schema). Status parity with an absent resource is the oracle for every hidden name (`project_entitlement_test_oracle`). + +## 1. `GET /api/v1/auth/provider` β€” public edition probe (FR-030, PR-B) + +```yaml +/api/v1/auth/provider: + get: + summary: Public login-button label and edition probe + description: No authentication, no side effects (allocates no pending login state). Returns only an operator-chosen label. Personal build answers 404. + responses: + '200': + content: { application/json: { schema: { type: object, additionalProperties: false, required: [display_name], properties: { display_name: { type: string, maxLength: 64 } } } } } + '404': { description: personal edition, or server_edition.enabled=false } +``` + +Never returns issuer, client id, tenant id, scopes, domains or provider family. + +## 2. `GET /api/v1/auth/me` β€” additive fields (FR-008, PR-B) + +Existing body (`auth_endpoints.go:58-65`) gains `groups: string[]` (the caller's own stored groups; `[]` when none) and `groups_updated_at: string|null` (RFC 3339). Session cookie or user JWT as today. + +## 3. `POST /api/v1/auth/token` β€” session-cookie principal only (FR-011, PR-C) + +```yaml +/api/v1/auth/token: + post: + summary: Mint a user JWT from a live session + security: [ { sessionCookie: [] } ] # a Bearer user JWT or mcp_agt_ token β†’ 401 + responses: + '200': { description: '{ token, expires_at }' as today } + '401': { description: no session cookie, or a derived credential was presented } +``` + +## 4. `GET /api/v1/admin/users` β€” additive fields (FR-008/FR-023, PR-B) + +Each user object (`admin_handlers.go:146-154,183-195`) gains `groups: string[]`, `groups_updated_at: string|null`, `subject_rebind_armed_at: string|null`. `POST /api/v1/admin/users/{id}/enable` additionally sets `subject_rebind_armed_at` on a real `disabled: true β†’ false` transition; `disable` clears it. Administrator session/JWT only, as today. + +## 5. `GET /api/v1/user/activity` β€” wired (FR-002, PR-C) + +```yaml +/api/v1/user/activity: + get: + summary: The caller's own activity records + security: [ { sessionCookie: [] }, { userJWT: [] } ] # agent tokens refused by the server-edition middleware as today + parameters: [ { name: limit, in: query }, { name: offset, in: query } ] # unchanged (user_activity.go:38-39); no new query parameters in this spec + responses: + '200': + description: Records where user_id == principal.user_id AND server_name ∈ entitlement set β€” both terms evaluated inside storage.ActivityFilter.Matches (new authorization-only UserID field beside AllowedServers, activity_models.go:283-299), never by post-filtering a page, so total counts only what the page may contain; projected and masked through the exported httpapi.(*Server).ActivityProjector() func(*storage.ActivityRecord) contracts.ActivityRecord injected via serveredition.Dependencies.ProjectActivity (the door holds storage records, user_activity.go:108-132; the masker is a *Server method on the contract type, activity.go:304, and storageToContractActivity at :383 converts β€” the projector composes both so the JSON shape and masking equal core /activity); no include_bodies export. + content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/ActivityRecord' } }, total: { type: integer } } } } } # today's shape (user_activity.go:66-70), unchanged +``` + +An `admin_user` session receives **today's empty `{items:[],total:0}`** (unchanged β€” the door is wired with a nil filter at HEAD, `setup.go:196`, `user_activity.go:100-105`, and SC-006 lists no exception; administrators read history on core `/activity*`). The filter is consulted only for `user`-typed principals. A tenant never receives a record whose `user_id` is another user's or whose server is outside the entitlement (FR-043(k)). + +## 6. `POST /api/v1/user/tokens`, `POST /api/v1/user/tokens/{name}/regenerate` β€” session-cookie only; `expires_in` capped (FR-011, PR-C) + +| Rule | Value | +|---|---| +| Principal | session cookie only; bearer user JWT or agent token β†’ 401 | +| `expires_in` | the core rule, shared through the exported `auth.ParseTokenExpiry` (`internal/auth/agent_token.go`; `httpapi.parseExpiry` at `tokens.go:460` is unexported and `internal/serveredition/api` cannot import `httpapi`, so the rule moves and `parseExpiry` becomes a wrapper): positive, ≀ `8760h` (365 days); `9000h` β†’ 400 `expires_in must be at most 365 days` | +| Default expiry | 30 days (unchanged); an owned token always has `expires_at` | +| `allowed_servers` | βŠ† entitlement set; `"*"` materialised to the set for tenants (never stored literally), literal for `admin_user`; an unentitled name β†’ 400 `server "x" is not available to you` (byte-identical to a nonexistent name) | +| Cap | per owner (FR-037): 101st for the same owner β†’ 409 `you have reached the limit of 100 agent tokens; revoke one to continue` (never a fleet total) | + +List (`GET /user/tokens`) and revoke keep accepting a bearer JWT (they extend nothing). + +## 7. `GET /api/v1/user/servers*`, `/user/diagnostics`, `/user/credentials*` β€” entitlement predicate (FR-004, PR-C) + +For a tenant: list doors return exactly the entitlement set (+ personal records); by-name doors (`/user/servers/{name}` get/update/delete/enable, credential connect/status/delete) answer for an unentitled or group-excluded name with the **same status and body** as for a nonexistent name (404). Comparison is exact, case-sensitive on the bare name. For an `admin_user` these doors render today's shared (+ personal) projection unchanged. + +## 8. Core `/api/v1` β€” tenant-session allowlist (FR-001/FR-002, PR-C) + +Credential precedence in `apiKeyAuthMiddleware` (one source evaluated; a failing present credential is terminal 401; **presence** is header/query *membership* β€” an empty `X-API-Key:`, `Authorization: Bearer ` or `?apikey=` is present-and-failing, never absent, unlike today's `ExtractToken` which collapses empty to absent, `server.go:592-615`): socket β†’ `X-API-Key` β†’ `Authorization: Bearer` (`mcp_agt_` = agent token; else global key; else user JWT via the edition hook) β†’ `?apikey=` β†’ `mcpproxy_session` cookie **only when none of the three headers/params is present**. + +Tenant (`user`-typed) principal β€” allowed, then filtered by the existing scope predicates: + +| Method | Route | Filter | +|---|---|---| +| GET | `/api/v1/status` | `CanEnumerateServer` | +| GET | `/api/v1/servers` | `visibleServers` | +| GET | `/api/v1/servers/{id}/**` except `/servers/{id}/tool-calls` **and except the static `/api/v1/servers/import/paths`** (`server.go:788` β€” host filesystem paths, not a server subtree; the matcher denies it explicitly *before* the `{id}` rule because a raw-path matcher cannot tell `import` from a server id) | `scopedServerSubtree` (404 parity) | +| GET | `/api/v1/tools` | scoped | +| GET | `/api/v1/index/search` | scoped **before the ranked cut**: `SearchToolsScoped(query, limit, canSeeServer)` (T075a β€” same query and scores, exhaustive `From`/`Size` paging with no result cap, filter then cut) so a hidden high-ranker can never displace an entitled hit and `total` counts only entitled hits; `total` (= `min(limit, entitled matches)`; matching is boolean, so corpus-independent) is byte-identical across the two fixtures, and so is membership whenever the entitled match count ≀ `limit` (every fixture case, US4.2); with more entitled matches than `limit`, top-K membership, scores and ordering are asserted against the per-fixture derivation only (Spec 105 retrieve oracle: corpus-dependent scores can reorder entitled hits across the cut); the #1166 post-filter for agent tokens is replaced by the same call | +| POST | `/api/v1/preflight` | `ResolveScope` with `Restricted=true` (FR-006) | +| GET | `/api/v1/profiles`, `/api/v1/profiles/active` (the only profile reads that exist, `server.go:771-772`; there is no `/profiles/{slug}` route) | **tenant projection**: omit every profile whose effective server set ∩ entitlement is empty; `GET /profiles/active` β†’ `""` when the global active profile would be omitted | +| GET/HEAD | `/events` | `eventVisibleToCaller`; principal re-resolved per frame (FR-005) | + +**Every other method+route** under `/api/v1` (including routes added later) β†’ `403` with the existing scoped-caller body, emitted before the handler (so before any body parse; a malformed body is 403, not 400). Named in the spec: `/tools/call` (every tool name, built-ins included), `/code/exec`, `/tool-calls/{id}/replay`, `/config` (GET/PATCH), `/config/apply`, `/servers` add/remove/enable, `/quarantine/*`, `/tokens*` (personal routes), `/secrets*`, `/sessions`, `/registries*` incl. `/refresh`, `/telemetry/*`, `/onboarding/*`, `/feedback`, `/connect*`, `/code/scripts`, `/diagnostics`, `/doctor`, `/annotations/coverage`, `/info`, `/routing`, `/docker/status`, `/stats/tokens`, `/security/*`, `/activity*`, `/tool-calls`, `/tool-calls/{id}`, `/servers/{id}/tool-calls`. + +`admin_user` session principal: administrator everywhere on core REST except `CanRevealSecrets` (raw secret reveal stays API-key/socket-only; `GET /config?reveal=…` and `/info`'s `web_ui_url` return masked values). + +The fixed 403 body (existing scoped-caller shape): `{"error":"forbidden","message":"this credential is not permitted to access this resource","request_id":"…"}` β€” identical for every refused route. + +## 9. `/mcp*` β€” unchanged credential set; forced auth under the server edition (FR-003/FR-029, PR-B) + +Accepts exactly `mcp_agt_` agent tokens, the global API key (`X-API-Key` / `Bearer` / `?apikey=`) and the socket. With `server_edition.enabled: true`, no credential β†’ 401 and any unrecognised bearer (session cookie, user JWT) β†’ 401 regardless of `require_mcp_auth`. Body unchanged (`{"error":"Authentication required…"}`); no `WWW-Authenticate` (Spec 089 scope). + +## 10. Login/logout (FR-024/FR-028, PR-B) + +- `GET /api/v1/auth/login?redirect_uri=`: `redirect_uri` accepted only as a same-origin path (single leading `/`, not `//` or `/\`, no scheme/host/backslash/CR/LF/control β€” checked on the percent-decoded value `r.URL.Query().Get` returns, so `%2F%2F`, `%5C`, `%0D%0A`, `%00` are caught too); otherwise replaced by `/ui/` silently. Boot: with `public_url` set, setup logs one INFO line with the resolved public URL and callback URL and makes no IdP request (US2.1). Response: 302 to the IdP with `state`, `nonce`, PKCE S256, `redirect_uri=/api/v1/auth/callback`. +- `GET /api/v1/auth/callback`: success β†’ 302 to the stored path + `Set-Cookie: mcpproxy_session=…; Path=/; HttpOnly; SameSite=Lax[; Secure]`; an IdP authorization-response error (`?error=access_denied&state=…` or any RFC 6749 Β§4.1.2.1 code) consumes the state first and is a refusal with reason `authorization_denied` (the IdP's `error_description` is never rendered β€” today a distinct 400 `missing code parameter` is returned before the state is touched, `oauth_handler.go:163-167`); refusal β†’ one generic page `Sign-in was not permitted (ref )` with status **403** for every refusal reason; unavailability (`discovery_failed`, `provider_error`, `internal_error`) β†’ **503** `Sign-in is temporarily unavailable (ref )`. The reason reaches only the server log and the `auth_event` line keyed by ``. +- `POST /api/v1/auth/logout`: unchanged; writes one `auth_event` (`surface: logout`). + +## 11. Not a surface (FR-016) + +There is no REST, SSE, MCP, CLI or Web UI route that reads or lists audit lines. `GET /api/v1/activity/export` is unchanged. + +## 12. `mcpproxy credential list|status` (FR-034, PR-A) + +Output begins with the line `Stored credentials are kept for a future broker and are NOT injected into upstream calls in this release.`; the REST status vocabulary (`connected|expired|not_connected|unavailable`) is unchanged. diff --git a/specs/107-server-edition-sso-hardening/contracts/telemetry-v13.md b/specs/107-server-edition-sso-hardening/contracts/telemetry-v13.md new file mode 100644 index 000000000..b740c8160 --- /dev/null +++ b/specs/107-server-edition-sso-hardening/contracts/telemetry-v13.md @@ -0,0 +1,24 @@ +# Telemetry payload v13 (FR-038, PR-B) + +`internal/telemetry/telemetry.go`: `const SchemaVersion = 13`; the v3…v12 change-log comment gains: + +``` +// v13 (Spec 107): feature_flags.server_edition_enabled (bool), feature_flags.idp_provider +// (closed enum google|github|microsoft|oidc|none β€” never an issuer), user_count_bucket +// (closed enum 0|1-10|11-100|101-1000|1000+; "0" when no user counter is installed β€” the personal +// edition β€” and omitted only when the installed counter errors). +// env_markers.is_container unchanged. No worker migration: fields ride in payload_json. +``` + +| Field | Location | Type / vocabulary | Source | Personal edition value | +|---|---|---|---|---| +| `feature_flags.server_edition_enabled` | `FeatureFlagSnapshot` (`feature_flags.go:13-51`, built in `BuildFeatureFlagSnapshot :122-170`) | bool | `config.ServerEditionEnabled(cfg)` (build-tagged accessor) | `false` | +| `feature_flags.idp_provider` | same | `google`\|`github`\|`microsoft`\|`oidc`\|`none` | `config.IdPProviderFamily(cfg)`; `none` when disabled/unset | `"none"` | +| `user_count_bucket` | `HeartbeatPayload` (`telemetry.go:141-310`), spliced in `BuildPayload` (`:1201`, after `:1391-1398`) | `0`\|`1-10`\|`11-100`\|`101-1000`\|`1000+` (`bucketUpstream` vocabulary, `registry.go:642-655`) | nil-safe `userCounter func() (int, error)` accessor installed by server-edition wiring (pattern `telemetry.go:427-456`); error β†’ field omitted | `"0"` when the accessor is nil (spec US7.1) | +| `env_markers.is_container` | unchanged (`env_markers.go:22`) | bool | unchanged | as detected | + +Privacy (Spec 042 contract): `TestPayloadHasNoForbiddenSubstrings` and `ScanForPII` run with a fixture whose issuer host (`login.corp.example`), group names, admin emails and server names are the forbidden substrings; `ScanForPII` rule 3 (`anonymity.go:604-615`, `DisallowUnknownFields` on `env_markers`) is unaffected because no new `env_markers` key is added. `docs/features/telemetry.md` "What is collected" lists the three fields; "What is NOT collected" keeps "User identity, email", "Server names/URLs" and adds "IdP issuer, group names". + +Test sites pinned at v12 β†’ 13 (eleven, per FR-038): `payload_v7_test.go:20,81,112`, `payload_v2_test.go:91,138`, `telemetry_test.go:315,323`, `payload_privacy_test.go:147`, `current_error_codes_test.go:268`, `tpa_scanner_test.go:267`, `tpa_funnel_v9_test.go:263`. Guard: `schema_version_guard_test.go` walks the package files and asserts zero hits for the v12 patterns β€” the regex is assembled from fragments (`"schema_version\":" + "12"`, `"!= " + "12"`, …) and the guard skips its own file, otherwise its literal self-matches and the guard can never pass; the server/personal accessor values are proven by tagged fixture files (`payload_v13_fixture_{server,personal}_test.go`). + +Worker (`~/repos/mcpproxy-telemetry`): no change β€” ingest stores `payload_json` wholesale and `validateV*Payload` accepts unknown top-level keys; dashboards query `json_extract(payload_json,'$.feature_flags.idp_provider')`. diff --git a/specs/107-server-edition-sso-hardening/data-model.md b/specs/107-server-edition-sso-hardening/data-model.md new file mode 100644 index 000000000..fd76092cb --- /dev/null +++ b/specs/107-server-edition-sso-hardening/data-model.md @@ -0,0 +1,109 @@ +# Data Model + +No BBolt schema migration. Every persisted change is an additive JSON field on an existing record (absent β†’ zero value on decode) or a config key. Wire shapes for config keys are in `contracts/config-keys.md`; the audit line in `contracts/audit-line.schema.json`. + +## 1. Persisted: `users.User` (`internal/serveredition/users/models.go:15-24`, bucket `users` + `users_by_email`) β€” PR-B/PR-C + +| Field | JSON | Type | Written by | Read by | Notes | +|---|---|---|---|---|---| +| `Groups` | `groups` | `[]string` | `upsertUser` on every successful login (B); wholesale replace, `[]` on absence/overage/non-array | `entitledServerNames` (C), `GET /auth/me`, `GET /admin/users` | Opaque, exact, case-sensitive strings; never sent to any IdP/upstream. Legacy providers always store `[]`. Upgraded records decode as `nil` β†’ treated as "matches no key" until next login (documented fail-closed window). | +| `GroupsUpdatedAt` | `groups_updated_at` | `time.Time` (omitempty) | same login write | `/auth/me`, `/admin/users` | Zero on upgraded records. | +| `SubjectRebindArmedAt` | `subject_rebind_armed_at` | `*time.Time` (omitempty) | enable handler on a real `Disabled trueβ†’false` transition (set); disable handler (clear); first successful login while set (clear, same store write as the rebind) | `HandleCallback` subject check; `/admin/users` | Absent on every upgraded record = closed. Single-use, durable, administrator-opened (FR-023). | +| `Provider`, `ProviderSubjectID` | unchanged names | | refreshed on **every** successful login (today `Provider` never, `ProviderSubjectID` only with an avatar, `oauth_handler.go:389-397`) | subject binding | `User.Validate` (`models.go:48-54`) admits `oidc` and keeps requiring a non-empty subject (critic G5). | + +Store contract additions (`users/store.go`): `UpdateUserLogin(ctx, verified LoginClaims) (LoginOutcome, error)` is **transaction-owned** β€” it re-reads the record by the email index **inside one `db.Update`**, evaluates the subject rule there (bind when empty/equal; rebind on a provider change or when `SubjectRebindArmedAt` is set, clearing the flag in the same write; otherwise `ErrSubjectMismatch` with nothing written; `ErrUserDisabled` on a disabled record), writes groups/subject/provider/last-login and returns `LoginOutcome{User, Created, Rebound, RebindConsumed}`. A pre-mutated `*User` cannot provide this: today's `GetUserByEmail` is a separate `View` (`store.go:128-164`) and `UpdateUser` re-reads only for the email index (`:166-221`), so two callbacks could both observe the armed flag and rebind to different subjects. Two concurrent logins with different subjects while the flag is set β†’ exactly one rebinds, the other is `subject_mismatch` (FR-023 fixture, T038). `LoginClaims{Email, Provider, Subject, Name, AvatarURL, Groups []string, GroupsKnown bool}` carries only verified values. No new bucket, no index on groups (entitlement is computed per request from one `GetUser`). + +## 2. Persisted: `users.Session` (`models.go:59-67`) β€” unchanged shape + +`IPAddress` is now derived through `config.ForwardedHeaders(r, trustedProxies)` (right-most untrusted hop) instead of an unconditional `X-Forwarded-For` read (`session_store.go:129-151`). No field change. + +## 3. Persisted: `auth.AgentToken` (`internal/auth/agent_token.go:33-45`) β€” persisted shape unchanged; three non-persisted carrier fields (PR-C) + +Stored grant `AllowedServers` remains the original grant (Spec 106 data model). Per-owner cap counts tokens sharing `UserID` (ownerless = one owner). `ExpiresAt` is always set for owned tokens (pinned by test; only ownerless operator tokens may be zero). + +| Field | JSON | Type | Written by | Read by | Notes | +|---|---|---|---|---|---| +| `OwnerEmail`, `OwnerProvider`, `OwnerRole` | `-` (`json:"-"`, **never persisted** β€” the BBolt record is `json.Marshal`ed, `agent_tokens.go:199`, so the tag keeps them out of the store) | `string` | `storage.ValidateAgentToken` from the single owner resolution (Β§6), on the **returned** `*AgentToken` value only | `AgentToken.AuthContext()` copies them into `AuthContext.Email/Provider/Role` | The token value is the only carrier storage can stamp: `ValidateAgentToken` returns `*auth.AgentToken` (`agent_tokens.go:943`) and every caller builds the context from it (`httpapi/server.go:580`, `server/server.go:427`, `sse_scope.go:43`). T071 asserts the persisted record bytes never contain them. | + +## 4. Ephemeral: `auth.AuthContext` (`internal/auth/context.go:14-39`) β€” PR-C/PR-D + +| Field | Change | Set by | +|---|---|---| +| `Email`, `Provider`, `Role` | now populated for **agent tokens** from the single owner resolution at authentication (today only `UserID`, `agent_token.go:54-72`); never persisted on the token record β€” carried on the validated token's `json:"-"` `Owner*` fields (Β§3) and copied by `AgentToken.AuthContext()` | `storage.ValidateAgentToken` stamps the token value via `SetAgentTokenOwnerResolver`; `AgentToken.AuthContext()` copies (C) | +| `CredentialKind` (new) | `socket\|api_key\|bearer_jwt\|agent_token\|cookie\|anonymous` β€” records which FR-001 source authenticated the request; drives the session-only mint doors (FR-011), `caller.kind` (FR-013) and `CanRevealSecrets` (FR-002) | `apiKeyAuthMiddleware`, `mcpAuthMiddleware`, server-edition middleware (type + constants defined by T076 in PR-C phase C.2; `stdioAuthContext` tags no kind β€” stdio identity comes from `transport.ConnectionSourceStdio`, PR-D) | +| `AllowedServers` for `UserContext` | materialised entitlement set (never nil, never `"*"`) instead of `nil` (`context.go:155-176`) | session-principal resolver (C) | +| `IsSessionPrincipal()` (new method) | `CredentialKind ∈ {cookie, bearer_jwt}` | β€” | +| `CanRevealSecrets()` | additionally `false` for session principals | β€” | + +## 5. Ephemeral: `audit.Attempt` (context value, `internal/audit/attempt.go`) β€” PR-D + +Immutable per dispatch attempt, installed before the first gate: + +```go +type Attempt struct { + RequestID, TransportRequestID, ParentID string + SessionID, WorkSessionID string + Server, Tool string // canonical pair (Spec 105 FR-009) + Operation string // read|write|destructive|unknown + Surface string // call_tool_read|call_tool_write|call_tool_destructive|direct|code_execution|rest + Source string // mcp|api|internal β€” from the mount point, never X-MCPProxy-Client + Origin string // local|socket|remote(reserved) + ClientName, ClientVersion, ClientIP string // client.* ; IP via trusted_proxies + Profile, ProfilePin string + ArgsSHA256 string // RFC 8785 over StripInternalArgs(args), pre-masking + ArgsBytes int + StartedAt time.Time +} +``` + +`Attempt` never holds arguments, responses, error text or `_auth_*` values (the structural rule of FR-015 is enforced by construction: the line builder has no parameter through which they could arrive). + +## 6. Ephemeral: `storage.OwnerResolution` (replaces two callbacks) β€” PR-C + +```go +type OwnerResolution struct { + Active bool + UserID, Email string + Provider, Role string // role live from admin_emails + Entitled []string // non-nil; the NARROWED grant: narrowScopeToEntitled(granted, entitledServerNamesFor(user, isAdmin), isAdmin) + // β€” ["*"] literal for an administrator, materialised for a tenant (contracts/entitlement-predicate.md Β§2) +} +type agentTokenOwnerResolver func(userID string, granted []string) (OwnerResolution, error) +``` + +Storage semantics (unchanged in spirit from `agent_tokens.go:911-939`): store error or `Active=false` β†’ `ErrAgentTokenOwnerInactive`; resolver error β†’ `ErrAgentTokenScopeUnavailable`; `token.AllowedServers = intersectAllowedServers(granted, Entitled)` (non-nil empty on empty; `Entitled` is already narrowed, so this is the narrow-only fence as today and an administrator's literal `"*"` survives β€” `intersect(["*"], ["*"]) = ["*"]`, FR-009); `token.OwnerEmail/OwnerProvider/OwnerRole = res.Email/Provider/Role` (Β§3). **One `GetUser` per authentication**: the resolver (built in `setup.go`) loads the record once, computes the entitlement set from that loaded record through `entitledServerNamesFor(user, isAdmin)` (`contracts/entitlement-predicate.md` Β§1) and narrows the stored grant against it with `narrowScopeToEntitled` (never returning the raw configuration list as `Entitled`) β€” the predicate never performs its own `GetUser` on this path, even though it now reads `User.Groups`. + +## 7. Ephemeral: OIDC provider state (`internal/serveredition/auth/oidc_provider.go`) β€” PR-B + +| Object | Fields | Lifetime | +|---|---|---| +| `discoveryDoc` | `Issuer, AuthorizationEndpoint, TokenEndpoint, JWKSURI, UserinfoEndpoint, TokenEndpointAuthMethodsSupported, IDTokenSigningAlgValuesSupported`, `fetchedAt`, `expiresAt` | lazy on first login; TTL from cache headers clamped to [5 min, 24 h]; re-fetched on error/expiry; never at boot | +| `jwksCache` | `map[kid]crypto.PublicKey`, `fetchedAt` | with discovery; one refetch per login attempt on unknown `kid` | +| `pendingState` (extends `oauth_handler.go:37-50`) | `+ Nonce string`, `+ RedirectRejected bool`, `+ CreatedAt` (exists) | 10-min sweep **and** a 10,000-entry cap, oldest evicted on insert (FR-021) | +| `OAuthUserInfo` (`oauth_providers.go:52-57`) | `+ Groups []string`, `+ EmailVerified *bool` | per callback | + +## 8. Config: `ServerEditionConfig` (`internal/config/server_edition_config.go`) β€” PR-A/B/C + +Removed (A): `WorkspaceIdleTimeout`, `MaxUserServers`. Retained for compatibility with a boot warning when `true` (A): `StoreIDPTokens`. Added (B): `PublicURL string`, `SessionCookieSecure string` (`auto|true|false`), `OAuth.IssuerURL`, `OAuth.Scopes []string`, `OAuth.GroupsClaim`, `OAuth.EmailVerifiedPolicy`, `OAuth.DisplayName`, `OAuth.AllowInsecureIssuer bool`. Added (C): `Access *ServerEditionAccessConfig{GroupServers map[string][]string; DefaultServers []string}`. `Validate()` becomes non-mutating; `ApplyDefaults()` (new, called once at setup) owns `SessionTTL`/`BearerTokenTTL` defaults, Microsoft `TenantID=common`, `Scopes` default + `openid`, `GroupsClaim="groups"`, `EmailVerifiedPolicy=refuse_false`, `SessionCookieSecure=auto`, and the `MCPPROXY_CRED_KEY` fallback. + +Personal build (A): `type ServerEditionConfig struct{ raw json.RawMessage }` with verbatim `MarshalJSON`/`UnmarshalJSON`; same for `AuthBrokerConfig`. + +`AuthBrokerConfig` (server, A): removed `Header`, `HeaderFormat`; accepted modes `{oauth_connect}` only. Server-build normaliser (`loader.go`, beside the `teams` alias at `:271-285`): drops `server_edition.max_user_servers`, `server_edition.workspace_idle_timeout`, `auth_broker.header`, `auth_broker.header_format`, and the whole `auth_broker` block of a server whose `mode ∈ {token_exchange, entra_obo}` β€” one warning each; write-time validation refuses the same with the same message. + +## 9. Config: top-level `Config` (`internal/config/config.go`) β€” PR-B/PR-D (edition-neutral) + +- `TrustedProxies []string` `json:"trusted_proxies,omitempty"` β€” CIDRs or addresses; env `MCPPROXY_TRUSTED_PROXIES` (comma list); hot; `slices.Equal` clause. +- `AuditLog *AuditLogConfig` `json:"audit_log,omitempty"` β€” wire struct `{Enabled *bool; Path string; Stdout *bool; MaxSizeMB, MaxBackups, MaxAgeDays *int; Compress *bool}` β€” pointer fields so omitted-vs-explicit is observable (omitted `compress` β†’ `true`, explicit `false` β†’ `false`; omitted `max_size_mb` β†’ 50, explicit `0` β†’ validation error; explicit values always win) β€” resolved into a plain `ResolvedAuditLog{Enabled, Path string, Stdout bool, MaxSizeMB, MaxBackups, MaxAgeDays int, Compress bool}` by `EffectiveAuditLog`; env `MCPPROXY_AUDIT_LOG_ENABLED|PATH|STDOUT`; restart-pinned; `jsonEqual` clause. Effective default computed by `config.EffectiveAuditLog(cfg, transport)` β€” `transport` is `http|stdio`; under the native stdio transport (stdout carries JSON-RPC) the **absent-block** server default resolves to *disabled* + one WARN, an **explicit** `enabled:true, stdout:true` with no `path` is a `StartupError` (exit 4) at sink construction β€” never silently disabled (FR-014 "an explicit value always wins") β€” and an explicit `path` is honoured (build-tagged): personal β†’ `Enabled=false`; server with block absent β†’ `{Enabled:true, Stdout:true, Path:""}` on HTTP, `{Enabled:false}` on stdio. +- Build-tagged accessors (`serveredition_accessors{,_stub}.go`): `EffectiveRequireMCPAuth`, `IdPProviderFamily`, `ServerEditionEnabled`, `PublicURL`, `EffectiveAuditLog`, `ForwardedHeaders(r, trusted)` (edition-neutral, lives in `trusted_proxies.go`). + +## 10. Activity records (`internal/storage/activity_models.go`) β€” unchanged + +No new field. `UserID/UserEmail` continue to be lifted from `_auth_*`; the audit line joins to the activity record on `request_id`. Historical `credential_broker` rows stay readable and labelled (FR-034). + +## 11. Telemetry (`internal/telemetry`) β€” PR-B + +`FeatureFlagSnapshot` + `ServerEditionEnabled bool` `json:"server_edition_enabled"`, `IdPProvider string` `json:"idp_provider"` (closed enum); `HeartbeatPayload` + `UserCountBucket string` `json:"user_count_bucket,omitempty"`; `SchemaVersion = 13`. `EnvMarkers` unchanged (`IsContainer` already present). + +## 12. Frontend state β€” PR-B/PR-C + +`stores/auth.ts`: `provider {display_name}` from the public probe; `principalKind: 'tenant'|'admin'|'api_key'` from `/auth/me` role + presence of an API key; every non-allowlisted core call gated on `principalKind !== 'tenant'` (FR-041). `AdminUser` interface + `groups`, `groups_updated_at`, `subject_rebind_armed_at`; `AdminServer` + `groups: string[]` (read-only chips derived client-side from `GET /config` β†’ `server_edition.access`, administrator only). No `contracts.ts` change (the generator emits vocabularies only; the in-sync test proves it). diff --git a/specs/107-server-edition-sso-hardening/plan.md b/specs/107-server-edition-sso-hardening/plan.md new file mode 100644 index 000000000..fe2b0e387 --- /dev/null +++ b/specs/107-server-edition-sso-hardening/plan.md @@ -0,0 +1,238 @@ +# Implementation Plan: Server Edition β€” SSO Front Door Hardened for Real IdPs + +**Branch**: `107-server-edition-sso-hardening` | **Date**: 2026-09-15 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `specs/107-server-edition-sso-hardening/spec.md` (Draft, 47 FRs, 11 SCs, 7 user stories). Evidence base: the seven lane maps and the completeness critic (`critic.md` C1–C14, G1–G11 override the lane maps where they conflict) and the maintainer's research decision of 2026-09-14 (posture (a) + a bounded slice of (c)); all anchors below were re-read at origin/main `b39800a89`. + +## Summary + +Make the sentence *"a person who signs in through the team's IdP can see and use exactly the servers their group grants, on every surface, and every decision about them is written down"* true, and remove the code and prose that promise things the proxy does not do. Four deliverables β€” **F4** freeze/cut of the latent multi-user/credential-injection chain, **F1** a discovery-based OIDC provider with JWKS-verified ID tokens and a groups claim, **F2** an IdP-group β†’ server allowlist composed as one more narrow-only term of Spec 105's *effective authorization* (plus the tenant principal on core REST that critic G1 showed is missing), **F3** a synchronous, attributable JSONL audit line with a checked-in JSON Schema β€” shipped as **four independently mergeable PRs in that order** (PR-A β†’ PR-D, Β§Delivery). Every PR is green under the same gate set (Β§Gates) and leaves the frozen tool-surface goldens untouched β€” the three byte-exact ones (`TestToolsListSnapshot_MatchesMergeBaseGoldens`, `TestToolsListSnapshot_DeltaIsEnumerated`, `TestMenuSurface_ExactDeltaFromPreFeature`) plus the direct-mode pair (FR-044). No new module dependency (FR-020, FR-015: JWK parsing and RFC 8785 canonicalisation are stdlib + the existing `golang-jwt/jwt/v5`; both are measured against the ~150-line rule in `research.md` D3/D5). + +## Technical Context + +**Language/Version**: Go 1.26 (`go.mod` toolchain; CI `unit-tests.yml` `go-version: "1.26"`); TypeScript 5.9 / Vue 3.5 for the Web UI; Python 3 only for `scripts/gen-roadmap.py`. +**Primary Dependencies**: existing only β€” `github.com/golang-jwt/jwt/v5 v5.3.1` (HS256 user JWT today; RS/PS/ES verification of ID tokens with a stdlib JWK β†’ `*rsa.PublicKey`/`*ecdsa.PublicKey` parser), `go-chi/chi` (route table walk for FR-043(f)), `lumberjack` via `internal/logs.newRotatingSink` (`logger.go:183`), BBolt (user store), Bleve (unchanged), `mark3labs/mcp-go v1.0.0` (unchanged). **No new dependency** β€” `go-oidc`/`keyfunc`/`jcs` are rejected in `research.md` D3/D5 with line counts. +**Storage**: BBolt `users` bucket gains three fields on `users.User` (additive JSON, no migration β€” absent fields decode to zero values, `data-model.md` Β§1); the audit sink is a rotated file and/or stdout, never a bucket (FR-016). No schema version bump in BBolt. +**Testing**: `go test -race` (personal packages with the CI `-skip` regex for `internal/server`), `go test -race -tags server` (server packages + `./internal/server/... ./internal/httpapi/...` per FR-047), `./scripts/test-api-e2e.sh` on an isolated port/data-dir, `go test ./cmd/release-gate/`, golangci-lint v2 (`.github/.golangci.yml`, run twice: bare and `--build-tags server`), vitest (`frontend/tests/unit/*.spec.ts` only), an ad-hoc Playwright spec for US4, and the real-instance rig of `quickstart.md`. +**Target Platform**: Linux container (`ghcr.io/smart-mcp-proxy/mcpproxy-server`, distroless, `0.0.0.0:8080`) behind a TLS-terminating ingress; macOS/Linux personal edition unchanged except SC-007. +**Project Type**: single Go module with an embedded Vue frontend; server-only code under `//go:build server` in `internal/serveredition/` and `internal/config/server_edition_config.go`. +**Performance Goals**: SC-009 β€” with `audit_log` on, administrator p95 for `call_tool_read`, `retrieve_tools`, `tools/list` regresses ≀ max(10 %, 5 ms) against merge-base on the frozen 527-tool snapshot (Spec 105 FR-011 method); scoped tenant `retrieve_tools` p95 within 20 ms of administrator. The audit line is one `encoding/json` marshal + one buffered write per decision; the entitlement resolution is one BBolt read per authentication (down from two, FR-004). +**Constraints**: no built-in MCP tool gains an argument (FR-044); no `_auth_*`/argument-derived value on an audit line (FR-015); fail-closed on entitlement lookup failure (Spec 106 FR-004); non-disclosing refusal everywhere (Spec 105 FR-010); single replica (in-memory `pendingStates`, BBolt single writer); server-edition code is invisible to `swag`/`verify-oas-coverage.sh` (`swaggerignore` at `config.go:584`) so its REST contracts are hand-maintained under `contracts/`. +**Scale/Scope**: a small team (≀ 1,000 users per the telemetry bucket vocabulary), ≀ 100 tokens per owner, ≀ 10,000 pending logins in memory, one IdP. + +## Constitution Check + +*GATE: passed before Phase 0 research; re-checked after Phase 1 design (this file).* + +| Principle | Compliance | Notes | +|---|---|---| +| I. Performance at Scale | βœ… | SC-009 budget; the entitlement resolution collapses two store reads into one (FR-004); discovery/JWKS are cached (5 min–24 h); no per-call store lookup for audit identity (FR-013 places owner identity on the validated token's `AuthContext` ephemerally). | +| II. Actor-Based Concurrency | βœ… with one justified exception | The audit sink is a single writer goroutine-free `bufio`+lumberjack writer guarded by one mutex β€” the spec forbids the event bus (it drops on a full 256-slot buffer, `event_bus.go:205-216`) and requires the line before the funnel returns (FR-012). Justified in Complexity Tracking. Everything else rides on existing context propagation. | +| III. Configuration-Driven Architecture | βœ… | Every knob is a `mcp_config.json` key with a documented default; `audit_log`, `trusted_proxies` and `public_url` get `MCPPROXY_*` env overrides (FR-014, FR-025, FR-027); `access.*`, `admin_emails`, `trusted_proxies` hot-reload; the rest is restart-pinned and *reported* by `DetectConfigChanges` (FR-039) β€” closing the current silent "No configuration changes detected" (`config_hotreload.go:420-423`). | +| IV. Security by Default | βœ… | Forced MCP auth under the server edition (FR-029), `email_verified_policy: refuse_false`, `trusted_proxies` default empty, Secure cookie `auto`, audit on by default in the server edition, fail-closed empty entitlement (FR-006), no anonymous administrator in the container. Personal edition defaults unchanged (SC-007). | +| V. TDD | βœ… | `tasks.md`: every implementation task is preceded by a failing-test task (the two regression pins that are green by design β€” `mcp_session_never_reaches_test.go` in T068 β€” are labelled as pins, not tests-first); the two-fixture sentinel oracle (Spec 105 shape) is built in the `setup_wiring_test.go` harness; guard tests pin absence of removed symbols (FR-035) and the route-table walk (FR-043(f)). | +| VI. Documentation Hygiene | βœ… | FR-019/FR-036/FR-046: new `docs/features/audit-log.md`, `docs/operations/deploying-for-a-team.md`, `server_edition` reference in `docs/configuration/config-file.md`, tombstone + rewrites, `CLAUDE.md` editions row, `.github/RELEASE_NOTICE.md` (FR-042). Sidebar entries added manually (`project_docs_site_pipeline`). | +| Arch: Event-driven / DDD / 3-layer upstream | βœ… | Audit is Infrastructure (`internal/audit/`), consumed from Presentation funnels in `internal/server`; the 3-layer upstream client loses only the dead brokered branches (`core/connection_http.go:171-192`). | +| Workflow: pre-commit gates, conventional commits, `Related #N` | βœ… | Β§Gates; commit convention per spec "Commit Message Conventions" (`Related #1177`, `Related #1169`; no AI attribution lines β€” the repo convention overrides the session reminder). | +| Workflow: branch strategy (`next`) | ⚠️ documented deviation | PRs base on `main`, as Spec 106's plan recorded; `next` receives RCs by tag. | + +## Project Structure + +### Documentation (this feature) + +```text +specs/107-server-edition-sso-hardening/ +β”œβ”€β”€ spec.md # acceptance contract (final) +β”œβ”€β”€ plan.md # this file +β”œβ”€β”€ research.md # Phase 0 decisions D1–D14 with alternatives +β”œβ”€β”€ data-model.md # entity/field changes, ephemeral records, config shapes +β”œβ”€β”€ quickstart.md # local real-instance rig (fake OIDC IdP + server binary) +β”œβ”€β”€ contracts/ +β”‚ β”œβ”€β”€ audit-line.schema.json # FR-013 JSON Schema (draft 2020-12), checked into docs/ by PR-D +β”‚ β”œβ”€β”€ audit-line-events.md # per-event required/optional/forbidden table + vocabularies +β”‚ β”œβ”€β”€ config-keys.md # every new/removed key: default per edition, live/restart, env, validation text +β”‚ β”œβ”€β”€ rest-endpoints.md # OAS snippets for the new/changed server-edition routes + tenant-session allowlist +β”‚ β”œβ”€β”€ entitlement-predicate.md # owner-resolution callback, restricted marker, session-principal hook +β”‚ └── telemetry-v13.md # payload fields, privacy rules +β”œβ”€β”€ tasks.md # PR-A..PR-D phases, US-tagged, failing-test-first +β”œβ”€β”€ verification.md # created empty by T004 (Phase 0); one section per PR, filled by the verification tasks +└── checklists/requirements.md +``` + +### Source Code (repository root) + +```text +internal/ +β”œβ”€β”€ audit/ # NEW (PR-D, edition-neutral): line builder, canonical JSON (RFC 8785), sink (file+stdout), attempt record on ctx +β”‚ β”œβ”€β”€ attempt.go # AuditAttempt context value (FR-012) +β”‚ β”œβ”€β”€ canonical.go # RFC 8785 serialiser + args_sha256 (FR-015) β€” stdlib +β”‚ β”œβ”€β”€ line.go # closed key set, per-event validation, redaction structural rule +β”‚ β”œβ”€β”€ sink.go # lumberjack file + os.Stdout writer, always-on atomic write-failure + sanitizer-hit counters (mirrored to Prometheus when metrics are on; read by doctor), once-per-minute log +β”‚ └── testdata/canonical/ # published JCS test vectors +β”œβ”€β”€ auth/ +β”‚ β”œβ”€β”€ context.go # AuthContext ephemeral Email/Provider/Role on agent tokens (FR-013); CredentialKind + IsSessionPrincipal (owned here β€” httpapi imports auth, so the type cannot live in httpapi; defined by T076 in PR-C phase C.2 because T078 in the same phase records it); CanRevealSecrets denies session principals (FR-002) +β”‚ └── agent_token.go # non-persisted OwnerEmail/OwnerProvider/OwnerRole (`json:"-"`) stamped by storage and copied by AuthContext() (FR-013, data-model Β§3); MaxTokens per owner semantics doc (FR-037); exported ParseTokenExpiry shared by core /tokens and /user/tokens (FR-011) +β”œβ”€β”€ config/ +β”‚ β”œβ”€β”€ config.go # + AuditLog, TrustedProxies (top-level, edition-neutral); Validate reaches ServerEdition.Validate (FR-039) +β”‚ β”œβ”€β”€ audit_log.go # NEW AuditLogConfig (pointer wire fields) + EffectiveAuditLog(cfg, transport) β€” under stdio the ABSENT-block default is suppressed (WARN), an explicit stdout-only block is StartupError{ExitCode:4}; StartupError also for an unopenable path; validateAuditLog reached from Config.Validate/ValidateDetailed +β”‚ β”œβ”€β”€ trusted_proxies.go # NEW CIDR parsing + IsTrustedProxy + forwarded-header helpers (FR-027); validateTrustedProxies reached from Config.Validate/ValidateDetailed +β”‚ β”œβ”€β”€ doctor_findings{,_serveredition,_stub}.go # NEW: config-derived doctor findings (edition-neutral file + server-tagged half); read the live config +β”‚ β”œβ”€β”€ loader.go # MCPPROXY_AUDIT_LOG_*, MCPPROXY_TRUSTED_PROXIES; server-build normaliser for removed keys/modes (FR-032) β€” records LoadDiagnostics on the Config (the loader has no logger; main/reload log them once) +β”‚ β”œβ”€β”€ load_diagnostics.go # NEW (edition-neutral): LoadDiagnostic type, Config.LoadDiagnostics(), LogLoadDiagnostics(cfg, logger) +β”‚ β”œβ”€β”€ env_serveredition{,_stub}.go # NEW: MCPPROXY_PUBLIC_URL (the one nested env alias, FR-025) +β”‚ β”œβ”€β”€ removed_keys{,_stub}.go # NEW: ValidateRemovedKeys on the raw document for PATCH / apply (FR-032 write-time refusal) +β”‚ β”œβ”€β”€ server_edition_config.go # βˆ’ MaxUserServers/WorkspaceIdleTimeout; + OAuth{IssuerURL,Scopes,GroupsClaim,EmailVerifiedPolicy,DisplayName,AllowInsecureIssuer}, PublicURL, SessionCookieSecure, Access{GroupServers,DefaultServers}; non-mutating Validate + ApplyDefaults +β”‚ β”œβ”€β”€ server_edition_config_stub.go / auth_broker_stub.go # json.RawMessage carriers (FR-040) +β”‚ β”œβ”€β”€ auth_broker.go # βˆ’ Header/HeaderFormat, βˆ’ token_exchange/entra_obo (FR-032) +β”‚ └── serveredition_accessors{,_stub}.go # NEW: build-tagged accessors (ForceMCPAuth, IdPProviderFamily, PublicURL…) so internal/server, internal/httpapi, internal/telemetry stay edition-neutral (FR-029, FR-038) +β”œβ”€β”€ httpapi/ +β”‚ β”œβ”€β”€ server.go # apiKeyAuthMiddleware credential precedence (presence = header/query membership) + session hook + tenant allowlist (FR-001/002); PATCH UseNumber + raw removed-keys check (FR-040/FR-032); /index/search calls SearchToolsScoped for scoped callers (filter before the ranked cut, T075a) +β”‚ β”œβ”€β”€ session_principal.go # NEW: SetSessionPrincipalResolver, tenantSessionAllowlist matcher (explicit deny for /servers/import/paths; no /profiles/{slug} β€” none exists), fixed 403 body +β”‚ β”œβ”€β”€ middleware.go # tagRequestMeta: client IP via trusted_proxies + mount (mcp|api) on the context for the audit line (PR-B) +β”‚ β”œβ”€β”€ profiles.go # tenant projection (FR-002) +β”‚ β”œβ”€β”€ preflight.go # restricted marker (FR-006) +β”‚ β”œβ”€β”€ sse_scope.go # re-resolve session principal per frame (FR-005) +β”‚ β”œβ”€β”€ swagger.go # forwarded headers gated by trusted_proxies (FR-027) +β”‚ └── tokens.go # 409 body per-owner wording (FR-037) +β”œβ”€β”€ index/{bleve.go,manager.go} # SearchToolsScoped: same query, exhaustive From/Size paging (no result cap), filter before the top-K cut (Spec 105 ranking-under-scope on the REST door) +β”œβ”€β”€ preflight/scope.go # ScopeInputs.Restricted (FR-006) +β”œβ”€β”€ storage/agent_tokens.go # per-owner cap; intersectAllowedServers non-nil empty; single owner-resolution callback (FR-004/006/037) +β”œβ”€β”€ storage/activity_models.go # ActivityFilter.UserID authorization term (both /user/activity terms inside Matches, FR-002) +β”œβ”€β”€ reqcontext/ # RequestMeta (client IP, mount) beside the request id +β”œβ”€β”€ server/ +β”‚ β”œβ”€β”€ server.go # mcpAuthMiddleware forced auth via accessor (FR-029); ctx-first funnels; `type ServerOption func(*Server)` + variadic `...ServerOption` on NewServer/NewServerWithConfigPath (`WithAuditSink`); stdioAuthContext tags ConnectionSourceStdio +β”‚ β”œβ”€β”€ mcp.go, mcp_routing.go, mcp_code_execution.go, output_sanitisation.go # audit attempt before first gate; authz/tool_call lines (FR-012) +β”‚ β”œβ”€β”€ serveredition_wire{,_stub}.go # install session-principal resolver + audit sink for login events +β”‚ └── server.go (boot) # registers the doctor sources: config.DoctorFindings + audit sink counter (FR-018/025/026/029) +β”œβ”€β”€ management/{service.go,diagnostics.go} # AddRuntimeWarningSource seam β€” Doctor() is the ONLY producer of `mcpproxy doctor` findings (doctor_cmd.go only renders GET /api/v1/diagnostics) +β”œβ”€β”€ jsruntime/runtime.go # ExecutionContext authorization-decision observer (FR-012 nested refusals) +β”œβ”€β”€ runtime/config_hotreload.go # server_edition / audit_log / trusted_proxies clauses (FR-039) +β”œβ”€β”€ serveredition/ +β”‚ β”œβ”€β”€ registry.go # Dependencies + ProjectActivity func(*storage.ActivityRecord) contracts.ActivityRecord (PR-C: convert+mask, the door holds storage records) + AuditSink audit.Sink (PR-D) +β”‚ β”œβ”€β”€ setup.go # cookie policy, public URL (+ one INFO boot line with the resolved public/callback URLs), session-principal resolver, single owner resolution, audit login sink +β”‚ β”œβ”€β”€ auth/ +β”‚ β”‚ β”œβ”€β”€ oauth_providers.go # registry accepts "oidc"; factory takes *ServerEditionOAuthConfig; the handler constructs the provider ONCE (h.provider) so discovery/JWKS caches outlive a request; legacy providers untouched +β”‚ β”‚ β”œβ”€β”€ oidc_provider.go # NEW: discovery cache, JWKS cache, verified ID token, nonce, client-auth method choice, userinfo sub check +β”‚ β”‚ β”œβ”€β”€ oidc_jwks.go # NEW: stdlib JWK β†’ public key (RSA/EC) +β”‚ β”‚ β”œβ”€β”€ oauth_handler.go # nonce, bounded pendingStates, relative redirect, generic refusal pages (incl. IdP error= responses β†’ authorization_denied), live admin_emails role via ServerEditionConfigProvider, subject binding, groups upsert, auth_event emit +β”‚ β”‚ β”œβ”€β”€ session_store.go # Secure policy; forwarded headers via trusted_proxies +β”‚ β”‚ β”œβ”€β”€ idp_subject_token.go # DELETED (FR-033) +β”‚ β”‚ └── middleware.go # session resolver reused by the core hook (FR-001) +β”‚ β”œβ”€β”€ api/ +β”‚ β”‚ β”œβ”€β”€ user_handlers.go # entitledServerNamesFor(user) core + entitledServerNames(userID) wrapper (one GetUser); group term; 8 Shared sites collapsed; session-only mint; parseExpiry +β”‚ β”‚ β”œβ”€β”€ auth_endpoints.go # /auth/me groups; /auth/token session-only; /auth/provider (public) +β”‚ β”‚ β”œβ”€β”€ admin_handlers.go # /admin/users groups + subject_rebind_armed_at; enable arms rebind +β”‚ β”‚ β”œβ”€β”€ user_activity.go # wired ActivityFilter for user-typed principals only; admin_user keeps today's empty response (FR-002 "unchanged") +β”‚ β”‚ β”œβ”€β”€ credential_handlers.go # entitlement predicate; "stored, not injected" (FR-034) +β”‚ β”‚ └── connector_provider.go # public_url base; βˆ’ ConnectorFor (FR-031) +β”‚ β”œβ”€β”€ users/{models,store}.go # Groups, GroupsUpdatedAt, SubjectRebindArmedAt; Validate admits "oidc" +β”‚ β”œβ”€β”€ multiuser/{router,tool_filter}.go # DELETED; activity.go stays +β”‚ β”œβ”€β”€ workspace/ # DELETED +β”‚ └── broker/{token_exchanger,credential_resolver,injector}.go # DELETED; audit.go trimmed +β”œβ”€β”€ transport/broker_auth.go # DELETED; http.go brokered plumbing removed; context.go + ConnectionSourceStdio (PR-D, audit caller.kind: stdio) +β”œβ”€β”€ upstream/core/{connection_http,client}.go # brokered branches removed +β”œβ”€β”€ telemetry/{feature_flags,telemetry,env_markers}.go # v13 fields (FR-038) +└── logs/{logger.go,sanitizer.go} # exported NewRotatingWriter (primitive args, eager path probe) + NewStringSanitizer(WithoutHighEntropy()) β€” the generic 32+-char rule would mask every args_sha256/email_hash; audit β†’ logs only, never the reverse (FR-014/FR-015) +cmd/mcpproxy/ +β”œβ”€β”€ credential_cmd.go # "stored, not injected" banner (FR-034) +β”œβ”€β”€ doctor_cmd.go # UNCHANGED β€” renders runtime_warnings; findings come from internal/management +└── status_serveredition.go # prints new provider unchanged +frontend/src/ +β”œβ”€β”€ views/settings/fields.ts # βˆ’ max_user_servers; + oidc fields, public_url, session_cookie_secure, trusted_proxies, audit_log.* +β”œβ”€β”€ views/teams/{Login,AdminUsers,AdminServers,UserActivity}.vue, stores/auth.ts, router/index.ts, App.vue, Dashboard.vue, Servers.vue, ServerCard.vue, stores/onboarding.ts, components/ModeSwitcher.vue # FR-041 principal-kind gating; /auth/provider probe +└── services/auth-api.ts # provider probe +frontend/tests/unit/*.spec.ts # settings-server-edition-wording extended; principal gating specs +tests/oauthserver/ # Options.OIDC, Options.UserClaims, id_token, /userinfo, CLI flags (FR-047) +scripts/ +β”œβ”€β”€ dev-server-edition.sh # NEW: quickstart rig (build, fake IdP, scratch instance, headless login, mint, /mcp, audit tail) +└── test-api-e2e.sh # audit-file assertion (FR-019) +docs/ # see FR-019/FR-036; website/sidebars.js entries +e2e/playwright/server-edition-tenant.spec.ts # NEW ad-hoc US4 spec +.github/{RELEASE_NOTICE.md, workflows/unit-tests.yml, .golangci.yml} # FR-042, FR-047 +roadmap.yaml, ROADMAP.md, specs/README.md # `sso` epic un-parked β†’ spec 107; regenerated +``` + +**Structure Decision**: single Go module, existing layout. The only new package is `internal/audit/` (edition-neutral, Infrastructure layer). Build-tagged *accessors* (`internal/config/serveredition_accessors{,_stub}.go`) are the one new pattern, copied from `internal/oauth/serverfields_*.go`, so that `internal/server`, `internal/httpapi` and `internal/telemetry` never import a server-only type. The `//go:build server` boundary is unchanged. + +## Delivery: four PRs, in order + +Each PR is independently mergeable: it compiles in both editions, passes every gate in Β§Gates, changes personal-edition behaviour only where SC-007 allows, and carries its own `.github/RELEASE_NOTICE.md` entry, docs and roadmap task ticks. Order is binding β€” each PR's tests rely on the previous PR's seams. A PR that is not merged when the next one starts is rebased, never stacked into one. + +| PR | Branch | Scope (FRs) | Stories | Why this order | +|---|---|---|---|---| +| **PR-A** β€” freeze/cut + honesty + hygiene | `107-a-freeze-cut` | FR-031..FR-036 (cut, dead knobs, `store_idp_tokens` no-op, Path B "stored, not injected", both-edition verification, docs corrections), FR-040 (personal-build raw-JSON carriers β€” required so PR-A's removed keys are not *erased* by a personal-build write-back), FR-039 part 1 (non-mutating `ServerEditionConfig.Validate` + separate apply step, reached from `Config.Validate`/`ValidateDetailed` β€” required for the write-time refusal of removed keys/modes), FR-037 (per-owner token cap, #1177 β€” Phase 0 hygiene, orthogonal to the cut per the freeze lane Β§6), FR-047 part 1 (lint `--build-tags server`; race job widened with the `-skip` regex; `RELEASE_NOTICE.md` created), FR-042 entry | US5, US6 | Smallest blast radius; deletes the contradictory `Router.GetServerForUser` rule set before F2 is built on the live seam; puts the server build under lint before B–D add code; the personal E2E is the required check for the transport/upstream cut. | +| **PR-B** β€” generic OIDC + front door + telemetry | `107-b-oidc-front-door` | FR-020..FR-024 (provider, verification, claims, subject binding, generic refusal), FR-025..FR-030 (public URL incl. `MCPPROXY_PUBLIC_URL`, Secure cookie, `trusted_proxies` + the edition-neutral request-metadata tag that PR-D's `client.ip`/mount provenance reads, relative redirect, forced MCP auth, `/auth/provider`), FR-008 **capture half** (groups stored on the user record at login, `/auth/me` and `/admin/users` show them β€” no authorization effect yet), FR-038 (telemetry v13), FR-039 part 2 (`server_edition` restart-pinned clauses + `admin_emails` hot + `trusted_proxies` hot), FR-047 part 2 (`tests/oauthserver` OIDC extension + CLI flags; `scripts/dev-server-edition.sh`), FR-036 config-file section for these keys, FR-042 entry | US2, US7 | F1 is the input F2 needs (groups only exist through `oidc`); the ingress prerequisites and forced MCP auth must exist before a tenant principal is introduced (no anonymous administrator to bind to, critic G1/agent5). Telemetry rides here because `idp_provider` needs the provider family enum this PR defines. | +| **PR-C** β€” group grants + tenant principal | `107-c-group-grants-tenant-principal` | FR-001..FR-006 (credential precedence, session hook, tenant allowlist, one predicate, single owner resolution, SSE re-resolve, empty = deny-all), FR-007 (`access` block), FR-008 **authorization half**, FR-009, FR-010, FR-011 (session-cookie-only mint doors, 365-day cap), FR-039 part 3 (`access.*` hot), FR-041 (Web UI), FR-043 (a,c,d,f,g,h,i,k), FR-045 matrix, FR-046 docs invariant, FR-047 part 3 (harness: group map, hot reload, empty entitlement, per-owner cap, session principal), FR-036 docs for the group map/tenant principal, FR-042 entry | US1, US4 | The authorization core; depends on stored groups (B) and on forced MCP auth (B). Also the largest PR β€” kept reviewable by landing A and B first. | +| **PR-D** β€” JSONL audit line | `107-d-audit-line` | FR-012..FR-019 (funnels, attempt record, schema, sinks, redaction, no read surface, `auth_event`, sink failure policy, docs + e2e assertion), FR-039 part 4 (`audit_log` restart-pinned), FR-043 (b,j), SC-009 benchmark, `docs/operations/deploying-for-a-team.md` + validated example (the capstone guide needs every key), FR-042 entry | US3 | Needs `caller.user_email`/`role` from C's single owner resolution, `session_admin` from C's hook, `trusted_proxies` from B for `client.ip`, `auth_event` reasons from B's callback branches. Last so its byte-absence and count fixtures run against the final authorization paths. | + +**FR-039 is split by key, on purpose**: the non-mutating validator (A) is a prerequisite for refusing removed keys at write time; each later PR adds the `DetectConfigChanges` clause for the keys it introduces, with `config_hotreload_test.go` coverage in the same PR (`Eventually` on the snapshot β€” `project_config_reload_commit_ordering`). +**FR-008 is split by half**: capture (B) is a data-model change with a fixture; composition (C) is the authorization change. B's `groups_claim_missing` is a warning log; D turns it into the `auth_event` flag. +**FR-036/FR-042 are per-PR**: each PR corrects the docs sentences it makes true and adds its own release-notice bullet; the deploy guide lands with D. + +## Execution (per PR) + +1. **Branch** from `origin/main` (`git switch -c origin/main`; the speckit script branches from current HEAD β€” `project_speckit_branch_base_gotcha`); work in a worktree (`isolation: worktree` for any subagent). +2. **Failing tests first** for every task (tasks.md pairs each implementation task with the test task that precedes it). Two kinds of red, and every test-first task carries a label β€” **[behaviour-red]**, **[compile-red until Txxx]** (naming every implementation task whose symbols it needs, so it compiles the moment the last of them lands) or **[tooling]**: (a) a test against an *existing* seam must fail **behaviourally** on HEAD β€” prove it by running it before the fix, never by neutering code in a way that orphans an import (`feedback_verify_test_bites_build_failure`); (b) a test that references a type, field or package the paired implementation task introduces (`Options.OIDC`, `auth.CredentialKind`, `internal/audit`, …) is red as a **compile failure** on HEAD β€” that is the expected red, recorded as such in verification.md, and the test must then pass unchanged once the implementation lands (no scaffold tasks; the pairing is the proof). +3. **Implement**, then run the gate set (Β§Gates) locally; the isolated-instance rules for `test-api-e2e.sh` apply (`reference_isolated_dev_instance`). +4. **Real-instance verification** per user story on the `quickstart.md` rig; record commands and outcomes in `verification.md` (one section per PR; never claim a skipped gate passed). +5. **Cross-model review**: `gtimeout 1500 codex exec -m gpt-5.6-sol -c model_reasoning_effort="high" --sandbox read-only -C --output-last-message "" < /dev/null` (maintainer directive 2026-09-15: the Copilot quota is exhausted, so the reviewer is the codex CLI with Sol 5.6, not opencode/astra; codex is static-only β€” run the tests yourself), briefs split per file group; an empty result is not a clean one (`feedback_codex_reviews_specs_too`); at most **10** fixβ†’re-review rounds per PR (`feedback_opencode_review_round_cap`), verify each finding before fixing. +6. **Publish** the PR with `Related #1177` / `Related #1169` (never `Closes`), no AI attribution lines (spec convention), body via `--body-file` (`reference_gh_pr_body_quoted_heredoc_backticks`); run `python3 scripts/gen-roadmap.py --check` before pushing (tasks.md ticks change the render); post qa-gate status if the PR is green-but-blocked (`project_qa_gate`). Do not merge without instruction. + +## Gates (every PR, in this order) + +```bash +# builds β€” never bare `-tags server` without -o (it clobbers ./mcpproxy) +go build -o /dev/null ./cmd/mcpproxy +go build -tags server -o mcpproxy-server ./cmd/mcpproxy +# server-edition race suites (CI job + FR-047 widening) +go test -race -tags server -timeout 20m ./internal/serveredition/... ./internal/config/... ./internal/oauth/... ./internal/storage/... +go test -race -tags server -timeout 20m -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/server/... ./internal/httpapi/... +# personal race suite with the CI skip regex (internal/server hangs to the 7m timeout otherwise) +go test -race -timeout 20m -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/... +go test -race -timeout 10m ./cmd/mcpproxy && go test -race -tags server -timeout 10m ./cmd/mcpproxy +# frozen tool-surface goldens, unregenerated (FR-044) +go test ./internal/server/ -run 'TestToolsListSnapshot_MatchesMergeBaseGoldens|TestToolsListSnapshot_DeltaIsEnumerated|TestMenuSurface_ExactDeltaFromPreFeature|TestDirectFullMode_ByteStableAgainstPreFeatureE2E' +# release gate audits +go test ./cmd/release-gate/ +# lint, both tag sets (CI v2 config is stricter than scripts/run-linter.sh) +/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml --timeout=10m ./... +/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml --build-tags server --timeout=10m ./... +# personal API E2E β€” isolated (pgrep for a running copy; pkill-stripped scratch copy; high port; restore the tracked config) +pgrep -f test-api-e2e.sh && echo "another run is live β€” wait" || LISTEN_PORT=18${RANDOM:0:3} ./scripts/test-api-e2e.sh; git checkout -- test/e2e-config.json +# generators +make swagger-verify && go run ./cmd/generate-types && go test ./cmd/generate-types/ -run TestContractsInSync +python3 scripts/gen-roadmap.py --check +cd frontend && npx vitest run # only frontend/tests/unit/*.spec.ts execute +gofmt -l $(git diff --name-only origin/main -- '*.go') # only files you touched +# native catalogue parity β€” required CI jobs (native-tests.yml:83,97) that no Go gate covers; run whenever fields.ts or SettingsCatalog.swift changes (PR-A, PR-B, PR-D) +python3 scripts/check-settings-parity.py +(cd native/macos/MCPProxy && swift test) # judge by the final summary line (project_swift_test_silent_miss) +``` + +PR-C and PR-D additionally run the ad-hoc Playwright spec (`e2e/playwright/server-edition-tenant.spec.ts`) against the quickstart rig, and PR-D runs the SC-009 benchmark against merge-base. + +## Config-field wiring checklist (applied to every key in `contracts/config-keys.md`) + +From the wiring lane Β§1 and `project_config_field_checklist`, each new or removed key touches, in the same PR: + +1. **Struct + JSON tag + default** β€” `internal/config/server_edition_config.go` (server-tagged) or `config.go`/`audit_log.go`/`trusted_proxies.go` (edition-neutral); defaults documented; `DefaultServerEditionConfig` is *not* called by the loader (`loader.go:267` unmarshals directly) so defaults are applied in the new `ApplyDefaults` step at setup, never inside `Validate` (FR-039). +2. **Validation** β€” non-mutating `Validate` reached from `Config.Validate()`/`ValidateDetailed()` under the server build; message text fixed in `contracts/config-keys.md` so PATCH and boot say the same thing. **Every key's rule is reachable from both `Config.Validate()` and `ValidateDetailed()`** β€” `trusted_proxies` (`validateTrustedProxies`, T050) and `audit_log` (`validateAuditLog`, T109) from the edition-neutral `config.go`; a rule that needs a top-level field beside a `server_edition.*` key (`session_cookie_secure: false` Γ— `tls.enabled`, T051) lives in the build-tagged `*Config`-level bridge `validateServerEditionConfig` (T019), never on `ServerEditionConfig.Validate`, which cannot see `Config.TLS`; the paired test drives boot load, `Config.Validate`, PATCH and `/config/apply`. **Removed keys are a raw-document check** (`config.ValidateRemovedKeys(map[string]any)`, server build; stub returns nil): `json.Unmarshal` into the typed `Config` silently drops unknown keys (`httpapi/server.go:5405-5408`), so `Validate` can never see `max_user_servers` or a removed `auth_broker.mode`; PATCH and `/config/apply` call the raw check on the generic map before typed decoding, boot normalises and warns instead. +3. **`DetectConfigChanges`** (`internal/runtime/config_hotreload.go:77-423`) β€” a clause per key group: `slices.Equal` for lists (`:380-386` nil-vs-`[]` comment), `jsonEqual` for structs with `omitempty` slices (`:357-359`), restart-pinned keys set `RequiresRestart` + `RestartReason`; test in `config_hotreload_test.go` (assert with `Eventually`). **A "live" key also needs live readers**: every request-time consumer of `trusted_proxies`, `admin_emails` and `access.*` reads through a provider closure (`Dependencies.ConfigProvider` / the httpapi config provider), never a slice or pointer captured at construction β€” with a test that mutates the value after construction (T047, T044). +4. **Env override** β€” `applyTLSEnvOverrides` (`loader.go:629-790`) for the top-level keys (`MCPPROXY_AUDIT_LOG_ENABLED|PATH|STDOUT`, `MCPPROXY_TRUSTED_PROXIES`); the one nested key with an env alias, `server_edition.public_url` ← `MCPPROXY_PUBLIC_URL` (FR-025), goes through a build-tagged `applyServerEditionEnvOverrides` (`internal/config/env_serveredition{,_stub}.go`, personal stub no-op) called from the same function; every other `server_edition.*` key stays file-only with `${env:}` refs (FR-020). +5. **Docs** β€” published `docs/configuration/config-file.md` (`server_edition` reference section β€” one complete table covering the retained keys too, T057) and `docs/configuration/environment-variables.md` (incl. `MCPPROXY_CRED_KEY`, whose only page T023 tombstones); the repo-root `docs/configuration.md` gets the same rows for the edition-neutral keys (it is unpublished β€” never *link* to it, `project_docs_site_pipeline`); `docs/operations/reverse-proxy.md` for `trusted_proxies`. +6. **`make swagger`** β€” regenerates `oas/swagger.yaml` + `oas/docs.go` for edition-neutral top-level keys (`audit_log`, `trusted_proxies`); `server_edition` stays `swaggerignore` (`config.go:584`) β€” its shape is documented in `contracts/config-keys.md` and `config-file.md` instead; `make swagger-verify` must be clean. +7. **`contracts.ts` generator** β€” `go run ./cmd/generate-types` + `TestContractsInSync` (`cmd/generate-types/main_test.go:30`); the generator emits vocabularies, not payload shapes, so it is expected to be a no-op β€” the task exists to *prove* it (drift is byte-compared). +8. **Settings catalogue** β€” `frontend/src/views/settings/fields.ts:323-327` (`SERVER_EDITION_FIELDS`) plus a new accordion for `audit_log.*`; `restart: true` on restart-pinned keys; `control: 'secret'` never for `client_secret` in the UI (masked read via `oauth.RedactedConfig`); the `Settings` column of `contracts/config-keys.md` is the authority for every key: rows for the new OIDC/front-door/`audit_log`/`trusted_proxies` keys; **Raw-JSON-only by decision** for `access` (no map control), `oauth.allow_insecure_issuer` (loopback-only development toggle) and the retained keys that have no row today (`admin_emails`, `session_ttl`, `bearer_token_ttl`, `oauth.client_id`, `oauth.tenant_id`, `oauth.allowed_domains` β€” SC-007 minimal UI change); **never a row** for `credential_encryption_key` (secret, env-only) and the deprecated `store_idp_tokens`; `frontend/tests/unit/settings-server-edition-wording.spec.ts` extended; `scripts/check-settings-parity.py` exempts `server_edition.` (`:48-50`) but **not** `audit_log.`/`trusted_proxies` β†’ the Swift `SettingsCatalog.swift` parity check runs in `native-tests.yml` β€” add the rows there or extend `WEB_ONLY_PREFIXES` (decision: add to the Swift catalogue as read-only rows, research D12). +9. **Redaction walk** β€” `oauth.RedactedConfig` masks by key-name heuristic (`redactview.go:110-126`); `client_secret`/`credential_encryption_key` already masked; new plain fields pass through the value detector; a leaf added to `ServerConfig` would trip the `serverfields_serveredition` canary β€” no key of this spec lives on `ServerConfig` (FR-007). +10. **Storage canary** β€” `TestSaveServerSyncFieldCoverage` (`internal/storage/async_ops_test.go:353,415`) lists top-level `ServerConfig` fields and explicitly skips `AuthBroker`; it cannot see nested broker leaves, so **no key of this spec touches it** (PR-A's `Header`/`HeaderFormat` removals are covered by the `internal/oauth/serverfields_serveredition.go` mask-table test instead). Existing tests that name a removed field or constant are retired/rewritten in the same PR (T017 lists them). +11. **Roadmap** β€” `roadmap.yaml`: un-park epic `sso` (`:803-809`) β†’ `spec: specs/107-server-edition-sso-hardening`, `status: in_progress`, four `tasks` rows (PR-A..D) with `pr:` filled as they open; `python3 scripts/gen-roadmap.py` (commit `ROADMAP.md`); `specs/README.md` row for 107 (manual, the 105 row at `:84` is the pattern). +12. **Release notice** β€” `.github/RELEASE_NOTICE.md` bullet (FR-042). + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| Mutex-guarded synchronous audit writer (Principle II) | FR-012: a line must exist before the funnel returns and must survive a saturated event bus and a mid-call crash (`authz` before the upstream call). | Event-bus subscriber (Option B in the audit lane Β§8) inherits the 256-slot non-blocking drop (`event_bus.go:205-216`) and has no `AuthContext`; a dedicated writer goroutine with a channel re-introduces the drop-or-block choice. One `sync.Mutex` around a `bufio.Writer` is the smallest correct thing; `RecordToolCallRejected` and preflight already write synchronously for the same reason (`event_bus.go:562-567`). | +| Second entry point into the server-edition session resolver (`SetSessionPrincipalResolver`) | FR-001: core `/api/v1` must accept a session with a *different* credential order than the server-edition group (`middleware.go:88-108` tries the cookie first). | Mounting the server-edition middleware on the core group would let a cookie rescue a failed API key (FR-001 forbids) and would put `AdminUserContext` on `/api/v1` with secret reveal (FR-002 forbids). One resolver function, two mount points, ordering owned by each caller. | +| Build-tagged accessors instead of `cfg.ServerEdition != nil && …` | FR-029/FR-038: `internal/server` and `internal/telemetry` must not import a server-only type; the personal stub is opaque JSON after FR-040. | Reading the raw JSON in the personal build to answer "is SSO enabled" is both wrong (the personal binary must not act on it) and a second parser. Pattern already exists (`internal/oauth/serverfields_*.go`). | +| `internal/audit` as a new package | Edition-neutral sink used by `internal/server` (dispatch) and `internal/serveredition/auth` (login events) β€” neither may import the other. | Placing it in `internal/logs` couples to zap cores (the stdout path must bypass the console encoder, `logger.go:201-206`); placing it in `internal/server` creates an import cycle for login events. | +| Audit stdout **default** suppressed under the native stdio transport (server-edition default is stdout) | Standard output *is* the MCP transport in stdio mode (`server.go:968-989`); a JSON audit line interleaved with JSON-RPC corrupts the client's stream. | Writing to stderr would interleave with the log stream instead; refusing to start would turn a *default* into a boot failure. Resolve: an **absent** block resolves to disabled + one WARN naming `audit_log.path`; an **explicit** stdout-only block is a `StartupError` (exit 4) β€” FR-014 "an explicit value always wins" forbids silently disabling what the operator asked for; an explicit path is honoured. | diff --git a/specs/107-server-edition-sso-hardening/quickstart.md b/specs/107-server-edition-sso-hardening/quickstart.md new file mode 100644 index 000000000..44c040f52 --- /dev/null +++ b/specs/107-server-edition-sso-hardening/quickstart.md @@ -0,0 +1,168 @@ +# Local Verification β€” server edition + fake OIDC IdP + +Isolated, synthetic, loopback-only. Nothing here touches `~/.mcpproxy`, the tray's core, or a production IdP. Every command is what `scripts/dev-server-edition.sh` (created in PR-B, `--phase` flag gates the steps that need later PRs) runs; the script is the single source of truth and this page is its narrative. Record exact commands and outcomes in `verification.md`, one section per PR. + +## Rules (from memory, all verified) + +- Build the server binary with `-o mcpproxy-server` β€” bare `go build -tags server ./cmd/mcpproxy` clobbers `./mcpproxy` (`project_server_tags_build_overwrite`). +- Run the instance with **both** `--config /mcp_config.json` **and** `--data-dir ` β€” `MCPPROXY_DATA`/bare `--data-dir` write the generated API key to a file the next boot never reads (`agent5` Β§4). Use a high port (`18xxx`), start it in its own Bash call with `run_in_background`, and wait out the BBolt lock (exit 3) on restart (`reference_isolated_dev_instance`). +- Kill only your own instance by PID; never `pkill -f mcpproxy` (the e2e script's blanket pkill is the trap, not the pattern). +- The fake IdP's login form is HTML: the headless recipe POSTs `username,password,consent=on,action=approve` plus the authorize query parameters to `/authorize` **without following redirects**, then GETs the `Location` (`project_oauthserver_test_rig`). + +## 0. One-time + +```bash +ROOT=$(mktemp -d /tmp/mcpproxy-107.XXXX); PORT=18$((RANDOM % 900 + 100)); IDP=19$((RANDOM % 900 + 100)) +go build -tags server -o "$ROOT/mcpproxy-server" ./cmd/mcpproxy +"$ROOT/mcpproxy-server" version | grep -q 'edition: server' # self-identifies +``` + +## 1. Fake OIDC IdP (PR-B extension of `tests/oauthserver`) + +```bash +go run ./tests/oauthserver/cmd/server -port "$IDP" -oidc \ + -redirect-uri "http://127.0.0.1:$PORT/api/v1/auth/callback" \ + -user 'alice@example.com:pass:eng' -user 'bob@example.com:pass:' -user 'carol@example.com:pass:ops' -user 'dana@example.com:pass:' \ + -groups-claim groups & IDP_PID=$! +curl -s "http://127.0.0.1:$IDP/.well-known/openid-configuration" | jq -e '.userinfo_endpoint and .jwks_uri and (.id_token_signing_alg_values_supported|index("RS256"))' +``` + +The server prints `Confidential ID` / `Confidential Secret`; export them as `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` (the config references them with `${env:}` β€” nested `server_edition.*` keys have no env override, FR-020). + +Tamper cases for the US2 matrix: restart with one of `-token-error bad-signature|wrong-iss|wrong-aud|expired|no-nonce|alg-none|hs256`, `-userinfo-sub-mismatch`, `-email-verified=false`, `-discovery-http-token-endpoint`, `-token-endpoint-redirect` (each maps to an `ErrorMode` field, research D2). + +## 2. Scratch configuration + +```bash +cat > "$ROOT/mcp_config.json" < "$ROOT/main.log" 2>&1 & MP_PID=$! +until curl -sf "http://127.0.0.1:$PORT/readyz" >/dev/null; do sleep 0.5; done +grep -E 'require_mcp_auth.*overrid|public_url|audit_log.*enabled' "$ROOT/main.log" +API_KEY=$(jq -r .api_key "$ROOT/mcp_config.json") # operator key; tenants never see it +``` + +## 4. Headless login (Alice) β€” no browser, no API key + +```bash +J="$ROOT/alice.jar" +# 4a. /auth/login stores state+nonce server-side and 302s to the IdP's authorization_endpoint +AUTH=$(curl -s -o /dev/null -w '%{redirect_url}' -c "$J" "http://127.0.0.1:$PORT/api/v1/auth/login?redirect_uri=/my/tokens") +echo "$AUTH" | grep -Eq 'code_challenge_method=S256' && echo "$AUTH" | grep -q 'nonce=' # FR-021 +# 4b. submit the fake IdP's form; do NOT follow the redirect +CB=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST "$AUTH" \ + --data-urlencode username=alice@example.com --data-urlencode password=pass --data consent=on --data action=approve) +# 4c. complete the callback; the session cookie lands in the jar +curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -b "$J" -c "$J" "$CB" # 302 -> /my/tokens +curl -s -b "$J" "http://127.0.0.1:$PORT/api/v1/auth/me" | jq -e '.email=="alice@example.com" and .groups==["eng"]' +grep -c 'mcpproxy_session' "$J" # 1 +``` + +Open-redirect check (US2.7): repeat 4a with `redirect_uri=https://evil.example/` and assert 4c redirects to `/ui/`. + +## 5. Tenant principal on core REST and the Web UI probe (PR-C) + +```bash +curl -s "http://127.0.0.1:$PORT/api/v1/auth/provider" | jq -e '.display_name=="Example Corp" and (keys|length==1)' # FR-030 +curl -s -b "$J" "http://127.0.0.1:$PORT/api/v1/servers" | jq -e '[.servers[].name]==["a"]' # entitlement-filtered +curl -s -b "$J" "http://127.0.0.1:$PORT/api/v1/user/servers" | jq -e '[.shared[].name]==["a"]' +curl -s -o /dev/null -w '%{http_code}\n' -b "$J" "http://127.0.0.1:$PORT/api/v1/config" # 403 (allowlist) +curl -s -o /dev/null -w '%{http_code}\n' -b "$J" -X POST -d '{}' "http://127.0.0.1:$PORT/api/v1/tools/call" # 403, before body parse +curl -s -o /dev/null -w '%{http_code}\n' -b "$J" -H 'X-API-Key: wrong' "http://127.0.0.1:$PORT/api/v1/status" # 401 (FR-001 precedence) +``` + +## 6. Mint an agent token as Alice, call `/mcp`, read the audit line + +```bash +TOK=$(curl -s -b "$J" -X POST "http://127.0.0.1:$PORT/api/v1/user/tokens" -H 'Content-Type: application/json' \ + -d '{"name":"t1","allowed_servers":["*"],"permissions":["read"],"expires_in":"720h"}' | jq -r .raw_token) +curl -s -b "$J" "http://127.0.0.1:$PORT/api/v1/user/tokens" | jq -e '.tokens[0].allowed_servers==["a"]' # "*" materialised (US1.3) +# JWT/cookie on /mcp is 401 (FR-003); no credential is 401 (FR-029) +curl -s -o /dev/null -w '%{http_code}\n' -b "$J" -X POST "http://127.0.0.1:$PORT/mcp" -d '{}' # 401 +curl -s -o /dev/null -w '%{http_code}\n' -X POST "http://127.0.0.1:$PORT/mcp" -d '{}' # 401 +# initialise + one allowed call + one hidden call through the token +mcp() { curl -s -X POST "http://127.0.0.1:$PORT/mcp" -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' "$@"; } +mcp -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"quickstart","version":"1"}}}' -D "$ROOT/init.h" >/dev/null +SID=$(grep -i '^mcp-session-id' "$ROOT/init.h" | tr -d '\r' | cut -d' ' -f2) +mcp -H "Mcp-Session-Id: $SID" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"call_tool_read","arguments":{"name":"a:echo","args":{"text":"hello","secret":"AKIAQUICKSTART7SENTINEL0"}}}}' | jq -c .result.isError +mcp -H "Mcp-Session-Id: $SID" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"call_tool_read","arguments":{"name":"b:echo","args":{}}}}' | jq -r '.result.content[0].text' # non-disclosing refusal +# audit lines (PR-D): one authz allow + one tool_call for a:echo, one authz deny for b:echo, no secret bytes +tail -n 3 "$ROOT/audit.jsonl" | jq -c '{event,decision,outcome,server,tool,reason,disclosed,caller:.caller.kind,email:.caller.user_email,rid:.request_id}' +grep -c 'AKIAQUICKSTART7SENTINEL0' "$ROOT/audit.jsonl" # 0 +grep -c '"event":"auth_event"' "$ROOT/audit.jsonl" # β‰₯1 (Alice's login, reason ok) +MCPPROXY_AUDIT_JSONL="$ROOT/audit.jsonl" go test ./internal/audit -run TestExternalJSONLValidates -count=1 # validates every line with the in-repo santhosh-tekuri/jsonschema (no npx, no network) +``` + +Correlate: `"$ROOT/mcpproxy-server" activity list --url "http://127.0.0.1:$PORT" --api-key "$API_KEY" --request-id ` β€” MCP ids are locally minted (no `X-Request-Id` on `/mcp`, critic C8), so the join key is the line's `request_id`, never an ingress header. + +## 7. Hot reload and the freshness bound + +```bash +jq '.server_edition.access.group_servers.eng=[]' "$ROOT/mcp_config.json" > "$ROOT/c.tmp" && mv "$ROOT/c.tmp" "$ROOT/mcp_config.json" +sleep 2; curl -s -b "$J" "http://127.0.0.1:$PORT/api/v1/servers" | jq -e '.servers==[]' # narrowed, no restart, no rotation +mcp -H "Mcp-Session-Id: $SID" -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"retrieve_tools","arguments":{"query":"echo"}}}' | jq -e '.result.content[0].text|test("a:echo")|not' +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $(curl -s -b "$J" -X POST http://127.0.0.1:$PORT/api/v1/auth/token | jq -r .token)" -X POST "http://127.0.0.1:$PORT/api/v1/auth/token" # 401 β€” a JWT cannot renew itself (FR-011) +``` + +## 8. Two-tenant token cap (US6, PR-A) + +Seed 100 tokens for Alice through `/user/tokens` in a loop; the 101st is 409 whose body names *her* count; Bob's first mint succeeds; an ownerless operator mint via `X-API-Key` succeeds. + +## 9. Personal-build round trip (US5, PR-A) + +```bash +go build -o "$ROOT/mcpproxy" ./cmd/mcpproxy +cp "$ROOT/mcp_config.json" "$ROOT/before.json" +"$ROOT/mcpproxy" serve --config "$ROOT/mcp_config.json" --data-dir "$ROOT" --listen 127.0.0.1:$((PORT+1)) & P=$!; sleep 3; kill $P +python3 - "$ROOT/before.json" "$ROOT/mcp_config.json" <<'PY' +import json,sys +a,b=[json.load(open(p),parse_float=str,parse_int=str) for p in sys.argv[1:]] +assert a["server_edition"]==b["server_edition"], "server_edition block changed on personal write-back" +PY +``` + +## 10. Teardown + +```bash +kill $MP_PID $IDP_PID; rm -rf "$ROOT" +``` + +## Playwright (US4, PR-C) + +`e2e/playwright/server-edition-tenant.spec.ts` drives the same rig: fresh context β†’ `/ui/` β†’ `/login` (button labelled `Example Corp`) β†’ fake IdP form β†’ dashboard β†’ server list (`a` only) β†’ token mint β†’ activity (served by `/user/activity`); asserts no XHR carries `?apikey=`/`X-API-Key`, every XHR is 2xx, and then walks the FR-045 refused-route list with `page.request` asserting 403. Reuse `e2e/playwright/node_modules`, pin the Chromium build, `data-test` selectors, `domcontentloaded` (`docs/development/web-ui-verification.md:29-70`). Synthetic keypresses do not reach document listeners β€” assert state via the DOM, not via Escape (`feedback_ui_verify_escape_false_negative`). diff --git a/specs/107-server-edition-sso-hardening/research.md b/specs/107-server-edition-sso-hardening/research.md new file mode 100644 index 000000000..2d642df1c --- /dev/null +++ b/specs/107-server-edition-sso-hardening/research.md @@ -0,0 +1,90 @@ +# Research + +Sources: `spec.md` (final), the seven lane maps (`agent0` auth, `agent1` frontend/CLI/docs, `agent2` authorization scope, `agent3` dead code, `agent4` audit, `agent5` remote access/container, `agent6` wiring/test rigs) and the completeness critic (C1–C14, G1–G11 β€” binding where a lane disagrees), the maintainer's research decision of 2026-09-14 (posture (a) + a bounded slice of (c); kill list Β§9), Specs 028/089/105/106, and the code at origin/main `b39800a89` (re-read for every anchor below). Where the evidence does not reach, the word used is "unknown" and the decision names the fixture that will settle it. + +## D1 β€” Four PRs, in the order A (cut) β†’ B (OIDC + front door + telemetry) β†’ C (group grants + tenant principal) β†’ D (audit line) + +- **Decision**: ship four independently mergeable PRs, each green under the full gate set (`plan.md` Β§Gates), in that fixed order; never stack them into one branch. +- **Rationale**: (i) PR-A deletes `multiuser.Router.GetServerForUser`, which encodes shared-before-personal precedence β€” the opposite of Spec 029 FR-009 (`agent3` Β§7) β€” so F2 must not be built while a second rule set exists; PR-A also puts the server build under lint (FR-047) before B–D add ~2k lines to it. (ii) Groups only exist through the `oidc` provider (FR-007 refuses a non-empty map for legacy providers), so B precedes C. (iii) Forced MCP auth (FR-029) must precede the tenant principal, or the container's anonymous administrator (`server.go:381,458`) has no principal for the grant to bind to (critic G1, `agent5` Risks). (iv) D's `caller.user_email`/`role` come from C's single owner resolution (FR-004/FR-013), `client.ip` from B's `trusted_proxies`, and `auth_event` reasons from B's callback branches β€” D last means its byte-absence and count fixtures run against the final paths once. +- **Alternatives rejected**: one PR (unreviewable: β‰ˆ1.8k deleted + β‰ˆ4k added non-test LOC, five doc rewrites); C before B with seeded groups (would ship an `access` block no provider can populate β€” a knob that does nothing, exactly the class of thing PR-A removes); D first as "observability before change" (its `caller` object would churn twice). +- **FR allocation**: see `plan.md` Β§Delivery. FR-037 (#1177) lands in A as Phase 0 hygiene β€” the freeze lane verified it is orthogonal to the cut (`agent3` Β§6) and C's two-tenant fixtures then start with per-owner semantics. FR-039 is split by key so each PR owns the `DetectConfigChanges` clause for the keys it introduces. FR-008 is split into capture (B) and composition (C). + +## D2 β€” Fake OIDC IdP: extend `tests/oauthserver` (Go, in-process + standalone), not dex/mock-oauth2-server + +- **Decision**: add `Options.OIDC bool`, `Options.UserClaims map[string]map[string]any` (keyed by `ValidUsers` entry), `Options.ClientRedirectURIs []string` to `tests/oauthserver`; when `OIDC` is on, discovery (`discovery.go:9-50`) advertises `userinfo_endpoint` and `id_token_signing_alg_values_supported: ["RS256"]`, `/.well-known/openid-configuration` stays an alias of the AS metadata (`server.go:266-268`), the auth-code token response (`token.go:54-185`) adds an RS256 `id_token` signed by the existing `KeyRing` (`jwks.go:37-105`, rotation already supported) with `iss,sub,aud,azp,exp,iat,nonce,email,email_verified,name,` taken from `UserClaims`, `/userinfo` returns the same claims for a valid access token, and the `nonce` from the authorize request is echoed. `cmd/server/main.go` gains `-oidc`, `-user "alice@example.com:pass:eng,sre"` (repeatable), `-redirect-uri` (repeatable), `-groups-claim`. Tamper knobs for the US2 matrix live on `ErrorMode` β€” **one knob per T037 network/integration case** (round 2): `IDTokenBadSignature`, `IDTokenWrongIssuer`, `IDTokenWrongAudience`, `IDTokenMultiAudNoAzp`, `IDTokenExpired`, `IDTokenNbfFuture`, `IDTokenNoNonce`, `IDTokenAlgNone`, `IDTokenHS256`, `IDTokenUnknownKid`, `IDTokenKeyAlgMismatch`, `EmailVerifiedFalse`, `GroupsNonArray`, `GroupsOverageMarker`, `GroupsAbsentEverywhere`, `UserinfoSubMismatch`, `UserinfoRedirect`, `UserinfoNonJSON`, `UserinfoUnavailable`, `DiscoveryHTTPTokenEndpoint`, `TokenEndpointRedirect`, plus the existing `AuthAccessDenied` (IdP `error=access_denied` on the callback β†’ `authorization_denied`). Cases that are pure verification logic with no IdP behaviour (a crafted `alg: none`/HS256 token) are minted in the test with the fake's `KeyRing` against the fake's real JWKS β€” never against a second fake. +- **Rationale**: the memory rule for this rig is "add `Options` fields + CLI flag, not a new fake" (`project_oauthserver_test_rig`); it already has discovery, JWKS with rotation, RS256 signing and a headless login recipe (POST `/authorize` with `username,password,consent=on,action=approve`, read `Location`); it is importable in-process from `internal/*` without an import cycle (`agent6` Β§3a) so the `setup_wiring_test.go` harness can drive login β†’ callback with a cookie jar and an `httptest` reverse proxy; the standalone `cmd/server` gives the `quickstart.md` recipe with no Docker. The in-tree `mockOAuthProvider` (`auth/integration_test.go:30-83`) has no discovery/JWKS and cannot exercise a discovery-based provider. +- **Alternatives rejected**: `dex` or `mock-oauth2-server` in Docker β€” do not fit `go test`, need Docker on the CI runner, and the tamper matrix (bad signature, `alg: none`, HS256, wrong `aud`) is far easier to inject in a fake we own; Keycloak in Docker for the deploy guide β€” used only as *documentation* (the guide's example config is validated by a server-tagged test that loads it, SC-005), never as a test dependency. + +## D3 β€” ID-token verification with stdlib JWK parsing + `golang-jwt/jwt/v5`; no `go-oidc`/`keyfunc` + +- **Decision**: `internal/serveredition/auth/oidc_jwks.go` parses a JWKS document (`kty: RSA` β†’ `n`,`e` base64url β†’ `*rsa.PublicKey`; `kty: EC` β†’ `crv`,`x`,`y` β†’ `*ecdsa.PublicKey`), cached per `kid` with the discovery cache's TTL; `oidc_provider.go` verifies with `jwt.ParseWithClaims` and `jwt.WithValidMethods(allowed)` where `allowed` = intersection of `id_token_signing_alg_values_supported` and `{RS256,RS384,RS512,PS256,PS384,PS512,ES256,ES384,ES512}` (never `none`/HS*), `WithIssuer(cfg.IssuerURL)` (exact), `WithAudience(cfg.ClientID)` + explicit `azp` check when `aud` has >1 member, `WithLeeway(60s)` for `exp`/`nbf`/`iat`, and a manual `nonce` comparison against the pending state. Unknown `kid` β†’ exactly one JWKS refetch per login attempt, then `id_token_invalid`. +- **Rationale**: FR-020 forbids a new module dependency unless the stdlib path exceeds ~150 lines; the JWKβ†’key conversion is β‰ˆ70 lines (RSA β‰ˆ30, EC β‰ˆ35, cache β‰ˆ20) and the claim checks are library calls; `jwt/v5` is already at `go.mod:18`. `go-oidc` would pull `golang.org/x/oauth2` and its own HTTP client (which follows redirects β€” FR-020 forbids that), `keyfunc` adds a background refresher goroutine we do not want on the login path. +- **Line budget**: measured by `wc -l` on `oidc_jwks.go` in PR-B; if it exceeds 150 lines the PR description argues the dependency question explicitly (the spec allows it only then). +- **Unknown settled by fixture**: the `alg` header of a token whose `kid` matches a key of a different type (RSA key, `ES256` header) β†’ `id_token_invalid` (fixture in the tamper matrix). + +## D4 β€” Client authentication method is chosen once from discovery; the back-channel client never follows redirects + +- **Decision**: for `oidc`, `token_endpoint_auth_methods_supported` decides *before* the single exchange: `client_secret_basic` when advertised (also the OIDC Discovery Β§3 default when the key is absent), else `client_secret_post` when advertised, else `discovery_failed`. A dedicated `*http.Client` with `CheckRedirect: func(...) error { return http.ErrUseLastResponse }` and a 10 s timeout serves discovery, JWKS, token and userinfo; any 3xx is `provider_error`. Every discovered endpoint must be absolute `https` (or `http` only when the host is loopback **and** `allow_insecure_issuer: true`), enforced before any redirect to the IdP. +- **Rationale**: an authorization code is single-use (no retry with a second method); today's package-level `httpClient` (`oauth_providers.go:158-163`) follows redirects, so a hostile discovery document could downgrade the secret to `http` or another host (spec Edge Cases; `agent5` a7). The legacy providers keep `client_secret_post` (`oauth_providers.go:163-189`) untouched (US2.10). +- **Alternative rejected**: try basic then post β€” double-spends the code and turns a misconfiguration into an IdP-side `invalid_grant` that the operator cannot diagnose. + +## D5 β€” `args_sha256` = SHA-256 over an RFC 8785 (JCS) serialisation implemented in stdlib + +- **Decision**: `internal/audit/canonical.go`: recursive serialiser over the JSON-decoded `map[string]interface{}` β€” object members sorted by UTF-16 code units of the key, ES6 `Number::toString` for `float64` (`strconv.FormatFloat(f, 'g', -1, 64)` with the ES6 exponent-form rules applied; `-0` β†’ `0`; `json.Number` decoded to float64 first so `1`, `1.0`, `1e0` hash alike), minimal string escaping per JCS Β§3.2.2.2, no whitespace, UTF-8. Input is `security.StripInternalArgs(args)` **before** any masking/truncation. Non-finite numbers cannot occur (the map came from `encoding/json`). Test vectors under `internal/audit/testdata/canonical/` (nested maps/arrays, non-ASCII and escaped strings, `1`/`1.0`/`1e0`, `-0`, `9007199254740993`, the JCS RFC appendix samples) and the US3 fixture computes the expected hash with a **separate** implementation in the test package (a naive sorted-key `json.Marshal` is *not* JCS-equivalent for numbers β€” that is precisely what the vectors catch). +- **Rationale**: FR-015 requires a stable hash across masking/truncation and independent recomputation; stdlib keeps the no-dependency rule; β‰ˆ120 lines. The activity log's raw arguments stay where they are β€” the line never carries them. +- **Alternative rejected**: `github.com/gowebpki/jcs` (dependency, ~500 lines); `json.Marshal` of a sorted map (number canonicalisation differs, so operators recomputing the hash from an activity export would disagree). + +## D6 β€” Audit sink: synchronous writes from an *attempt record* on the context; not the event bus, not a zap core + +- **Decision**: each dispatch path (`handleCallToolVariant`, direct-name dispatch in `mcp_routing.go:412-469`, REST `/tools/call` β†’ `CallToolDirect`, nested `jsruntime` sub-calls) installs an immutable `audit.Attempt` on the request context **before its first gate** with: activity `request_id` (the locally minted id, `mcp.go:837`), `transport_request_id` (REST only, `reqcontext.GetRequestID`), `parent_id`, canonical `(server, tool)`, tier, surface, `args_sha256`, `args_bytes`, `session_id`, `work_session_id`, `client{name,version}` (caller-asserted, untrusted), `source` (from the mount point, never from `X-MCPProxy-Client`), `origin` (`transport.GetConnectionSource`), `client.ip` (via `trusted_proxies`). `emitActivityPolicyDecision(ctx, …)` and `emitActivityToolCallCompleted(ctx, …)` take `ctx` **first** (FR-012), read the attempt + `auth.AuthContextFromContext(ctx)` and call `audit.Sink.Write(line)` synchronously; the sink is a `sync.Mutex`-guarded `bufio.Writer` over lumberjack (file) and/or `os.Stdout`, flushed per line (write-through), never through zap. +- **Rationale**: the bus is lossy (`event_bus.go:205-216`), `policy_decision` rows carry no identity (`activity_service.go:732-773`), the closed `BlockReason*` enum reaches only counters (`mcp.go:787-797`), and the dispatch id is a local that never reaches the managed client's context (`mcp.go:2503-2508`) β€” so a tap anywhere downstream cannot assemble (identity, decision, outcome) (`agent4` Β§8, spec FR-012). Limiter sheds happen inside `managed.Client.CallTool` after every gate (`managed/client.go:815-836`) and return a typed `*limiter.LimitError` to the completion path, so the shed is the `tool_call` line's `outcome: rejected`, never a second `authz` (Edge Cases). Nested refusals return from `jsruntime.(*ExecutionContext).checkDispatchGates` (`runtime.go:384`) before any emitter β€” the `ExecutionContext` gains an authorization-decision observer that the wrapper installs (FR-012). +- **Alternatives rejected**: bus subscriber (drops; no `AuthContext`); zap core with `json_format` (the console core is a coloured dev encoder, `logger.go:201-206`, and the stdout path must be raw JSON; secret sanitizer stays as defence-in-depth by wrapping the writer, not by routing through zap); a "started" line (FR-012 forbids; the `authz allow` line already precedes the upstream call so a crash mid-call keeps the authorization record). +- **Count invariant** (tested under bus saturation with 2,000 calls): `authz` lines = pre-dispatch decisions; `tool_call` lines = `authz allow` lines. + +## D7 β€” Tenant principal on core REST: one resolver, two mount points; allowlist as a (method, path) matcher checked before the handler + +- **Decision**: `httpapi.Server.SetSessionPrincipalResolver(func(r *http.Request, kind credentialKind, value string) (*auth.AuthContext, error))` β€” nil in the personal build; installed by `internal/server/serveredition_wire.go` from the same user lookup and live role derivation the server-edition middleware uses (`middleware.go:138-214`, `:228-246`). `apiKeyAuthMiddleware` (`httpapi/server.go:453`) evaluates exactly one credential source in the FR-001 order (socket β†’ `X-API-Key` β†’ bearer β†’ `?apikey=` β†’ cookie only when none of the three is present); a present-but-failing credential is 401. Immediately after a `user`-typed principal is installed, a **tenant-session allowlist** runs: a compiled matcher over `(method, r.URL.Path)` for exactly the FR-045 "core reads" row (`GET /api/v1/status`, `/servers`, `/servers/{id}/**` reads except `/servers/{id}/tool-calls`, `/tools`, `/index/search`, `POST /preflight`, `/profiles` reads, `GET|HEAD /events`); everything else answers the fixed scoped-caller 403 body before the handler runs (so before any body parse). A test walks the production route table with `chi.Walk` and asserts every route's verdict against the FR-045 matrix, so a route added later without an entry fails the test (FR-043(f)). +- **Rationale**: the `/api/v1` group is a `Route` sub-mux (`httpapi/server.go:749-755`) whose `Use` middlewares run before its own route match, so `chi.RouteContext(...).RoutePattern()` is only `/api/v1/*` there β€” the allowlist cannot key on the pattern and must match the path itself; default-deny makes an over-narrow matcher fail safe. `/events` is mounted with `With(apiKeyAuthMiddleware())` outside the group (`:1016-1017`) and gets the same middleware. Mounting the server-edition middleware on the core group is rejected in `plan.md` Complexity Tracking (cookie would rescue a failed key; secret reveal would leak). +- **Entitlement on the principal**: `AllowedServers` = `entitledServerNames` at authentication, materialised (never nil, never `"*"`) so `CanEnumerateServer` (`context.go:224-233`) and `scopeAllowedServers` filter as they do for tokens; `CanRevealSecrets` gains `&& !IsSessionPrincipal()` (FR-002). + +## D8 β€” One entitlement predicate, one owner resolution per authentication, empty = deny-all at the source + +- **Decision**: the group term enters `entitledServerNames` (`user_handlers.go:808-851`) at the `:842` term for non-administrators only β€” `s.Shared && (mapInactive || s.Name ∈ groupGrant(user))` β€” reading `User.Groups` (stored at login) and the live `access` block through `ServerEditionConfigProvider`. The seven door-local `Shared` re-implementations (`user_handlers.go:345,488,529,603,654`, `user_activity.go:153`, `credential_handlers.go:352`) collapse onto a helper `tenantEntitled(userID, isAdmin) (set, error)` that calls the predicate; a guard test (AST walk over `internal/serveredition/api` non-test files) fails if `.Shared` is read anywhere outside `entitledServerNames` and that helper. For administrators the list doors keep their shared(+personal) projection (FR-004 "administrator projection unchanged"). The two storage callbacks (`SetAgentTokenOwnerGate` `agent_tokens.go:865`, `SetAgentTokenScopeResolver` `:846`) collapse into one `SetAgentTokenOwnerResolver(func(userID, granted) (OwnerResolution, error))` returning `{Active, UserID, Email, Provider, Role, Entitled []string}`; storage keeps `ErrAgentTokenOwnerInactive`/`ErrAgentTokenScopeUnavailable` and stamps `Email/Provider/Role` on three **non-persisted** `json:"-"` fields of the validated `*auth.AgentToken` it returns (`OwnerEmail/OwnerProvider/OwnerRole`), which `AgentToken.AuthContext()` copies onto the `AuthContext` (today it copies `UserID` only, `agent_token.go:54-72`; the token value is the only carrier, since every caller builds the context from the returned token β€” `httpapi/server.go:580`, `server/server.go:427`, `sse_scope.go:43`). The resolver loads the user record once and hands it to the predicate core `entitledServerNamesFor(user, isAdmin)` (the `userID` wrapper does its own single load for the REST doors), so the new `User.Groups` read never becomes a second `GetUser` (round-5 finding). `intersectAllowedServers` (`:706-739`) returns `[]string{}` (non-nil) on empty; `preflight.ScopeInputs` gains `Restricted bool`, set by `httpapi/preflight.go:139-146` for every non-administrator context so `normalizeTokenServers` (`preflight/scope.go:150-162`) treats empty as deny-all only for restricted callers. +- **Rationale**: `agent2` Β§1/Β§3 and critic C3 β€” eight loops already diverge; the empty-intersection trap is live on REST preflight; two store reads per authentication (`agent_tokens.go:917-939`) become one; group exclusion must be a term of `AllowedServers` so every Spec 105 surface and 106's per-request narrowing apply unchanged (FR-009/FR-010). The `"*"` grant means every *shared* admin server; personal records are untouched; matching is exact and case-sensitive on the bare name (the four `strings.EqualFold` doors converge β€” spec Edge Cases). +- **Alternative rejected**: a second predicate on the REST doors ("group-aware `Shared`") β€” exactly the drift FR-004 closes; a per-`ServerConfig` `groups:` list β€” trips the `serverfields_serveredition` mask canary, `TestSaveServerSyncFieldCoverage` and the generators (FR-007); a persisted `Email/Provider/Role` on the token record β€” a stale copy of the user record that a rename or demotion would leave wrong until the next mint. + +## D9 β€” Freshness bound is made real by making the three minting doors session-cookie-only and capping `expires_in` + +- **Decision**: `POST /api/v1/auth/token`, `POST /api/v1/user/tokens`, `POST /api/v1/user/tokens/{name}/regenerate` accept only a principal whose credential kind is the session cookie (the server-edition middleware records the kind on the `AuthContext`; a bearer JWT or `mcp_agt_` β†’ 401). `/user/tokens` validates `expires_in` with the core `parseExpiry` rule (`httpapi/tokens.go:456ff`: positive, ≀ 365 d). Owned tokens always carry an expiry (default 30 d, `user_handlers.go:1128`); a test pins that only ownerless operator tokens may hold a zero `ExpiresAt` (`agent_token.go:75-80`). Sessions keep the fixed, non-sliding TTL (`session_store.go:44,62`). +- **Rationale**: today `/auth/token` accepts any `IsUser()` principal including a JWT (`auth_endpoints.go:105-153`) β€” indefinite self-renewal (`middleware.go:201-205` names it) β€” and the user mint door parses `expires_in` with a bare `time.ParseDuration` (`:1120-1126`), so the groups staleness bound was unbounded (FR-011). No IdP re-query is built (the refresh-token chain is what PR-A removes). +- **Bound, documented**: session TTL + max(JWT TTL, longest owned token ≀ 365 d); administrator `disable` is immediate through the owner gate. + +## D10 β€” Front door: `public_url` is configuration, forwarded headers are gated by `trusted_proxies`, cookie `Secure` is `auto`, redirect is same-origin path only + +- **Decision**: `server_edition.public_url` (absolute origin, no path) is the sole source of the IdP `redirect_uri`, the connect-flow base (`connector_provider.go:50-62` stops latching the first-seen origin) and the cookie `Secure` decision when set; when unset, today's `Host`+`X-Forwarded-Proto` derivation applies but forwarded headers are honoured only from `trusted_proxies` (top-level CIDR list, edition-neutral, default empty, hot). A single helper `config.ForwardedHeaders(r, trusted)` returns `{Scheme, Host, ClientIP}` and is the only reader of `X-Forwarded-For`/`X-Real-IP`/`X-Forwarded-Proto`/`X-Forwarded-Host` in the process (`session_store.go:129-151`, `oauth_handler.go:413-423`, `connector_provider.go:126-135`, `httpapi/swagger.go:105-114`, audit `client.ip`); right-most untrusted hop for the IP. `session_cookie_secure: auto|true|false` (`setup.go:115` hardcoded `false` today); validation refuses `false` with an `https` `public_url` or in-process TLS. `redirect_uri` on `/auth/login` must begin with a single `/`, not `//` or `/\`, no scheme/host/backslash/CR/LF/control char; otherwise `/ui/` + `redirect_rejected` flag carried in the pending state. +- **Rationale**: `agent5` Β§3 checklist a1–a5 and critic C9 (XFF *is* used, untrusted); Spec 089 FR-014 forbids header-derived local/remote classification β€” the helper never feeds one. `public_url` is a warning + doctor finding when unset on a non-loopback listener, not a validation error (the published image listens on `0.0.0.0:8080`, `Dockerfile:36`). +- **Unknown, settled by fixture**: whether `cloudflared`/Funnel set `X-Forwarded-Proto` β€” irrelevant once `public_url` is set; the deploy guide sets it. + +## D11 β€” Forced MCP authentication under the server edition via a build-tagged accessor + +- **Decision**: `config.EffectiveRequireMCPAuth(cfg) bool` β€” server build: `cfg.RequireMCPAuth || (cfg.ServerEdition != nil && cfg.ServerEdition.Enabled)`; personal build: `cfg.RequireMCPAuth`. `mcpAuthMiddleware` (`server.go:348-462`, checks at `:353` and `:450`) calls it. An explicit `require_mcp_auth: false` under the server edition boots with one notice, a `doctor` finding and a release-notice bullet (FR-029/FR-042). +- **Rationale**: 17/46 server installs run with it off (research Β§10.8); the container's anonymous administrator (`server.go:381,458`) is the one path where F2 has no principal; a validation error would break every existing container on upgrade. + +## D12 β€” Settings parity: `audit_log.*` and `trusted_proxies` are added to the Swift catalogue as read-only rows; `server_edition.*` stays web-only + +- **Decision**: `scripts/check-settings-parity.py` exempts only `server_edition.` (`:48-50`); the new edition-neutral keys get rows in `native/macos/MCPProxy/.../SettingsCatalog.swift` (read-only display, restart-pinned) so `native-tests.yml` stays green; the `access` map is Raw-JSON-only in the Web UI (no map control, `fields.ts:8-15`) and shown as read-only chips on `AdminServers.vue`. +- **Alternative rejected**: extending `WEB_ONLY_PREFIXES` for edition-neutral keys β€” they *are* personal-edition keys (available-but-off), so the tray catalogue should know them. + +## D13 β€” Personal-build config integrity: `json.RawMessage` carriers + `UseNumber` on the PATCH path + +- **Decision**: `ServerEditionConfig` and `AuthBrokerConfig` stubs become `struct{ Raw json.RawMessage }` with `MarshalJSON`/`UnmarshalJSON` that carry the bytes unchanged (promotion trap noted: `feedback_json_methods_promote_to_embedders` β€” the stubs are pointer fields, not embedded, so no promotion); `handlePatchConfig` decodes both the patch body and the marshalled base with `json.Decoder.UseNumber` (`httpapi/server.go:5332,5376`). Fixture: load β†’ save β†’ PATCH an unrelated key β†’ save; compare both blocks under an independent `UseNumber` structural comparator with `9007199254740993` and `0.1000000000000000055511151231257827` planted (FR-040). +- **Rationale**: critic G3 β€” the `struct{}` stub writes `{}` back and defeats the legacy `teams` fallback, silently disabling SSO on the next server boot; the same defect erases every server's `auth_broker`. The personal build must not *validate* the blocks (it cannot parse them), only carry them. + +## D14 β€” Telemetry v13: three fields, closed vocabularies, no worker change + +- **Decision**: `FeatureFlagSnapshot` + `server_edition_enabled bool`, `idp_provider` ∈ `google|github|microsoft|oidc|none` via `config.IdPProviderFamily(cfg)` (build-tagged accessor; never the issuer); `BuildPayload` splices `user_count_bucket` ∈ `0|1-10|11-100|101-1000|1000+` (`bucketUpstream` vocabulary, `registry.go:642-655`) through a nil-safe `userCounter` accessor (pattern `telemetry.go:427-456`), installed by server-edition wiring; `env_markers.is_container` unchanged. `SchemaVersion = 13` with the header comment extended; all eleven v12 test sites updated and a grep guard test asserts no `schema_version":12|!= 12|SchemaVersion\s*=\s*12` survives under `internal/telemetry`. +- **Rationale**: `agent6` Β§2 β€” `ScanForPII` rule 3 decodes `env_markers` with `DisallowUnknownFields`; `payload_v7_test.go:20` pins the version; the worker stores `payload_json` wholesale (`src/index.ts:160-165`) so no migration; `docs/features/telemetry.md` promises never to collect identity/server names β€” the fixture uses the issuer host, group names and emails as forbidden substrings (US7). + +## Tooling + +SpecKit templates and scripts present (`.specify/`); `roadmap.yaml` epic `sso` (`:803-809`) is the entry to un-park; `python3 scripts/gen-roadmap.py --check` is enforced by pre-commit and CI only when `tasks.md`/`roadmap.yaml` are staged (`agent1` Β§4) β€” run it on every PR anyway. Cross-model review via `opencode run --model github-copilot/gpt-6-astra` (fallback `gpt-5.6-sol`), `gtimeout`, stdin closed, project-local `opencode.json`, 10-round cap per PR. `.github/RELEASE_NOTICE.md` does not exist at HEAD β€” PR-A creates it (`project_release_body_is_llm_generated`). diff --git a/specs/107-server-edition-sso-hardening/spec.md b/specs/107-server-edition-sso-hardening/spec.md new file mode 100644 index 000000000..3c0541fbc --- /dev/null +++ b/specs/107-server-edition-sso-hardening/spec.md @@ -0,0 +1,420 @@ +# Feature Specification: Server Edition β€” SSO Front Door Hardened for Real IdPs + +**Feature Branch**: `107-server-edition-sso-hardening` +**Created**: 2026-09-15 +**Status**: Draft +**Input**: User description: "Server edition: SSO front door hardened for real IdPs β€” a generic discovery-based OIDC provider with JWKS-verified ID tokens and a groups claim; an IdP-group β†’ server allowlist composed as an intersection into the Spec 105 effective authorization; an attributable append-only JSONL audit line with a stable, documented schema; and a freeze/cut of the latent multi-user and credential-injection code whose docs promise behaviour that does not exist. Plus the smallest set of container/ingress prerequisites that make the SSO door safe behind a TLS-terminating ingress, and four telemetry measurement fields." + +**Related**: Spec 105 (agent-scope hardening β€” the *effective authorization* and *non-disclosing refusal* definitions below extend 105's and inherit its administrator exception list and retained effects; PR #1279 = 105 FR-009 only, still OPEN β€” see Assumptions), Spec 106 (owner-qualified revocation, per-request narrow-only entitlement, fail-closed lookup β€” FR-003/FR-004 are the seam this spec feeds), Spec 028 (agent tokens; FR-016 wildcard = all non-quarantined servers), Spec 024 (multi-user OAuth login), Spec 029 (mcpproxy teams β€” the workspace/injection promises this spec withdraws), Spec 089 (remote-access tunnel β€” this spec makes the SSO door safe to serve through an ingress; it does not build the authorization-server role), issue #1177 (global agent-token cap), issue #1169 (live admin role). Decision record: the maintainer's research decision of 2026-09-14 (posture (a) + a bounded slice of (c)); not re-litigated here. + +## Context & Motivation + +The server edition today is an SSO login (Google, GitHub, Microsoft only), an admin REST surface and agent-token scoping on top of the personal proxy. A platform engineer who wants a team gateway asks for the same four things in every channel we mined: sign in with the corporate IdP, grant access by IdP group, see every tool call attributed to a person, and run it as a normal service behind an ingress. Read as an attacker or an auditor, the front door has these properties, every one verified in this worktree (origin/main `b39800a89`; lane maps verified at `c80ff0c14`, anchors re-checked here): + +1. **A valid SSO identity has no principal on the core REST surface or on `/mcp`.** `apiKeyAuthMiddleware` accepts only the socket, the global API key or an `mcp_agt_` token (`internal/httpapi/server.go:452-533`); `GET /api/v1/status` β€” the call the Web UI uses to learn the edition β€” sits inside that group. A tenant therefore cannot use the Web UI at all without the operator's global key (`frontend/src/stores/auth.ts:19-27` concludes "personal edition" on the 401; `router/index.ts:222-227` then hides `/login`), and a tenant who is handed that key is an administrator everywhere. On `/mcp` a user JWT is "a token that matches nothing": 401 under `require_mcp_auth`, otherwise an **anonymous administrator** (`internal/server/server.go:450-459`). The only path from an IdP identity to a tool call is an agent token the user mints (`internal/serveredition/api/user_handlers.go:1072ff`). (critic G1) +2. **The provider registry is three hard-coded keys** (`internal/serveredition/auth/oauth_providers.go:60-64`), rejected again by `ServerEditionConfig.Validate` (`internal/config/server_edition_config.go:76-79`) and at every login by `users.User.Validate` (`internal/serveredition/users/models.go:48-51`). No groups claim is read anywhere (`OAuthUserInfo` = `Email/DisplayName/SubjectID/AvatarURL`, `oauth_providers.go:52-57`). +3. **The ID token is trusted, not verified.** `parseIDToken` decodes the payload without signature, `iss`, `aud`, `exp` or `nonce` checks (`oauth_providers.go:464-495`); no nonce is sent; `email_verified` is never consulted. A generic issuer URL from config would widen that trust to whatever the operator points at, and the groups claim would become attacker-influenced input to authorization. +4. **The door is unsafe behind an ingress.** The post-login `?redirect_uri=` is stored verbatim and 302'd to (`oauth_handler.go:115-118`, `:303-307` β€” an open redirect); the session cookie is `Secure=false` at its only production call site (`internal/serveredition/setup.go:115`); the callback URL is built from `Host` + an untrusted `X-Forwarded-Proto` (`oauth_handler.go:413-423`); `X-Forwarded-For` is trusted from anyone (`session_store.go:129-151`); host validation is inactive off loopback (`internal/server/host_validation.go:123-127`) and the published image listens on `0.0.0.0:8080` (`Dockerfile:36`). 17 of 46 server installs run with `require_mcp_auth` off. +5. **Entitlement is one predicate in theory and eight in practice.** `entitledServerNames` (`user_handlers.go:808-851`, `:842 sc.Shared || isAdmin`) feeds mint, rotate and every agent-token authentication (`setup.go:73-92` β†’ `internal/storage/agent_tokens.go:929-938`), but `/user/servers` list/get/update/delete/enable, `/user/diagnostics` and the credential surface each re-implement the `Shared` test (`user_handlers.go:345,488,529,603,654`, `user_activity.go:153`, `credential_handlers.go:352`) and already diverge (critic C3). Any new grant term added in one place leaks through the other seven. +6. **The empty set is two things.** `intersectAllowedServers` returns `nil` on an empty intersection (`agent_tokens.go:735-737`); `CanAccessServer` reads that as deny-all (`internal/auth/context.go:84-97`) but `preflight.ResolveScope` reads it as *unrestricted* (`internal/preflight/scope.go:150-162`, fed verbatim by `internal/httpapi/preflight.go:139-146`). A group allowlist makes empty intersections routine. +7. **Nothing sees (identity, decision, outcome) together.** Identity rides as `_auth_*` keys inside activity `arguments` (`internal/server/mcp.go:649-688`); `policy_decision` rows carry no identity and only prose (`internal/runtime/activity_service.go:732-773`); the closed `BlockReason*` enum reaches only telemetry counters (`mcp.go:787-797`); the event bus drops on a full 256-slot buffer (`internal/runtime/event_bus.go:205-216`); arguments are persisted raw. A security team cannot approve a schema once. +8. **The code and the docs promise credential injection that never happens.** `multiuser.Router`/`ToolFilter`, `workspace.Manager`, `broker.TokenExchanger`/`CredentialResolver`/`HeaderInjector` and `Client.SetBrokeredAuth` have zero production callers (`multiuser/router.go:42-57` says "NOT YET WIRED … LATENT"; verified class-A set β‰ˆ 1,830 non-test LOC β€” not the research's 8.6k, which is all server-edition code), while `docs/cli/credential-commands.md:15-16` says "the proxy injects it at call time" and `docs/features/auth-broker.md:82-106` describes per-request resolution, header injection and per-(user, server) connection keying. +9. **A global token cap is a cross-tenant oracle** β€” `tokenBucket.Stats().KeyN >= auth.MaxTokens` (`agent_tokens.go:194-198`, #1177) lets one tenant exhaust every tenant's slots, and the 409 body (`user_handlers.go:1174`) tells any tenant that the fleet total reached 100. + +This spec makes the sentence *"a person who signs in through the team's IdP can see and use exactly the servers their group grants, on every surface, and every decision about them is written down"* true, and removes the code and prose that promise things the proxy does not do. It is the acceptance contract for four deliverables (F1 generic OIDC, F2 group grants, F3 audit line, F4 freeze/cut) plus the front-door prerequisites the remote-access design (Spec 089) will inherit, and it lists every deferral with its consequence. + +## Scope Boundary *(read before planning)* + +| Already exists | Reused, not rebuilt | +|---|---| +| Owner gate and per-authentication scope resolver on owned agent tokens (`setup.go:58-92`, `agent_tokens.go:911-938`) | the single seam every new grant term enters through | +| `entitledServerNames` and `narrowScopeToEntitled` (`user_handlers.go:808-851`, `:978ff`) | become **the** tenant entitlement predicate (FR-004) | +| `AuthContext` + `CanAccessServer` / `CanEnumerateServer` / `IsScopedCaller` / `AuthorizeServerOp` / `requireAdminRead` β€” every core REST door already keys on `!IsAdmin()` (`context.go:84-97,224-246`, `internal/auth/server_ops.go:102-107`, `internal/httpapi/tokens.go:110-112`) | a tenant session principal drops into them behind one new **tenant-session allowlist** on the core `/api/v1` group (FR-002): the read doors the Web UI needs pass through these predicates; every other core route β€” dispatch doors (`/tools/call`, `/code/exec`, `/tool-calls/{id}/replay`) and the ungated mutations and disclosures (`/registries/{id}/refresh`, `/telemetry/*`, `/onboarding/*`, `/feedback`, `/code/scripts`, `/diagnostics`, `/doctor`, `/connect*`, `/annotations/coverage`) β€” is 403 for a `user`-typed principal before the body is parsed | +| Session manager, HS256 user JWT, server-edition auth middleware (`internal/serveredition/auth/`) | reused as the tenant principal resolver on core REST, installed as an edition hook | +| Provider data struct + registry, PKCE/state generation, `HandleLogin`/`HandleCallback`, `upsertUser` (`oauth_providers.go`, `oauth_handler.go`) | gain one discovery-based provider, a nonce, a verified ID token, a groups claim and a public base URL; the three fixed providers keep working | +| Activity emit funnels (`mcp.go:766`, `:787`), `telemetry.BlockReason*` (`internal/telemetry/preflight_counters.go:45-57`), `security.StripInternalArgs`, lumberjack rotating sink (`internal/logs/logger.go:183-197`), log secret sanitizer | the audit line is written from the funnels, synchronously, using these | +| Config-field wiring pattern (`DetectConfigChanges` `internal/runtime/config_hotreload.go:77-420`, `ServerEditionConfigProvider` `setup.go:145-152`, `${env:}` refs) | followed for every new key | +| Telemetry `FeatureFlagSnapshot` (`internal/telemetry/feature_flags.go:122-170`), `EnvMarkers.IsContainer` (`env_markers.go:22`), `bucketUpstream` (`registry.go:642-655`) | three fields added; `is_container` already exists | +| Test rigs: `tests/oauthserver` (discovery + JWKS + RS256, no id_token/userinfo/groups), `setup_wiring_test.go` harness, Spec 105's two-fixture sentinel oracle *shape* | extended, not duplicated | + +**Surfaces and callers.** This spec covers: the server-edition REST routes (`/api/v1/auth/*`, `/user/*`, `/admin/*`); the core REST API (`/api/v1/*`, `/events`) for the new **tenant principal**; the HTTP MCP surfaces for **agent tokens owned by tenants**; and the Web UI for tenants. `/mcp*` **never** accepts a session cookie or user JWT (FR-003); tenants reach tools only through agent tokens they own. Native stdio and the local socket remain administrator-only by construction. **Administrators** β€” API key, socket, and `admin_user` (an SSO user listed in `admin_emails`) β€” keep every capability they have today except the items in the **administrator exception list** defined once in SC-006 and referenced everywhere: forced MCP authentication under the server edition (FR-029), administrator-email users exempt from the group map (FR-009), the administrator session principal on core REST with secret reveal masked (FR-002), audit lines for every administrator dispatch (FR-012), the per-owner token cap (FR-037), cookie-only JWT renewal and the administrator-armed rebind (FR-011, FR-023), removed/deprecated keys and modes and the withdrawn `store_idp_tokens` writer (FR-032..FR-034), the withdrawn open redirect and untrusted forwarded headers (FR-026..FR-028), and `DetectConfigChanges` now reporting `server_edition`/`audit_log`/`trusted_proxies` edits (FR-039). Tool-definition goldens are unchanged throughout (FR-044). + +**Editions.** F1, F2 and the login-side prerequisites are server-edition code (`//go:build server`). F3, `trusted_proxies`, the per-owner cap and the telemetry fields are edition-neutral code whose *defaults* differ by edition where stated (FR-014, FR-037). The forced-MCP-auth and tenant-principal hooks are edition-neutral seams with a nil/passthrough personal implementation (FR-001, FR-029). F4 touches edition-neutral transport/upstream files the personal binary compiles (`internal/transport/broker_auth.go`, `internal/upstream/core/connection_http.go:171-192`), so the personal E2E suite is a required check for the cut (FR-035). The personal edition's observable behaviour changes in exactly the places SC-007 names. + +## Definitions *(binding)* + +- **Tenant**: an SSO-authenticated user whose email is not in `server_edition.admin_emails`. **Administrator**: API key, socket, or an SSO user listed there (`ServerEditionConfig.IsAdminEmail`, evaluated live per request as today β€” `middleware.go:228-246`). **Operator**: the holder of the global API key or the local socket. +- **Tenant principal**: the `AuthContext` a tenant's session cookie or user JWT installs on a core REST request (FR-001): type `user`, `UserID`, `Email`, and an **allowed-server set equal to the tenant's entitlement set** at the moment of authentication (materialised, never `nil`, never `"*"`). It carries no permission tier and no profile pin; it never reaches `/mcp`. An **administrator session principal** is the `admin_user` context installed the same way; it is an administrator except that it never satisfies the secret-reveal predicate (FR-002). +- **Group**: an opaque string taken from the IdP's configured groups claim at login, compared exactly (case-sensitive, no normalisation) against the keys of the group map. Groups are proxy-side data about a user, never sent to any IdP or upstream. +- **Group map**: `server_edition.access.group_servers` (group value β†’ list of admin-config server names, `"*"` = every shared server) plus `server_edition.access.default_servers` (the grant for a user whose stored groups match no key; absent, `null` or `[]` all mean *no default grant*). The map is **active** when the `access` block is present; absent block = today's `Shared`-only semantics. There is no silent allow-all: with the block present, `"*"` is the only way to grant everything. +- **Group grant** of a user: ⋃ `group_servers[g]` for *g* ∈ the user's stored groups, βˆͺ `default_servers` when no stored group matches any key. A group value with no map entry contributes nothing. +- **Entitlement set** of a tenant *u*, evaluated live on every authentication from the current configuration and the stored user record: personal servers of *u* (minus admin-name collisions, as today `user_handlers.go:853-869`) βˆͺ { admin-config servers *s* : `s.Shared` ∧ (map inactive ∨ *s* ∈ group grant(*u*)) }. An administrator's entitlement set is the whole configuration (unchanged) where the predicate is consulted for administrators today β€” token mint/rotate and the literal `*`; the administrator's `/user/*` list doors keep their shared projection (FR-004). Computed by `entitledServerNames` and by nothing else (FR-004). It is a set: it may be empty, and **an empty set is deny-all on every consumer** (FR-006). +- **Effective authorization** of a request (extends Spec 105 Definitions): caller kind; the caller's allowed-server set β€” for an owned agent token `stored grant ∩ owner's entitlement set` on every authentication (Spec 106 FR-004; wildcard materialised for tenants, literal for administrators, as today), for a tenant principal the entitlement set itself; the permission set (agent tokens only); and the effective profile scope after resolution. Group membership is a term of the entitlement set and therefore of the effective authorization; it is never a separate check on any surface. +- **Authorized population**, **registration identity**: as in Spec 105. +- **Non-disclosing refusal**: as in Spec 105 β€” the response for a resource outside the caller's effective authorization is indistinguishable in status, body and timing class from the response for a nonexistent resource of the same kind. MCP surfaces use Spec 105 FR-010 shapes; core REST subtree doors use 404 parity (`internal/httpapi/scope_subtree.go:185-205`); server-edition doors use status parity with an absent resource (never "body must not contain the name"). **There is no "not in your group" status, body, reason string or caller-visible log line** β€” group exclusion is indistinguishable from non-existence. +- **Verified ID token**: a JWT whose signature validates against a key from the issuer's discovered `jwks_uri`, whose `iss` equals the configured issuer exactly (OIDC Discovery Β§4.3, byte for byte), whose `aud` contains the configured client id (with `azp` equal to it when `aud` has more than one member), whose `exp`/`nbf`/`iat` are valid within 60 s skew, and whose `nonce` equals the single-use nonce bound to the login state that started the flow (FR-021). +- **Public URL**: the absolute origin (`scheme://host[:port]`, no path) at which browsers reach the proxy through whatever sits in front of it. Configuration, never derived from request headers. +- **Trusted proxy**: a source address inside the top-level `trusted_proxies` CIDR list. Only requests from trusted proxies have `X-Forwarded-For` / `X-Real-IP` / `X-Forwarded-Proto` / `X-Forwarded-Host` honoured; from anyone else those headers are ignored. +- **Audit line**: one JSON object per line, schema-versioned, written synchronously by the dispatch path that made the decision, attributable to a caller, never containing raw arguments, responses, credentials, cookies, tokens or free-text reasons (FR-012..FR-019). It is an operator-only sink and has no read surface. +- **Latent code**: a production symbol that is not reachable from any production composition root at HEAD β€” either it has zero non-test callers outside its defining file, or its only callers are themselves latent (e.g. `broker.ConnectionKey` is called only from `multiuser.Router`, `router.go:195`, and `Router` is never constructed, `router.go:42`) β€” as listed in the verified class-A table in the freeze lane, which names the unreachable root and its transitive members; or a config knob no production code reads. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 β€” A tenant sees and uses exactly the servers their group grants, everywhere (Priority: P1) + +An administrator shares servers `jira`, `github` and `prod-db` and writes `access: {group_servers: {"eng": ["jira","github"], "sre": ["*"]}, default_servers: []}`. Alice (groups `["eng"]`) sees `jira` and `github` in the Web UI, can mint an agent token for them, and her agent can discover and call only their tools; `prod-db` does not exist for her on any surface β€” not its name, tools, prompts, counts, status, logs, activity or existence. Bob (no matching group) sees no shared servers. Carol (`admin_emails`) sees everything. When the IdP moves Alice to `sre`, her next login widens her; when the administrator removes the `eng` mapping or un-shares `github`, her next request narrows her without any token being rotated. + +**Why this priority**: "Onboarding is adding someone to a group" is the canonical team ask; today the only lever is a binary share flag, and every leak is a fleet map. + +**Independent Test** (fixture pair, as Spec 105 US1): fixture **A** β€” shared servers `a`, `b`, `a__b`, each with tools, prompts, usage history, log lines and a distinctive hidden **sentinel** string; `access: {group_servers: {eng: [a], ops: [a, b]}, default_servers: []}`; user Alice with stored groups `[eng]`, Bob with `[]`, Carol with `[ops]`, Dana in `admin_emails`; an agent token minted by Alice with `allowed_servers: ["*"]` and one with `["a"]`. Fixture **B** β€” identical except `b` and `a__b` are absent. Every scenario runs the same request as Alice (session on REST; her tokens on MCP) against both fixtures under the `setup_wiring_test.go` harness and asserts normalised equality and no sentinel; Bob's responses in fixture A equal an unentitled tenant's in fixture B; Carol's include `b`; Dana's are unchanged from today. The applicability matrix in FR-045 says which surface exposes which operation. + +**Acceptance Scenarios**: + +1. **Given** Alice's session, **When** she calls `GET /api/v1/user/servers`, or `/user/servers/{name}` (get, update, delete, enable) naming `b` or `a__b`, or `/user/diagnostics`, or connects a credential to `b`, **Then** the list contains only `a` and her personal records, and each named request receives the same status and body as for a nonexistent name β€” on both fixtures, identically. +2. **Given** Alice's session, **When** she calls the core REST read doors `GET /api/v1/servers`, `/status`, `/tools`, `/index/search`, `/servers/{id}/**` (reads, excluding `/servers/{id}/tool-calls`), `/preflight`, `/profiles`, opens `/events`, and reads her own history through `GET /api/v1/user/activity`, **Then** every response and every SSE frame is identical between fixtures after normalisation, contains no sentinel, hidden-id requests get 404 parity, `/preflight` with `b`'s tool ids reports them exactly as it reports nonexistent ids, `/profiles` lists no profile whose servers are all hidden (fixture A carries a profile `ops-only` = `[b]` and the global active profile set to it; Alice's `/profiles` omits it and `/profiles/active` is `""` on both fixtures; Carol sees it), and `/user/activity` returns only records whose `user_id` is Alice's **and** whose server is in her entitlement β€” never Bob's or the operator's calls to `a`; **When** she calls core `/activity*`, `/tool-calls*` or `/servers/{id}/tool-calls`, **Then** 403 (FR-002 β€” those records are server-scoped only and would show every caller's arguments and responses on a shared server). +3. **Given** Alice mints a token with `allowed_servers: ["*"]`, **When** she lists it, **Then** its stored grant is materialised to `[a]` (never `["*"]`, never `[a, b]`); **When** she requests `["a", "b"]`, **Then** the mint is refused with the existing uniform `server "b" is not available to you` body (`user_handlers.go:951-955`) and no token is created; **When** she requests `["a__b"]`, **Then** the refusal is byte-identical to `["nonexistent"]`. +4. **Given** Alice's `["a"]` token, **When** it exercises every Spec 105 US1 scenario (cache redemption, `set_profile`, profile URL, `retrieve_tools` metadata, prompts, `tail_log`, dispatch denial text, direct listing and describe), **Then** the responses are identical between fixtures under the two-fixture oracle and contain no sentinel, excluding only the Spec 105 items still open on `main` (Assumptions). +5. **Given** Bob (no groups) mints a token with `["*"]`, **When** the token authenticates on `/mcp`, **Then** `retrieve_tools` returns no upstream tools, every dispatch is refused non-disclosingly, `set_profile` lists no servers; **When** the same token calls `GET /api/v1/preflight` with any tool ids, **Then** every id is out of scope β€” never "unrestricted". +6. **Given** Alice's session and token are live, **When** the administrator removes `eng` from the map or un-shares `a` (hot reload), **Then** the next REST request, the next `/events` frame and the next MCP request on an existing session see her narrowed; **When** the mapping is re-added, **Then** the next request sees `a` again; no restart, no token rotation. +7. **Given** Carol logs in with groups `[ops]` and the IdP later removes her from `ops`, **When** she has not re-authenticated, **Then** she keeps `b` for at most her session TTL plus the longer of the JWT TTL and the longest-lived token she owns (FR-011, the documented bound β€” a JWT cannot renew itself through `/auth/token` and cannot mint or rotate a token through `/user/tokens`; a token's expiry is capped at 365 days), or until an administrator disables her; **When** she re-authenticates, **Then** her stored groups are replaced and `b` is gone on the next request. +8. **Given** Dana (`admin_user`), **When** she exercises every scenario above, **Then** she sees the whole configuration exactly as today. + +--- + +### User Story 2 β€” A team signs in through its own IdP behind an ingress, and the proxy verifies what the IdP says (Priority: P1) + +An operator running `ghcr.io/smart-mcp-proxy/mcpproxy-server` behind a TLS-terminating ingress at `https://mcp.example.com` configures `provider: oidc` with their Keycloak/Okta/Authentik/Auth0/Entra issuer URL, client id and secret (`${env:}` from a Kubernetes Secret), scopes and a groups claim name, plus `public_url` and the ingress CIDR in `trusted_proxies`. Users sign in; their groups are captured from a **verified** ID token; a forged or replayed token, a wrong audience, an unverified email, a tampered post-login redirect, or a spoofed forwarded header gets nowhere; an IdP outage never takes the proxy down. Google, GitHub and Microsoft configurations keep working unchanged. + +**Why this priority**: Without generic OIDC, Okta/Keycloak shops are excluded outright; without verification, the group claim is attacker-influenced input to authorization; without the base-URL/cookie/redirect/forwarded-header fixes the login either fails at the IdP (`redirect_uri_mismatch`), loops (cookie dropped), or is an open redirect. + +**Independent Test**: `tests/oauthserver` extended with `Options.OIDC` (discovery advertises `userinfo_endpoint` and `id_token_signing_alg_values_supported`; the auth-code token response carries an RS256 `id_token` signed by the existing rotating key ring with `sub,email,email_verified,name,nonce,`; `/userinfo`; per-user `Options.UserClaims`), driven headlessly through login β†’ callback with a cookie jar through the `setup_wiring_test.go` harness, behind an `httptest` reverse proxy that sets or strips `X-Forwarded-*` and rewrites `Host`, from a trusted and an untrusted source address; a matrix of tampered tokens (bad signature, wrong `iss`, wrong `aud`, expired, wrong/missing `nonce`, `alg: none`, HS256, `email_verified: false`) and of hostile provider metadata (an `http` `token_endpoint`/`jwks_uri` in the discovery document; a token endpoint that answers 302 to another origin β€” FR-020), each asserting login refusal with one generic page, no user record created or updated, no client secret sent to a non-https or redirected endpoint, and one `auth_event` line with the closed reason. + +**Acceptance Scenarios**: + +1. **Given** `provider: oidc`, `issuer_url`, `client_id`, `client_secret: "${env:OIDC_CLIENT_SECRET}"`, `public_url: "https://mcp.example.com"`, **When** the proxy boots, **Then** it starts without contacting the IdP, logs the resolved public and callback URLs, and the first `GET /api/v1/auth/login` fetches `/.well-known/openid-configuration`, verifies its `issuer` equals the configured value exactly, caches it, and redirects to the IdP's `authorization_endpoint` with `redirect_uri=https://mcp.example.com/api/v1/auth/callback`, PKCE S256, `state` and `nonce`. +2. **Given** the callback returns an ID token, **When** it is not a *verified ID token* (Definitions), **Then** the login is refused with the generic page of scenario 8, the user store is untouched, the failed check's name (never a claim value) is logged, and the nonce is single-use. +3. **Given** a verified token carrying the configured groups claim, **When** login completes, **Then** the user record stores exactly those values (a flat JSON array of strings, or a single string; anything else is treated as absent); **Given** the claim is absent from the ID token, **When** `userinfo_endpoint` is available, **Then** userinfo is consulted once over the back channel and its claims are used only if its `sub` is a string exactly equal to the verified ID token's `sub` (OIDC Core Β§5.3.2) β€” a missing or different `sub` refuses the login (`userinfo_subject_mismatch`) with the generic page and leaves the user store untouched; **Given** the userinfo request itself fails β€” transport error or timeout, a 3xx (FR-020, non-redirecting client), a non-200 status, or a body that is not a JSON object (`fetchOIDCUserInfo` returns an error for each, `oauth_providers.go:283-308`) β€” **Then** the login is refused as `provider_error` with the 503 unavailability page of scenario 9, the user store is untouched and stored groups are **not** reset (an IdP hiccup never silently converts a grant into the default grant, and never logs a user in with `[]`); **Given** it is absent from both, or the token carries an Entra overage marker (`_claim_names`/`_claim_sources`), **Then** stored groups are `[]` (fail closed), the login succeeds, and a warning naming the user id and the claim name looked for (never the token) is logged. +4. **Given** `email_verified: false` under the default `email_verified_policy: refuse_false`, **Then** login is refused (`email_unverified`); **Given** the claim is absent, **Then** login proceeds; **Given** `require_true`, **Then** absence is refused too; **Given** `ignore`, **Then** the claim is not consulted (docs warn that self-asserted emails can then pass `allowed_domains`). `allowed_domains` applies as today, case-insensitively. +5. **Given** an existing user (by normalised email) bound to `(provider, sub)`, **When** the same provider presents the same email with a different `sub`, **Then** login is refused (`subject_mismatch`) with the generic page and an `auth_event` line; **When** the operator has switched the configured provider, **Then** the first login re-binds the record to the new `(provider, sub)` and its `auth_event` line (`reason: ok`) carries `provider_rebound` in `flags`; `Provider` and `ProviderSubjectID` are refreshed on every successful login (today `Provider` is never updated and `ProviderSubjectID` only when an avatar URL is present β€” `oauth_handler.go:395-397`, critic C4). +6. **Given** `public_url: https://mcp.example.com`, **When** login runs behind an ingress that rewrites `Host` to `127.0.0.1:8080` and sends no `X-Forwarded-Proto`, **Then** the IdP `redirect_uri` is `https://mcp.example.com/api/v1/auth/callback` and the session cookie is `Secure; HttpOnly; SameSite=Lax`; **Given** `public_url` is unset, **When** a request arrives from an address outside `trusted_proxies` carrying `X-Forwarded-Proto: https` and `X-Forwarded-For: 1.2.3.4`, **Then** both headers are ignored (scheme from the listener, IP from `RemoteAddr`); **When** the same request arrives from a trusted proxy, **Then** both are honoured. +7. **Given** `GET /api/v1/auth/login?redirect_uri=https://evil.example/`, `//evil.example`, `/\evil.example`, or `javascript:`, **When** login completes, **Then** the browser is sent to `/ui/` and the attempt's `auth_event` line carries `redirect_rejected` in `flags`; **Given** `redirect_uri=/my/tokens?x=1`, **Then** it is honoured. +8. **Given** a login is denied for any reason (verification failure, domain, disabled user, missing or unverified email, invalid state, IdP error, subject mismatch), **When** the browser lands on the callback, **Then** it sees one generic page "Sign-in was not permitted (ref ``)" with the same status for every reason; the closed reason is only in the server log and the `auth_event` line keyed by that id. +9. **Given** the IdP is unreachable when a user clicks sign-in, **When** discovery, the JWKS fetch, the token exchange or an attempted userinfo fetch fails, **Then** the browser receives `503` "Sign-in is temporarily unavailable (ref ``)", the proxy stays up, readiness is unaffected, the next login retries, and the log line names the issuer and the error class, never the client secret or any token. +10. **Given** `provider: google` (or `github`, `microsoft`) with today's configuration, **When** users log in, **Then** their provider-specific exchange and claim handling is unchanged (no nonce, no JWKS step, unverified back-channel `parseIDToken`, `client_secret_post`, stored groups `[]`; with `access` configured such tenants receive only the default grant, and validation refuses a non-empty `group_servers` map for these providers β€” FR-007), while the provider-neutral front-door hardening applies to them exactly as to `oidc`: subject binding (FR-023), the generic refusal page (FR-024), `public_url`/Secure cookie/trusted proxies/relative redirect (FR-025..FR-028), forced MCP auth (FR-029) and the `store_idp_tokens` no-op (FR-033). + +--- + +### User Story 3 β€” A security reviewer approves one audit schema and can answer "who called what, and what happened" (Priority: P1) + +An operator deploys the server-edition container and gets JSON audit lines on stdout by default (a rotated file on a host, if configured). For every tool-call authorization decision β€” allowed or refused, including refusals the caller could not distinguish from non-existence β€” and for every completed call, one JSON line appears with a stable, versioned, documented schema: who (caller kind, user id and email, token name and prefix), what (server, tool, operation tier, surface), the decision and its closed reason, a hash of the arguments, the outcome, duration and error class, a client IP gated by `trusted_proxies`, and a request id that ties the line to the activity record. No line ever contains an argument value, a response, a secret, a cookie, a token, or a free-text reason. Any stdout/file log shipper picks the lines up with no proxy-side connector. + +**Why this priority**: "Show me every time this tool was called and by whom" is the second-ranked ask and the moment a proxy becomes a gateway; today denials are unattributed, the closed reason never reaches storage, and the activity DB cannot be forwarded. + +**Independent Test**: A personal-edition and a server-edition instance with `audit_log` on (file and stdout); drive the dispatch matrix β€” `call_tool_read|write|destructive`, direct-name dispatch, nested `call_tool` in a script, REST `/api/v1/tools/call` β€” per caller kind (the six `caller.kind` values that can reach an upstream dispatch β€” `api_key`, `socket`, `anonymous`, `agent_token` (tenant-owned and administrator-owned, distinguished by `caller.role`), `session_admin` on `/api/v1/tools/call`, `internal`; `session_user` is **unreachable** for dispatch by construction β€” FR-002 refuses every tenant-session dispatch door before the body is parsed and FR-003 keeps sessions off `/mcp` β€” and appears only on `auth_event` lines, pinned by FR-043(j)) Γ— situation (top-level call; nested code-execution child, which keeps the script's `caller.kind` and adds `surface: code_execution` + `parent_id`; limiter shed, which keeps the caller and adds `outcome: rejected` + a limiter reason), with one allowed call, one upstream error, one tier denial, one scope denial of a hidden server, one quarantine block, one limiter shed, one output-sanitisation block, one bare `retrieve_tools` and one bare `read_cache` invocation (no lines), arguments containing a synthetic secret and a 100 KB payload; assert against the published JSON Schema: exactly one `authz` line per pre-dispatch decision and one `tool_call` line per `authz allow` (a post-dispatch sanitisation block is a `tool_call` with `outcome: blocked`, a limiter shed a `tool_call` with `outcome: rejected` β€” never a second `authz`); no line for a built-in invocation as such (`retrieve_tools`, `read_cache`, `upstream_servers` …), closed vocabularies, the structural redaction rule of FR-015 (no key of the line ever carries an argument, response, error-text or `_auth_*` value) plus byte-absence of every fixture **sentinel** β€” the synthetic secrets and payload strings are high-entropy values chosen so that they cannot occur in a schema constant, a vocabulary word or their own expected hashes (a short value such as `"1"` or `"api"` is not a usable sentinel), `args_sha256` equality with an independently computed canonical hash, and that the line count equals the number of decisions even with the activity event bus saturated. + +**Acceptance Scenarios**: + +1. **Given** the sink is enabled, **When** Alice's token calls `jira:create_issue` successfully, **Then** one `authz` line (`decision: allow`, written after the last gate passes and before the upstream call) and one `tool_call` line (`outcome: success`) share the same `request_id` and both carry `caller.kind: agent_token`, `caller.user_id`, `caller.user_email: alice@example.com`, `caller.token_name`, `caller.token_prefix`, `server: jira`, `tool: create_issue`, `operation: write`, `surface`, `args_sha256`; only the `tool_call` line carries `duration_ms`, `error_class`, `request_bytes` and `response_bytes` (the `authz` line is written before the call exists β€” FR-013 forbids those keys on it). +2. **Given** Alice's token calls hidden `prod-db:query`, **When** the call is refused non-disclosingly, **Then** the caller's response names nothing, while the single `authz` line records `server: prod-db`, `decision: deny`, `reason: token_scope`, `disclosed: false` and carries no `outcome`, `duration_ms` or `error_class` key (those belong to `tool_call` lines β€” FR-013), and no `tool_call` line follows. +3. **Given** arguments contain `AKIA…` and a password, and the upstream error message contains another secret, **When** the call is audited, **Then** the lines hold only `args_sha256` and `args_bytes`; the hash is over canonical JSON of the arguments with every `_auth_*` key removed; `error_class` is one of the closed values; no line contains any of the values even when the log secret sanitizer is disabled. +4. **Given** the activity event bus is saturated (256 queued events), **When** 2,000 calls are made of which 1,500 pass every pre-dispatch gate (some of those then shed by the limiter or blocked post-dispatch), **Then** 2,000 `authz` lines (1,500 `allow`) and exactly 1,500 `tool_call` lines are written before the handlers return and none is dropped, while the activity database may hold fewer. +5. **Given** an anonymous MCP caller (`require_mcp_auth: false`, personal edition), **Then** `caller.kind: anonymous` β€” never `admin`; **Given** the tray socket, **Then** `socket`; **Given** the API key, **Then** `api_key`; **Given** an `admin_user` session on `/api/v1/tools/call`, **Then** `session_admin`. +6. **Given** a script issues three nested calls, one refused, **When** the script finishes, **Then** each child line carries `surface: code_execution`, `parent_id` = the wrapper's activity request id (the `parentCallID` the wrapper already mints and threads, `mcp_code_execution.go:680-704`) and the *script's* caller identity; the `code_execution` wrapper itself writes **no** line (FR-012 β€” it is a built-in invocation), so `parent_id` is a correlation key into the activity log, not a reference to an audit line; three `authz` lines (two `allow`, one `deny`) and two `tool_call` lines result. +7. **Given** a login refusal (US2.2, US2.4, US2.5), a login success (with or without a re-bind and a rejected redirect, US2.5/US2.7) or a logout, **Then** exactly one `auth_event` line per attempt records the singular terminal `reason`, the attempt's `flags`, and the user id when the attempt reached the user store and a record exists β€” or `email_hash` (SHA-256 of the normalised email) when the email is verified but the store was not yet consulted (`domain_not_allowed`, `userinfo_subject_mismatch`, a userinfo-stage `provider_error` β€” FR-013), or neither when the attempt failed before any identity was verified β€” never the raw email of a non-user, never a hash of an unverified claim, never a token. Token mint/rotate/revoke produce no audit line (they stay in the activity log and the operational log as today). +8. **Given** any REST, SSE, MCP, CLI or Web UI surface, **When** any caller β€” administrator included β€” asks for audit lines, **Then** there is no such endpoint or command; the file and stdout are the only readers' surfaces. +9. **Given** `audit_log.path` cannot be created at boot, **Then** the proxy refuses to start with exit code 4 and an actionable message; **Given** the disk fills after boot, **When** a call runs, **Then** the call proceeds, `mcpproxy_audit_write_failures_total` increments (when metrics are enabled), `mcpproxy doctor` reports the count, and an error is logged at most once per minute. +10. **Given** the personal edition with no `audit_log` block, **Then** nothing is written and startup says nothing about audit; **Given** the server edition with no block on the HTTP transport, **Then** lines go to stdout and one startup line says so; **Given** the server edition with no block under the native stdio transport, **Then** nothing is written, one WARN names `audit_log.path` as the stdio-compatible sink, and the JSON-RPC stream on stdout is untouched (FR-014); **Given** an explicit `enabled: true, stdout: true` with no `path` under stdio, **Then** the process exits 4 with the FR-014 message; **Given** `enabled: false` under the server edition, **Then** nothing is written and a startup warning says attribution is off. + +--- + +### User Story 4 β€” A tenant uses the Web UI with nothing but their SSO session (Priority: P2) + +Alice opens `https://mcp.example.com/ui/`, is sent to `/login`, signs in with Keycloak, and lands on a dashboard that shows her entitled servers, her own activity (from `/user/activity`), her tokens and diagnostics. She never sees or types the operator's API key. What she sees through the UI equals what her session sees on REST (US1.2). Administrators who sign in see the whole fleet and can save settings. A session presented alongside a *failed* explicit credential is not a fallback. + +**Why this priority**: Without a tenant principal on REST there is nothing for the group grant to bind to on the UI path (critic G1); without the UI, the server edition is API/CLI-only for tenants, which no ask supports. + +**Independent Test**: Playwright against a server-tagged instance with the fake IdP: fresh browser β†’ `/ui/` β†’ `/login` β†’ dashboard β†’ server list β†’ token mint β†’ activity (served by `/user/activity`), with no `?apikey=` and an empty `localStorage['mcpproxy-api-key']`; assert every XHR succeeds via the cookie; assert that every method+route under `/api/v1` outside the FR-002 allowlist returns the fixed 403 for the tenant session β€” walked from the production route table, not a hand-written list, and including at least `GET /api/v1/config`, `GET /activity`, `GET /activity/summary`, `GET /tool-calls`, `GET /servers/{id}/tool-calls` (for a server she *can* see), `GET /info`, `GET /routing`, `GET /docker/status`, `GET /stats/tokens`, `GET /security/overview`, `POST /api/v1/tools/call` (every tool name, built-ins included), `POST /code/exec`, `POST /tool-calls/{id}/replay` (for a call she *can* see), `POST /registries/{id}/refresh`, `POST /telemetry/update-failure`, `POST /onboarding/mark`, `POST /feedback`, `GET /code/scripts`, `GET /diagnostics`, `GET /doctor`, `GET /telemetry/payload`, `GET /connect*`, `GET /annotations/coverage`, `PATCH /config`, `/servers` add/remove, `/quarantine/*` and the personal-edition `/tokens` routes; assert an `admin_user` session reaches admin pages, the full server list and a Settings save, and that `GET /api/v1/config` with secret reveal requested returns masked values for that session. + +**Acceptance Scenarios**: + +1. **Given** a fresh browser with no API key and no session, **When** `/ui/` loads, **Then** the UI learns the edition from the public `GET /api/v1/auth/provider` (200 with `{display_name}` on a server build; 404 on a personal build), routes to `/login`, and labels the button with `display_name`. +2. **Given** Alice signs in, **When** the UI calls `GET /api/v1/status` and the other core read doors through the cookie, **Then** they succeed as a tenant principal, `edition: server` is detected, the router does not redirect `/my/*` to the dashboard, and every page she can navigate to loads. +3. **Given** Alice's session, **When** the UI or a script calls any core REST route outside the FR-002 allowlist β€” every mutation and administrative read (`/tools/call` with any tool name including every built-in, `/code/exec`, `/tool-calls/{id}/replay`, `GET /config`, `PATCH /config`, `/servers` add/remove/enable, `/quarantine/*`, `/tokens` personal routes, `/config/apply`, `/secrets*`, `/sessions`, `/registries*` including `/refresh`, `/telemetry/*`, `/onboarding/*`, `/feedback`, `/connect*`) every global disclosure (`/code/scripts`, `/diagnostics`, `/doctor`, `/annotations/coverage`, `/info`, `/routing`, `/docker/status`, `/stats/tokens`, `/security/*`) and every core history read (`/activity*`, `/tool-calls*`, `/servers/{id}/tool-calls`), **Then** 403 with the existing scoped-caller body β€” never 500, never 404 β€” from the allowlist gate before the body is parsed, so a malformed body is still 403. +4. **Given** a request carrying a **wrong** `X-API-Key` **and** a valid session cookie, **Then** 401 β€” an explicit credential that fails is terminal; the cookie is consulted only when no `X-API-Key`, `Authorization: Bearer` or `?apikey=` is present. +5. **Given** a `Bearer ` on a core REST route, **Then** it is accepted exactly like the cookie (same resolver); a `Bearer mcp_agt_…` is an agent token as today; a user JWT on `/mcp` remains refused (FR-003). +6. **Given** Dana's `admin_user` session, **When** she opens `/admin/*`, the core dashboard and Settings, **Then** she sees and can change everything an API-key administrator can, except that secret-reveal-gated values remain masked for session principals (FR-002). +7. **Given** Alice is disabled by an administrator while her tab is open, **Then** her next XHR is 401 and the UI returns to `/login`. + +--- + +### User Story 5 β€” The code and the docs stop promising credential injection (Priority: P2) + +A developer reading the repo or the published docs finds no code path, config knob, CLI sentence or doc claiming that the proxy injects per-user credentials into upstream calls, keys connections per user, runs per-user workspaces, or exchanges tokens (RFC 8693 / Entra OBO). The latent zero-caller chain is deleted, its six security-regression tests are retired by name in the PR, the stored-credential surface says "stored, not injected", both editions build and pass their suites, and old configuration files still load. + +**Why this priority**: The over-claim is a credibility problem regardless of strategy; the unwired chain compiles into the *personal* binary; a stored IdP refresh token with no reader is a secret at rest with no purpose; and the dead `Router.GetServerForUser` encodes shared-before-personal precedence, a contradictory second rule set the allowlist must not inherit. + +**Independent Test**: `go build -o /dev/null ./cmd/mcpproxy`, `go build -tags server -o /dev/null ./cmd/mcpproxy`, `go vet` and `golangci-lint --build-tags server` clean; guard tests assert the listed symbol declarations, struct fields, accepted-mode values, brokered branches, doc sentences and UI field are absent (the compatibility decoder for `store_idp_tokens` and the normaliser's deprecation-warning strings are exempt β€” FR-035); an old config containing `auth_broker.mode: token_exchange`, `auth_broker.header`, `max_user_servers`, `workspace_idle_timeout` and `store_idp_tokens: true` loads under the server build with one deprecation log line per key/mode and no error, and under the personal build with no warning and both blocks preserved (FR-032/FR-040); `./scripts/test-api-e2e.sh` and `go test -race ./internal/upstream/... ./internal/transport/...` stay green; `TestOAuthClients_CarryStaticHeaders` (#1271) still passes. + +**Acceptance Scenarios**: + +1. **Given** the cut, **When** the FR-031 symbols are searched, **Then** none exists in non-test or test code, and `internal/upstream/core/connection_http.go` has no brokered branch. +2. **Given** a config file with the removed keys or modes, **When** the server starts, **Then** it starts, logs one line per key/mode (`server_edition.max_user_servers is no longer supported and was ignored`; `auth_broker.mode "token_exchange" was never implemented; the auth_broker block for server "x" was ignored`), and the next write-back drops them; **When** the same keys or modes arrive through `PATCH /api/v1/config`, **Then** validation refuses them with the same message. +3. **Given** `store_idp_tokens: true`, **When** a user logs in, **Then** no IdP token is persisted, no offline-access scope or parameter is requested, and boot logged one deprecation warning. +4. **Given** a user has completed an `oauth_connect` flow, **When** they run `mcpproxy credential list` or read `docs/cli/credential-commands.md`, **Then** the output and the page state in the first paragraph that a stored credential is **not** used by proxied requests in this release. +5. **Given** the published docs build, **Then** `docs/features/idp-token-storage.md` is a one-paragraph tombstone, `docs/features/auth-broker.md` and `docs/cli/credential-commands.md` open with the "stored, not injected" statement and have lost the injection/keying/exchange sections, every sentence listed in FR-036 is corrected, and every `website/sidebars.js` entry still resolves. +6. **Given** the activity log contains historical `credential_broker` rows, **Then** they remain readable and labelled. + +--- + +### User Story 6 β€” One tenant cannot exhaust or observe another tenant's token quota (Priority: P3) + +Each token owner has their own cap. A tenant who creates 100 tokens hits their cap; the operator and every other tenant can still mint; the 409 a tenant receives says nothing about anyone else. + +**Why this priority**: The global cap is a cross-tenant denial oracle and a trivial DoS β€” small, embarrassing, and squarely tenant isolation; F2 multiplies the tenants sharing one bucket. + +**Independent Test**: Two tenants and the operator each mint to the cap; assert the 101st for the same owner is 409, the first for the other owner succeeds, and the refusal body references only the caller's own count. + +**Acceptance Scenarios**: + +1. **Given** Alice holds 100 tokens, **When** Bob mints, **Then** success; **When** Alice mints, **Then** 409 whose body says the limit is per user. +2. **Given** ownerless operator tokens number 100, **When** Alice mints her first, **Then** success. + +--- + +### User Story 7 β€” The maintainer can measure whether any of this is used (Priority: P3) + +Heartbeats say whether the server edition is enabled, which provider family is configured, how many users exist (bucketed), and whether the process runs in a container β€” never an issuer host, a group name, an email or a server name. + +**Independent Test**: Payload privacy suite (`TestPayloadHasNoForbiddenSubstrings`, `ScanForPII`) with the new fields populated from a fixture whose issuer host, group names and emails are the forbidden substrings; every exact `SchemaVersion == 12` assertion and `"schema_version":12` fixture (eleven test sites at HEAD plus the production constant, FR-038) updated to 13. + +**Acceptance Scenarios**: + +1. **Given** `provider: oidc` with issuer `https://login.corp.example` and 7 users, **Then** the heartbeat carries `feature_flags.server_edition_enabled: true`, `feature_flags.idp_provider: "oidc"`, `user_count_bucket: "1-10"`, `env_markers.is_container` as detected, and the string `corp.example` appears nowhere in the payload; a personal-edition instance carries `false`, `"none"`, `"0"`. +2. **Given** the schema, **Then** `SchemaVersion` is 13 with the change-log comment extended and `docs/features/telemetry.md` updated; the worker needs no migration (fields ride in `payload_json`). + +--- + +### Edge Cases + +- **Administrator callers.** Unchanged everywhere except the administrator exception list of SC-006. +- **Pre-upgrade users.** Existing user records have no groups. With the map inactive nothing changes. Activating the map treats them as matching no key until their next login β€” a deliberate fail-closed window the docs must state ("enabling `access` requires users to sign in again"). +- **Personal servers under the group map.** A tenant's own personal records are not admin servers; the group grant neither adds nor removes them (they remain token-scope inputs only, never connected β€” Spec 029 FR-008 retired). Name collisions with admin servers are excluded for tenants as today. +- **`"*"` semantics.** `"*"` in a group list or in `default_servers` means every *shared* admin server (Spec 028 FR-016 "all non-quarantined" is applied afterwards by the surfaces as today). It never means private servers. A stored token grant `["*"]` is materialised to the entitlement set on every authentication (106 US2.4). +- **Unknown names in the map.** A name that matches no configured server is inert, reported once per load as a warning and as a `mcpproxy doctor` finding, never a validation failure (servers may be added later); validation refuses names that are not valid server names. **Matching is exact and case-sensitive on the bare name** β€” the comparison `CanAccessServer` and every MCP surface perform. The four by-name tenant doors that compare with `strings.EqualFold` today (`user_handlers.go:488,529,603,654`) converge onto the exact comparison when they collapse onto the entitlement predicate (FR-004); a name differing only in case is thereafter "absent" (non-disclosing). Tenant-only; no administrator surface changes. +- **Round-trip of `default_servers`.** Because absent, `null` and `[]` all mean "no default grant", a `PATCH /api/v1/config` round-trip that drops an empty slice (the nil-vs-`[]` trap `config_hotreload.go:380-386` documents) cannot change semantics. +- **Hot reload ordering.** The entitlement set is evaluated per authentication from the live configuration, so an un-shared or un-mapped server disappears from the next request and the next SSE frame; an in-flight call keeps the authorization it was dispatched with (106 Assumptions). +- **Groups claim shapes.** Entra emits group object ids (GUIDs) and omits the claim above ~200 groups (overage β†’ `[]`, documented with Entra's app-role alternative); Okta emits names (needs the `groups` scope + claim); Auth0 needs a namespaced custom claim (`groups_claim: "https://example.com/groups"`); Keycloak needs a group-membership mapper and emits `/path/names` unless the mapper strips the slash; Authentik ships `groups` in the profile scope. The proxy compares strings exactly; the docs carry this per-IdP table. +- **Issuer with a path or trailing slash.** Compared byte-for-byte with the discovery document's `issuer` (OIDC Discovery Β§4.3); a mismatch refuses the login (`discovery_failed`) and the log line names both values (they are not secrets) with the fix. +- **Discovery, JWKS rotation and outage.** Discovery is fetched lazily on first use, cached with a bounded TTL (respecting cache headers, minimum 5 min, maximum 24 h) and re-fetched on error or expiry; an unknown `kid` triggers exactly one JWKS refetch per login attempt; an unreachable issuer, JWKS, token endpoint or userinfo endpoint at login time refuses that login with 503 + request id (`provider_error`/`discovery_failed`) and never falls back to unverified parsing or to "groups absent"; readiness never depends on the IdP. +- **Client authentication (`oidc`).** An authorization code is single-use, so there is **no retry with a second method**: the method is chosen *before* the one exchange from the discovery document's `token_endpoint_auth_methods_supported` β€” `client_secret_basic` when advertised (also the OIDC Discovery Β§3 default when the key is absent), else `client_secret_post` when advertised, else `discovery_failed`; the legacy providers keep `client_secret_post` as today (`oauth_providers.go:163-189`). PKCE S256 always; `nonce` always; `openid` added to `scopes` if missing. The fake IdP matrix covers both methods and an absent key. +- **No email from the IdP.** Login fails as today (`oauth_handler.go:226`; reason `email_missing`); `preferred_username`/`sub` are not substituted (Out of Scope). +- **Pending login state** is in-process with a 10-minute TTL (`stateMaxAge`, `oauth_handler.go:347-357`) but today **unbounded** β€” `HandleLogin` inserts without a cap (`:120-125`), so unauthenticated `GET /auth/login` hits grow the map until the sweep. FR-021 bounds it (10,000 entries, oldest evicted, tested at 10,001 logins); a second replica is unsupported (single-replica contract, documented). Ingress-level rate limiting of the login endpoint stays deferred (Out of Scope) β€” the cap bounds memory, not attempts. +- **Session principal on write doors.** A tenant principal has no permission tier, but "no tier" refuses nothing on a route that never asks for one, so the boundary is the FR-002 **allowlist** (default deny), not the per-door gates: any door that checks `HasPermission`, `AuthorizeServerOp` or `requireAdminRead` would refuse it with the same shape as an agent token lacking that capability, and the allowlist refuses it first, with the same 403 body. `POST /api/v1/tools/call`, `/code/exec` and `/tool-calls/{id}/replay` are refused for every tool name, built-ins (`read_cache`) included β€” the built-in switch in `CallToolDirect` has no tier gate of its own β€” so a tenant session never produces or redeems a cache entry (the `CallerKindUser` gate at `internal/cache/authorization.go:103-104` is therefore never exercised β€” pinned by test). Personal-edition `/api/v1/tokens` routes refuse tenant sessions (allowlist) and admit administrator sessions (they key on `requireAdminRead`). +- **CSRF posture.** `SameSite=Lax` (`session_store.go:65`) is the shipped boundary for every cookie-authenticated mutation the server-edition group already accepts (`POST /user/tokens`, `/admin/*`); the core REST doors follow the same rule. The exposure is bounded because tenant sessions cannot mutate anything on core REST, and administrator sessions never receive raw secrets (FR-002). The cookie is never read from `?apikey=` or any query string. Cross-site `POST` with the cookie is a fixture (SC-004). A stricter CSRF token is listed in Out of Scope with its consequence. +- **Type-keyed MCP gates.** `directEntryInScope`, `promptServerAllowed`, `profilePinFromContext`, `mcp_describe_direct.go:98`, `mcp_direct_callability.go:55,106` and the producer snapshot `cache_authz.go:37` key on `Type == AuthTypeAgent` and would treat a user context as unrestricted. They are untouched because FR-003 guarantees no user context reaches `/mcp` and FR-043(a) pins that with a test; they must be unified on `IsScopedCaller` before any user principal is ever admitted to `/mcp` (Out of Scope). The one Type-keyed site a user principal *does* reach β€” the SSE refresher (`internal/httpapi/sse_scope.go:20`) β€” is new code under FR-005. +- **Web UI edition probe.** `GET /api/v1/auth/provider` is the side-effect-free probe: `/api/v1/auth/login` would allocate a `pendingStates` entry per page load (`oauth_handler.go:120-125`) and 302 to the IdP, and `/api/v1/status` is 401 without a credential in both editions. The public login redirect already discloses the IdP host to anyone; an operator-chosen `display_name` discloses strictly less. +- **Audit line and the async detector.** Detection is asynchronous; the line carries no detection verdict and no constant placeholder; the activity record keeps the verdict and the two are joined on `request_id`. A second `detection` line is out of scope. +- **Audit line and request ids.** MCP request ids are locally minted (`mcp.go:835-848`; no request-id middleware on `/mcp`, `server.go:2671-2705`); REST-originated calls additionally carry `transport_request_id` from `X-Request-Id`; the MCP `session_id` lets ingress logs correlate through session and timestamp. +- **Audit line and built-in tools.** Built-in invocations (`upstream_servers`, `quarantine_security`, `retrieve_tools`, `read_cache`, …) are not audited in v1 (FR-012 scope; Out of Scope row). Their activity rows are unchanged β€” including the deliberate identity strip on the two mutating ones (`mcp_activity_args.go:97`); those two are administrator-only by `AuthorizeServerOp`, so the attribution lost is "which administrator", which the activity log did not carry before either. +- **Audit line for administrators and anonymous callers.** API key, socket and (personal-edition) anonymous callers all carry an admin-typed context; `caller.kind` is derived from `transport.ConnectionSource` and the `Anonymous` bit, which today's activity metadata does not emit. +- **Limiter shed.** Admission runs inside the managed client after every server-side gate (`managed/client.go:815-836`), so a shed call already has its `authz allow` line; the shed is the call's `tool_call` line with `outcome: rejected` and `reason: limiter_queue_full|limiter_queue_timeout` (the distinction `event_bus.go:562` already carries), written by the completion path from the returned `*limiter.LimitError` β€” not from the `RecordToolCallRejected` observer, which has no request context (FR-012). +- **Long calls and crashes.** The `authz` line is written before the upstream call, so a process killed mid-call still leaves the record that a (possibly destructive) call was authorized; the `tool_call` line is lost for that call β€” documented. +- **`origin` is reserved** with values `local|socket|remote`; this spec emits `local` (TCP) and `socket` (tray); `remote` is reserved for Spec 089 FR-010 so the schema does not churn. +- **Docker/distroless.** `audit_log.stdout` writes raw JSON lines to standard output, never through the coloured console encoder (`logger.go:201-206`); `mcpproxy version`/`doctor` in the image need `--entrypoint`; stdio upstreams are unavailable in the distroless image (unchanged). +- **`trusted_hosts` in the container.** Host validation wraps only `/mcp` and is inactive on a non-loopback listener; the docs must not tell container operators to rely on it for the SSO door β€” `public_url` and `trusted_proxies` are the controls. +- **Retained effects from Spec 105** (shared limiter, cross-server scan admission, prompt-refresh deadline, shared log rotation) are excluded from this spec's differential oracle exactly as in 105 SC-001. +- **Personal-build config integrity.** The personal binary unmarshals `server_edition` into `struct{}` and writes back `{}` (`server_edition_config_stub.go:6`; `cmd/mcpproxy/main.go:608-612`; critic G3), which defeats the legacy `teams` fallback and silently disables SSO on the next server-edition boot; the same `struct{}` stub erases every server's `auth_broker` block (`auth_broker_stub.go:5-13`); FR-040 makes the personal build round-trip both blocks semantically intact (raw JSON carrier; key/value equality, not source bytes). +- **Config PATCH.** `Config.Validate`/`ValidateDetailed` reach a **non-mutating** `ServerEditionConfig.Validate` under the server build (critic G4) so `PATCH /api/v1/config` rejects an invalid block instead of persisting it for the next restart; defaults and the `MCPPROXY_CRED_KEY` fallback move to a separate apply step at setup. +- **Frozen tool-surface goldens** (Spec 105 FR-012) pass unregenerated: no built-in MCP tool gains an argument for groups or audit. + +## Requirements *(mandatory)* + +### Functional Requirements + +**A. Tenant principal and effective authorization (closes critic G1; empty-set dual semantics)** + +- **FR-001** *(tenant principal on core REST via an edition hook; binding credential precedence)*: `apiKeyAuthMiddleware` (`internal/httpapi/server.go:452-533`) MUST resolve the request's credential in exactly this order, and the **first source that is present is the only one evaluated** β€” a presented credential that fails is terminal (401), never a fallback to the next source: (1) the local socket; (2) `X-API-Key`; (3) `Authorization: Bearer` β€” an `mcp_agt_` value is an agent token, otherwise the value is compared with the global API key and, on mismatch, handed to the edition hook **as a user JWT only** (HS256-valid, live user, not disabled β€” never as a reason to read the cookie); (4) `?apikey=`; (5) **only when none of (2)–(4) is present**, the `mcpproxy_session` cookie through the same hook. The hook (`SetSessionPrincipalResolver`, installed by server-edition setup beside the scope resolver; nil in the personal build) reuses the server-edition middleware's user lookup and live role derivation (`middleware.go:138-214`, `:228-246`) β€” one resolver, two mount points β€” but **not** that middleware's own ordering, which tries the cookie before the bearer (`middleware.go:88-108`); the core mount point evaluates one source only. Fixtures: wrong `X-API-Key` + valid cookie β†’ 401; wrong bearer + valid cookie β†’ 401; wrong `?apikey=` + valid cookie β†’ 401; valid bearer JWT + valid cookie β†’ the bearer's principal; `X-API-Key` + bearer both present β†’ `X-API-Key` decides. **Invariant closed**: a tenant never needs, and is never handed, the operator's global key to use the product, and no failed explicit credential is rescued by a cookie. +- **FR-002** *(principal shape; tenant-session allowlist)*: A tenant MUST become a `UserContext` with `AllowedServers` = the entitlement set at authentication time (materialised, never `nil`, never `"*"`), no permissions, no pin; an administrator-email user MUST become an `AdminUserContext` (already `IsAdmin()`). Session principals of either kind MUST NOT satisfy `CanRevealSecrets` (raw credential reveal stays API-key/socket-only); every other administrator capability is unchanged for `admin_user` sessions. Read doors filter through `CanEnumerateServer`/`scopeAllowedServers`; write and administrative doors that already carry `AuthorizeServerOp`/`requireAdminRead` (both key on `!IsAdmin()`) refuse with no code change. **But the core group is not gated door by door**: session admission happens for the whole `/api/v1` group (`httpapi/server.go:749-755`), and several routes have no scope, tier or admin gate at all β€” the dispatch doors `POST /tools/call` (`:911`; its handler maps dispatch errors to 500, `:5538-5566`, and `CallToolDirect` routes the built-ins `read_cache`, `retrieve_tools`, `doctor`, `list_registries`, `search_servers`, `upstream_servers`, `quarantine_security`, `code_execution` with no permission-tier check, `mcp.go:6161-6187`), `POST /code/exec` (`:920`, `code_exec.go:86-182`, no outer gate) and `POST /tool-calls/{id}/replay` (`:904`, `:4983-4994` β€” the scoped-caller check only decides *visibility* and then dispatches); the ungated mutations `POST /registries/{id}/refresh` (`:945`), `POST /telemetry/update-failure` (`:896`), `POST /onboarding/mark` (`:986`), `POST /feedback` (`:971`); and the ungated disclosures `GET /code/scripts` (`:924`, returns the scripts directory path), `GET /diagnostics`/`/doctor` (`:882-883`; only the per-server entries are scope-filtered, `management/diagnostics.go:94-97`, the deprecated-config, Docker and runtime-warning sections are global, `:193-218`), `GET /telemetry/payload`, `GET /connect*`, `GET /onboarding/state`, `GET /annotations/coverage`. Therefore the core group MUST gain a **tenant-session allowlist**, evaluated in the middleware immediately after FR-001 installs a `user`-typed principal and before any handler parses a body: exactly the *core reads* row of the FR-045 matrix (`GET /status`, `/servers`, `/servers/{id}/**` reads **except** `/servers/{id}/tool-calls`, `/tools`, `/index/search`, `POST /preflight`, `/profiles` reads, and `GET|HEAD /events`, the one route mounted outside the group behind the same middleware, `:1016-1017`) passes through to the existing scope predicates β€” with one door that has no adequate predicate today and MUST gain a **tenant projection**: `GET /api/v1/profiles` filters each profile's server list for a scoped caller but still lists **every profile's name** (`internal/httpapi/profiles.go:61-85`), so a profile whose servers are all hidden appears with an empty list, and `GET /api/v1/profiles/active` returns the global active slug without consulting the caller at all (`:104-108`); for a `user`-typed principal the list MUST omit every profile whose effective server set has an empty intersection with the entitlement set (the listed ones keep filtered servers and counts, as today), and `/profiles/active` MUST answer `""` when the global active profile is one the tenant would not be shown β€” so a hidden-only profile's name, count and active status are invisible (Spec 105 FR-003: no non-selectable profile is ever named); `PUT /profiles/active` is a mutation (403 by the allowlist). The agent-token behaviour on these two routes is unchanged (Out of Scope, with the consequence). **Every other method+route behind `apiKeyAuthMiddleware`, including any route added later**, answers a `user`-typed principal with the fixed 403 scoped-caller body regardless of tool name, body or path parameter (default deny β€” a future ungated route is closed, not open). **History reads are not on the allowlist**: core `/activity*`, `/tool-calls`, `/tool-calls/{id}` and `/servers/{id}/tool-calls` are scoped by *server only* β€” `applyActivityScope` sets `AllowedServers` and `ActivityFilter.Matches` tests nothing but the server (`internal/httpapi/activity.go:147-150`, `internal/storage/activity_models.go:385-390`), `/tool-calls` passes a server-only `ToolCallScope` (`server.go:4762-4778`) and `ToolCallRecord` has no owner field at all (`internal/contracts/types.go:941-959`) β€” so a tenant session on them would read every caller's arguments and responses on a shared entitled server (Bob's and the operator's calls to `a`), a cross-tenant disclosure. A tenant reads history through **`GET /api/v1/user/activity` only**, which today is wired with a `nil` filter and returns an empty list (`setup.go:196-198`, `user_activity.go:100-105`): it MUST be wired to the storage manager through the existing `multiuser.ActivityFilter` (`multiuser/activity.go:34-66`, retained by FR-031) so that a tenant receives exactly the records with `UserID == principal.UserID` **and** `ServerName` in the entitlement set (both terms inside the storage filter, so `total` counts only what the page may contain), with the core masking applied before serialisation and no `include_bodies` export; an `admin_user` session is unchanged there. Tool-call history (`/tool-calls*`) stays administrator-only for sessions until `ToolCallRecord` carries trustworthy owner attribution (Out of Scope). API-key, socket, `admin_user`-session and agent-token behaviour on every route is unchanged (a tenant-owned agent token keeps today's server-scoped `/activity*` and `/tool-calls*` reads β€” Spec 105/106 state, named in Out of Scope with its consequence); tenants get their own diagnostics from `/user/diagnostics`. **Invariant closed**: every REST scope door has a populated set to filter on; no door falls open for a user type; no cookie-authenticated raw-secret reveal; no tenant session dispatches, replays, executes or redeems anything through REST, no tenant session reaches a global-state disclosure or mutation, and no tenant session reads another caller's arguments or responses. +- **FR-003** *(`/mcp` credential set unchanged)*: `/mcp*` MUST continue to accept exactly agent tokens, the global API key and the socket; a session cookie or user JWT on any MCP surface MUST be a 401 (FR-029 forces MCP authentication under the server edition). The only path from an SSO identity to a tool call remains an owned agent token. **Invariant closed**: no second effective-authorization path outside the owner gate and scope resolver; Spec 105/106 parity by construction. The stale sentences "JWT bearer token for MCP" (`docs/development/server-edition-multiuser-auth.md:40,53`) MUST be corrected. +- **FR-004** *(one entitlement predicate)*: Every tenant-facing decision that answers "may this user see, use, mint against, connect to or diagnose server *N*" MUST be computed by `entitledServerNames` (or a function of it) β€” including the eight `Shared`-test sites (the seven door-local re-implementations `user_handlers.go:345,488,529,603,654`, `user_activity.go:153`, `credential_handlers.go:352`, plus the predicate's own `:842` term, which becomes the **only** place `ServerConfig.Shared` is read for a tenant decision) and the tenant principal's `AllowedServers` (FR-002) β€” computed once per request. **Administrator projection unchanged**: for an `admin_user` the `/user/servers` list/get/update/delete/enable, `/user/credentials*` and `/user/diagnostics` doors render the shared (+ personal) projection they render today (`user_handlers.go:343-352`, `credential_handlers.go:341-372`, `user_activity.go:145-163` all select on `Shared` regardless of role) β€” collapsing them onto the predicate MUST NOT widen an administrator's view of those doors to the whole configuration (the predicate's `|| isAdmin` branch, `:842`, keeps feeding mint/rotate and the literal `*` only, as today; `/admin/servers` is the whole-config surface); SC-006 and US1.8 hold. Today an agent-token authentication reads the user record **twice**: the owner gate returns only `(active bool, error)` (`setup.go:58-69`) and the scope resolver loads the same record again (`setup.go:73-92`; storage calls both in sequence, `agent_tokens.go:917-939`). The two callbacks MUST collapse into **one owner resolution per authentication** returning active state, identity (id, email, provider, live role) and the entitlement set β€” the storage manager keeps its fail-closed handling (store error or missing owner β†’ `ErrAgentTokenOwnerInactive`; entitlement error β†’ `ErrAgentTokenScopeUnavailable`) β€” and that result feeds both the intersection and the ephemeral identity fields FR-013 needs β€” the resolver loads the user record **once** and derives the entitlement set from that loaded record (`entitledServerNamesFor(user, isAdmin)`, the core the `userID`-taking wrapper calls after its own single load), so adding the stored-groups term (FR-009) does not reintroduce a second `GetUser`; and the misleading "deliberately the SAME predicate" comment at `user_handlers.go:776-783` corrected (critic C3). By-name doors answer for an unentitled name exactly as for an absent one; list doors omit it. A guard test MUST fail if `ServerConfig.Shared` is read anywhere in the tenant-facing handlers (`internal/serveredition/api`) **outside** the predicate (`entitledServerNamesFor`, the core that takes the loaded user record, and its `entitledServerNames(userID)` wrapper) and its dedicated administrator-projection helper β€” the predicate itself is the one permitted reader. **Invariant closed**: REST listing, diagnostics, minting and MCP scope can no longer disagree. +- **FR-005** *(session principal re-resolved per SSE frame)*: The SSE per-frame refresher (`internal/httpapi/sse_scope.go:17-43`), which today re-validates only agent tokens and returns the base context for every other type (`:20`), MUST re-resolve a session principal through the FR-001 hook before each frame, so un-sharing or un-mapping narrows the next frame (Spec 106 US2.6 shape) and a disabled user's stream ends. This is new code, not inheritance. The cache read gate for user principals (`cache/authorization.go:103-104`) is not exercised (Edge Cases) and is pinned by test rather than changed. +- **FR-006** *(empty set is deny-all everywhere)*: An empty effective allowed-server set for any non-administrator MUST be deny-all on every consumer. `intersectAllowedServers` (`agent_tokens.go:706-739`) MUST return a non-nil empty slice on an empty result and on empty `proposed`; the REST preflight input (`internal/httpapi/preflight.go:139-146`) MUST carry an explicit *restricted* marker for every non-administrator context so that `ResolveScope`'s "empty means unrestricted" rule (`internal/preflight/scope.go:150-162`) applies only to genuinely unrestricted callers; an unentitled tenant's token evaluates every id as out of scope on `/api/v1/preflight` exactly as on `/mcp`. Fixture: US1.5. **Invariant closed**: the empty-intersection trap β€” an unentitled token is never "unrestricted" anywhere. + +**B. IdP-group β†’ server grants (F2)** + +- **FR-007** *(configuration)*: `server_edition.access` MUST be an optional block `{group_servers: {: [|"*", …]}, default_servers: [|"*", …]}` (Definitions: absent/`null`/`[]` `default_servers` = no default grant; block absent = today's semantics; block present with an empty map = every tenant gets only the default grant β€” fail closed). Validation MUST refuse a non-empty `group_servers` unless `oauth.provider` is `oidc` (the only provider that yields groups in this spec), refuse names that are not valid server names, and warn (never fail) on names that match no configured server. The block MUST be hot-reloadable and reported by `DetectConfigChanges` (FR-039); it lives under `server_edition`, never on `ServerConfig` (which would trip the `serverfields_serveredition` mask canary, `TestSaveServerSyncFieldCoverage` and the swagger/contracts generators). **Invariant closed**: a misconfigured map fails at write or boot, never silently at login; deleting mappings can only narrow. +- **FR-008** *(groups captured and stored)*: On every successful login through the `oidc` provider the user record MUST store the groups claim values read from a *verified ID token*, or from `userinfo_endpoint` over the back channel when the token lacks the claim (accepted only when the userinfo `sub` exactly equals the verified ID token's `sub`; otherwise `userinfo_subject_mismatch`, login refused, store untouched β€” FR-022), replacing the previous list wholesale (including with `[]`), with a `groups_updated_at` timestamp; absence in both, a non-array/non-string shape, or an overage marker MUST store `[]` (fail closed) and log `groups_claim_missing` with the claim name and set the same flag on the attempt's `auth_event` line. Legacy providers store `[]`. `users.User` gains `groups []string`, `groups_updated_at` and `subject_rebind_armed_at` (FR-023); `User.Validate` MUST admit `oidc` and MUST continue to require a non-empty subject (critic G5). `GET /api/v1/auth/me` MUST return the caller's own groups; `GET /api/v1/admin/users` MUST include each user's groups and `groups_updated_at` (`AdminUsers.vue` shows them). +- **FR-009** *(entitlement composition)*: The entitlement set (Definitions) MUST be evaluated on every authentication from the live configuration and the stored user record β€” inside `entitledServerNames` at the `:842` term, for non-administrators only β€” so that mint (`resolveTokenServerScope` `:907-960`), rotate (`regenerateUserToken` `:1255-1300`), per-authentication narrowing (`NarrowTokenServerScope` via `setup.go:73-92` β†’ `agent_tokens.go:929-938`), the tenant principal (FR-002) and the SSE refresher (FR-005) all agree without a second predicate; tests assert each path separately with the two-fixture oracle. Composition is narrow-only: the group grant intersects with sharing; a token's stored grant intersects with the entitlement; the profile scope intersects afterwards (`serverInScope`, `mcp_visibility.go:157-166`, unchanged), so the effective scope on every MCP surface is `group ∩ token ∩ profile`. Administrator-email users are exempt and keep the literal `"*"` (`:941-943`, `:1009-1011`). **Invariant closed**: group exclusion is a term of `AllowedServers`, so every Spec 105 surface, 106's per-request narrowing and 028's wildcard rule apply to it unchanged. +- **FR-010** *(non-disclosing group exclusion)*: A group-excluded server MUST be indistinguishable from a nonexistent server on every surface (Definitions), with exactly one documented, temporary exception: the Spec 105 items still open on `main` (Assumptions β€” `retrieve_tools` `usage_summary`/`session_risk`, the "Available servers" error text and the scope-denial text) disclose a group-excluded server exactly as they disclose a token-excluded one today, because group exclusion is a term of the same `AllowedServers` set those surfaces already filter on; this spec adds nothing to that leak and widens no surface, its fixtures for those named surfaces assert the then-current documented outcome and flip to two-fixture equality when the corresponding Spec 105 item merges, and FR-046 and the deploy guide name them to operators. Everywhere else, no status, body, header, reason string, availability suggestion, count, SSE frame, diagnostics entry or caller-visible log line may reveal that a server exists but is not granted; mint-time refusals reuse `server %q is not available to you` (`user_handlers.go:951-955`); the closed reason on the caller-invisible audit line is `token_scope` (the term that fired). Group names appear in caller-facing output only in the caller's own `/auth/me` and in administrator user listings. **Invariant closed**: no new existence oracle; the fixture pair of US1 is the proof. +- **FR-011** *(freshness: a real bound, documented)*: Groups refresh only at login. Today the bound is **unbounded**: `POST /api/v1/auth/token` accepts any `IsUser()` principal β€” including a bearer JWT β€” and mints a fresh full-TTL JWT (`auth_endpoints.go:105-153`; the middleware comment at `middleware.go:201-205` names the indefinite renewal), so a JWT can renew itself forever and keep minting 30-day agent tokens without the user ever re-authenticating. The same chain exists one hop further: the server-edition middleware admits a bearer JWT on every `/user/*` route (`middleware.go:100-108`, `:163ff`; mounted at `setup.go:209-215`) and the mint and rotate doors require only `IsUser()` (`createUserToken` `user_handlers.go:1071-1076`, `getUserID` `:1335-1340`), so a JWT minted in the session's last second could mint a 30-day agent token in *its* last second β€” a serial bound of session TTL **+** JWT TTL **+** token TTL, and the user mint door parses `expires_in` with a bare `time.ParseDuration` (`:1120-1126`) with **no upper bound**, unlike core `/api/v1/tokens`, which caps at 365 days (`parseExpiry`, `httpapi/tokens.go:456ff`). Therefore the three **credential-minting doors** β€” `POST /api/v1/auth/token`, `POST /api/v1/user/tokens` and `POST /api/v1/user/tokens/{name}/regenerate` β€” MUST accept only a **session-cookie** principal (the Web UI calls all three with the cookie, `frontend/src/services/auth-api.ts:34`, `services/api.ts:1169,1185`; a bearer JWT or agent token β†’ 401 β€” a derived credential never mints another credential), `POST /user/tokens` MUST validate `expires_in` with the core `parseExpiry` rule (positive, at most 365 days), an owned token always carries an expiry (the door always sets one, default 30 days, `:1128`; `IsExpired` treats a zero `ExpiresAt` as never-expiring, `agent_token.go:75-80`, which only ownerless operator tokens can hold β€” pinned by a test), and sessions keep their fixed, non-sliding TTL (`session_store.go:44,62`). List and revoke on `/user/tokens` keep accepting a bearer JWT (they extend nothing). The bound is then **session TTL + the longer of JWT TTL and the longest-lived owned agent token (≀ 365 days)** (a JWT or token minted in the session's last second lives its own TTL), because the scope resolver reads stored `User.Groups` on every token authentication with no session check. Documentation MUST state that bound, and that an administrator `disable` takes effect on the next request through the existing owner gate (`setup.go:58-69`). Fixtures: a JWT presented to each of the three doors is refused; a chained session β†’ JWT β†’ agent-token clock test proves access cannot extend past the bound; `expires_in: "9000h"` on `/user/tokens` is 400. No IdP re-query is built. + +**C. Attributable JSONL audit line (F3, edition-neutral)** + +- **FR-012** *(synchronous lines at the funnels; scope = upstream tool dispatch)*: F3 audits **upstream tool dispatch** β€” every path that resolves a `(server, tool)` pair and decides whether to call it: the `call_tool_*` variants, direct-name dispatch, nested script sub-calls, and the REST `/tools/call` / `/code/exec` / replay paths when they reach such a pair. Invocations of built-in tools as such (`retrieve_tools`, `describe_tool`, `read_cache`, `set_profile`, `upstream_servers`, `quarantine_security`, `list_registries`, `search_servers`, `doctor`, and the `code_execution` wrapper itself) produce **no** line in v1 β€” they have no canonical server and their activity rows stay as today (Out of Scope, with the consequence stated). The funnels `emitActivityPolicyDecision` (`mcp.go:787-797`) and `emitActivityToolCallCompleted` (`:766`) MUST take the request `context.Context` as their **first** parameter; that shifts `status` from argument index 5 to 6 in `emitActivityToolCallCompleted`, so the `statusArgIndex` pin in `TestActivityCompletionNeverHardcodesSuccess` (`activity_result_status_test.go:120-124`) MUST be updated to 6 in the same change β€” the guard's semantic (no call site hard-codes `"success"`) is retained, only its index changes β€” and the comment at `mcp.go:762-765` ("new parameters have to go after it") MUST be corrected to say the context precedes it. Because the policy funnel today receives no arguments, tier or surface (`mcp.go:772-795`) and the dispatch paths mint their activity id as a local (`mcp.go:2190-2197`; `mcp_code_execution.go:691-704`) that never reaches the context handed to the managed client (`mcp.go:2503-2508`), each dispatch path MUST establish an immutable **audit attempt** record on the context **before its first gate** β€” activity request id, transport request id, `parent_id`, canonical target, tier, surface, and the stripped arguments' `args_sha256`/`args_bytes` β€” that every gate, both funnels, the nested observer and the completion path read from; the line is never assembled from a gate's local variables. On REST-originated direct dispatch the activity id **is** the transport id (`mcp_routing.go:412-414` prefers the transport-supplied id), so `request_id` and `transport_request_id` carry the same value there; on `/mcp` (no request-id middleware) only `request_id` exists. When the sink is enabled, lines are written synchronously before the funnel returns β€” never through the event bus. Coverage: every `call_tool_*` gate and completion (`mcp.go:2211-2425, 2912-2934` β€” anchors shift after PR #1279), direct-name dispatch (`mcp_routing.go:412-469`), post-dispatch output-sanitisation and output-schema blocks (`output_sanitisation.go:95ff`, `mcp.go:2689`), nested script sub-calls (`mcp_code_execution.go:869,927`, with `parent_id`), and the REST direct call path (same handler). **Limiter sheds are not authorization decisions**: admission runs inside `managed.Client.CallTool` *after* every server-side gate and immediately before the invoker (`internal/upstream/managed/client.go:815-836`; `acquireAdmission` reports only failures, `admission.go:52-74`, and its `Rejection` carries no request context or arguments, `limiter/observer.go:15-37`), so the `authz allow` line has already been written when a shed happens; a shed is therefore recorded on the **`tool_call`** line the completion path writes when the typed `*limiter.LimitError` returns to it (`outcome: rejected`, `reason: limiter_queue_full|limiter_queue_timeout`), never as a second `authz` line and never from the `RecordToolCallRejected` observer tap (`event_bus.go:530-583`, which stays an activity-only path). **Nested script refusals need their own tap**: scope and permission denials inside a script are returned by `jsruntime.(*ExecutionContext).checkDispatchGates` (`internal/jsruntime/runtime.go:384-418`) on both the single and the batch path *before* `upstreamToolCaller.CallTool` runs, so the completion emitters in `mcp_code_execution.go` are never reached for them; `ExecutionContext` MUST gain an authorization-decision observer that `checkDispatchGates` invokes with the request context, `parent_id`, the canonical target, the tier and the stripped arguments, and a refusal it reports is not reported again downstream. **Phases (binding)**: every pre-dispatch decision produces exactly one `authz` line β€” `decision: allow` after the last gate passes and before the upstream call, or `decision: deny` at the refusing gate β€” and a pre-dispatch denial produces no `tool_call` line; every call whose `authz` line said `allow` produces exactly one `tool_call` line at completion β€” whether it reached the upstream (`success`/`error`), was shed by the limiter before the invoker (`rejected`), or was blocked **post-dispatch** by output sanitisation or output schema (`outcome: blocked`, `reason: output_sanitisation|output_schema`) β€” never as a second `authz` line, because the call was authorized. So the count invariant is: `authz` lines = pre-dispatch decisions; `tool_call` lines = `authz allow` lines. There is never a "started" line. Identity MUST come from `auth.AuthContextFromContext(ctx)` and `transport.GetConnectionSource(ctx)` β€” never from arguments or `_auth_*` metadata (the forgery guard at `mcp_activity_args.go:87-98` covers activity rows only) and never from a caller-supplied header: `reqcontext.GetRequestSource(ctx)` is **not** audit provenance, because on REST it is rewritten from the unauthenticated `X-MCPProxy-Client` header (`concurrency_shed.go:12-22`), so the line's `source` is `mcp` on the MCP surfaces, `api` on every REST route and `internal` for proxy-originated calls, derived from the mount point, not the header (FR-013). **Invariant closed**: attribution cannot be forged by a caller and cannot be dropped under load. +- **FR-013** *(schema v1, closed vocabularies)*: Each line is one JSON object with `schema_version: 1` and exactly these keys (absent optional keys omitted, never null-padded): `ts` (RFC 3339, UTC `Z`, fixed nine fractional digits β€” Go layout `2006-01-02T15:04:05.000000000Z`, not `RFC3339Nano`, which trims trailing zeros), `event` (`authz`|`tool_call`|`auth_event`), `request_id` (the activity request id), `transport_request_id` (REST only; equals `request_id` on REST-originated direct dispatch, FR-012), `parent_id`, `session_id`, `work_session_id`, `origin` (`local`|`socket`|`remote`), `source` (`mcp`|`api`|`internal` β€” **`api` for every REST-originated line**: the activity log's `cli` classification comes from the caller-asserted `X-MCPProxy-Client` header (`internal/httpapi/concurrency_shed.go:12-22` maps any `cli/` prefix to `SourceCLI`), which any REST client can set, so it is never audit provenance; the asserted value rides in `client.name`, documented as caller-supplied and untrusted), `surface` (`call_tool_read`|`call_tool_write`|`call_tool_destructive`|`direct`|`code_execution`|`rest`), `caller` {`kind` (`api_key`|`socket`|`stdio`|`anonymous`|`agent_token`|`session_user`|`session_admin`|`internal` β€” `session_user` can occur only on `auth_event` lines, because no tenant-session dispatch door exists, FR-002/FR-003; `stdio` is the native stdio transport, which installs an administrator context with no listener β€” it is tagged as its own connection source so it is never mistaken for an API-key caller), `user_id`, `user_email`, `email_hash`, `role`, `provider`, `token_name`, `token_prefix`, `profile_pin`}, `client` {`name`, `version`, `ip`}, `profile`, `server` (canonical), `tool` (raw upstream name β€” the exact pair Spec 105 FR-009 dispatches), `operation` (`read`|`write`|`destructive`|`unknown`), `decision` (`allow`|`deny`; `authz` only), `reason` β€” **event-specific vocabularies, not the shared telemetry enum**: on `authz` lines `none` (with `decision: allow`) or a **pre-dispatch** gate β€” `intent_invalid|intent_rejected|profile_scope|token_scope|token_permission|server_quarantined|tool_pending_approval|tool_changed_approval|tool_not_callable|other` (the `telemetry.BlockReason*` enum, `preflight_counters.go:37-73`, **minus** `output_sanitisation|output_schema`, which are post-dispatch and can never be an `authz` reason under the FR-012 phase model); on `tool_call` lines only `output_sanitisation|output_schema` (with `outcome: blocked`) or `limiter_queue_full|limiter_queue_timeout` (with `outcome: rejected`), otherwise omitted; for `auth_event` the **terminal result** of the attempt: `ok|logout|authorization_denied|id_token_invalid|nonce_mismatch|audience_mismatch|issuer_mismatch|token_expired|email_missing|email_unverified|domain_not_allowed|subject_mismatch|userinfo_subject_mismatch|user_disabled|state_invalid|provider_error|discovery_failed|internal_error` β€” `authorization_denied` is an IdP authorization-response error (`error=access_denied` or any other RFC 6749 Β§4.1.2.1 code) arriving on the callback with a valid `state`: the state is consumed first, the generic 403 page is rendered (FR-024), and the IdP's `error`/`error_description` reach only the server log β€” `internal_error` is the proxy-side terminal failure after the identity was verified: user upsert, JWT generation or session creation/persistence (`oauth_handler.go:242-247`, `:260-270`, `:276-289`), each a distinct terminal branch today; every terminal branch of the callback maps to exactly one reason, and the FR-024 fixture set includes fault injection on the user store, the JWT signer and the session store), `disclosed` (false when the refusal was non-disclosing to the caller), `flags` (`auth_event` only: a closed array `provider_rebound|redirect_rejected|groups_claim_missing`, omitted when empty β€” the non-terminal facts of one attempt, so `reason` stays singular), `outcome` (`success`|`error`|`blocked`|`rejected`; **`tool_call` lines only** β€” forbidden on `authz` and `auth_event` lines, a pre-dispatch denial is expressed by `decision: deny` alone; on a `tool_call` line `blocked` is accompanied by `reason: output_sanitisation|output_schema` and `rejected` by `reason: limiter_queue_full|limiter_queue_timeout` β€” the only reasons a `tool_call` line carries), `error_class` (`upstream_error`|`upstream_timeout`|`upstream_unavailable`|`validation`|`sanitisation`|`internal`|`cancelled`), `duration_ms`, `request_bytes`, `response_bytes` (these four, like `outcome`, are **`tool_call` lines only** β€” forbidden on `authz` and `auth_event` lines, and the per-`event` schema table says so), `args_sha256`, `args_bytes`. `client.ip` is `RemoteAddr` unless the peer is a trusted proxy (FR-027). `caller.user_email`, `caller.provider` and `caller.role` for an owned agent token come from the **single owner resolution** of FR-004 at authentication and are placed on the validated token's `AuthContext` ephemerally β€” storage stamps them on three **non-persisted** fields of the validated `*auth.AgentToken` value (`OwnerEmail`, `OwnerProvider`, `OwnerRole`, tagged `json:"-"` so the BBolt record never carries them) and `AgentToken.AuthContext()` copies them into the `Email`/`Provider`/`Role` fields that already exist on `AuthContext` (`context.go:22-27`; today the constructor copies only `UserID`, `agent_token.go:54-72`, and every caller β€” `httpapi/server.go:580`, `server/server.go:427`, `sse_scope.go:43` β€” builds the context from the returned token, so the token value is the only carrier storage can stamp) β€” never persisted on the token record, no per-call store lookup. Identity on an `auth_event` line is **stage-dependent, not reason-dependent**: `caller.user_id` when the attempt reached the user store and a record exists (`ok`, `subject_mismatch`, `user_disabled`, `internal_error`, `logout`); otherwise `email_hash` **only when a verified email is known** (a *verified ID token* for `oidc`, the back-channel userinfo for the legacy providers) and the store was not yet consulted β€” `domain_not_allowed`, `userinfo_subject_mismatch`, and `provider_error` **when raised by the userinfo fetch after a verified ID token** (US2.3; the record is never looked up on that path, so `user_id` is forbidden there even for a returning user); a refusal before any identity is established (`state_invalid`, `authorization_denied`, `discovery_failed`, `provider_error` from discovery, JWKS or the token exchange, `id_token_invalid`, `nonce_mismatch`, `audience_mismatch`, `issuer_mismatch`, `token_expired`, `email_missing`, `email_unverified`) carries neither, and an unverified token claim is never hashed. A JSON Schema for the line MUST be checked in under `docs/`, with a per-`event` table of required, optional and forbidden keys, and every US3 fixture line validates against it. Adding a key is a minor change; removing or renaming a key or narrowing a vocabulary bumps `schema_version`. +- **FR-014** *(sinks and defaults)*: A top-level `audit_log: {enabled, path, stdout, max_size_mb (50), max_backups (10), max_age_days (90), compress (true)}` with environment overrides `MCPPROXY_AUDIT_LOG_ENABLED`, `MCPPROXY_AUDIT_LOG_PATH`, `MCPPROXY_AUDIT_LOG_STDOUT` in `applyTLSEnvOverrides` (`internal/config/loader.go:628-790`). The file sink reuses the lumberjack rotating sink (`logger.go:183-197`, generalised to a `LogConfig`-shaped struct), append-only, write-through, a line never split across files; `stdout` writes raw JSON lines to `os.Stdout`, bypassing the console encoder (`logger.go:201-206`). **Defaults differ by edition, the code does not**: personal edition `enabled: false` (a single-operator proxy already keeps the activity log; a second copy of every call by default doubles the PII surface with no consumer); server edition, when the block is absent, `enabled: true, stdout: true, path: ""` (container-native: stdout is free and the operators who deploy this image are the ones who asked for the line) with one startup line stating it; an explicit `enabled: false` under the server edition MUST log a startup warning. An explicit value always wins. **Native stdio transport exception** (the default is resolved by `config.EffectiveAuditLog(cfg, transport)`): under `mcpproxy serve` with no listener, standard output *is* the MCP transport (`server.go:968-989`), so the stdout sink can never be used there β€” with the block **absent** the server-edition default resolves to `{enabled: false}` with one WARN naming `audit_log.path` as the stdio-compatible sink (only the *default* is suppressed); an **explicit** `enabled: true, stdout: true` with no `path` under stdio is a sink-construction failure (typed `StartupError`, exit code 4, "audit_log.stdout cannot be used under the stdio transport (stdout carries JSON-RPC); set audit_log.path") β€” refused, never silently disabled; an explicit `path` is honoured and a `stdout: true` beside it is dropped with the same WARN. The HTTP transport is unaffected. Validation (boot and `PATCH /config`/`/config/apply`, per FR-039) MUST refuse `enabled: true` with neither `stdout: true` nor a non-empty `path` ("audit_log is enabled but has no sink"), and non-positive `max_size_mb`/`max_backups`/`max_age_days` when a file sink is configured; the server-edition absent-block default is applied before validation so it always has a sink. Wired per the config-field checklist: `DetectConfigChanges` (the whole block restart-pinned), `make swagger`, env, docs, Settings catalogue entry. +- **FR-015** *(redaction guarantee)*: A line MUST never contain: an argument value, a response fragment, a raw token, cookie, JWT, API key, `Authorization` header, the prose `reason`, error message text, or any `_auth_*` key. `args_sha256` is SHA-256 over the RFC 8785 (JSON Canonicalization Scheme) serialisation of `security.StripInternalArgs(args)` β€” members sorted by UTF-16 code units, ES6 number serialisation (so `1`, `1.0` and `1e0` hash alike, `-0` serialises as `0`), minimal escaping, no whitespace, UTF-8, non-finite numbers refused (the argument map is JSON-decoded, so none can occur) β€” computed at the funnel before any masking or truncation so it is stable; the implementation is stdlib-only (no new dependency; planning measures it against the ~150-line rule of FR-020) and ships with published test vectors (nested maps and arrays, non-ASCII and escaped strings, `1` vs `1.0` vs `1e0`, `-0`, large integers) that the independent hash in the US3 fixture is computed from with a separate implementation in the test package; the writer additionally passes each line through the log secret sanitizer as defence in depth β€” **fixed-prefix credential patterns only** (`sk-ant-`, `AKIA`, `ghp_`/`gho_`, `Bearer …`, …), never the generic high-entropy rule (`sanitizer.go:104` would rewrite every `args_sha256`/`email_hash`); every caller- or operator-controlled string the line carries (`client.name`/`client.version`, `token_name`, `profile`, and `server`/`tool` on a refused dispatch) is sanitised **per field** at build time with the same patterns plus a length cap, so the whole-line pass is the identity on every well-formed line and a hit there is a builder bug, counted and logged (FR-016). No `include_args` mode exists in v1. The guarantee is **structural** β€” the line is built only from the closed key set of FR-013, and no key is ever populated from an argument value, a response fragment, an error message or an `_auth_*` member β€” and it is *tested* with sentinels: a regression test seeds a distinct high-entropy synthetic secret in arguments, response, error text and a caller-supplied `_auth_user_email`, and asserts byte-absence of all four (byte-absence of *arbitrary* values is not the criterion: a value like `"1"` legitimately occurs inside `"schema_version":1` or a hash, which is why the sentinels are chosen to be unable to collide with schema constants, vocabularies or their own expected hashes). **Invariant closed**: the audit sink cannot become a second store of secrets or a forged-identity stamp. +- **FR-016** *(hidden names are recorded, not echoed; no read surface)*: On a non-disclosing refusal the line MUST record the real `server` and `tool` the caller named, with `disclosed: false`; the caller-facing response is governed by Spec 105 FR-010 and MUST NOT change shape because a line was written. **One documented exception**: on a refused dispatch `server` and `tool` are caller-supplied strings (the caller may name any `server:tool` pair), so they are sanitised **per field** at build time like every other caller- or operator-controlled string (FR-015: the fixed-prefix credential patterns and a length cap β€” never the generic high-entropy rule, which would mask legitimate long identifiers and hashes), and the writer's whole-line defence-in-depth pass may likewise rewrite a credential-shaped value; a line so rewritten is still written, the rewrite is counted (`Sink.SanitizerHits()`, mirrored to `mcpproxy_audit_sanitizer_hits_total`, reported by `mcpproxy doctor`) and logged at most once per minute, and `docs/features/audit-log.md` names this as the one case where the recorded name is not byte-identical to the one the caller sent. A configured server whose name is not credential-shaped is always recorded verbatim. There MUST be no REST, SSE, MCP, CLI or Web UI surface that reads or lists audit lines; `activity export` is unchanged; the path is documented as operator-only. **Invariant closed**: attribution for the operator never becomes an oracle for the caller. +- **FR-017** *(front-door events β€” the one bounded extension of F3)*: Exactly **one** `auth_event` line MUST be written per **terminal login attempt observed by the proxy** β€” an attempt that reaches the callback, or that `GET /auth/login` itself refuses (`discovery_failed`, `provider_error` from discovery) β€” at its terminal outcome on the callback (or at the earliest refusing step when the callback never reaches an exchange β€” `state_invalid`, `discovery_failed`, `provider_error`), and one per logout; a redirect to the IdP that never returns (an abandoned `pendingStates` entry) has no terminal outcome, writes no line and is outside the one-line invariant: `reason` is the singular terminal result (`ok`, the refusal reason of FR-013, or `internal_error` for a proxy-side failure after verification β€” user store, JWT signer, session store, FR-024), and the non-terminal facts of the same attempt β€” the record was re-bound (`provider_rebound`), the requested `redirect_uri` was replaced by `/ui/` (`redirect_rejected`, carried from the pending state to the callback; a login started but never completed writes no line and loses only the flag, since `/ui/` is what was stored), the groups claim was missing (`groups_claim_missing`) β€” ride in the closed `flags` array. Identity per FR-013's `auth_event` rule (`caller.user_id`, else `email_hash` only for a verified email, else neither). Rationale for the extension beyond "tool-call decision and outcome": FR-024 makes every login refusal a generic page, so the audit line is the only structured, attributable, redacted record of *why* a sign-in was refused and *who* signed in β€” the identity half of "who called what"; it is one more `event` value on the same sink with the same redaction rules, no new funnel and no new schema. Token mint/rotate/revoke are **not** audited by this spec (the activity log records them as today; an audit event for them is deferred, Out of Scope). Emitted only where the surface exists (login lines are server-edition-only by construction); code is edition-neutral. Login events are not rate-limited by this spec (Spec 089 FR-013). +- **FR-018** *(sink failure policy)*: An unwritable `audit_log.path` at boot MUST fail startup with exit code 4 (configuration error) and an actionable message; a runtime write failure MUST NOT fail the call β€” it increments `mcpproxy_audit_write_failures_total` (metrics enabled) and a counter exposed in `mcpproxy doctor`, and logs at most once per minute. No `audit_log.strict` mode exists. +- **FR-019** *(documentation and tests)*: A published page `docs/features/audit-log.md` (plus `website/sidebars.js` entry) MUST document the schema with every vocabulary, the versioning rule, the crash window (Edge Cases), the Docker/stdout recipe, and one vendor-neutral log-shipper example (a stdout/file scrape β€” no vendor integration is built or maintained); `docs/features/sensitive-data-detection.md:360`'s SIEM recipe points at it. `TestActivityCompletionNeverHardcodesSuccess`, `mcp_activity_agent_test.go` and the `_auth_*` forgery tests (`mcp_activity_args_test.go:460-527`) MUST be extended rather than duplicated; `scripts/test-api-e2e.sh` MUST gain an audit-file assertion. + +**D. Generic OIDC provider (F1)** + +- **FR-020** *(provider, config, discovery)*: The provider registry (`oauth_providers.go:60-64`, `GetProvider` `:116-122`) MUST accept `oidc`, constructed from the OAuth config rather than a tenant id (the factory widens to take `*config.ServerEditionOAuthConfig`; the two production call sites `oauth_handler.go:129,193` pass it; the third, `idp_subject_token.go:95`, is removed by FR-033). `ServerEditionOAuthConfig` (`server_edition_config.go:31-37`) gains `issuer_url` (required for `oidc`; `https`, or `http` only when **both** hold: the host is loopback **and** `allow_insecure_issuer: true` β€” non-loopback `http` is refused by validation regardless of the flag), `scopes []string` (default `["openid","profile","email"]`; `openid` added if missing), `groups_claim` (default `"groups"`), `email_verified_policy` (`refuse_false` default | `require_true` | `ignore`), `display_name` (login-button label; falls back to the provider family name). Discovery of `authorization_endpoint`, `token_endpoint`, `jwks_uri` (required) and `userinfo_endpoint` (optional) from `/.well-known/openid-configuration` is **lazy on first use**, cached with a bounded TTL, re-fetched on error, and never blocks boot or readiness (Edge Cases). Every discovered endpoint MUST be an absolute `https` URL β€” `http` only under the same two-condition exception as the issuer (loopback host **and** `allow_insecure_issuer: true`) β€” and a discovery document violating that is rejected as `discovery_failed` before any redirect to the IdP or any request carrying the client secret is made. The back-channel HTTP client used for discovery, JWKS, token exchange and userinfo MUST NOT follow redirects (today's default client at `oauth_providers.go:158-163` does): any 3xx from those endpoints is `provider_error`, so a compromised or misconfigured IdP cannot downgrade the client secret or the code to plain http or to another host. Fixtures: a discovery document with an `http` `token_endpoint`, and a token endpoint answering 302 to another origin β€” both refuse the login with the generic page and one `auth_event`, and the client secret is never sent. The three hardcoded provider lists (`server_edition_config.go:76-79`, `users/models.go:48`, `frontend/src/views/settings/fields.ts:326`) MUST change together; `cmd/mcpproxy/status_serveredition.go` prints the new value unchanged; `google`, `github`, `microsoft` MUST keep their provider-specific behaviour exactly as today (US2.10 enumerates what is provider-specific and which provider-neutral FRs apply to every provider). Secrets stay `${env:}`-referenced; no environment variable for nested keys. No new module dependency: JWK (RSA/EC) parsing is stdlib, `golang-jwt/jwt/v5` is already present; planning may argue for `go-oidc`/`keyfunc` against CLAUDE.md's rule only if the stdlib path exceeds ~150 lines. +- **FR-021** *(ID-token verification and nonce, `oidc` only)*: Every `oidc` ID token MUST be a *verified ID token* (Definitions) before any claim is read: JWKS-verified with an algorithm from `id_token_signing_alg_values_supported` restricted to RS256/RS384/RS512/PS256/PS384/PS512/ES256/ES384/ES512 (never `none`, never HS*), exact `iss`, `aud`/`azp`, `exp`/`nbf`/`iat` with 60 s skew, and a per-login `nonce` stored beside the state in `pendingStates` (`oauth_handler.go:120-125`), sent on the authorization request and consumed once. `pendingStates` MUST be bounded: at most 10,000 entries, the oldest (`CreatedAt`) evicted on insert when full, in addition to the existing 10-minute sweep (Edge Cases); a fixture inserts 10,001 and asserts the first is gone and the login that used it fails `state_invalid`. A failed check refuses the login with the generic page, leaves the user store untouched, and logs the failed check's name (never a claim value). The unverified `parseIDToken` (`oauth_providers.go:466-495`) MUST be unreachable for `oidc` and is retained only for the three legacy providers. **Invariant closed**: the groups claim and email that drive authorization are IdP-signed, audience-bound, fresh and replay-proof. +- **FR-022** *(claims)*: For `oidc`, `sub` is required (it becomes `ProviderSubjectID`), `email` is required (`email_missing` otherwise), `email_verified` per `email_verified_policy` (US2.4), groups per FR-008 (ID token first, `userinfo` fallback only when the token lacks the claim, and **no userinfo claim is read before its `sub` is compared with the verified ID token's `sub`** β€” the existing parser accepts userinfo `sub` independently, `oauth_providers.go:282-320`, and gains that comparison; mismatch or absence is `userinfo_subject_mismatch`; a userinfo fetch that fails at the transport, status, redirect or decoding level is `provider_error` β†’ 503, login refused, store untouched β€” never "claim absent" β€” US2.3, and one fixture per failure class); `OAuthUserInfo` (`oauth_providers.go:52-57`) gains `Groups []string` and `EmailVerified *bool`. +- **FR-023** *(subject binding)*: A user record MUST store `(provider, provider_subject_id)` and refresh both on every successful login. Same provider, same normalised email, different subject β†’ login refused with the generic page and an `auth_event` (`subject_mismatch`); configured-provider change β†’ the first login re-binds and its `auth_event` line carries the `provider_rebound` flag. Email stays the lookup key (no store migration). **Remedy for a genuine subject change** (the IdP re-created the account): there is no delete-user route and `disable` only sets `Disabled` and revokes sessions and tokens β€” it does not clear `ProviderSubjectID` (`admin_handlers.go:220-276`) β€” so the binding is re-armed through the existing administrator surfaces without a new one, and the window MUST be **persisted state**, not inferred: `users.User` gains `subject_rebind_armed_at` (nullable timestamp; absent on every upgraded record = closed). The enable handler sets it **only on a real `Disabled: true β†’ false` transition** (`admin_handlers.go:276-306` today writes only `Disabled = false`); a subsequent `disable` clears it. While it is set, the **first successful login** (every other check of FR-021/FR-022 passed) for that record accepts the presented `(provider, sub)` as the new binding, clears the field atomically in the same store write, and carries `provider_rebound` in the line's `flags`; a *failed* login attempt does not consume it; two concurrent successful logins are serialised by the store so exactly one rebinds and the other then matches or is refused. When it is not set, `subject_mismatch` stands. `GET /api/v1/admin/users` shows the field (administrator-only); no audit key carries the enable timestamp. The rebind window is therefore explicitly administrator-opened, single-use, durable across restarts and audited; no dedicated reset action is built (Out of Scope). Fixtures: restart between enable and login; failed-then-successful login; concurrent logins. Today `Provider` is never refreshed and `ProviderSubjectID` only when an avatar URL is present (`oauth_handler.go:389-397`); both are refreshed on every successful login. **Invariant closed**: an IdP email collision cannot silently take over an existing account, and the only rebind path is one an administrator opened. +- **FR-024** *(login refusals)*: Every login denial MUST render the same generic page and status (US2.8); the closed reason enum reaches only the server log and the `auth_event` line, keyed by the request id shown to the user; unavailability is the one distinct class (503 "Sign-in is temporarily unavailable", US2.9) and covers both IdP-side failures (`discovery_failed`, `provider_error`) and proxy-side failures after verification (`internal_error` β€” user store, JWT signer, session store; today three ad-hoc 500 bodies, `oauth_handler.go:242-289`), so a failure that is not the user's fault is never rendered as "not permitted". Fault-injection fixtures cover each of the three internal branches: 503 page, one `auth_event` with `internal_error`, and β€” for a failure before the upsert commits β€” no user record created. + +**E. Front door behind ingress / container (prerequisites for Spec 089; in scope)** + +- **FR-025** *(public URL)*: `server_edition.public_url` (optional; absolute `http(s)://host[:port]`, no path, query or fragment; env `MCPPROXY_PUBLIC_URL`) MUST, when set, be the sole source of the IdP `redirect_uri` (`/api/v1/auth/callback`), of the connect-flow base URL (`internal/serveredition/api/connector_provider.go:50-62`, which today latches the first-seen origin for the process lifetime), and of the scheme that decides the session cookie's `Secure` attribute; `Host` and `X-Forwarded-*` are then ignored for these purposes. When unset, today's derivation applies with forwarded headers gated by FR-027; when unset **and** the listener is non-loopback, startup MUST log a warning recommending it and `mcpproxy doctor` MUST report it β€” it is not a validation error, because the published image listens on `0.0.0.0:8080` (`Dockerfile:36`) and every existing container deployment would fail on upgrade. When `public_url` is set and the observed request scheme after trusted forwarding disagrees with it on the callback, the proxy MUST log an operator-readable warning with the request id ("public_url is https but this request arrived over http β€” check ingress TLS termination or trusted_proxies") and proceed; login is never blocked on the check. **Invariant closed**: with `public_url` set, Host-header injection cannot redirect the IdP callback; the IdP's exact-match `redirect_uri` registration remains the guard when it is unset (documented). +- **FR-026** *(Secure cookie)*: `server_edition.session_cookie_secure` MUST be `auto` (default: `Secure` when the effective scheme is https β€” `public_url`, an in-process TLS listener, or a **trusted** `X-Forwarded-Proto: https`), `true`, or `false` (`setup.go:115`, `session_store.go:57-67`). Validation MUST refuse `false` when `public_url` is `https://…` or the process terminates TLS itself (message names the two keys); an explicit `false` in any other deployment is honoured with one startup warning and a `mcpproxy doctor` finding (its only legitimate use is a plain-http loopback or test deployment). `HttpOnly` and `SameSite=Lax` are unchanged. **Invariant closed**: under `auto` and `true` the session cookie is never sent in clear over an https deployment, `false` cannot be combined with a declared https front door, and an untrusted client cannot force `Secure` on an http deployment. +- **FR-027** *(trusted proxies, edition-neutral)*: A top-level `trusted_proxies` list of CIDRs or addresses (env `MCPPROXY_TRUSTED_PROXIES`, default empty = trust nobody) MUST gate every use of `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-Host` in the process: honoured only when `RemoteAddr` is inside the list, taking the right-most untrusted hop for the client IP (`session_store.go:129-151`; `oauth_handler.go:413-423`; `connector_provider.go:126-135`; swagger `httpapi/swagger.go:105-114`; the audit `client.ip`); otherwise ignored and `RemoteAddr` used. No forwarded header may ever feed a local/remote or administrator classification (Spec 089 FR-014). Wired per the config-field checklist (`DetectConfigChanges` hot, swagger, env, `docs/operations/reverse-proxy.md`). **Invariant closed**: a direct client cannot spoof the session IP, the callback scheme, the `Secure` decision or the audit IP. +- **FR-028** *(post-login redirect)*: `redirect_uri` on `GET /api/v1/auth/login` (`oauth_handler.go:115-118`) MUST be accepted only as a same-origin path: begins with a single `/`, not `//` or `/\`, contains no scheme, host, backslash, CR/LF or control character; anything else is replaced by `/ui/` (never an error, never echoed) and recorded as the `redirect_rejected` flag on the attempt's `auth_event` line (FR-017). The stored value is what `HandleCallback` redirects to (`:303-307`). The Web UI passes `window.location.pathname` today and needs no change. **Invariant closed**: the open redirect. +- **FR-029** *(no anonymous administrator under the server edition; edition-neutral hook)*: When `server_edition.enabled` is true, `mcpAuthMiddleware` (`server.go:353,450`) MUST behave as if `require_mcp_auth` were `true` regardless of the config value: no credential β†’ 401; an unrecognised bearer (including a session cookie or user JWT) β†’ 401; agent tokens, the global API key and the socket are unchanged. Implemented as a build-tagged accessor on the config (personal build: returns `RequireMCPAuth`) so `internal/server` stays edition-neutral. An explicit `require_mcp_auth: false` is **not** a validation error (no boot failure on upgrade for the 17/46 installs); boot MUST log one notice that it is being overridden, `mcpproxy doctor` MUST name the override, and the release notice (FR-042) MUST name the change. **Invariant closed**: in the published container no unauthenticated caller receives an admin-typed `AnonymousContext` (`server.go:381-384`, `:455-458`); the group grant always has a principal to bind to. +- **FR-030** *(login-page provider label and edition probe)*: A public `GET /api/v1/auth/provider` MUST return only `{display_name}` (FR-020; falling back to the provider family name) with no side effects, so `Login.vue:28` can label the button and the Web UI can detect the edition before login; it MUST NOT return the issuer, client id, tenant id, scopes or domains; the personal build answers 404. **Invariant closed**: no new unauthenticated disclosure beyond an operator-chosen label (the public login redirect already discloses the IdP host). + +**F. Freeze / cut of latent code (F4)** + +- **FR-031** *(delete class A β€” zero production callers)*: The following MUST be deleted with their tests: `multiuser.Router`, `multiuser.ToolFilter` (`internal/serveredition/multiuser/router.go`, `tool_filter.go`); package `internal/serveredition/workspace`; `broker.TokenExchanger`, `broker.CredentialResolver` with its interfaces/errors, `broker.HeaderInjector`/`ConnectionKey` (`token_exchanger.go`, `credential_resolver.go`, `injector.go`); the dead audit constants (`Inject`, `Acquire`, `Refresh`, `TokenExchange`, `EntraOBO`, `auditMethodForMode`); `auth.GetValidIDPSubjectToken`/`ErrReauthRequired`, `OAuthProvider.RefreshAccessToken`, `OAuthConnector.Refresh`; `(*CredentialHandlers).ConnectorProvider`, `connectorProvider.ConnectorFor` and its interface assertion; `core.(*Client).SetBrokeredAuth`, the `brokeredAuth` field and every brokered branch in `internal/upstream/core/connection_http.go:28-29,44-45,171-192` and `client.go:93-99`; `internal/transport/broker_auth.go` and the `BrokeredAuth` plumbing in `transport/http.go:135-139,178-192`; the Web UI field `fields.ts:327`. `multiuser/activity.go`, the `users` store methods, personal-server REST records and the Teams UI stay. No Go symbol, package or directory is renamed (`teamsapi`/`teamsauth` aliases stay). The PR MUST list by name the six security-regression tests retired with the code (`TestRouter_BrokeredConnectionKey_AgentTokenNeverPoolsOntoItsOwner` and `TestRouter_BrokeredConnectionKey_AdminUserSessionKeysAsItself`, `multiuser/router_broker_agent_test.go:46,85`; `TestResolve_NoStaticFallback_OnExchangeFailure` and `TestResolve_CrossUserIsolation_NeverReturnsAnotherUsersCredential`, `broker/credential_resolver_test.go:329,500`; `TestClient_BrokeredConnection_FailsClosed_OnlyHeadersStrategy`, `upstream/core/connection_http_broker_test.go:65`; `TestOAuthClients_RefuseBrokeredAuth`, `transport/oauth_static_headers_test.go:78`) and state that they guarded paths that are nil in production, so a future "wire the broker" PR starts by re-homing them. +- **FR-032** *(dead knobs and never-implemented modes)*: `max_user_servers` and `workspace_idle_timeout` MUST be removed from `ServerEditionConfig` (`server_edition_config.go:17-20,45-46,96-101`); `auth_broker.header`/`header_format` MUST be removed; the modes `token_exchange` and `entra_obo` MUST be removed from the validator's accepted set (`internal/config/auth_broker.go:113-138`). **Loading** an old file containing any of them MUST succeed: the loader's normalise-on-load step (the `teams`β†’`server_edition` pattern, `loader.go:271-285`) drops the key β€” or, for a removed mode, the whole `auth_broker` block of that server β€” with one deprecation warning naming it, and the next write-back omits it; `DetectConfigChanges` MUST NOT report the dropped keys. **Build split (one rule for both blocks)**: normalisation, the deprecation warning and the write-time refusal happen in the **server build only**. The personal build has no field-level view of either block β€” `server_edition` is a `struct{}` stub (`server_edition_config_stub.go:6`) and `AuthBrokerConfig` is a `struct{}` stub too (`internal/config/auth_broker_stub.go:5-13`; copied as a no-op at `merge.go:678-684`), so today it cannot tell `header` from `mode` and in fact writes both blocks back as `{}`, erasing them (critic G3 for `server_edition`; the same defect for `auth_broker`). FR-040 therefore makes the personal build carry **both** `server_edition` and each server's `auth_broker` as opaque raw JSON, preserved as loaded β€” deprecated keys and modes included, no warning, no normalisation β€” and the next **server-edition** load normalises them (dropping `max_user_servers`, `workspace_idle_timeout`, `auth_broker.header`/`header_format`, or the whole `auth_broker` block of a server whose mode is `token_exchange`/`entra_obo`, one warning each) and the next server-edition write-back omits them. SC-005's one-warning-per-key fixture and the PATCH refusal fixture run under `-tags server`; the personal build neither warns nor refuses (it cannot validate what it does not parse) and a personal-build fixture proves a valid `oauth_connect` block survives a load β†’ write-back cycle. **Writing** them through `PATCH /api/v1/config`/`/config/apply` on the server build MUST be refused by validation with the same actionable message. `oauth_connect` and the Spec 106 AuthBroker preservation/redaction sites (`runtime/lifecycle.go:1301-1313`, `merge.go:679-683`, `oauth/serverfields_serveredition.go`) are unchanged. +- **FR-033** *(`store_idp_tokens` becomes a no-op)*: `persistIDPSubjectToken`, `GetValidIDPSubjectToken`, `ErrReauthRequired` and the offline-access scopes/parameters (`oauth_providers.go:29-38,76-78,108-110,130-148`; `oauth_handler.go:139-144,252`) MUST be removed; the config key is retained for compatibility and logs one deprecation warning at boot when `true`. Rationale: a long-lived IdP refresh token at rest with no reader is a leak surface, not a feature. +- **FR-034** *(Path B connect chain frozen, honestly)*: The `oauth_connect` REST routes, CLI `mcpproxy credential`, `internal/cliclient/credentials.go`, the AES credential store, `MCPPROXY_CRED_KEY`/`credential_encryption_key` and the connect audit events are retained (they have live callers; the maintainer's zero-caller criterion does not reach them). `mcpproxy credential list/status` output MUST print, and `docs/cli/credential-commands.md`, `docs/features/auth-broker.md` MUST open with, the statement that a stored credential is kept for a future broker and is **not** injected into upstream calls; the "Credential resolution", "Header injection" and "Per-(user, server) connection keying" sections (`auth-broker.md:82-106`) and the `token_exchange`/`entra_obo` rows are removed. The REST status vocabulary (`connected|expired|not_connected|unavailable`, `credential_handlers.go:24`) is unchanged in this spec (open decision: rename `connected`β†’`stored` is an API shape change for the CLI client). Historical `credential_broker` activity rows MUST remain readable and labelled (`storage.ActivityTypeCredentialBroker`, `frontend/src/utils/activity.ts:33,53` stay). Restoration of the deleted chain is a `git revert` when the research's Phase 2 trigger fires. +- **FR-035** *(both editions verified)*: The cut PR MUST pass `go build` for both editions, `./scripts/test-api-e2e.sh`, `go test -race ./internal/upstream/... ./internal/transport/...`, `go test -race -tags server ./internal/serveredition/... ./internal/config/... ./internal/oauth/...`, `golangci-lint --build-tags server`, keep `TestOAuthClients_CarryStaticHeaders` (#1271) green, update the `serverfields_serveredition` mask table and `TestSaveServerSyncFieldCoverage` as required by the removed fields, and swap the inert `token_exchange` mode string in the fixtures that carry it (`credential_handlers_test.go`, `user_handlers_redaction_test.go:78`, `setup_wiring_test.go:257`, `runtime/server_sharing_test.go:37`, `broker/credential_store_test.go:58`) to `oauth_connect`; a guard test MUST assert the absence of the **removed production declarations and callable branches**: every Go symbol FR-031 and FR-033 delete (type, function, method, field and constant declarations, checked by name over the non-test AST), the struct fields `MaxUserServers`/`WorkspaceIdleTimeout`/`Header`/`HeaderFormat` and the `token_exchange`/`entra_obo` values in the validator's accepted-mode set (FR-032), the brokered branches in `connection_http.go`/`transport/http.go`, and the injection/keying/exchange sentences FR-036 removes from the docs. The guard MUST NOT flag the **compatibility literals** the same FRs require to exist: the retained `store_idp_tokens` decoder field and its deprecation warning (FR-033), and the key and mode strings inside the server-build normaliser and its warnings (FR-032) β€” those are proven by the load/write-back fixtures of SC-005, not by absence. +- **FR-036** *(docs corrected, tombstones not deletions)*: `docs/features/idp-token-storage.md` becomes a one-paragraph tombstone (its `website/sidebars.js:130` entry and external links keep resolving); `docs/features/auth-broker.md` and `docs/cli/credential-commands.md` are rewritten per FR-034 (sidebar `:55,129` unchanged). Correct: `docs/development/server-edition-multiuser-auth.md:40,53` (JWT is not an MCP credential), `:54-55` ("per-user connections" β€” none exist; isolation is REST listing + token scope + the tenant principal), `:206-210` ("nothing re-validates at use time" β€” stale since #1272; owned tokens are narrowed on every authentication), `:76-81` (JWT renewal through `POST /auth/token` β€” now session-cookie-only, FR-011), the route table's "Session/JWT" column for `POST /user/tokens` and `/user/tokens/{name}/regenerate` (session-cookie-only, FR-011), `:242-243` (workspace/router rows), `:27-28` (removed knobs), `:115-121` (per-owner cap), plus the group map and the tenant principal; `docs/features/activity-log.md:28,300` (only `connect` broker events ever existed; now historical); `docs/configuration/upstream-servers.md:112` (drop "token brokering"); `CLAUDE.md` editions row (no server `.deb`/tar.gz; link to the new reference); `docs/features/agent-tokens.md` gains the invariant of FR-046. A `server_edition` reference section MUST be added to the published `docs/configuration/config-file.md` (every key of this spec, default, restart/live, validation message, the per-IdP groups-claim table, the container topology with `public_url` and `trusted_proxies`; never a link to the unpublished root `docs/configuration.md`); `docs/configuration/environment-variables.md` gains the new variables; `docs/getting-started/installation.md:368-400` Docker recipe gains `public_url`, `trusted_proxies`, `${env:}` secrets and notes the stdout audit default; a new published `docs/operations/deploying-for-a-team.md` (sidebar entry) gives a Kubernetes + Keycloak walkthrough (Deployment, Secret via `${env:}`, Ingress, `public_url`, `trusted_proxies`, `access`, stdout audit, single-replica contract, the `--config`+`--data-dir` volume rule) whose configuration example is loaded and validated by a server-tagged test (SC-005). + +**G. Hygiene, measurement, compatibility** + +- **FR-037** *(per-owner token cap, #1177)*: `auth.MaxTokens` MUST be enforced per owner in `CreateAgentToken` (`agent_tokens.go:194-198`): tokens with the same `UserID` count together, ownerless operator tokens form one owner, default 100 per owner, replacing the global bucket count. The 409 bodies at `internal/httpapi/tokens.go:224` and `user_handlers.go:1165-1174` MUST reference only the caller's own count. Applies in both editions (personal owners are all ownerless, so behaviour there is unchanged). **Invariant closed**: no cross-tenant exhaustion, no fleet-total oracle. +- **FR-038** *(telemetry)*: `FeatureFlagSnapshot` (`feature_flags.go:13-51,122-170`) MUST gain `server_edition_enabled` (bool) and `idp_provider` ∈ `google|github|microsoft|oidc|none` (via a build-tagged accessor mirroring `internal/oauth/serverfields_*.go`; never the issuer); `BuildPayload` MUST splice `user_count_bucket` ∈ `0|1-10|11-100|101-1000|1000+` (the `bucketUpstream` vocabulary, `registry.go:642-655`) through a nil-safe store accessor (`telemetry.go:427-456` pattern); `env_markers.is_container` (`env_markers.go:22`) is unchanged and asserted present in the server-edition heartbeat test. `SchemaVersion` becomes 13 (the production constant `internal/telemetry/telemetry.go:138`) with the header comment extended and **every** exact v12 assertion and serialised fixture updated β€” at HEAD eleven test sites: `payload_v7_test.go:20,81,112`, `payload_v2_test.go:91,138`, `telemetry_test.go:315,323`, `payload_privacy_test.go:147`, `current_error_codes_test.go:268`, `tpa_scanner_test.go:267`, `tpa_funnel_v9_test.go:263` β€” with a guard (`grep -rnE 'schema_version":12|!= 12|SchemaVersion\s*=\s*12' internal/telemetry` empty, covering the constant as well as the fixtures and comparisons) so no stale v12 expectation survives; `TestPayloadHasNoForbiddenSubstrings` and `ScanForPII` pass with the issuer host, group names, emails and server names as forbidden substrings; `docs/features/telemetry.md` lists the fields. No `is_k8s`, no `image_variant` (not requested). No worker migration. +- **FR-039** *(config validation and reload)*: `Config.Validate()`/`ValidateDetailed()` MUST reach a **non-mutating** `ServerEditionConfig.Validate()` under the server build (today it is called only at `setup.go:96` and mutates β€” critic G4), so `PATCH /api/v1/config` and `/config/apply` refuse a broken block at write time with the messages of this spec; defaults and the `MCPPROXY_CRED_KEY` fallback move to a separate apply step at setup. `DetectConfigChanges` (`config_hotreload.go:77-420`) MUST gain clauses for `server_edition` (`enabled`, `oauth.*`, `public_url`, `session_cookie_secure`, `session_ttl`, `bearer_token_ttl` restart-pinned with a reason; `admin_emails`, `access.*` hot, read live through `ServerEditionConfigProvider` `setup.go:145-152`), `audit_log` (restart-pinned) and `trusted_proxies` (hot), using the `slices.Equal`/`jsonEqual` patterns at `:357-386`, with `config_hotreload_test.go` coverage (assert reloaded values with `Eventually` β€” commit happens after snapshot publish). +- **FR-040** *(personal-build config integrity)*: The personal binary MUST round-trip the `server_edition` block and every server's `auth_broker` block **semantically intact** (a `json.RawMessage`-backed stub instead of `struct{}` β€” `server_edition_config_stub.go:6`, `auth_broker_stub.go:5-13`), so API-key bootstrap (`cmd/mcpproxy/main.go:608-612`) and `PATCH /api/v1/config` can no longer erase SSO or broker configuration (critic G3 and its `auth_broker` twin). The contract is **semantic JSON equality**, not source bytes: every save re-serialises the typed document with `json.MarshalIndent` (`loader.go:392-393`) and PATCH marshals β†’ generic-map merge β†’ marshals again (`httpapi/server.go:5367-5400`), so whitespace, key order and number spelling of the original text are not preserved even through a raw message β€” what is preserved is every key and value. **Numbers are the one value class the PATCH path damages today**: both the patch body and the marshalled base are decoded into `map[string]interface{}` without `UseNumber` (`httpapi/server.go:5332`, `:5376`), so any integer above 2^53 or any decimal that `float64` cannot represent inside the opaque block is rounded by an unrelated PATCH before the raw carrier is re-serialised; the PATCH decoder and the base-map decoder MUST therefore use `json.Decoder.UseNumber` (so every number rides through the merge as its decimal text β€” `json.Number` marshals verbatim), and the personal-build stubs MUST marshal the raw carrier unchanged. The comparator for the fixture is **independent of the FR-015 canonicaliser** (whose ES6 number rule would round the same way and pass vacuously): decode both documents with `UseNumber` and compare the trees structurally, numbers by decimal text. Fixture: load through the personal build, save, PATCH an unrelated key, save again, and assert the two blocks equal the originals under that comparator β€” including a `9007199254740993` integer and a `0.1000000000000000055511151231257827` decimal planted in each block β€” then a server-edition load with `Enabled` still true and the `oauth_connect` block intact. +- **FR-041** *(Web UI)*: The frontend MUST detect the edition through FR-030 before login and through the session afterwards, send no `?apikey=`, route to `/login` on a 401 from a core API call in the server edition, and work for a tenant with the session alone β€” which means a tenant page calls only the FR-002 allowlist plus `/auth/*` and `/user/*` (diagnostics from `/user/diagnostics`, never core `/diagnostics`/`/doctor`; history from `/user/activity`, never core `/activity*`/`/tool-calls*`; no `/code/scripts`, `/telemetry/*`, `/onboarding/*`, `/connect*` calls), and the Playwright run of US4 fails on any tenant XHR answered 403. **The current UI fires non-allowlisted calls unconditionally and MUST gate them on the principal kind** (the auth store knows the session's role from `/auth/me`): `App.vue:92-99,139-156` calls `GET /api/v1/info` and `/routing` on boot and on every re-mount (`systemStore.fetchInfo`/`fetchRouting`; `ModeSwitcher.vue:375` too), `Dashboard.vue:678,723,736,793,807` calls `/connect`, `/docker/status`, `/config`, `/sessions` and `/stats/tokens`, `Servers.vue:319` and `ServerCard.vue:445` call `/security/overview` through `useSecurityScannerStatus`, `stores/onboarding.ts:78` calls `/onboarding/state`, and `Activity.vue` calls core `/activity*`/`/sessions`; for a tenant session these calls are skipped (the TopHeader renders no version/update or routing chip, the dashboard renders no Docker, connect, session or token-statistics cards, the scanner badge is hidden, the activity view uses `/user/activity`) rather than issued-and-403'd. Rather than allowlisting these routes: `/info` builds a `web_ui_url` that embeds the global API key for `CanRevealSecrets` callers (`httpapi/server.go:1501-1525`) and discloses the listen address and update state, `/routing`, `/docker/status`, `/stats/tokens` and `/security/overview` are global-state disclosures (FR-045), so none is a tenant projection; the Settings catalogue (`fields.ts:323-327`) MUST expose `oauth.provider` incl. `oidc`, `oauth.issuer_url`, `oauth.scopes`, `oauth.groups_claim`, `oauth.email_verified_policy`, `oauth.display_name`, `public_url`, `session_cookie_secure`, `trusted_proxies`, `audit_log.*`, MUST drop `max_user_servers`, and MUST NOT expose `client_secret` unmasked; the `access` map is edited through the Raw JSON tab (no map control exists β€” `fields.ts:8-15`) and shown read-only per server on the admin server page as group chips; `AdminUsers.vue` shows groups. Unit tests live in `frontend/tests/unit/*.spec.ts` (`settings-server-edition-wording.spec.ts` extended); an ad-hoc Playwright spec covers US4 against a server-tagged instance (the standing sweep boots the personal binary). +- **FR-042** *(release notice)*: The release that ships this spec MUST carry a `.github/RELEASE_NOTICE.md` entry (consumed at `release.yml:1466`; the release body is otherwise LLM-generated from commits) naming: forced MCP authentication under the server edition, the server-edition audit-to-stdout default, the per-owner token cap, `trusted_proxies` now gating forwarded headers (session IP and callback scheme change for deployments that relied on untrusted `X-Forwarded-*`), the removed keys/modes and the `store_idp_tokens` no-op, the "stored, not injected" wording, `POST /auth/token`, `POST /user/tokens` and `POST /user/tokens/{name}/regenerate` no longer accepting a bearer JWT or an agent token (scripts that renewed a JWT with a JWT, or minted/rotated agent tokens with a JWT, must call these doors with a live session cookie β€” an agent token is a credential in its own right for `/mcp` and core REST and never mints a JWT or another token, FR-011/FR-043(i)), `expires_in` on `/user/tokens` now capped at 365 days, and the "sign in again after enabling `access`" window. +- **FR-043** *(invariants pinned by test)*: (a) a session cookie and a user JWT on every `/mcp*` route yield 401 with the server edition enabled, and a request with no credential yields 401 even with `require_mcp_auth: false`; (b) no `_auth_*`-derived or argument-derived value reaches an audit line; (c) an empty effective entitlement yields no server on any surface, REST preflight included; (d) administrator responses match the pre-feature fixture except SC-006's list; (e) both editions build and the personal e2e suite passes after FR-031; (f) a tenant session receives the fixed 403 on every `/api/v1` method+route outside the FR-002 allowlist β€” the test walks the production route table (`entitlement-test-oracle` pattern) so a route added later without a gate fails the test β€” including `/tools/call` for every tool name (`read_cache` and every other built-in), `/code/exec` and `/tool-calls/{id}/replay`, and the gate fires before the body is parsed (a malformed body is still 403, not 400); (g) a failed explicit credential plus a valid cookie is 401, for each of `X-API-Key`, bearer and `?apikey=`; (h) session principals never reveal raw secrets; (i) a bearer JWT or agent token on `POST /auth/token`, `POST /user/tokens` and `POST /user/tokens/{name}/regenerate` is 401, an owned token never has a zero `ExpiresAt`, and `/user/tokens` refuses `expires_in` above 365 days; (j) no `tool_call` line exists without a matching `authz allow` line, no line exists for a built-in invocation as such, no `authz` or `tool_call` line carries `caller.kind: session_user`, and no `authz` line carries `outcome` or an `output_*` reason; (k) a tenant session on core `/activity*`, `/tool-calls*` and `/servers/{id}/tool-calls` is 403, and `/user/activity` never returns a record whose `user_id` is another user's or whose server is outside the entitlement (fixture: Alice, Bob and the operator each call `a`, with sentinels in arguments and responses). +- **FR-044** *(goldens and tool surface)*: No built-in MCP tool gains or changes an argument; every frozen tool-surface golden (`internal/server/testdata/toolslist_goldens/*` and the pre-feature goldens) MUST pass unregenerated; no new MCP tool is added; group filtering and audit are server-side only. +- **FR-045** *(applicability matrix)*: Tests MUST follow this matrix for the tenant fixture pair and assert unavailable operations stay unavailable: + + | Operation | tenant session (REST) | tenant-owned agent token (MCP, per Spec 105 FR-014; REST as today) | `admin_user` session | + |---|---|---|---| + | `/user/servers` list/get/update/delete/enable, `/user/diagnostics`, `/user/credentials*` | entitlement-filtered; status parity with absent | n/a | **unchanged**: the shared + personal projection these doors render today (`user_handlers.go:343-352`, `credential_handlers.go:341-372`, `user_activity.go:145-163`); `/admin/servers` remains the whole-config surface | + | `/user/activity` | own records only: `user_id` = principal **and** server ∈ entitlement (FR-002) | n/a (refused by the server-edition middleware as today) | unchanged β€” today's `{items:[],total:0}` (the door is wired with a nil filter at merge-base, `setup.go:196`, `user_activity.go:100-105`; administrators read history on core `/activity*`, and SC-006 lists no exception for this door) | + | `/user/tokens` mint/rotate | grant βŠ† entitlement; `*` materialised; session-cookie principal only (a bearer JWT is 401, FR-011); expiry ≀ 365 days | n/a | `*` literal; session-cookie only; expiry ≀ 365 days | + | core reads (**the FR-002 allowlist, exhaustively**): `GET /status`, `/servers`, `/servers/{id}/**` reads except `/servers/{id}/tool-calls`, `/tools`, `/index/search`, `POST /preflight`, `/profiles` reads, `/events` | scoped via principal set; restricted marker on preflight; re-resolved per SSE frame; `/profiles` omits hidden-only profiles and `/profiles/active` is `""` for a hidden active profile (FR-002 tenant projection) | scoped as today (+ FR-006; `/profiles` still names hidden-only profiles with empty server lists β€” Out of Scope) | administrator | + | core history: `/activity*`, `/tool-calls`, `/tool-calls/{id}`, `/servers/{id}/tool-calls` | 403 (allowlist; server-only scope would disclose other callers' records) | server-scoped as today (Out of Scope) | administrator | + | `GET /config`, `/secrets*`, `/sessions`, `/registries*`, token statistics | 403 (allowlist; `requireAdminRead` behind it) | 403 as today | administrator; secret reveal masked | + | core dispatch: `POST /tools/call` (all names), `POST /code/exec`, `POST /tool-calls/{id}/replay` | 403 (allowlist, before body parse) | as today | administrator | + | core mutations incl. `PATCH /config`, `/config/apply`, `/servers` add/remove/enable, `/quarantine/*`, `/registries/{id}/refresh`, `/telemetry/update-failure`, `/onboarding/mark`, `/feedback`, `/connect*`, personal `/tokens` routes | 403 (allowlist) | as today | administrator | + | global disclosures: `GET /diagnostics`, `/doctor`, `/code/scripts`, `/telemetry/payload`, `/connect*`, `/onboarding/state`, `/annotations/coverage` | 403 (allowlist); tenants use `/user/diagnostics` | as today | administrator | + | any `/api/v1` route not listed above (incl. routes added later) | 403 (allowlist default) | as today | administrator | + | `/mcp*` with session/JWT | 401 | n/a | 401 | + | audit lines | none readable | none readable | none readable (filesystem/stdout only) | + +- **FR-046** *(documented invariant)*: The agent-token and server-edition documentation MUST state: "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." It MUST list the covered surfaces, the bounded staleness of groups (FR-011, including the closed JWT self-renewal), the retained Spec 105 effects, the still-open Spec 105 items (Assumptions) and the single-replica assumption. +- **FR-047** *(CI, lint, test rigs)*: The lint job MUST run with `--build-tags server`; the server-edition race job MUST add `./internal/server/... ./internal/httpapi/...` under the tag **with the `unit-tests.yml:148` `-skip` regex** (`E2E|Binary|MCPProtocol|…`) so the job does not hang on the binary-spawning tests; `golangci-lint … --build-tags server` is documented as required before pushing. `tests/oauthserver` MUST gain `Options.OIDC` and `Options.UserClaims` (US2) and a matching CLI flag on `cmd/server`; the `setup_wiring_test.go` harness MUST be extended with the group map, its hot reload, the empty entitlement, the per-owner cap, the session principal and the audit line; the two-fixture sentinel oracle of Spec 105 is implemented in that harness for every US1 surface (Assumptions). + +### Key Entities + +- **Tenant principal / administrator session principal**: the per-request identity a session cookie or user JWT installs on core REST β€” user id, email, role, entitlement set; no permission tier, no pin; never reveals raw secrets (FR-001/002). +- **OIDC provider**: an `OAuthProvider` built from `issuer_url` + lazy discovery, carrying the JWKS cache, scopes, nonce requirement, groups-claim name and display name; the only provider that verifies ID tokens (FR-020..FR-022). +- **User record**: gains `groups []string`, `groups_updated_at`, `subject_rebind_armed_at`; keeps `(provider, provider_subject_id)` as the binding refreshed on every login (FR-008, FR-023). +- **Access configuration / group grant / entitlement set**: `server_edition.access.{group_servers, default_servers}`, hot-reloadable, validated at write and boot; the live per-authentication server set derived from stored groups, the map, sharing and personal records (Definitions, FR-007, FR-009). +- **Audit line / audit sink**: the schema-v1 JSON object of FR-013 (`authz`/`tool_call` per call, `auth_event`), the rotating file and/or stdout writer with a failure counter; no read surface (FR-012..FR-018). +- **Public URL / trusted proxies / cookie policy**: the three configuration facts that replace request-header trust for the front door (FR-025..FR-027). +- **Latent code inventory**: the verified zero-caller class-A table that FR-031 deletes; the class-B connect chain that FR-034 freezes. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: For every User Story 1 scenario on every applicable surface (FR-045 matrix; Spec 105 FR-014 matrix for the token), the tenant's normalised response is identical between fixture A and fixture B, contains no sentinel, and Bob's (no-group) responses equal an unentitled tenant's on 100% of doors including REST preflight β€” excluding only the retained Spec 105 effects and the named still-open Spec 105 items, whose fixtures assert the documented outcome. +- **SC-002**: 100% of the tampered-token matrix of US2 is refused with one indistinguishable page and status, zero user records created or updated, and one `auth_event` line each; every trusted-proxy and `public_url` case of US2.6 yields the expected callback URL, cookie attributes and recorded IP; every rejected redirect of US2.7 lands on `/ui/`; an IdP outage yields 503 with readiness unaffected. +- **SC-003**: In the US3 fixture, `authz` line count equals pre-dispatch decisions and `tool_call` line count equals `authz allow` lines (sheds and post-dispatch blocks included, built-in invocations excluded) under bus saturation; every line validates against the published schema; every line satisfies the structural redaction rule of FR-015 and zero bytes of any fixture sentinel (secret, argument payload, response, token, reason prose) appear in any line; `args_sha256` matches the independent canonical hash in 100% of lines; `caller.kind` is correct for all seven dispatch-reachable values β€” `api_key`, `socket`, `stdio`, `anonymous`, `agent_token`, `session_admin`, `internal` (eight in the schema; `session_user` appears on `auth_event` lines only β€” a fixture asserts no `authz`/`tool_call` line ever carries it), the caller-asserted `X-MCPProxy-Client: cli/…` header never yields `source: cli`, and nested children and limiter sheds keep the originating caller's kind. +- **SC-004**: US4 Playwright: a fresh browser reaches `/login` via the probe, and a tenant completes login β†’ server list β†’ token mint β†’ activity with zero API-key usage; every listed refused door returns 403 for the tenant session; a wrong explicit credential plus a valid cookie returns 401; a cross-site `POST` with the cookie is refused; an `admin_user` session saves Settings and receives masked secrets. +- **SC-005**: Both editions build; the personal E2E suite and the race suites of FR-035 pass; the guard test finds none of the FR-031..FR-033 removed declarations, accepted-mode values or callable branches (the retained compatibility literals β€” the `store_idp_tokens` decoder and the normaliser's key/mode strings β€” are exempt by name, FR-035); an old config with every removed key and mode loads under `-tags server` with exactly one deprecation line each and the same PATCH is refused, while the personal build preserves both blocks semantically intact with no warning (FR-032/FR-040); the docs site builds with the tombstone and every sidebar entry resolving; the `deploying-for-a-team.md` configuration example loads and validates in a server-tagged test. +- **SC-006** *(administrator exception list)*: Administrator responses on every surface are identical before and after this feature β€” "before" is the merge-base of this feature's PR, i.e. including whatever part of Spec 105 has landed, whose own administrator exceptions (105 SC-005: legacy/internal cache entries, exact registration identity, withheld unattributed definitions/prompts, canonical container ownership and log routing) are inherited from that baseline and not re-listed here β€” for the same fixture after nondeterministic-field normalisation, except: (a) an uncredentialed or unrecognised-bearer request on `/mcp` with the server edition enabled is a 401 instead of an anonymous administrator (FR-029); (b) administrator-email users are exempt from the group map, so their listings are unchanged while tenants' shrink (FR-009); (c) an administrator session principal is accepted on core REST doors it could not reach before, with secret reveal masked (FR-001/FR-002); (d) administrator dispatches and administrator logins now produce audit lines (FR-012, FR-017); (e) the per-owner token cap applies to owned administrator tokens (FR-037); (f) removed keys and modes are dropped at load with a warning and refused at write, and `store_idp_tokens` no longer writes (FR-032/FR-033); (g) `?redirect_uri=` absolute targets and untrusted forwarded headers are withdrawn for everyone (FR-026..FR-028); (h) `DetectConfigChanges` now reports `server_edition`, `audit_log` and `trusted_proxies` edits, and `PATCH /config` now validates the `server_edition` block (FR-039); (i) `POST /api/v1/auth/token`, `POST /api/v1/user/tokens` and `POST /api/v1/user/tokens/{name}/regenerate` accept only a session-cookie principal, so an `admin_user` bearer JWT can no longer renew itself or mint/rotate agent tokens, and `expires_in` on `/user/tokens` is capped at 365 days for administrators too (FR-011); (j) an administrator disableβ†’enable cycle now arms a single-use subject rebind on that record (FR-023); (k) **additive fields and new responses only** β€” `GET /api/v1/auth/me` gains `groups`/`groups_updated_at` and `GET /api/v1/admin/users` gains `groups`, `groups_updated_at` and `subject_rebind_armed_at` per user (today neither carries them: `auth_endpoints.go:58-65,96-102`, `admin_handlers.go:146-154,183-195`; FR-008/FR-023), the public `GET /api/v1/auth/provider` exists (FR-030), the telemetry payload carries the three new fields at v13 (FR-038), `GET /config` shows the new keys with `client_secret` masked, and `mcpproxy doctor`/`GET /diagnostics` may carry the new findings (audit write failures, missing `public_url` off loopback, `session_cookie_secure: false`, `require_mcp_auth` override, unknown map names β€” FR-018/FR-025/FR-026/FR-029, Edge Cases); pre-existing fields, statuses and bodies are unchanged and the parity fixture compares with the new keys removed. All tool-surface goldens pass unregenerated (FR-044). +- **SC-007** *(personal-edition parity)*: The personal edition's `./scripts/test-api-e2e.sh`, tool-surface goldens (unregenerated) and Web UI sweep pass; its only observable changes are the available-but-off `audit_log` block, `trusted_proxies` gating forwarded headers (previously trusted from anyone in the swagger base URL only), the preserved `server_edition` and per-server `auth_broker` blocks on write-back (previously written back as `{}`; no warning, no normalisation β€” FR-032/FR-040), the per-owner cap (no-op for ownerless tokens), the removed never-called brokered-auth branches, and the three new telemetry payload fields (`server_edition_enabled`, `idp_provider`, `user_count_bucket`) with `false`/`"none"`/`"0"` (`is_container` pre-exists and is unchanged). +- **SC-008**: Groups staleness bound holds: after an IdP-side removal without re-login, access persists no longer than session TTL + the longer of JWT TTL and the longest-lived owned token (≀ 365 days), and a bearer JWT cannot extend that through `/auth/token`, `/user/tokens` or `/user/tokens/{name}/regenerate` (the chained session β†’ JWT β†’ token clock fixture of FR-011); after re-login or administrator `disable`, the next request is narrowed; an un-share takes effect on the next request of an existing token and the next SSE frame with no rotation or restart. +- **SC-009** *(performance)*: Measured as Spec 105 FR-011 (frozen 527-tool snapshot, 200 calls after 20 warm-ups, CI reference runner, merge-base comparison): with `audit_log` enabled, administrator p95 for `call_tool_read`, `retrieve_tools` and `tools/list` regresses by no more than 10% or 5 ms, whichever is larger; a scoped tenant's `retrieve_tools` p95 is within 20 ms of the administrator figure. +- **SC-010**: Telemetry payload privacy suites pass with the new fields; `SchemaVersion == 13` at every pin and fixture FR-038 lists, and the grep guard is empty; ROADMAP.md is regenerated (`scripts/gen-roadmap.py`) and `specs/README.md` lists 107. +- **SC-011**: Every acceptance scenario in this spec exists as an automated test by name in the server-edition suite (`-tags server`) or the personal suite as applicable; server-edition packages are linted and race-tested in CI under the tag with the skip regex (FR-047). + +## Assumptions + +- **Spec 105 delivery state.** PR #1279 (105 FR-009) is OPEN at HEAD; 105's FR-005 usage/risk statistics, FR-010 available-servers text and the two-fixture oracle **do not exist as test infrastructure on `main`** (`specs/105` holds only `spec.md`; critic C11). This spec implements the two-fixture comparison in its own harness for its surfaces (FR-047) and reuses PR #1279's oracle where it has landed. A group-excluded server leaks through the still-open 105 items (`retrieve_tools` `usage_summary`/`session_risk` at `mcp.go:2005-2041`; the "Available servers" error at `mcp.go:2475-2490`; scope-denial text at `mcp.go:2289`, `mcp_routing.go:434`) exactly as a token-excluded one does today, and closes when 105 does; those items are named in FR-046 and excluded from SC-001 by name. Line anchors in `mcp.go:2211-2425` shift when #1279 merges. +- **Administrators are exempt from the group map** ("admins keep today's behaviour" β€” maintainer decision). An operator who wants an administrator narrowed removes them from `admin_emails`. +- **The group grant narrows sharing rather than replacing it**: a server must be shared *and* granted, because every composition term in Specs 028/105/106 is narrow-only and a typo in the map must never expose a private server; `"*"` is the one-switch shortcut. +- **Groups refresh only at login.** No IdP re-query path exists and building one (refresh tokens, `store_idp_tokens`) is the chain this spec removes; the window is made genuinely bounded by closing JWT self-renewal (FR-011), documented, and administrator `disable` is the immediate remedy. +- **Email remains the lookup key**; subject binding (FR-023) is layered on it to stop silent takeover without a user-store migration. +- **Legacy providers keep unverified ID-token parsing** (trusted back-channel over TLS, as today); extending verification to Google/Microsoft is a separate small follow-up. +- **`email_verified_policy` defaults to `refuse_false`** because a spec titled "hardening" must not let a self-asserted email pass `allowed_domains` by default; the cost β€” Keycloak/Authentik admin-created users with "Email verified" unticked are refused with `email_unverified` until the operator ticks it or sets `ignore` β€” is documented next to the knob. +- **Audit defaults differ by edition, code does not** (FR-014): stdout is the container-native sink and the server edition exists for operators who asked for attribution; SIEM connectors are explicitly not built (research kill list). Two lines per allowed call (decision before the upstream call, outcome after) is chosen over one because a process killed mid-call must not erase the record that a destructive call was authorized. +- **Fail-open on runtime sink failure** is preferred to failing tool calls; boot-time misconfiguration fails closed (exit 4). +- **Non-disclosing refusal** is the chosen strategy throughout, matching Specs 105/106 and the entitlement-test oracle (status parity with an absent resource). +- **No new module dependency** anywhere in this spec (JWKS parsing is stdlib + the existing `golang-jwt/jwt/v5`). +- **The loader is lenient** on unknown keys (critic G2); removing knobs is non-breaking for existing files, and the normalise-on-load step (FR-032) is what makes removed *modes* non-breaking too. +- **Cut vs freeze follows the zero-caller criterion**: class-A symbols with zero production callers are deleted; the Path B `oauth_connect` chain has live REST/CLI callers and is frozen with honest wording (FR-034); `store_idp_tokens` is the one live writer with zero readers and becomes a no-op (FR-033). +- **Single replica** behind one stable public URL (in-memory OAuth state, BBolt single writer); the container docs state `strategy: Recreate`. +- **The `MCPPROXY_DATA` / bare `--data-dir` loader bug** is out of this spec; the deploy guide uses the working `--config /mcp_config.json --data-dir ` pair. + +## Out of Scope + +Deferred, each with the consequence of deferring: + +| Deferred item | Consequence | +|---|---| +| OAuth 2.1 authorization-server role, DCR, `WWW-Authenticate`/`resource_metadata` on `/mcp`, audience-bound tokens, consent ceremony, a dedicated tunnel listener, per-listener path filtering, tunnel-origin rate limits (Spec 089 FR-005..007, FR-009, FR-013, FR-014) | Claude custom connectors still cannot reach `/mcp` without a static bearer; SSO routes share the single mux with `/mcp`, so an ingress exposing the door must filter paths itself; the SSO session is designed so it *can* later become the consent credential but nothing is built; `origin: remote` is reserved, never emitted. | +| Session cookie or user JWT on `/mcp` | Tenants reach tools only through owned agent tokens (FR-003); no second effective-authorization path exists. | +| Per-caller (owner) filtering of core `/activity*` and `/tool-calls*` for **agent tokens**, and an owner field on `ToolCallRecord` | A tenant-owned agent token keeps today's server-only scope on those REST reads (Spec 105/106 state): it can read other callers' arguments and responses on a server it is entitled to. This spec closes the new session door (FR-002: 403; tenants use `/user/activity`) and does not widen the token one; closing it for tokens is a Spec 105/106 follow-up because `ToolCallRecord` carries no owner and `ActivityRecord.UserID` is the token owner's id, not a per-token identity. | +| The FR-002 `/profiles` tenant projection applied to **agent tokens** on REST | A scoped agent token on `GET /api/v1/profiles` keeps today's shape (`profiles.go:61-85`): every profile name is listed with its server list filtered, so a hidden-only profile's *name* and the global `/profiles/active` slug remain visible to a token on REST (the REST management API is outside Spec 105's surface list; `set_profile` on `/mcp` is already non-disclosing under 105 FR-003). Closing it for tokens is a Spec 105 follow-up on the same branch as the activity door above; this spec opens no new token door. | +| Unifying the `Type == AuthTypeAgent` gates on MCP with `IsScopedCaller` (`mcp_direct_scope.go:47,187,225`, `profile_resolver.go:32`, `mcp_direct_callability.go:55,106`, `mcp_describe_direct.go:98`, `cache_authz.go:37`) | Unnecessary while FR-003/FR-043(a) hold and PR #1279 reshapes those files; must be done before any user principal is ever admitted to `/mcp`. | +| Rate limiting / brute-force protection of `/api/v1/auth/login` beyond the bounded state map | An internet-facing login endpoint remains unthrottled; operators rate-limit at the ingress (Spec 089 FR-013). | +| A CSRF token for cookie-authenticated core REST mutations beyond `SameSite=Lax` | Administrator sessions can be CSRF'd into mutations by a same-site page; tenant sessions cannot mutate; secrets are never revealed to sessions. | +| Background IdP group re-query between logins | Group changes propagate at next login (FR-011 bound). | +| ID-token verification for Google/Microsoft; substituting `preferred_username`/`sub` for a missing email | Unchanged trusted-back-channel stance; Keycloak service accounts and Entra guests without email cannot log in, as today. | +| `activity export` adopting the audit schema; a second async `detection` audit line; audit lines for REST configuration mutations, prompt fetches and quarantine changes; **audit lines for built-in tool invocations as such** (`retrieve_tools`, `describe_tool`, `read_cache`, `set_profile`, `upstream_servers`, `quarantine_security`, `list_registries`, `search_servers`, `doctor`, the `code_execution` wrapper); MCP authentication failures (revoked/expired token) as audit lines | Two shapes exist (activity export for the DB, audit line for forwarding); detection verdicts, config changes and built-in invocations are attributed only in the activity log (which strips identity for `upstream_servers`/`quarantine_security` β€” administrator-only built-ins β€” `mcp_activity_args.go:97`); discovery and cache reads by a scoped token leave no audit trace, only their upstream dispatches do; unauthenticated `/mcp` attempts stay in the operational log. | +| Renaming `connected`β†’`stored` in `GET /user/credentials` (FR-034) | The REST status vocabulary remains `connected` while the docs and CLI say "stored, not injected"; open decision. | +| A dedicated admin "reset subject binding" action (FR-023) | A subject change is accepted only on the first login after an administrator disableβ†’enable cycle (FR-023). | +| Audit lines for agent-token mint/rotate/revoke (`token_event`) | Token lifecycle stays in the activity log and the operational log; the audit sink records tool-call decisions, outcomes and sign-in events only (FR-017). | +| Declarative Settings control for the `access` map | Raw JSON tab only. | +| A per-tenant personal-server quota (the removed `max_user_servers` never enforced one) | No quota, as today. | +| `is_k8s`, `image_variant` telemetry markers; a dedicated D1 column | Dashboards query `json_extract` over `payload_json`. | +| `${file:}` secret provider, `MCPPROXY_DATA` loader fix, non-root image, Helm, compose, personal-edition image variants | Separate container-first work; the deploy guide uses `${env:}` from a Secret and the `--config`+`--data-dir` pair. | +| Wider `-tags server` race coverage beyond the packages FR-047 names | Local runs remain the guard for the rest. | + +Not deferred β€” permanently out of scope for this spec: Helm, Vault/secret-manager SDKs, SIEM/OTel push connectors, HA/external DB/multi-replica, HITL approval holds, per-tenant rate limits or quotas, SAML/SCIM/teams-or-org entity, roles beyond admin/user, per-tool policy on top of the group map, admin-locked inventory and destructive-tool deny policy (separate research items), wiring the credential broker (Phase 2 trigger in the decision record), any rename of `teams`-era Go symbols, any change to built-in MCP tool arguments, any new REST read surface for the audit file, and the Spec 105 open items delivered by PR #1279. + +## Commit Message Conventions *(mandatory)* + +When committing changes for this feature, follow these guidelines: + +### Issue References +- βœ… **Use**: `Related #[issue-number]` - Links the commit to the issue without auto-closing (e.g. `Related #1177`) +- ❌ **Do NOT use**: `Fixes #[issue-number]`, `Closes #[issue-number]`, `Resolves #[issue-number]` - These auto-close issues on merge + +**Rationale**: Issues should only be closed manually after verification and testing in production, not automatically on merge. + +### Co-Authorship +- ❌ **Do NOT include**: `Co-Authored-By: Claude ` +- ❌ **Do NOT include**: "πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" + +**Rationale**: Commit authorship should reflect the human contributors, not the AI tools used. + +### Example Commit Message +``` +feat(serveredition): generic OIDC provider with verified ID tokens + +Related #1177 + +[Detailed description of what was changed and why] + +## Changes +- [Bulleted list of key changes] +- [Each change on a new line] + +## Testing +- [Test results summary] +- [Key test scenarios covered] +``` diff --git a/specs/107-server-edition-sso-hardening/tasks.md b/specs/107-server-edition-sso-hardening/tasks.md new file mode 100644 index 000000000..7f2a98ba2 --- /dev/null +++ b/specs/107-server-edition-sso-hardening/tasks.md @@ -0,0 +1,313 @@ +# Tasks: Server Edition β€” SSO Front Door Hardened for Real IdPs + +**Input**: spec.md, plan.md, research.md (D1–D14), data-model.md, contracts/, quickstart.md +**Organization**: four PRs in fixed order (plan.md Β§Delivery). Inside each PR, phases follow user stories; every implementation task is preceded by the failing-test task that pins it; every story ends with a real-instance verification task that records commands and outcomes in `verification.md`. Tests are **required** (constitution V; spec SC-011). + +## Format: `[ID] [P?] [Story] Description β€” files` + +- **[P]**: parallelisable (different files, no dependency on an unticked task in the same phase) +- **[USn]**: spec user story; **[X]** = cross-cutting (no story) +- **Red kind** (plan.md Β§Execution step 2) on every test-first task: **[behaviour-red]** = compiles on HEAD and fails for the stated reason; **[compile-red]** = references a symbol its paired implementation task introduces (named in the task) and is red as a build failure until that task lands; **[tooling]** = no Go test (shell/static check). A `[compile-red]` task names *every* implementation task whose symbols it needs β€” it must compile the moment the last of them lands, never later. +- Anchors are origin/main `b39800a89`; `mcp.go:2211-2425` shifts when PR #1279 merges β€” re-derive before editing. + +Gate set for every PR = plan.md Β§Gates. Never claim a skipped gate passed; write "not run: " in verification.md. + +--- + +## Phase 0 β€” Setup (shared) + +- [x] T001 [X] Un-park roadmap epic `sso` (`roadmap.yaml:803-809`): `status: in_progress`, `priority: P2`, `spec: specs/107-server-edition-sso-hardening`, four `tasks` rows (PR-A..PR-D, `pr:` empty); run `python3 scripts/gen-roadmap.py` and commit `ROADMAP.md`; add the 107 row to `specs/README.md` (pattern of the 105 row at `:84`) β€” `roadmap.yaml`, `ROADMAP.md`, `specs/README.md` +- [x] T002 [X] Validate `specs/107-server-edition-sso-hardening/checklists/requirements.md` against the final spec; record any open item as an Assumption, never as a NEEDS CLARIFICATION β€” `checklists/requirements.md` +- [x] T003 [P] [X] Smoke-test the cross-model reviewer for each PR: `codex exec -m gpt-5.6-sol --sandbox read-only "Reply with exactly: PONG" < /dev/null` must print PONG (reviewer = codex Sol 5.6 while the Copilot quota is exhausted, maintainer directive 2026-09-15) β€” no repo file +- [x] T004 [P] [X] Create `verification.md` with one empty section per PR (`## PR-A`, … `## PR-D`), each with sub-headings "Real instance", "Automated checks", "Cross-review" (Spec 106 shape) β€” `specs/107-server-edition-sso-hardening/verification.md` + +--- + +# PR-A β€” freeze/cut + honesty + hygiene (`107-a-freeze-cut`) + +**Goal**: FR-031..FR-037, FR-039 part 1, FR-040, FR-042 entry, FR-047 part 1. Stories US5, US6. Both editions build; personal E2E is the required check for the transport/upstream cut. + +## Phase A.1 β€” CI and lint under the server tag (FR-047 part 1) + +- [ ] T005 [X] Add a second lint step `--build-tags server` to `.github/workflows/unit-tests.yml:294-304` and widen the `server-edition` job (`:340`) with `./internal/server/... ./internal/httpapi/... ./internal/storage/...` under `-tags server` **with** the `-skip` regex from `:148`; document both in `CLAUDE.md` "Lint" block β€” `.github/workflows/unit-tests.yml`, `CLAUDE.md` +- [ ] T006 [P] [X] Run the widened suites locally and inventory every pre-existing lint finding the server tag exposes (record the exact list and count in verification.md). Fix in PR-A only the findings in files PR-A already touches; every other pre-existing finding lands in a separate prerequisite `chore(lint):` PR opened before PR-A (or, if ≀ 5 and trivial, as one isolated `chore:` commit at the head of PR-A) so PR-A stays reviewable and within F1–F4 β€” files as found +- [ ] T007 [P] [X] Create `.github/RELEASE_NOTICE.md` (consumed at `release.yml:1466`) with the PR-A bullets of FR-042: removed keys/modes, `store_idp_tokens` no-op, "stored, not injected", per-owner token cap β€” `.github/RELEASE_NOTICE.md` + +## Phase A.2 β€” US5: the code and the docs stop promising credential injection (P2) + +**Independent test**: both builds + `go vet` + lint clean; the FR-035 guard test finds none of the removed declarations; the old-config fixtures load with one warning per key/mode (server) and round-trip intact (personal); `test-api-e2e.sh` and `go test -race ./internal/upstream/... ./internal/transport/...` green; `TestOAuthClients_CarryStaticHeaders` green. + +### Tests first (must fail on HEAD) + +- [ ] T008 [US5] **[behaviour-red]** (every listed declaration exists on HEAD) Write `internal/config/latent_symbols_guard_test.go` (both tags): walk the non-test AST of `internal/serveredition/...`, `internal/transport`, `internal/upstream/core`, `internal/config` and fail while any FR-031/FR-033 declaration exists (`Router`, `NewRouter`, `ToolFilter`, `NewToolFilter`, `workspace.Manager`, `TokenExchanger`, `CredentialResolver`, `HeaderInjector`, `ConnectionKey`, `AuditActionInject|Acquire|Refresh`, `AuditMethodTokenExchange|EntraOBO`, `auditMethodForMode`, `GetValidIDPSubjectToken`, `ErrReauthRequired`, `RefreshAccessToken`, `(*OAuthConnector).Refresh`, `ConnectorProvider`, `ConnectorFor`, `SetBrokeredAuth`, `brokeredAuth`, `BrokeredAuth`, `EffectiveHeaders`, `refuseBrokeredOAuth`), the fields `MaxUserServers`, `WorkspaceIdleTimeout`, `Header`, `HeaderFormat`, and the literals `"token_exchange"`/`"entra_obo"` inside `validateServerAuthBroker`'s accepted set β€” exempting by name the `StoreIDPTokens` decoder field, its warning string and the normaliser's key/mode strings (FR-035) β€” `internal/config/latent_symbols_guard_test.go` +- [ ] T009 [P] [US5] **[compile-red until T018 (`ValidateRemovedKeys`, `LoadDiagnostics`)]** Write `internal/config/legacy_keys_load_test.go` (`-tags server`): a fixture config carrying `server_edition.max_user_servers`, `workspace_idle_timeout`, `store_idp_tokens: true` and two servers with `auth_broker.mode: token_exchange` / `auth_broker.header: X-Auth` loads without error and records exactly one **load diagnostic** per key/mode on the returned `Config` (`cfg.LoadDiagnostics() []LoadDiagnostic`, T018 β€” the loader has no logger: `loadConfigFile` (`loader.go:242`) returns only an error and `main.go` builds the logger at `:499`, after the load at `:744`, so the warning cannot be emitted from the loader and, once the typed decode has dropped the keys, cannot be reconstructed later); a second assertion drives the same diagnostics through the emit helper with a zap observer and sees one WARN line per diagnostic; `DetectConfigChanges` reports none of them, and the write-back omits them; the same document sent through `PATCH /api/v1/config` and `/config/apply` is refused with the `contracts/config-keys.md` messages by the **raw-document check** (`config.ValidateRemovedKeys(raw map[string]any)`, T018) β€” `Config.Validate()` alone cannot see them because `json.Unmarshal` into the typed struct silently drops unknown keys (`httpapi/server.go:5405-5408`); the test asserts all three write doors (PATCH, apply, boot load) separately β€” `internal/config/legacy_keys_load_test.go`, `internal/config/testdata/legacy_server_edition.json`, `internal/httpapi/config_patch_removed_keys_test.go` (`-tags server`) +- [ ] T010 [P] [US5] **[behaviour-red for `personal_roundtrip_test.go` (the personal stubs are `struct{}` today, so the blocks are erased on write-back); compile-red until T020 for `config_patch_roundtrip_test.go`** (it needs the exported merge helper `httpapi.MergeConfigPatch`, which T020 adds as a one-line wrapper over the unexported `deepMergeJSON`, `httpapi/server.go:5512`) Write `internal/config/personal_roundtrip_test.go` (`!server`): load the same fixture, `SaveConfig`, PATCH-merge an unrelated key through `httpapi.MergeConfigPatch` (the `handlePatchConfig` merge path, T020), save again; compare `server_edition` and every `auth_broker` block under an independent `UseNumber` structural comparator with `9007199254740993` and `0.1000000000000000055511151231257827` planted; assert no warning was logged β€” `internal/config/personal_roundtrip_test.go`, `internal/httpapi/config_patch_roundtrip_test.go` +- [ ] T011 [P] [US5] **[compile-red until T019 (`ApplyDefaults`); the `Config.Validate` reach case is behaviour-red]** Write `internal/config/server_edition_validate_test.go` (`-tags server`): `Config.Validate()`/`ValidateDetailed()` reach `ServerEditionConfig.Validate` (an enabled block with `provider: bogus` fails at `Config.Validate`, today it passes); `Validate` is non-mutating (`TenantID`, TTLs, `CredentialEncryptionKey` unchanged after the call); `ApplyDefaults` fills them β€” `internal/config/server_edition_validate_test.go` +- [ ] T012 [P] [US5] **[behaviour-red]** Write `cmd/mcpproxy/credential_cmd_wording_test.go` (`-tags server`): `credential list` and `credential status` output begins with the "stored … NOT injected" line of `contracts/rest-endpoints.md` Β§12 β€” `cmd/mcpproxy/credential_cmd_wording_test.go` +- [ ] T013 [P] [US5] **[behaviour-red]** Write `docs/docs_injection_claims_test.go` (or extend `cmd/release-gate/`): fail while any FR-036 sentence survives (`"injects it at call time"`, `"Credential resolution"`, `"Header injection"`, `"Per-(user, server) connection keying"`, `"token_exchange"` row, `"entra_obo"` row, `"JWT bearer token for MCP"`, `"nothing re-validates at use time"`, `"per-user connections"`, the `workspace/` row) and while `website/sidebars.js:55,129,130,263` name a file that does not exist β€” `cmd/release-gate/docs_claims_test.go` + +### Implementation + +- [ ] T014 [US5] Delete class A: `internal/serveredition/multiuser/{router.go,tool_filter.go,router_test.go,router_broker_test.go,router_broker_agent_test.go}`, package `internal/serveredition/workspace/`, `internal/serveredition/broker/{token_exchanger.go,credential_resolver.go,injector.go}` + their tests + `audit_resolver_test.go`; trim `broker/audit.go` to `AuditMethodConnect`/`AuditActionConnect`; remove `TestIntegration_CrossUserIsolation/_GetServerForUser/_ToolFilterAccessibleServerNames` from `multiuser/isolation_test.go` (keep `_ActivityIsolation*`); list the six retired security-regression tests by name in the PR body (FR-031) β€” files above +- [ ] T015 [US5] Delete `internal/serveredition/auth/idp_subject_token.go` (+ the five `TestGetValidIDPSubjectToken_*` and the two `TestHandleCallback_*IDPSubjectToken*`), `OAuthProvider.RefreshAccessToken` (`oauth_providers.go:212-256`), `OfflineAccessScopes`/`OfflineAuthParams` and their use at `oauth_handler.go:139-144`, the `persistIDPSubjectToken` call at `:252`, `SetCredentialStore` on the handler if now unused; `(*OAuthConnector).Refresh` (`oauth_connector.go:265-300` + tests `:291-354`); `(*CredentialHandlers).ConnectorProvider`, `connectorProvider.ConnectorFor` + assertion (`credential_handlers.go:63-68`, `connector_provider.go:102-105,137-138`) β€” files above +- [ ] T016 [US5] Remove the brokered seam from the personal binary: `internal/transport/broker_auth.go` (+ `broker_auth_test.go`, `broker_auth_integration_test.go`), `HTTPTransportConfig.BrokeredAuth` + `refuseBrokeredOAuth`/`effectiveHeaders` in `transport/http.go:135-139,178-192`, `Client.SetBrokeredAuth`/`brokeredAuth`/brokered branches in `upstream/core/connection_http.go:28-29,44-45,171-192` and `client.go:93-99` (+ `connection_http_broker_test.go`), `TestAuthStrategies_BrokeredWinsOverOAuthBlock` (`auth_strategy_oauth_block_test.go:71-88`), `TestOAuthClients_RefuseBrokeredAuth` (`oauth_static_headers_test.go:78-91`); keep `TestOAuthClients_CarryStaticHeaders` green β€” files above +- [ ] T017 [US5] Remove `MaxUserServers`/`WorkspaceIdleTimeout` (`server_edition_config.go:17-20,45-46,96-101`), `AuthBrokerConfig.Header/HeaderFormat` + defaults (`auth_broker.go:44-64`), the `token_exchange`/`entra_obo` constants and validator arms (`auth_broker.go:11-13,74-79,113-138`); update the `internal/oauth/serverfields_serveredition.go` mask table (+ its test) for the removed leaves β€” the storage canary `TestSaveServerSyncFieldCoverage` (`async_ops_test.go:353,415`) sees only the top-level `AuthBroker` field and skips it, so it needs **no** change and cannot cover nested leaves; **retire or rewrite every existing test that names a removed symbol or asserts the old mutating `Validate`** (compile-red otherwise): `internal/config/server_edition_config_test.go:24-25,146-149,216-217,251-252,271-272,326,342,390-391` (`MaxUserServers`/`WorkspaceIdleTimeout`), `internal/config/auth_broker_test.go:27-42,66-94,104,120,157,164` (`Header`/`HeaderFormat`/`AuthBrokerModeTokenExchange`/`EntraOBO`), `internal/config/teams_credential_test.go:36ff` (`MCPPROXY_CRED_KEY` fallback moves from `Validate` to `ApplyDefaults` β€” the assertion moves with it, T019); swap the inert `token_exchange` fixture strings to `oauth_connect` in `api/credential_handlers_test.go`, `api/user_handlers_redaction_test.go:78`, `setup_wiring_test.go:257`, `runtime/server_sharing_test.go:37`, `broker/credential_store_test.go:58`, `config/auth_broker_test.go` β€” files above +- [ ] T018 [US5] Server-build normaliser beside the `teams` alias (`internal/config/loader.go:271-285`), run on the **raw map before the typed decode**: drop the removed keys/modes and append one `LoadDiagnostic{Key, Message}` each (exact strings in `contracts/config-keys.md`) to an unexported slice on `Config` exposed by `LoadDiagnostics()`; `store_idp_tokens: true` adds its deprecation diagnostic the same way; the diagnostics are logged **once** by `config.LogLoadDiagnostics(cfg, logger)` β€” called from `cmd/mcpproxy/main.go` right after `logs.SetupLogger` (`:499`) and from the hot-reload path after each successful reload (the reload has a logger) β€” never from the loader itself (no logger exists there, finding of round 2); the personal build records no diagnostics (opaque carriers); make `DetectConfigChanges` blind to the dropped keys (nothing to add β€” assert in T009). **Write-time refusal is a raw-document check, not `Config.Validate`**: add `config.ValidateRemovedKeys(raw map[string]any) []ValidationError` (server build; personal stub returns nil) that walks `server_edition.{max_user_servers,workspace_idle_timeout}` and every `mcpServers[].auth_broker.{header,header_format,mode∈{token_exchange,entra_obo}}` in the generic map, and call it in `handlePatchConfig` on the merged map **before** `json.Unmarshal(mergedBytes, &merged)` (`httpapi/server.go:5398-5408`), in `/config/apply` on the decoded raw document, and nowhere on the boot path (boot normalises + records diagnostics instead) β€” `internal/config/loader.go`, `internal/config/loader_serveredition.go` (new, `//go:build server`), `internal/config/removed_keys{,_stub}.go` (new), `internal/config/load_diagnostics.go` (new, edition-neutral type + `LogLoadDiagnostics`), `internal/httpapi/server.go`, `cmd/mcpproxy/main.go`, `internal/runtime/config_hotreload.go` (reload emit) +- [ ] T019 [US5] Split `ServerEditionConfig.Validate` into non-mutating `Validate()` + `ApplyDefaults()` (TTLs, Microsoft `TenantID`, `MCPPROXY_CRED_KEY` fallback); call `Validate` from `Config.Validate()` and `ValidateDetailed()` under the server build (`internal/config/config_validate_serveredition.go` + `_stub.go`); call `ApplyDefaults()` then `Validate()` at `setup.go:96` β€” `internal/config/server_edition_config.go`, `internal/config/config.go`, `internal/config/config_validate_serveredition{,_stub}.go`, `internal/serveredition/setup.go` +- [ ] T020 [US5] Personal-build carriers: `type ServerEditionConfig struct{ raw json.RawMessage }` and `type AuthBrokerConfig struct{ raw json.RawMessage }` with verbatim `MarshalJSON`/`UnmarshalJSON`. **Omission is the parent pointer's job, not the carrier's**: `Config.ServerEdition` and `ServerConfig.AuthBroker` are `*T` with `omitempty` (`config.go:584,752`), so a document without the key leaves the pointer nil and the key is omitted on write; a JSON `null` also decodes to a nil pointer. A non-nil carrier with nil `raw` (only constructible from Go code) marshals as `{}` β€” `MarshalJSON` must return valid JSON and cannot remove the parent key. A build-tagged `Clone()` deep-copies the carrier (`append(json.RawMessage(nil), raw...)`); the existing value copy `broker := *src.AuthBroker` at `merge.go:678-684` (inside `CopyServerConfig`) would alias the backing array and becomes `dst.AuthBroker = src.AuthBroker.Clone()`; **there is no `ServerEdition` copy call at HEAD** (`merge.go` copies servers only; the config snapshot shares the top-level pointer and nothing mutates it in the personal build), so `Clone()` on `ServerEditionConfig` is provided for symmetry and pinned by a unit test but wired nowhere; test: mutating the source `AuthBroker` carrier after `CopyServerConfig` leaves the copy unchanged; `handlePatchConfig` decodes patch and base with `json.Decoder.UseNumber` (`internal/httpapi/server.go:5332,5376`); export `MergeConfigPatch(base, patch map[string]any) map[string]any` as a one-line wrapper over `deepMergeJSON` (`:5512`) for T010 β€” `internal/config/server_edition_config_stub.go`, `internal/config/auth_broker_stub.go`, `internal/config/merge.go`, `internal/httpapi/server.go` +- [ ] T021 [P] [US5] `mcpproxy credential list|status` banner (`cmd/mcpproxy/credential_cmd.go:32` `Short:` and the output header) β€” `cmd/mcpproxy/credential_cmd.go` +- [ ] T022a [P] [US5] **[behaviour-red]** (the row exists at `fields.ts:327`) Extend `frontend/tests/unit/settings-server-edition-wording.spec.ts` to assert that no `SERVER_EDITION_FIELDS` entry has key `server_edition.max_user_servers` β€” `frontend/tests/unit/settings-server-edition-wording.spec.ts` +- [ ] T022 [P] [US5] (after T022a) Web UI: drop `server_edition.max_user_servers` from `SERVER_EDITION_FIELDS` (`frontend/src/views/settings/fields.ts:327`) β€” `frontend/src/views/settings/fields.ts` +- [ ] T023 [P] [US5] Docs corrections (FR-036, this PR's slice): `docs/features/idp-token-storage.md` β†’ one-paragraph tombstone; `docs/features/auth-broker.md` and `docs/cli/credential-commands.md` open with "stored, not injected", lose `:82-106` and the `token_exchange`/`entra_obo` rows, and `auth-broker.md` keeps a `credential_encryption_key` / `MCPPROXY_CRED_KEY` paragraph (the tombstoned `idp-token-storage.md` was its only home); `docs/development/server-edition-multiuser-auth.md:27-28,40,53,54-55,206-210,242-243` corrected; `docs/features/activity-log.md:28,300`; `docs/configuration/upstream-servers.md:112`; `CLAUDE.md` editions row (no server `.deb`/tar.gz); `internal/config/auth_broker.go:24-27` and `config.go:746-752` comments β€” files above +- [ ] T024 [US5] Run both builds, `go vet ./...`, both lint passes, `go test -race ./internal/upstream/... ./internal/transport/...`, the server race suites, and confirm T008–T013 now pass; regenerate nothing (no OAS/contract change in this PR) but run `make swagger-verify` and `TestContractsInSync` to prove it β€” verification.md Β§PR-A +- [ ] T025 [US5] Real-instance verification: quickstart Β§9 (personal-build round trip of a config with the removed keys and a valid `oauth_connect` block), then a server-edition boot of the same file asserting the one-warning-per-key log lines and `Enabled` still true; `mcpproxy credential list` shows the banner; record in verification.md β€” `verification.md` + +## Phase A.3 β€” US6: per-owner token cap (P3, #1177) + +- [ ] T026 [US6] **[behaviour-red]** (existing APIs; the cap is global today) Write failing `internal/storage/agent_tokens_owner_cap_test.go`: owner A at 100 β†’ A's 101st `ErrAgentTokenLimitReached`, B's first succeeds, ownerless operator tokens count as one owner; and `internal/serveredition/api/user_token_cap_test.go` (`-tags server`): the 409 body names only the caller's count; `internal/httpapi/tokens_cap_test.go`: personal 409 body unchanged in meaning β€” files above +- [ ] T027 [US6] Enforce per owner in `CreateAgentToken` (`internal/storage/agent_tokens.go:194-198`): **there is no owner index** (records are keyed by hash, `UserID` lives inside the JSON) and once the global cap goes the bucket may hold far more than `MaxTokens` rows, so the count is a **full bucket walk** that decodes each record and counts `UserID == new.UserID`, stopping early only once `MaxTokens` matches are found (never after `MaxTokens` rows); correct the now-false comment at `:87-92` ("the bucket is capped at auth.MaxTokens entries"); the T026 fixture seeds > 100 other-owner rows *before* the target owner's rows in key order and asserts the target owner's 100th token still succeeds and the 101st fails; reword the 409 bodies at `internal/httpapi/tokens.go:224` and `user_handlers.go:1165-1174`; update `docs/development/server-edition-multiuser-auth.md:115-121` and `docs/features/agent-tokens.md`; commit message `Related #1177` β€” files above +- [ ] T028 [US6] Real-instance verification: quickstart Β§8 on a server-edition instance with two seeded users (harness or `curl` loop); record in verification.md β€” `verification.md` + +## Phase A.4 β€” Delivery + +- [ ] T029 [X] Run the full gate set (plan.md Β§Gates) including the isolated `test-api-e2e.sh`; record every command and outcome β€” `verification.md` +- [ ] T030 [X] Cross-model review with codex `gpt-5.6-sol` (briefs split: cut diff / config normaliser + carriers / docs), ≀ 10 rounds, verify each finding before fixing; record rounds and verdicts β€” `verification.md` +- [ ] T031 [X] Open PR-A (`--body-file`; `Related #1177`; the six retired tests listed by name; no AI attribution); tick the roadmap task row `pr:`; run `python3 scripts/gen-roadmap.py --check`; repair CI until green; do not merge without instruction β€” `roadmap.yaml`, `ROADMAP.md` + +--- + +# PR-B β€” generic OIDC + front door + telemetry (`107-b-oidc-front-door`) + +**Goal**: FR-020..FR-030, FR-008 capture half, FR-038, FR-039 part 2, FR-047 part 2, FR-036 config-file section for these keys, FR-042 entry. Stories US2, US7. Depends on PR-A (non-mutating `Validate`, lint under the tag). + +## Phase B.1 β€” Test rig: fake OIDC IdP and dev script (FR-047 part 2) + +- [ ] T032 [US2] **[compile-red until T033]** Write failing `tests/oauthserver/oidc_test.go`: with `Options.OIDC`, discovery carries `userinfo_endpoint` + `id_token_signing_alg_values_supported`, the auth-code token response carries an RS256 `id_token` whose `nonce`/`sub`/`email`/`email_verified`/`name`/`groups` match `Options.UserClaims`, `/userinfo` returns the same claims for the access token, and **every** `ErrorMode` tamper knob of research D2 (the eleven original ones plus `IDTokenMultiAudNoAzp`, `IDTokenNbfFuture`, `IDTokenUnknownKid`, `IDTokenKeyAlgMismatch`, `GroupsNonArray`, `GroupsOverageMarker`, `GroupsAbsentEverywhere`, `UserinfoRedirect`, `UserinfoNonJSON`, `UserinfoUnavailable`, `AuthAccessDenied` (exists)) produces the intended defect, one sub-test per knob so an unimplemented knob is a named failure β€” `tests/oauthserver/oidc_test.go` +- [ ] T033 [US2] Implement `Options.OIDC`, `Options.UserClaims`, `Options.ClientRedirectURIs`, `Options.GroupsClaim`, the full `ErrorMode` knob list of research D2 (as extended in round 2 β€” every T037 network/integration case has a knob; the pure-verification cases T037 lists that need no IdP behaviour, e.g. `alg: none`/HS256 crafted locally, are minted in the test with the fake's `KeyRing` against the fake's real JWKS), `id_token` minting in `token.go:54-185` using the `KeyRing`, `/userinfo` handler, discovery additions (`discovery.go:9-50`), nonce capture in `authorize.go`; keep every existing test green β€” `tests/oauthserver/{options.go,discovery.go,token.go,authorize.go,userinfo.go(new),types.go}` +- [ ] T034a [US2] **[compile-red until T033 (`Options` fields) and T034 (`parseFlags`)]** Write failing `tests/oauthserver/cmd/server/main_test.go`: a table test over `parseFlags(args []string) (oauthserver.Options, error)` β€” `-oidc`, `-user email:sub:name:group1,group2`, `-redirect-uri`, `-groups-claim`, every tamper flag β†’ the expected `Options`/`ErrorMode`; an unknown tamper name β†’ error β€” `tests/oauthserver/cmd/server/main_test.go` +- [ ] T034 [US2] (after T033 and T034a; not parallel β€” the flag parser targets `Options` fields T033 introduces) Extract `parseFlags` from `main()` and add the CLI flags `-oidc`, `-user`, `-redirect-uri`, `-groups-claim`, tamper flags on `tests/oauthserver/cmd/server/main.go`; print the OIDC endpoints β€” `tests/oauthserver/cmd/server/main.go` +- [ ] T035 [P] [US2] Create `scripts/dev-server-edition.sh` implementing quickstart Β§0–§4 and Β§6 (`--phase b|c|d` gates the later steps), with PID-scoped teardown and no `pkill`; the stdio fixture is `node $PWD/tests/echo-rugpull-server/index.js` (a deterministic `echo` tool when `DESC_FILE` is unset β€” there is no `test/fixtures/echo-server` in the repo; the script runs `npm ci --prefix tests/echo-rugpull-server` once); tooling task (no Go test): `bash -n` + `shellcheck` gate and the T062 real-instance run are its verification; document it in `docs/development/server-edition-multiuser-auth.md` β€” `scripts/dev-server-edition.sh`, `docs/development/server-edition-multiuser-auth.md` + +## Phase B.2 β€” US2: generic OIDC provider with verified ID tokens (P1) + +**Independent test**: the US2 matrix through the `setup_wiring_test.go` harness against the extended `tests/oauthserver`, behind an `httptest` reverse proxy, from trusted and untrusted source addresses. + +### Tests first + +- [ ] T036 [US2] **[compile-red until T043]** Write failing `internal/serveredition/auth/oidc_jwks_test.go`: RSA and EC JWK β†’ public key, unknown `kty` refused, malformed `n`/`e` refused; `internal/serveredition/auth/oidc_discovery_test.go`: lazy fetch on first login (boot makes no request β€” assert with a counting `httptest` server), `issuer` byte-compare (trailing slash mismatch β†’ `discovery_failed`), cache TTL clamped to [5 min, 24 h], refetch on error, `http` endpoint refused unless loopback + `allow_insecure_issuer`, 3xx from any back-channel endpoint β†’ `provider_error` (non-redirecting client), client-auth method chosen from `token_endpoint_auth_methods_supported` (basic / post / absent β†’ basic / neither β†’ `discovery_failed`) β€” files above +- [ ] T037 [P] [US2] **[compile-red until T043 (provider/verifier); the handler-level assertions β€” user store untouched, stored groups untouched, `email_verified_policy` refusals, `groups_claim_missing` warning β€” are compile-red until T044 too, so the file compiles only once both land]** Write failing `internal/serveredition/auth/oidc_verify_test.go` (every case is driven through the in-process `tests/oauthserver` knob named in T032/T033, or β€” for crafted-token cases β€” minted with its `KeyRing`; no case is synthesised against a second fake): the tamper matrix (bad signature, wrong `iss`, wrong `aud`, multi-`aud` without matching `azp`, expired, `nbf` future, wrong/missing `nonce`, `alg: none`, HS256, unknown `kid` β†’ one JWKS refetch then refuse, key-type/alg mismatch) each β†’ the closed reason, user store untouched, log line names the check and never a claim value; `email_verified_policy` Γ— {true,false,absent} Γ— {refuse_false,require_true,ignore}; groups from the token, from userinfo only when the token lacks the claim and `sub` matches (mismatch/absent β†’ `userinfo_subject_mismatch`), userinfo transport/3xx/non-200/non-JSON β†’ `provider_error` with stored groups untouched, absent in both / non-array / Entra overage marker β†’ `[]` + `groups_claim_missing` warning β€” `internal/serveredition/auth/oidc_verify_test.go` +- [ ] T038 [P] [US2] **[compile-red until T044/T045]** Write failing `internal/serveredition/auth/oauth_handler_subject_test.go`: same provider+email, different `sub` β†’ `subject_mismatch`; configured-provider change β†’ first login re-binds (`provider_rebound`); `subject_rebind_armed_at` set only on a real disableβ†’enable transition, consumed by the first successful login (restart between enable and login; failed-then-successful; two concurrent logins for the same email with **two different subjects** β†’ exactly one rebinds, the other is `subject_mismatch`, and the stored subject equals the winner's β€” driven through `UpdateUserLogin` directly with a barrier so both calls start after the flag is armed); `Provider`/`ProviderSubjectID` refreshed on every login β€” `internal/serveredition/auth/oauth_handler_subject_test.go`, `internal/serveredition/api/admin_handlers_rebind_test.go` +- [ ] T039 [P] [US2] **[compile-red until T044]** Write failing `internal/serveredition/auth/oauth_handler_refusal_test.go`: every refusal reason renders the one generic 403 page with a request id β€” including an IdP authorization-response error on the callback (`?error=access_denied&state=…`, the fake's `AuthAccessDenied` knob, and a user who declines consent on the fake's form): the pending state is consumed **before** the response is classified, the page is the generic 403 (never the IdP's `error_description`), the typed terminal reason is `authorization_denied` (asserted through the handler's `LoginResult` observer hook of T044 β€” **no audit line is asserted in PR-B**; T106 in PR-D re-drives these fixtures and asserts exactly one `auth_event`) β€” today the callback returns a distinct 400 `missing code parameter` before touching the state (`oauth_handler.go:163-167`); `discovery_failed`/`provider_error`/`internal_error` (fault injection through the T044 seams: a failing `loginStore`, a failing `bearerSigner`, a failing `sessionCreator` β€” one sub-test per `internal_error` branch) render the 503 page; no user record created before the upsert commits; readiness unaffected during an IdP outage; `pendingStates` capped at 10,000 (insert 10,001, the first is gone, its login β†’ `state_invalid`); legacy providers (`google`, `github`, `microsoft`) unchanged: no nonce, no JWKS, `client_secret_post`, stored groups `[]` β€” `internal/serveredition/auth/oauth_handler_refusal_test.go` +- [ ] T040 [P] [US2] **[compile-red until T041]** Write failing `internal/config/server_edition_oidc_config_test.go`: `provider: oidc` admitted; `issuer_url` required/https rule/loopback exception; `scopes` default + `openid` appended; `groups_claim`, `email_verified_policy` enum, `display_name`; `users.User.Validate` admits `oidc` and still requires a non-empty subject (`internal/serveredition/users/models_test.go`) β€” files above + +### Implementation + +- [ ] T041 [US2] Config: add `IssuerURL`, `AllowInsecureIssuer`, `Scopes`, `GroupsClaim`, `EmailVerifiedPolicy`, `DisplayName` to `ServerEditionOAuthConfig` (`server_edition_config.go:31-37`); provider enum + rules in `Validate`, defaults in `ApplyDefaults`; `users/models.go:48` admits `oidc`; `cmd/mcpproxy/status_serveredition.go` unchanged (prints the value) β€” `internal/config/server_edition_config.go`, `internal/serveredition/users/models.go` +- [ ] T042 [US2] Widen the provider factory: `providerRegistry map[string]func(*config.ServerEditionOAuthConfig) *OAuthProvider` (`oauth_providers.go:60-64`, `GetProvider :116-122`); legacy factories ignore everything but `TenantID`; **the provider is constructed once**: `NewOAuthHandler` resolves it through the registry and stores it as `h.provider` (no network I/O at construction β€” discovery stays lazy), and the two call sites `oauth_handler.go:129,193` read `h.provider` instead of calling `GetProvider` per request (today every call returns a fresh value, `oauth_providers.go:59-64,116-122`, so a discovery/JWKS cache on the provider would have request lifetime, not the [5 min, 24 h] TTL); the T036 counting test asserts one discovery fetch across login β†’ callback β†’ second login, a refetch after TTL expiry, and exactly one JWKS refetch per unknown-`kid` attempt β€” `internal/serveredition/auth/oauth_providers.go`, `internal/serveredition/auth/oauth_handler.go`, tests that swap the registry (`oauth_handler_test.go:97-118`, `integration_test.go:116-128`) +- [ ] T043 [US2] Implement `oidc_jwks.go` (stdlib JWK β†’ key, per-`kid` cache; measure with `wc -l`, budget 150) and `oidc_provider.go` (discovery cache, non-redirecting 10 s client, endpoint https rule, client-auth method choice, `BuildAuthURL` with `nonce`, `ExchangeCode`, verified ID token via `jwt/v5` with the allowed-alg intersection, `iss`/`aud`/`azp`/`exp`/`nbf`/`iat` (60 s), nonce, `email_verified`, groups from token then userinfo with the `sub` comparison); `parseIDToken` unreachable for `oidc` (retained for legacy); `OAuthUserInfo` + `Groups`, `EmailVerified` β€” `internal/serveredition/auth/oidc_provider.go`, `oidc_jwks.go`, `oauth_providers.go` +- [ ] T044 [US2] `HandleLogin`/`HandleCallback`: nonce stored beside the state; bounded `pendingStates` (10,000, oldest evicted); closed reason enum type `LoginRefusal` with the FR-013 vocabulary (+ `authorization_denied` for an IdP authorization-response error β€” `error=access_denied` or any other RFC 6749 Β§4.1.2.1 code β€” arriving on the callback with a valid `state`; the state is consumed first, then the response classified; the IdP's `error`/`error_description` reach only the server log); one generic 403 page and one 503 page (templates with the request id); every callback branch mapped to exactly one reason and reported once through a handler-level `LoginResultObserver func(LoginResult)` hook (`LoginResult{RequestID, Surface, Reason LoginRefusal, UserID, EmailHash, Flags}`; nil = no-op) β€” PR-B's T038/T039 assert the typed result through it and PR-D's T107 installs the `auth_event` emitter on the same hook; **fault-injection seams**: `OAuthHandler` holds concrete `*users.UserStore`/`*SessionManager` fields and calls the package function `GenerateBearerToken` directly (`oauth_handler.go:25-30,242-289`), so it gains three narrow unexported interfaces/func fields β€” `loginStore` (`UpdateUserLogin`, `GetUserByEmail`), `sessionCreator` (`CreateSession`), `bearerSigner func(...) (string, error)` β€” defaulted to the real implementations in `NewOAuthHandler` and swappable from the test package (same package tests); **role derived live**: the handler takes the `ServerEditionConfigProvider` (`setup.go`) and derives the login role from the *current* `admin_emails` (today `h.config.IsAdminEmail` reads the boot pointer, `oauth_handler.go:25,254`, so a JWT minted after a hot-reload demotion carried the stale role β€” #1169's remaining horizon); T038/T039 mutate `admin_emails` after handler construction and assert the next login's role; groups upsert (`Groups`, `GroupsUpdatedAt`, wholesale replace) and subject binding with `SubjectRebindArmedAt` consumed atomically in a **transaction-owned** `users.(*UserStore).UpdateUserLogin(ctx, verified LoginClaims) (LoginOutcome, error)` β€” `LoginClaims{Email, Provider, Subject, Name, AvatarURL, Groups []string, GroupsKnown bool}`; the store re-reads the record **inside one `db.Update`** (by the email index), evaluates the subject rule there (`ProviderSubjectID` empty or equal β†’ bind; provider changed β†’ rebind + `Rebound=true`; different subject with `SubjectRebindArmedAt` set β†’ rebind + clear the flag; different subject otherwise β†’ `ErrSubjectMismatch`, nothing written; `Disabled` β†’ `ErrUserDisabled`), writes groups/subject/provider/last-login in that same transaction and returns `LoginOutcome{User, Created, Rebound, RebindConsumed}` β€” a pre-mutated `*User` handed to the store cannot be a compare-and-set (today `GetUserByEmail` is a separate `View`, `store.go:128-164`, and `UpdateUser` only re-reads to maintain the email index, `:166-221`, so two callbacks could both observe the armed flag and rebind to different subjects); `HandleCallback` maps `LoginOutcome`/errors to the FR-013 reasons; `Provider`/`ProviderSubjectID` refreshed every login β€” `internal/serveredition/auth/oauth_handler.go`, `internal/serveredition/auth/login_pages.go` (new), `internal/serveredition/users/{models.go,store.go}` +- [ ] T045 [P] [US2] Admin handlers: `enable` sets `subject_rebind_armed_at` on a real transition, `disable` clears it (`admin_handlers.go:220-306`); `GET /admin/users` returns `groups`, `groups_updated_at`, `subject_rebind_armed_at`; `GET /auth/me` returns `groups`, `groups_updated_at` β€” `internal/serveredition/api/admin_handlers.go`, `internal/serveredition/api/auth_endpoints.go` +- [ ] T046 [US2] Harness: extend `internal/serveredition/setup_wiring_test.go` `newWiringHarness` with an in-process `tests/oauthserver` (OIDC) and a `loginAs(t, email, groups) *http.CookieJar` helper driving login β†’ form POST β†’ callback; add an `httptest` reverse-proxy helper that sets/strips `X-Forwarded-*` and rewrites `Host`, dialing from a chosen source address β€” `internal/serveredition/setup_wiring_test.go`, `internal/serveredition/harness_oidc_test.go` (new) + +## Phase B.3 β€” Front door behind an ingress (FR-025..FR-030; US2.6–2.10) + +### Tests first + +- [ ] T047 [US2] **[compile-red until T050]** Write failing `internal/config/trusted_proxies_test.go`: CIDR/IP parsing, `ForwardedHeaders(r, trusted)` honours `X-Forwarded-For` (right-most untrusted hop)/`X-Real-IP`/`X-Forwarded-Proto`/`X-Forwarded-Host` only from a trusted peer; env `MCPPROXY_TRUSTED_PROXIES`; `DetectConfigChanges` reports `trusted_proxies` live; **live consumer test**: every replaced reader (T050) is handed a `func() []string` provider β€” mutate the provider's list after construction and assert the next request honours/ignores `X-Forwarded-*` accordingly (a "live" key whose readers captured a boot slice is not live) β€” `internal/config/trusted_proxies_test.go`, `internal/runtime/config_hotreload_test.go`, `internal/httpapi/swagger_forwarded_test.go`, `internal/serveredition/auth/session_store_forwarded_test.go` +- [ ] T048 [P] [US2] **[compile-red until T050 (`config.ForwardedHeaders`, the trusted-proxy provider) and T051]** Write failing `internal/serveredition/auth/front_door_test.go` (harness): `public_url` set + ingress rewriting `Host` and stripping XFP β†’ `redirect_uri` is `/api/v1/auth/callback` and the cookie is `Secure`; **boot log** (US2.1): with `public_url` set, setup logs one INFO line carrying the resolved public URL and callback URL and makes no IdP request (counting fake); `public_url` unset + untrusted XFP/XFF β†’ ignored; trusted β†’ honoured; `session_cookie_secure` `auto|true|false` matrix; `MCPPROXY_PUBLIC_URL` overrides the file value, an unset variable leaves it, and the personal build ignores the variable (opaque carrier); validation refuses `false` with https `public_url`/TLS; unset `public_url` on a non-loopback listener β†’ boot warning + `doctor` finding **as a `runtime_warnings` entry of `GET /api/v1/diagnostics`** (`mcpproxy doctor` only renders the daemon's `contracts.Diagnostics`, `doctor_cmd.go:100-128`; findings are produced by `management.(*service).Doctor`, `internal/management/diagnostics.go:47-227` β€” T052 adds the seam; asserted through the service, `internal/management/diagnostics_serveredition_test.go`); scheme-disagreement warning with request id; `redirect_uri` matrix (`https://evil.example/`, `//evil.example`, `/\evil.example`, `javascript:`, `/my/tokens?x=1`, plus the FR-028 control-character and encoded forms β€” `/ok%0D%0ASet-Cookie:x`, `/ok%00`, `/%5Cevil.example`, `/%2F%2Fevil.example` (`r.URL.Query().Get` percent-decodes, so the check runs on the decoded value), a literal tab, `/ok\r\n` β€” each β†’ `/ui/`) β†’ `/ui/` or honoured, `RedirectRejected` carried on the pending state; `connector_provider.go` base URL from `public_url` (no first-seen latch) β€” `internal/serveredition/auth/front_door_test.go`, `internal/serveredition/api/connector_provider_test.go` +- [ ] T049 [P] [US2] **[compile-red until T052 (accessors) and T053 (route)]** Write failing `internal/server/mcp_auth_forced_test.go` (`-tags server`, and a `!server` twin): with `server_edition.enabled` and `require_mcp_auth: false`, no credential β†’ 401, session cookie β†’ 401, user JWT β†’ 401, agent token/API key/socket unchanged; boot notice logged; personal build unchanged; `internal/serveredition/api/auth_provider_probe_test.go`: `GET /api/v1/auth/provider` returns only `{display_name}` (fallback to the provider family name when `display_name` is unset; legacy providers too), no `pendingStates` growth, **404 when `server_edition.enabled` is false or the `oauth` block is absent** (the route is registered only by `SetupAll` on an enabled block, so a disabled server build answers like the personal build), 404 on the personal build (`internal/httpapi` stub test) β€” files above + +### Implementation + +- [ ] T050 [US2] `internal/config/trusted_proxies.go` (edition-neutral): `TrustedProxies []string` on `Config`, parsing, `ForwardedHeaders`, env override in `applyTLSEnvOverrides` (`loader.go:629-790`), `DetectConfigChanges` `slices.Equal` clause; **validation wired into both `Config.Validate()` and `ValidateDetailed()`** (`validateTrustedProxies(cfg)` called from each, message text from `contracts/config-keys.md`) so boot, PATCH and `/config/apply` refuse an unparsable entry identically (FR-039) β€” T047 drives all four doors, not only the parser; replace the four raw readers (`session_store.go:129-151`, `oauth_handler.go:413-423`, `connector_provider.go:126-135`, `httpapi/swagger.go:105-114`) β€” each is a static function today with no config access, so each consumer gains a `trustedProxies func() []string` **provider** (httpapi: from the server's config provider; serveredition: from `Dependencies.ConfigProvider`) evaluated per request, never a slice captured at construction (the key is declared live in `contracts/config-keys.md`); add the **request-metadata tag** PR-D's audit line needs (`RemoteAddr` is on `*http.Request`, never on the context β€” `transport/context.go:20-30` carries only the connection source): an edition-neutral middleware `httpapi.tagRequestMeta` installed on the `/api/v1` group, `/events` and the `/mcp*` mounts that stores `{ClientIP: ForwardedHeaders(r, trusted).ClientIP, Mount: mcp|api}` on the context (`reqcontext.RequestMeta`), the mount value fixed by the mount point and never by a header; test both mounts and trusted/untrusted peers β€” files above, `internal/runtime/config_hotreload.go`, `internal/httpapi/middleware.go`, `internal/reqcontext/*.go`, `internal/server/server.go` +- [ ] T051 [US2] `PublicURL`, `SessionCookieSecure` on `ServerEditionConfig`; validation + `ApplyDefaults` β€” the `session_cookie_secure: false` Γ— (`https` `public_url` **or** `tls.enabled`) refusal needs the top-level `Config.TLS`, which `ServerEditionConfig.Validate` cannot see, so it lives in the build-tagged `*Config`-level bridge `validateServerEditionConfig(cfg *Config)` of T019 (`config_validate_serveredition.go`, reached from `Config.Validate()`/`ValidateDetailed()`) and T048 drives it through `Config.Validate`, PATCH and `/config/apply`; env override `MCPPROXY_PUBLIC_URL` via a build-tagged `applyServerEditionEnvOverrides(cfg)` (`internal/config/env_serveredition.go` + `_stub.go` no-op) called from `applyTLSEnvOverrides` (`loader.go:629`) β€” the one nested `server_edition.*` key with an env alias (FR-025); `buildCallbackURL` and `connector_provider.go:50-62` prefer `public_url`; `NewSessionManager(..., securePolicy)` replacing the hardcoded `false` at `setup.go:115` with `auto|true|false` resolution (`public_url` scheme, in-process TLS, trusted XFP β€” the trusted list read through the live provider of T050); one INFO boot line with the resolved public URL and callback URL (US2.1); boot warnings + `doctor` findings (via the T052 `config.DoctorFindings` source β€” `cmd/mcpproxy/doctor*.go` is **not** touched: it renders `runtime_warnings` as today); `redirect_uri` sanitiser in `HandleLogin` (`oauth_handler.go:115-118`) β€” `internal/config/server_edition_config.go`, `internal/serveredition/setup.go`, `internal/serveredition/auth/{oauth_handler.go,session_store.go}`, `internal/serveredition/api/connector_provider.go`, `internal/config/doctor_findings{,_stub}.go` +- [ ] T052 [US2] Build-tagged accessors `internal/config/serveredition_accessors.go` (`//go:build server`) + `_stub.go`: `EffectiveRequireMCPAuth`, `ServerEditionEnabled`, `IdPProviderFamily`, `PublicURL`; `mcpAuthMiddleware` (`internal/server/server.go:353,450`) uses `EffectiveRequireMCPAuth`; boot notice + `doctor` finding for an overridden `require_mcp_auth: false`. **Doctor seam** (there is no `cmd/mcpproxy` producer: `doctor` fetches `GET /api/v1/diagnostics`, `doctor_cmd.go:100-128`, produced by `management.(*service).Doctor`, `internal/management/diagnostics.go:47-227`, which appends free-text `RuntimeWarnings` and counts them in `TotalIssues`): `management.Service` gains `AddRuntimeWarningSource(func() []string)` (guarded like `scanEnricher`, `service.go:178-182`), consulted by `Doctor` after the TCC probe (`:210-213`); `internal/server` registers two sources at boot β€” `config.DoctorFindings(cfg)` (edition-neutral file `internal/config/doctor_findings.go` + build-tagged `doctor_findings_serveredition{,_stub}.go` for the server-only findings: overridden `require_mcp_auth`, unset `public_url` on a non-loopback listener, explicit `session_cookie_secure: false`, unknown `access` map names β€” T074 adds the last) reading the **live** config through the runtime's config provider, and in PR-D the audit sink's failure counter (T109). Test through the service (`internal/management/diagnostics_serveredition_test.go`, `-tags server`) and one `doctor` JSON/pretty render test on a fixture `Diagnostics` β€” files above, `internal/server/server.go`, `internal/management/{service.go,diagnostics.go}`, `internal/config/doctor_findings{,_serveredition,_stub}.go` +- [ ] T053 [P] [US2] Public `GET /api/v1/auth/provider` in `internal/serveredition/api/auth_endpoints.go` (mounted beside login/callback at `setup.go:160-161`, outside every auth group, only when the block is enabled β€” a disabled block registers nothing, so 404 falls out of chi exactly as on the personal build); personal build has no route (404 falls out of chi) β€” `internal/serveredition/api/auth_endpoints.go`, `internal/serveredition/setup.go` +- [ ] T054a [P] [US2] **[behaviour-red]** Frontend unit tests first: extend `frontend/tests/unit/settings-server-edition-wording.spec.ts` (the OIDC + front-door rows of `contracts/config-keys.md` exist, `client_secret` does not) and add `auth-provider-probe.spec.ts` (the auth store calls `GET /api/v1/auth/provider` before any authenticated call; `Login.vue` renders `display_name`; a 404 from the probe means personal edition) β€” red because none of the rows/probe exist β€” `frontend/tests/unit/*.spec.ts` +- [ ] T054 [US2] (after T054a) Web UI: `services/auth-api.ts` probe; `stores/auth.ts` learns the edition from the probe before any authenticated call; `Login.vue:28` labels the button with `display_name`; `fields.ts` gains the OIDC + front-door rows of `contracts/config-keys.md` (no `client_secret`; `oauth.allow_insecure_issuer` is deliberately **Raw-JSON-only** β€” a loopback-only development toggle that must not look like a normal setting); the retained keys keep their **Raw-JSON-only** disposition of today (`admin_emails`, `session_ttl`, `bearer_token_ttl`, `oauth.client_id`, `oauth.tenant_id`, `oauth.allowed_domains` β€” the `Settings` column of `contracts/config-keys.md` is the authority; `credential_encryption_key` is secret/env-only and `store_idp_tokens` is deprecated: neither ever gets a row), and `settings-server-edition-wording.spec.ts` asserts the exact row set so an undecided key cannot appear by accident β€” `frontend/src/services/auth-api.ts`, `frontend/src/stores/auth.ts`, `frontend/src/views/teams/Login.vue`, `frontend/src/views/settings/fields.ts` + +## Phase B.4 β€” Config wiring for `server_edition` (FR-039 part 2) + +- [ ] T055 [US2] **[behaviour-red]** (the keys exist after T041/T051; the clause does not) Write failing `internal/runtime/config_hotreload_serveredition_test.go` (`-tags server`): an `enabled`/`oauth.*`/`public_url`/`session_cookie_secure`/`credential_encryption_key`/TTL edit β†’ `RequiresRestart` with the reason and `ChangedFields: server_edition` (one case per key; `credential_encryption_key` is bound at setup, `setup.go:120-126`, so it is restart-pinned too); an `admin_emails`-only edit β†’ live, `ChangedFields: server_edition.admin_emails`; a personal-build twin asserts raw-byte comparison reports `server_edition` changed-and-restart-pinned β€” `internal/runtime/config_hotreload_serveredition_test.go` +- [ ] T056 [US2] Implement the clauses (`jsonEqual` on a projection struct; `slices.Equal` for `admin_emails`) in `internal/runtime/config_hotreload.go` after the `update_check` clause (`:413-417`), build-tagged projection helper in `internal/config`. Keep the function's existing early-return shape (`:92-182`: a `listen`/TLS/data-dir/API-key/timeout change returns immediately with only that field, and the restart re-reads everything) β€” the new clauses follow the same rule, so T055 tests single-key edits only and documents that a mixed edit with one of those keys reports the early-return key alone, as every existing key does β€” `internal/runtime/config_hotreload.go`, `internal/config/serveredition_accessors{,_stub}.go` +- [ ] T057 [P] [US2] Docs: `server_edition` reference section in `docs/configuration/config-file.md` β€” **one complete key table**: every PR-B key *and* the retained keys `enabled`, `admin_emails`, `session_ttl`, `bearer_token_ttl`, `credential_encryption_key` (+ `MCPPROXY_CRED_KEY`), `store_idp_tokens` (deprecated, no-op), `oauth.{provider,client_id,client_secret,tenant_id,allowed_domains}`, and a "removed keys" row for `max_user_servers`/`workspace_idle_timeout` with their boot diagnostic text (default, restart/live, validation text, the per-IdP groups-claim table from spec Edge Cases, the `public_url`/`trusted_proxies` container topology, `email_verified_policy` cost note); `docs/configuration/environment-variables.md` (`MCPPROXY_TRUSTED_PROXIES`, `MCPPROXY_PUBLIC_URL`, and `MCPPROXY_CRED_KEY` β€” its only page today is `idp-token-storage.md`, which T023 tombstones); `docs/operations/reverse-proxy.md` (`trusted_proxies`; `trusted_hosts` is not the SSO control); `docs/getting-started/installation.md:368-400` Docker recipe (`public_url`, `trusted_proxies`, `${env:}` secrets); `docs/development/server-edition-multiuser-auth.md:4,9,13-31` (four providers); root `docs/configuration.md` rows for `trusted_proxies` β€” files above +- [ ] T058 [P] [US2] `make swagger` for `trusted_proxies` on the `Config` schema; `make swagger-verify` clean; `go run ./cmd/generate-types` + `TestContractsInSync` (expected no-op, proven); Swift `SettingsCatalog.swift` read-only row for `trusted_proxies`; run `python3 scripts/check-settings-parity.py` and `swift test` from `native/macos/MCPProxy` locally (both are required CI jobs in `native-tests.yml:83,97` and neither is in the Go gate set) β€” `oas/swagger.yaml`, `oas/docs.go`, `native/macos/MCPProxy/.../SettingsCatalog.swift` +- [ ] T059 [P] [US2] `.github/RELEASE_NOTICE.md` PR-B bullets: forced MCP auth under the server edition, `trusted_proxies` now gating forwarded headers, generic OIDC, `email_verified_policy` default β€” `.github/RELEASE_NOTICE.md` + +## Phase B.5 β€” US7: telemetry v13 (P3) + +- [ ] T060 [US7] **[compile-red until T061]** Write failing `internal/telemetry/payload_v13_test.go`: server-edition fixture β†’ `server_edition_enabled: true`, `idp_provider: "oidc"`, `user_count_bucket: "1-10"`, `env_markers.is_container` present; personal β†’ `false`/`"none"`/`"0"`; the server-edition fixture lives in a `//go:build server` helper file and the personal fixture in a `//go:build !server` twin so both accessors are proven under their own tag (the shared test body is untagged); `TestPayloadHasNoForbiddenSubstrings` + `ScanForPII` with issuer host, group names, emails, server names as forbidden; `schema_version_guard_test.go` asserting no v12 pin survives β€” the guard assembles its regex from fragments (`"schema_version\":" + "12"` etc.) and skips its own file, otherwise its literal self-matches β€” `internal/telemetry/payload_v13_test.go`, `internal/telemetry/payload_v13_fixture_server_test.go`, `internal/telemetry/payload_v13_fixture_personal_test.go`, `internal/telemetry/schema_version_guard_test.go` +- [ ] T061 [US7] Implement: `FeatureFlagSnapshot` fields via the accessors; `UserCountBucket` splice with a nil-safe `userCounter` installed from `internal/server/serveredition_wire.go`; `SchemaVersion = 13` + comment; update the eleven v12 sites (`payload_v7_test.go:20,81,112`, `payload_v2_test.go:91,138`, `telemetry_test.go:315,323`, `payload_privacy_test.go:147`, `current_error_codes_test.go:268`, `tpa_scanner_test.go:267`, `tpa_funnel_v9_test.go:263`); `docs/features/telemetry.md` β€” `internal/telemetry/{feature_flags.go,telemetry.go}`, the eleven test files, `internal/server/serveredition_wire.go`, `docs/features/telemetry.md` + +## Phase B.6 β€” Delivery + +- [ ] T062 [US2] Real-instance verification: quickstart Β§0–§4 + Β§6 (`--phase b`: login, `/auth/me` groups, JWT/cookie on `/mcp` β†’ 401, no-credential `/mcp` β†’ 401 with `require_mcp_auth: false`), the open-redirect case, one tamper case (`-token-error bad-signature` β†’ 403 generic page, no user created), `public_url` with a `Host`-rewriting `socat`/`curl -H Host:` front; record in verification.md β€” `verification.md` +- [ ] T063 [US7] Real-instance verification: dump the heartbeat payload (`GET /api/v1/telemetry/payload` with the API key) from the server-edition instance and the personal instance; grep for the issuer host and emails (0 hits); record β€” `verification.md` +- [ ] T064 [X] Full gate set; `wc -l internal/serveredition/auth/oidc_jwks.go` recorded against the 150-line budget β€” `verification.md` +- [ ] T065 [X] Cross-model review (briefs: provider+verification / handler+pages+subject binding / front door+config / telemetry), ≀ 10 rounds β€” `verification.md` +- [ ] T066 [X] Open PR-B (`Related #1169` for the live-role reuse); roadmap tick; `gen-roadmap.py --check`; CI green; no merge without instruction β€” `roadmap.yaml`, `ROADMAP.md` + +--- + +# PR-C β€” group grants + tenant principal (`107-c-group-grants-tenant-principal`) + +**Goal**: FR-001..FR-011, FR-039 part 3, FR-041, FR-043 (a,c,d,f,g,h,i,k), FR-045, FR-046, FR-047 part 3, FR-036 slice, FR-042 entry. Stories US1, US4. Depends on PR-B (stored groups, forced MCP auth, probe). + +## Phase C.1 β€” Two-fixture oracle in the harness (FR-047 part 3) + +- [ ] T067 [US1] **[tooling β€” fixture construction, not a failing test]** Extend `internal/serveredition/setup_wiring_test.go`: fixture **A** (`a`, `b`, `a__b` shared with tools/prompts/usage/log lines and per-server high-entropy sentinels; `access: {group_servers: {eng:[a], ops:[a,b]}, default_servers: []}`; users Alice `[eng]`, Bob `[]`, Carol `[ops]`, Dana admin; Alice tokens `["*"]` and `["a"]`; profile `ops-only=[b]` globally active) and fixture **B** (no `b`/`a__b`); `assertTwoFixture(t, op)` runs `op` against both, normalises nondeterministic fields, asserts equality and byte-absence of every sentinel; `assertStatusParity(t, ...)` for by-name doors; **ranked-window door** (`GET /api/v1/index/search`): asserted with Spec 105's **retrieve oracle** β€” per-fixture derivation = exhaustive search of the corpus, **filtered to the entitled set, then cut to `limit`** (Spec 105 "Ranking under scope": the returned set equals the top-K of the filtered list); cross-fixture equality excludes `score` and ordering (both depend on the corpus β€” BM25 statistics include the hidden documents); **membership and `total` are asserted equal across fixtures only where the fixture guarantees it**: matching is boolean and corpus-independent (`bleve.go:233-268`, all `Should` clauses), so `total` (= `min(limit, entitled matches)` after T075a) is always equal, and membership is equal whenever the entitled match count ≀ `limit` β€” every ranked-window case in T070a is built that way and asserts that precondition explicitly; with more entitled matches than `limit`, top-K membership follows Spec 105's exclusion (corpus-dependent scores can reorder *entitled* hits across the cut) and is asserted against the per-fixture derivation only, never across fixtures β€” what T075a closes is displacement by the *hidden* population, not re-ranking among entitled hits; fixture A carries a hidden `b` tool whose text outranks `a`'s for the query so the displacement case is exercised (this is the T070a red), plus byte-absence of `b`'s sentinels. Today the handler post-filters a global top-K (`httpapi/server.go:3789-3810` after `bleve.go:272-275` has already applied `Size = limit`) β€” the #1166 shape β€” so a hidden high-ranker displaces an entitled result and `total` differs between fixtures: an existence oracle that FR-010/US4.2 ("identical between fixtures") forbids for the new tenant principal; T075a closes it on this REST door (the MCP `retrieve_tools` twin stays Spec 105's item, T072/T091) β€” `internal/serveredition/setup_wiring_test.go`, `internal/serveredition/fixture_oracle_test.go` (new), `internal/serveredition/testdata/fixture_{a,b}.json` +- [ ] T068 [P] [US1] Guard tests written first β€” **[behaviour-red]** for the AST guard (`user_handlers.go:345,488,529,603,654`, `user_activity.go:153`, `credential_handlers.go:352` trip it on the PR-B base), **[pin β€” green on the PR-B base by design]** for `mcp_session_never_reaches_test.go` (FR-043(a) became true in PR-B/T052; this file is the regression pin PR-C carries, not a red test, and is counted as a guard, not a test-first task): `internal/serveredition/api/user_handlers_shared_guard_test.go` (AST walk over the non-test files of `internal/serveredition/api`: a selector `.Shared` on a `*config.ServerConfig` is permitted only inside `entitledServerNamesFor` (the predicate core, T075; the `entitledServerNames(userID)` wrapper reads nothing itself), `adminSharedProjection` and the **administrator-only** `admin_handlers.go` (`:551-597` the share toggle, `:637` the `/admin/servers` enrichment β€” `/admin/servers` is the whole-config surface, FR-004), which is exempted by file name; `req.Shared` on the toggle's request struct is not a `ServerConfig` read but the walk is untyped, so the file exemption covers it; the guard is **behaviour-red** on HEAD because `user_handlers.go:345,488,529,603,654`, `user_activity.go:153` and `credential_handlers.go:352` all trip it until T075); `internal/server/mcp_session_never_reaches_test.go` (FR-043(a): cookie/JWT on every `/mcp*` route β†’ 401; no credential β†’ 401 with `require_mcp_auth: false` under the server tag; the `CallerKindUser` cache gate `cache/authorization.go:103-104` is never exercised β€” pinned with a counter) β€” files above + +## Phase C.2 β€” US1: one entitlement predicate, group grants, empty = deny-all (P1) + +### Tests first + +- [ ] T069 [US1] **[compile-red until T074]** Write failing `internal/config/server_edition_access_test.go`: `access` block shape, absent/`null`/`[]` `default_servers` equivalence, `group_servers` non-empty requires `oidc`, invalid server names refused, unknown names warn (+ `doctor` finding), hot reload reports `server_edition.access` live β€” `internal/config/server_edition_access_test.go`, `internal/runtime/config_hotreload_serveredition_test.go` +- [ ] T070 [P] [US1] **[behaviour-red]** (existing doors, existing `entitledServerNames`; fixture A's `access` block is ignored on HEAD so Alice sees `b`) Write failing `internal/serveredition/api/entitlement_group_test.go` (two-fixture): Alice `/user/servers` = `[a]` + personal; by-name doors for `b`/`a__b` = status parity with nonexistent (exact, case-sensitive); `/user/diagnostics`, `/user/credentials*` same; Bob = unentitled tenant in B; Carol includes `b`; Dana unchanged; mint `["*"]` β†’ stored `[a]`; `["a","b"]` β†’ `server "b" is not available to you`; `["a__b"]` byte-identical to `["nonexistent"]`; rotate re-narrows; admin projection of the `/user/*` doors unchanged; **timing class** (Spec 105 Definitions) is pinned deterministically, not with a clock: every by-name door resolves the entitlement set **before** any store or upstream lookup of the named resource, and a counting store/provider stub asserts that a hidden name and an absent name perform the same number of store reads and the same code path (no upstream call, no credential-store read) β€” `internal/serveredition/api/entitlement_group_test.go` +- [ ] T070a [P] [US1] **[behaviour-red]** (the seam exists; a hidden high-ranker displaces an entitled hit today) Write failing `internal/httpapi/index_search_scoped_test.go` (`-tags server`, two-fixture) + `internal/index/search_scoped_test.go`: fixture A's hidden `b` tool outranks `a`'s tool for the query with `limit=1` β†’ the response is `a`'s tool with `total: 1`, byte-identical to fixture B; `limit` larger than the entitled population β†’ every entitled hit, none hidden, `total` = entitled count (cross-fixture membership equality holds here because entitled matches ≀ `limit`; the case asserts that precondition); **more entitled matches than `limit`** (three entitled `a` tools match, `limit=2`, fixture A adds hidden `b` matches) β†’ each fixture's response equals the top-2 of *its own* exhaustive filtered search (per-fixture derivation) with `total: 2` on both; membership is **not** compared across fixtures (Spec 105 "Ranking under scope": hidden documents can re-rank entitled hits across the cut); **hidden prefix longer than one page**: fixture A carries 300 hidden `b` tools that all outrank the one entitled `a` hit for the query (> the 256-hit page) and `limit=1` still returns `a`'s tool with `total: 1`, byte-identical to fixture B (paging is exhaustive, no cap); empty entitlement β†’ `results: []`, `total: 0` **without a bleve search** (counting index stub β€” fail closed, Spec 106 FR-004); an administrator/API-key caller is byte-identical to merge-base (`SearchTools(query, limit)` path untouched, SC-006); the agent-token caller on this door is narrowed the same way (it shares `auth.IsScopedCaller`, so #1166's post-filter is replaced, not duplicated); scores and relative ordering of the returned hits equal the unfiltered search's (the scoped path never adds a scoring clause) β€” `internal/httpapi/index_search_scoped_test.go`, `internal/index/search_scoped_test.go` +- [ ] T071 [P] [US1] **[compile-red until T076/T077]** Write failing `internal/storage/agent_tokens_owner_resolution_test.go`: one resolver call per authentication (counting stub), `Email/Provider/Role` stamped on the returned token's `OwnerEmail/OwnerProvider/OwnerRole` (`json:"-"`) and copied by `AgentToken.AuthContext()` into `AuthContext.Email/Provider/Role`, and **not persisted** β€” the raw BBolt record bytes for the token contain none of the three planted sentinel values, and a token re-read from the store carries empty `Owner*` fields until the next validation, `ErrAgentTokenOwnerInactive`/`ErrAgentTokenScopeUnavailable` mapping, `intersectAllowedServers` non-nil empty on empty and on empty `proposed`; **administrator literal star survives** β€” a resolver returning `Entitled: ["*"]` for an administrator-owned `["*"]` token leaves `AllowedServers == ["*"]` after validation, while a tenant's `["*"]` token with `Entitled: ["a"]` validates to `["a"]` (FR-009; the resolver, not storage, decides which β€” the storage intersection is only the narrow-only fence); `internal/httpapi/preflight_restricted_test.go`: an unentitled token on `POST /api/v1/preflight` evaluates every id out of scope (today "unrestricted"); `internal/preflight/scope_restricted_test.go` β€” files above +- [ ] T072 [P] [US1] **[behaviour-red]** (existing MCP doors; the group term is missing) Write failing `internal/server/mcp_group_scope_test.go` (`-tags server`, harness): Alice's `["a"]` token through every Spec 105 US1 scenario (cache redemption, `set_profile`, profile URL, `retrieve_tools` metadata, prompts, `tail_log`, dispatch denial, direct listing/describe) under the two-fixture oracle, excluding by name the still-open 105 items (`usage_summary`/`session_risk`, "Available servers" text, scope-denial text) whose fixtures assert today's documented outcome; Bob's `["*"]` token β†’ no tools, non-disclosing refusals, empty `set_profile`; hot reload (`eng` removed / `a` un-shared / re-added) narrows and widens the next MCP request on an existing session and the next SSE frame β€” `internal/server/mcp_group_scope_test.go`, `internal/httpapi/sse_scope_group_test.go` +- [ ] T073 [P] [US1] **[compile-red until T076 (`auth.CredentialKind`) and T078]** Write failing `internal/serveredition/api/freshness_bound_test.go`: a bearer JWT on `POST /auth/token`, `POST /user/tokens`, `/user/tokens/{name}/regenerate` β†’ 401; agent token β†’ 401; cookie β†’ 200; `expires_in: "9000h"` β†’ 400; an owned token never has a zero `ExpiresAt` (only ownerless may); chained session β†’ JWT β†’ token clock test with a fake clock proves access ends at session TTL + max(JWT TTL, token TTL); list/revoke still accept a JWT β€” `internal/serveredition/api/freshness_bound_test.go` + +### Implementation + +- [ ] T074 [US1] `ServerEditionAccessConfig` on `ServerEditionConfig` (`Access *…`), validation, unknown-name warning + `doctor` finding (a case in `config.DoctorFindings`'s server-tagged half, T052 seam β€” reads the live config so a hot-reloaded map is re-checked on the next `doctor` run), `DetectConfigChanges` clause (`jsonEqual`, live) β€” `internal/config/server_edition_config.go`, `internal/config/doctor_findings_serveredition.go`, `internal/runtime/config_hotreload.go` +- [ ] T075 [US1] Group term inside the predicate (`user_handlers.go:808-851`, the `:842` term) for non-administrators, reading `User.Groups` and the live `access` block through `ServerEditionConfigProvider` (plumb the provider into `UserHandlers`): the core becomes **`entitledServerNamesFor(user *users.User, isAdmin bool)`** (takes the already-loaded record; nil user β†’ error, never an empty grant) and `entitledServerNames(userID, isAdmin)` is the wrapper that performs exactly one `GetUser` and calls it β€” today the predicate never loads the record (`ListUserServers(userID)` + live admin servers), so the groups read must not become a second `GetUser` beside the owner resolver's (`contracts/entitlement-predicate.md` Β§1–§2; T071's counting store asserts one read per token authentication); `tenantEntitled` helper (wraps the wrapper for the door handlers); collapse the seven `Shared` sites (`user_handlers.go:345,488,529,603,654`, `user_activity.go:153`, `credential_handlers.go:352`) onto it with exact-name comparison and status parity; `adminSharedProjection` keeps the `admin_user` view of those doors; correct the comment at `:776-783` β€” `internal/serveredition/api/{user_handlers.go,user_activity.go,credential_handlers.go}` +- [ ] T075a [US1] Scope-aware index search β€” filter **before** the ranked cut on `GET /api/v1/index/search`: `index.(*BleveIndex).SearchToolsScoped(query string, limit int, inScope func(serverName string) bool)` (+ `Manager` passthrough) runs the **same** boolean query as `SearchTools` (`bleve.go:227-280`, no extra clause β€” a `Must` term on `server_name` would change scores; same deterministic score-then-id sort) and pages through the ranked result **exhaustively** with `searchReq.From`/`searchReq.Size` (page size `max(limit, 256)`): each page is filtered through `inScope`, and paging stops only when `limit` in-scope hits have been collected **or** `From >= searchResult.Total` β€” there is **no** result cap (Spec 105 "Ranking under scope": the returned set equals the top-K of an *exhaustive* search filtered to authorized hits; any cap would let a hidden population larger than the cap displace an entitled hit and make membership/`total` differ between fixtures β€” a finding of review round 5); the result is the top-`limit` of the filtered ranking with the unfiltered scores; an `inScope` that admits nothing short-circuits to an empty result without a search; `internal/server.(*Server).SearchToolsScoped` + the `httpapi.ServerController` interface method (`httpapi/server.go:143`); `handleSearchTools` (`:3789-3810`) calls it whenever `auth.IsScopedCaller(ctx)` with `canSeeServer` as the predicate and drops the post-filter loop; `total` = `len(results)` as today; unscoped callers keep the exact `SearchTools(query, limit)` call; **every test double of `httpapi.ServerController` gains the new method in the same commit** or `go test ./...` (the CI compile gate, `unit-tests.yml:148`) fails: `MockServerController` (`internal/httpapi/contracts_test.go:28,194`, embedded by `annotation_coverage_test.go:18` and wrapped by `agent_token_gating_test.go:19`), `baseController` (`security_test.go:226,260`) and `mockController` (`code_exec_test.go:21,59`) β€” `internal/index/{bleve.go,manager.go}`, `internal/server/server.go`, `internal/httpapi/server.go`, `internal/httpapi/{contracts_test.go,security_test.go,code_exec_test.go}` +- [ ] T076 [US1] Storage: `SetAgentTokenOwnerResolver` + `OwnerResolution` replacing the two callbacks (`agent_tokens.go:846,865,911-939`); ephemeral `Email/Provider/Role` on the validated `AuthContext` β€” carried by three new **non-persisted** fields `OwnerEmail/OwnerProvider/OwnerRole` (`json:"-"`) on `auth.AgentToken` (`internal/auth/agent_token.go:33-45`) that `ValidateAgentToken` stamps on the token value it returns (`:943`) and `AgentToken.AuthContext()` (`:54-72`) copies into `Email/Provider/Role` β€” the token value is the only carrier, because every caller builds the context from the returned token (`httpapi/server.go:580`, `server/server.go:427`, `sse_scope.go:43`); the resolver built in `setup.go` does one `GetUser`, calls `entitledServerNamesFor(user, isAdmin)` (T075) on that record and returns `Entitled = narrowScopeToEntitled(granted, thatSet, isAdmin)` (`user_handlers.go:978ff`) β€” **never the raw whole-configuration list**: `intersectAllowedServers` treats a stored `"*"` as "take every entry of `proposed`" (`agent_tokens.go:697-738`), so an administrator's literal `["*"]` would be frozen into a snapshot of the current configuration and violate FR-009 (T071 pins it: Dana's `["*"]` token authenticates with `AllowedServers == ["*"]`, Alice's with `[a]`); **`auth.CredentialKind` + constants (`socket|api_key|bearer_jwt|agent_token|cookie|anonymous`) and `IsSessionPrincipal()` are defined here, in `internal/auth/context.go`** (they are needed by T078 in this phase and by T083 in C.3 β€” defining them in T083 would leave T078 uncompilable; round-2 finding 1), and `mcpAuthMiddleware`/`ValidateAgentToken` stamp `agent_token`; `intersectAllowedServers` non-nil empty; `setup.go:58-92` installs the single resolver first; **migrate every existing caller of `SetAgentTokenOwnerGate`/`SetAgentTokenScopeResolver` to the new resolver in the same commit** before removing the setters, or the PR does not compile: `internal/storage/agent_token_scope_test.go`, `internal/storage/agent_token_lifecycle_test.go`, `internal/serveredition/setup_admin_liveness_test.go`, `internal/serveredition/setup_wiring_test.go`, `internal/serveredition/api/admin_disable_tokens_test.go` (the five test files at HEAD; `setup.go:58,73` are the two production callers) and the comment at `storage/manager.go:33` β€” `internal/storage/agent_tokens.go`, `internal/serveredition/setup.go`, `internal/auth/{context.go,agent_token.go}`, `internal/server/server.go`, the five test files above +- [ ] T077 [P] [US1] Restricted marker: `preflight.ScopeInputs.Restricted`; `httpapi/preflight.go:139-146` sets it for every non-administrator; `normalizeTokenServers` honours it β€” `internal/preflight/scope.go`, `internal/httpapi/preflight.go` +- [ ] T078 [P] [US1] Session-cookie-only minting doors + the expiry rule on `/user/tokens` (`auth_endpoints.go:105-153`, `user_handlers.go:1071-1076,1120-1126`): `httpapi.parseExpiry` (`tokens.go:460`) is unexported and `internal/serveredition/api` does not import `internal/httpapi`, so move the rule to an exported `auth.ParseTokenExpiry(expiresIn string, now time.Time) (time.Time, error)` in `internal/auth/agent_token.go` (positive, ≀ 365 d, fixed error text) and make `httpapi.parseExpiry` a one-line wrapper β€” both doors then share defaults, cap and wording; `auth.CredentialKind` (defined in `internal/auth` by T076, this phase) recorded as `cookie`/`bearer_jwt` by the server-edition middleware (`middleware.go:88-108`) on the `AuthContext` β€” `internal/serveredition/api/{auth_endpoints.go,user_handlers.go}`, `internal/serveredition/auth/middleware.go`, `internal/auth/{context.go,agent_token.go}`, `internal/httpapi/tokens.go` +- [ ] T079 [US1] Real-instance verification: quickstart Β§6 (mint `["*"]` β†’ `[a]`, hidden `b` refusal), Β§7 (hot reload narrows without restart/rotation; JWT cannot renew), two-fixture runs from the harness output captured as evidence; record β€” `verification.md` + +## Phase C.3 β€” US4: a tenant uses the Web UI with nothing but their SSO session (P2) + +### Tests first + +- [ ] T080 [US4] **[compile-red until T083 (`SetSessionPrincipalResolver`)]** Write failing `internal/httpapi/session_principal_test.go`: FR-001 precedence fixtures (wrong `X-API-Key` + valid cookie β†’ 401; wrong bearer + cookie β†’ 401; wrong `?apikey=` + cookie β†’ 401; **empty-but-present** `X-API-Key:` / `Authorization: Bearer ` / `?apikey=` + valid cookie β†’ 401 each β€” presence is header/query *membership* (`r.Header.Values`, `r.URL.Query().Has`), not a non-empty value, so the current `ExtractToken` collapse of empty to absent (`server.go:592-615`) cannot rescue the cookie; valid bearer JWT + cookie β†’ bearer's principal; `X-API-Key` + bearer β†’ `X-API-Key` decides; cookie alone β†’ principal; nil resolver (personal) β†’ 401 as today); `CanRevealSecrets` false for session principals (`GET /config?reveal`, `/info` `web_ui_url` masked for `admin_user`) β€” `internal/httpapi/session_principal_test.go` +- [ ] T081 [P] [US4] **[compile-red until T083]** Write failing `internal/httpapi/tenant_allowlist_walk_test.go` (`-tags server`): `chi.Walk` over the production router; for every method+route assert the FR-045 verdict for a `user`-typed principal (allowlist rows β†’ 2xx/404-parity through the scope predicates; everything else β†’ the fixed 403, before body parse β€” send a malformed body and assert 403 not 400); named must-refuse list from US4 Independent Test included explicitly, plus the static routes that a naive `/servers/{id}/**` raw-path match would admit β€” `GET /api/v1/servers/import/paths` (`server.go:788`, host filesystem paths + existence, not a server subtree) β†’ fixed 403 β€” and `GET /api/v1/profiles/active` on the allow side (there is no `/profiles/{slug}` route, `server.go:771-778`); a route added later without an entry fails the test β€” `internal/httpapi/tenant_allowlist_walk_test.go` +- [ ] T082 [P] [US4] **[compile-red until T083/T086; the `/profiles` and SSE cases are behaviour-red once T083 lands]** Write failing `internal/httpapi/profiles_tenant_projection_test.go`: tenant `/profiles` omits hidden-only profiles, `/profiles/active` is `""` for a hidden active profile, Carol sees it, agent-token behaviour unchanged; `internal/httpapi/sse_session_refresh_test.go`: session principal re-resolved per frame, un-share narrows the next frame, disabled user ends the stream; `internal/serveredition/api/user_activity_wired_test.go`: Alice, Bob and the operator each call `a` with sentinels β†’ Alice's `/user/activity` returns only her records on `a` in today's `{items,total}` shape with `limit`/`offset`, `total` counts only those (both terms applied inside `storage.ActivityFilter.Matches`, never by post-filtering a page), masking applied; **Dana (`admin_user`) still receives `{items:[],total:0}`** β€” merge-base behaviour, since the door is wired with a nil filter today (`setup.go:196`, `user_activity.go:100-105`) and SC-006 lists no exception for it (wiring `multiuser.ActivityFilter` unguarded would hand administrators the fleet's history there, `multiuser/activity.go:45`); core `/activity*`/`/tool-calls*`/`/servers/{id}/tool-calls` β†’ 403 (FR-043(k)) β€” files above + +### Implementation + +- [ ] T083 [US4] **Consumes** `auth.CredentialKind` / `IsSessionPrincipal()` exactly as T076 declared them in `internal/auth/context.go` (no declaration here β€” T076 is the single owner; the type lives in `auth`, not `httpapi`, because `httpapi` imports `auth`); `internal/httpapi/session_principal.go`: `SessionPrincipalResolver` (takes `auth.CredentialKind`), `SetSessionPrincipalResolver`, `tenantSessionAllowlist` matcher (FR-045 core-reads row, with the explicit `/servers/import/paths` deny ahead of the `/servers/{id}/**` rule and no `/profiles/{slug}` entry), fixed 403 body; rewrite the credential precedence in `apiKeyAuthMiddleware` (`server.go:453-533`) to evaluate one source; apply on `/events` (`:1016-1017`); `CanRevealSecrets` change (`internal/auth/context.go` is touched only for the `CanRevealSecrets` predicate, never for a second `CredentialKind` declaration) β€” `internal/httpapi/session_principal.go`, `internal/httpapi/server.go`, `internal/auth/context.go` +- [ ] T084 [US4] Server-edition side: build the resolver in `setup.go` from the middleware's lookup (`middleware.go:138-214,228-246`) with the entitlement set materialised for tenants; install through `internal/server/serveredition_wire.go` (`httpAPIServer.SetSessionPrincipalResolver`) β€” `internal/serveredition/setup.go`, `internal/serveredition/auth/middleware.go`, `internal/server/serveredition_wire.go` +- [ ] T085 [P] [US4] `/profiles` tenant projection (`internal/httpapi/profiles.go:61-85,104-108`); SSE refresher re-resolves session principals (`sse_scope.go:17-43`) β€” `internal/httpapi/profiles.go`, `internal/httpapi/sse_scope.go` +- [ ] T086 [P] [US4] Wire `GET /api/v1/user/activity` through `multiuser.ActivityFilter` with both terms inside the storage filter: `storage.ActivityFilter` gains an authorization-only `UserID` field evaluated in `Matches` beside `AllowedServers` (`activity_models.go:283-299,349-365`), so `listByUserID`'s read-everything-then-post-filter (`multiuser/activity.go:107-150`) is replaced by one storage query with `UserID` and a non-nil `AllowedServers` set and `total` comes from storage; response shape stays `{items,total}` (`user_activity.go:66-70`) with `limit`/`offset` only (no new query parameters β€” `contracts/rest-endpoints.md` Β§5); projection + masking via an exported **projector** `httpapi.(*Server).ActivityProjector() func(*storage.ActivityRecord) contracts.ActivityRecord` β€” the door holds `*storage.ActivityRecord` (`user_activity.go:108-132`), the masker is a `*Server` method on the *contract* type (`maskActivityPayloads`, `activity.go:304`), and the conversion between them is the unexported `storageToContractActivity` (`activity.go:383`); the projector composes convert β†’ mask so the tenant door emits the same JSON shape and the same masking as core `/activity` β€” injected into a **new typed field** `serveredition.Dependencies.ProjectActivity func(*storage.ActivityRecord) contracts.ActivityRecord` (added to the struct at `internal/serveredition/registry.go:19-37` β€” no such field exists) from `serveredition_wire.go`; T082 asserts one record's JSON on `/user/activity` equals the same record's JSON on core `/activity` for an administrator (masked fields included); **`admin_user` unchanged means empty**: the handler answers `IsAdmin()` principals with today's `{items:[],total:0}` and consults the filter only for `user`-typed principals (the filter's admin branch, `multiuser/activity.go:45`, is never reached from this door) β€” `internal/storage/activity_models.go`, `internal/serveredition/setup.go`, `internal/serveredition/api/user_activity.go`, `internal/serveredition/multiuser/activity.go`, `internal/httpapi/activity.go`, `internal/serveredition/registry.go`, `internal/server/serveredition_wire.go` +- [ ] T087 [US4] **[behaviour-red]** Frontend unit tests, written first (red before T088) in `frontend/tests/unit/`: `principal-gating.spec.ts` (each gated fetch is skipped for a tenant, issued for admin/api-key), `admin-users-groups.spec.ts`, extended `settings-server-edition-wording.spec.ts` β€” `frontend/tests/unit/*.spec.ts` +- [ ] T088 [US4] Web UI (FR-041) β€” implemented **after** T087's specs are written and red: `stores/auth.ts` `principalKind`; gate `App.vue:92-99,139-156` (`/info`, `/routing`), `ModeSwitcher.vue:375`, `Dashboard.vue:678,723,736,793,807` (`/connect`, `/docker/status`, `/config`, `/sessions`, `/stats/tokens`), `Servers.vue:319`/`ServerCard.vue:445` (`/security/overview`), `stores/onboarding.ts:78`, `Activity.vue` (β†’ `/user/activity` for tenants) on `principalKind !== 'tenant'`; router: 401 from a core call in the server edition β†’ `/login`; never send `?apikey=` for sessions; `AdminUsers.vue` groups column; `AdminServers.vue` read-only group chips; `fields.ts` (`oauth.provider` incl. `oidc` already from B) β€” files above +- [ ] T089 [US4] Playwright `e2e/playwright/server-edition-tenant.spec.ts` per quickstart "Playwright": fresh context β†’ probe β†’ `/login` β†’ fake IdP β†’ dashboard β†’ servers β†’ mint β†’ activity with zero API-key usage and every XHR 2xx; walk the refused-route list via `page.request` asserting 403; wrong `X-API-Key` + cookie β†’ 401; cross-site `POST` with the cookie refused (SameSite); `admin_user` saves Settings and sees masked secrets β€” `e2e/playwright/server-edition-tenant.spec.ts` +- [ ] T090 [US4] Real-instance verification: quickstart Β§5 (`curl`) and the Playwright run against the rig (`--phase c`); screenshots of the tenant dashboard captured from the Chrome window (`feedback_chrome_window_screenshots`); record β€” `verification.md` + +## Phase C.4 β€” Docs, wiring, delivery + +- [ ] T091 [P] [X] Docs (FR-036 slice + FR-046): `docs/features/agent-tokens.md` invariant paragraph (covered surfaces, staleness bound incl. closed JWT renewal, retained 105 effects, open 105 items, single replica); `docs/development/server-edition-multiuser-auth.md` (`:76-81` session-cookie-only renewal, route table "Session/JWT" column, group map, tenant principal, `:51-57` isolation sentence, `:155-231` entitlement section incl. an **upgrade-state table**: upgraded user records decode with `groups == nil` (matches no key β†’ default grant only) the moment an `access` block is enabled; a live session or bearer JWT is narrowed on its next request and every owned token on its next authentication, both *before* the user re-logs in; `admin_emails` users are unaffected; groups refresh only at the next login; provider change β†’ automatic rebind (`provider_rebound`); same-provider subject change β†’ administrator `disable` (revokes sessions and tokens) β†’ `enable` β†’ single-use rebind on the next successful login); `docs/configuration/config-file.md` `access` rows + per-IdP table; `docs/features/settings-page.md:22,33` β€” files above +- [ ] T092 [P] [X] `.github/RELEASE_NOTICE.md` PR-C bullets: minting doors session-cookie-only, `expires_in` cap, "enabling `access` narrows existing sessions, JWTs and tokens immediately to the default grant until each user signs in again", the subject-rebind procedure (disable β†’ enable β†’ next login), tenant Web UI β€” `.github/RELEASE_NOTICE.md` +- [ ] T093 [X] Full gate set + goldens unregenerated (FR-044) + administrator parity fixture (FR-043(d)/SC-006: compare administrator responses to merge-base with the additive keys removed; includes `GET /api/v1/user/activity` as `admin_user` = `{items:[],total:0}`, the merge-base response per FR-045) β€” `verification.md` +- [ ] T094 [X] Cross-model review (briefs: predicate+storage / httpapi hook+allowlist / SSE+profiles+activity / frontend), ≀ 10 rounds β€” `verification.md` +- [ ] T095 [X] Open PR-C; roadmap tick; `gen-roadmap.py --check`; CI green; no merge without instruction β€” `roadmap.yaml`, `ROADMAP.md` + +--- + +# PR-D β€” JSONL audit line (`107-d-audit-line`) + +**Goal**: FR-012..FR-019, FR-039 part 4, FR-043 (b,j), SC-003, SC-009, deploy guide, FR-042 entry. Story US3. Depends on PR-C (owner identity on tokens, `session_admin`, `trusted_proxies`, `auth_event` reasons). + +## Phase D.1 β€” `internal/audit` package (edition-neutral) + +### Tests first + +- [ ] T096 [US3] **[compile-red until T099]** Write failing `internal/audit/canonical_test.go`: published JCS vectors in `internal/audit/testdata/canonical/*.json` (RFC 8785 appendix samples, nested maps/arrays, non-ASCII/escaped strings, `1`/`1.0`/`1e0` equal, `-0`β†’`0`, `9007199254740993`, UTF-16 key ordering); an **independent** test-package implementation computes the expected hashes; `_auth_*` keys excluded via `security.StripInternalArgs` β€” `internal/audit/canonical_test.go`, `internal/audit/testdata/canonical/` +- [ ] T097 [P] [US3] **[compile-red until T099]** Write failing `internal/audit/line_test.go` + `schema_test.go`: every line the builder can produce validates against `contracts/audit-line.schema.json` (copied to `docs/schemas/audit-line-v1.schema.json`) β€” the published file is **consumer-tolerant** (`additionalProperties: true` at every object level so a strict consumer survives a minor additive change under the same `$id`), and `schema_test.go` derives the **producer-strict** variant at test time (flips every `additionalProperties` to `false` in the loaded document) so the exact key set is proven by the test, not the published file; `schema_test.go` also accepts an external JSONL path (`MCPPROXY_AUDIT_JSONL= go test ./internal/audit -run TestExternalJSONLValidates`) so the quickstart/T116 gate validates a real sink file with the `santhosh-tekuri/jsonschema/v6` dependency already in `go.mod` β€” no `npx ajv-cli`; **negative fixtures** for the identity rules the schema now encodes: `session_user`/`session_admin` without `user_id` or with the wrong `role`, `anonymous` with `user_id`, `anonymous` with `provider`, `agent_token` without `token_name`/`token_prefix`, an **owned** `agent_token` (`user_id` present) missing any of `user_email`/`role`/`provider`, an ownerless `agent_token` carrying `role` or `provider`, `email_hash` beside `user_id`, `auth_event` with `user_email`, `surface: login` with `reason: logout`, `surface: logout` with `reason: ok`, and the **reason-conditioned identity rules** of the `auth_event` branch: `reason: ok` (and `logout`, `subject_mismatch`, `user_disabled`) with `caller.kind: anonymous` or without `user_id`, `domain_not_allowed`/`userinfo_subject_mismatch` without `email_hash` or with a `session_*` caller, `provider_error` with `user_id`, a pre-identity reason (`nonce_mismatch`) with `email_hash` β€” each must be rejected; a **schema-identity sync test** asserts that `schema_version` const, `$id`, `title` and the published filename `docs/schemas/audit-line-v.schema.json` carry the same `N` and that the contract copy and the published copy are byte-identical; per-event forbidden keys are **unrepresentable**, not checked at runtime: three event-specific constructors (`NewAuthz`, `NewToolCall`, `NewAuthEvent`) take typed structs that have no field for a forbidden key, so an `authz` line with `outcome` does not compile and the count invariant (SC-003) cannot be broken by a dropped line; the schema test validates every constructor's output; the structural redaction rule (builder has no parameter for argument/response/error text); sentinel byte-absence test with **nine** distinct high-entropy sentinels β€” args, response, error text, caller-supplied `_auth_user_email`, and the caller/operator-controlled strings the line *does* carry: `client.name`/`client.version` (MCP `clientInfo` / `X-MCPProxy-Client`), `token_name`, `profile`, and a caller-supplied `server` and `tool` on a **refused** dispatch (the caller may name any `server:tool` pair; the `authz deny` line records it per FR-016) β€” each untrusted string field is sanitised **per field** at build time (the fixed-prefix secret patterns masked, length-capped; never the high-entropy rule, which would mask legitimate long identifiers and break FR-016's attribution) and the schema is validated *after* sanitisation; the whole-line defence-in-depth pass of FR-015 uses **only the fixed-prefix credential patterns** (`sk-ant-`, `AKIA`, `ghp_`/`gho_`, `Bearer …`, …) and **never the generic `high_entropy` rule** (`sanitizer.go:104` masks any quoted 32+ char `[A-Za-z0-9+/]` run β€” it would rewrite every `args_sha256` and `email_hash`, breaking the schema after validation); fixture: a line whose `args_sha256`/`email_hash` and a 40-char alphanumeric `server` name survive the pass byte-identical, while a planted `AKIA…` in `client.name` and a caller-supplied `AKIA…:ghp_…` target on a refused dispatch are masked **at build time by the per-field pass** so the writer's whole-line pass finds nothing; **the whole-line pass is asserted to be the identity on every constructor output** (every fixture line: `sanitised == line`, byte-compared) β€” because every caller- or operator-controlled field is already sanitised per field, the pass can only fire on a builder bug, and that is reported: a hit increments `Sink.SanitizerHits()` (always-on atomic, beside `WriteFailures()`, mirrored to `mcpproxy_audit_sanitizer_hits_total` and read by `doctor`) and logs one WARN per minute naming the field-less fact that a line was rewritten; the masked line is still written (defence in depth wins over FR-016's real-name rule β€” the exception FR-016 itself now states, documented in `docs/features/audit-log.md`) β€” `internal/audit/{line_test.go,schema_test.go,sink_test.go}` +- [ ] T098 [P] [US3] **[compile-red until T099]** Write failing `internal/audit/sink_test.go`: the sink owns an **always-on** atomic write-failure counter (`Sink.WriteFailures() uint64`, independent of the metrics flag β€” doctor and the Prometheus mirror both read it); file sink append-only, write-through, a line never split across rotation (lumberjack via the exported `logs.NewRotatingWriter`), stdout sink writes raw JSON to an injected `io.Writer` (never through zap), unwritable path at construction β†’ error mapped to exit code 4 by the caller (lumberjack opens lazily on first `Write`, so the constructor must probe the path itself β€” `OpenFile(O_APPEND|O_CREATE|O_WRONLY)` then close β€” before installing the rotating writer), runtime write failure β†’ counter increments, call proceeds, error logged at most once per minute (fake clock), 2,000 concurrent writes under a saturated fake event bus β†’ 2,000 lines β€” `internal/audit/sink_test.go` + +### Implementation + +- [ ] T099 [US3] Implement `internal/audit/{attempt.go,canonical.go,line.go,sink.go}` per data-model Β§5 and contracts; the dependency runs one way only (`audit` β†’ `logs`, never `logs` β†’ `audit`): `internal/logs` exports `NewRotatingWriter(path string, maxSizeMB, maxBackups, maxAgeDays int, compress bool) (io.WriteCloser, error)` built on the same lumberjack construction as `newRotatingSink` (`logger.go:183-197`; primitive arguments, no `audit` type) and `NewStringSanitizer(opts ...SanitizerOption) *StringSanitizer` exposing the pattern set of `SecretSanitizer.sanitizeString` (`sanitizer.go:160`) without a zap core (`NewSecretSanitizer` takes and returns a `zapcore.Core`, `sanitizer.go:26`, and is not an `io.Writer`) with a `WithoutHighEntropy()` option; `audit.Sink` wraps the writer with `NewStringSanitizer(WithoutHighEntropy())` (T097 explains why the generic rule cannot run over a line that carries hashes) β€” `internal/audit/*.go`, `internal/logs/{logger.go,sanitizer.go}` + +## Phase D.2 β€” US3: lines at the funnels (P1) + +### Tests first + +- [ ] T100 [US3] **[behaviour-red]** Write failing `internal/server/activity_funnel_ctx_first_test.go`: an AST test asserting the first parameter of `emitActivityPolicyDecision` and `emitActivityToolCallCompleted` is `context.Context` (red on HEAD: `mcp.go:766,787` take strings first) β€” the existing `statusArgIndex` pin (`activity_result_status_test.go:133`, `status` at index 5) is **not** touched here: moving it to 6 before the signature changes would make the guard inspect `errorMsg` and pass for the wrong reason; the pin and the `mcp.go:762-765` comment move to 6 atomically inside T103; extend `mcp_activity_args_test.go:460-527` so a caller-supplied `_auth_*` never reaches the activity metadata (behaviour-red: the strip is asserted on the existing seam) β€” the audit-line half of that assertion and every `caller.kind` case live in T101 (`TestGetAuthMetadata_*` in `mcp_activity_agent_test.go` exercises the activity-log `_auth_*` metadata, `mcp.go:647-668`, not audit caller identity, so it is the wrong host for `caller.kind`) β€” files above +- [ ] T101 [P] [US3] **[compile-red until T099/T103]** Write failing `internal/server/audit_caller_test.go` (the `caller.kind`/`origin` derivation helper `auditCallerFromContext(ctx) audit.Caller` T103 adds, one case per kind: API key, socket/tray, **stdio** (stdio installs `auth.AdminContext()` with no connection source, `server.go:1039`, and `GetConnectionSource` defaults to TCP, `transport/context.go:25`, so without a stdio tag the line would say `api_key` β€” `stdioAuthContext` must tag `transport.ConnectionSourceStdio` and the derivation maps it to `caller.kind: stdio`, `origin: local`), anonymous, owned/ownerless agent token, `session_admin` (`CredentialKind` cookie/bearer), internal) and `internal/server/audit_dispatch_matrix_test.go` (both tags; server tag adds tenant tokens and `session_admin` on `/api/v1/tools/call`): the US3 matrix β€” surfaces (`call_tool_read|write|destructive`, direct, nested script, REST `/tools/call`, **native stdio** through the in-process stdio server) Γ— caller kinds (`api_key`, `socket`, `anonymous`, `agent_token` tenant/admin by `caller.role`, `session_admin`, `internal`, `stdio`) Γ— situations (allowed, upstream error, tier denial, scope denial of a hidden server with `disclosed:false` and the real name recorded, quarantine block, limiter shed β†’ `tool_call rejected`, output-sanitisation block β†’ `tool_call blocked`, bare `retrieve_tools`/`read_cache` β†’ no line, 100 KB payload, synthetic secret) asserting: schema validity, count invariants under bus saturation (2,000 calls, 1,500 allow), `args_sha256` equality with the independent hash, `caller.kind` correctness, `source: api` despite `X-MCPProxy-Client: cli/x`, nested children keep the script's caller + `surface: code_execution` + `parent_id`, the wrapper writes no line, no `session_user` on `authz`/`tool_call`, no `outcome` on `authz` (FR-043(j)) β€” `internal/server/audit_dispatch_matrix_test.go` +- [ ] T102 [P] [US3] **[compile-red until T104]** Write failing `internal/jsruntime/runtime_authz_observer_test.go`: `checkDispatchGates` (`runtime.go:384-418`) reports scope/permission refusals through the observer on both the single and the batch path with the request context, `parent_id`, canonical target, tier and stripped arguments, exactly once β€” `internal/jsruntime/runtime_authz_observer_test.go` + +### Implementation + +- [ ] T103 [US3] `ctx` as the first parameter of `emitActivityPolicyDecision` (`mcp.go:787`) and `emitActivityToolCallCompleted` (`:766`) and every call site (`mcp.go:2211-2425,2912-2934,6872`, `mcp_routing.go:425-468`, `output_sanitisation.go:113,138,287,328`, `mcp_code_execution.go:869,927`, test fixtures); install `audit.Attempt` before the first gate in `handleCallToolVariant` (`:2190-2197`), direct dispatch (`mcp_routing.go:412-414`), the REST direct path and the nested wrapper (`mcp_code_execution.go:691-704`); update the `statusArgIndex` pin to 6 and the `mcp.go:762-765` comment in the same commit (T100); funnels build and write `authz`/`tool_call` lines from the attempt + `AuthContext` + `reqcontext.RequestMeta` (client IP, mount; T050) + connection source; **sink wiring path** (no field exists today, `serveredition_wire.go:28-42`; the constructors take no options, `server.go:173,211`, and production calls `NewServerWithConfigPath`, `main.go:629ff`): add `type ServerOption func(*Server)` (package `server`; distinct from the existing `MCPProxyOption`, `mcp.go:271`) and a variadic `opts ...ServerOption` to **both** `NewServer` and `NewServerWithConfigPath` (`NewServer` forwards `opts` unchanged; backward-compatible β€” every existing caller passes none), `func WithAuditSink(sink audit.Sink) ServerOption`; `cmd/mcpproxy/main.go` constructs the sink and calls `NewServerWithConfigPath(cfg, path, logger, server.WithAuditSink(sink))` β†’ `Server.auditSink` β†’ `MCPProxyServer.auditSink` (nil = no-op in the personal default) and a **new typed field** `serveredition.Dependencies.AuditSink audit.Sink` (added at `internal/serveredition/registry.go:19-37`) set by `wireServerEditionOAuth` for T107; `caller.kind` derivation (incl. `stdio` via the new `transport.ConnectionSourceStdio` tag set in `stdioAuthContext`, `server.go:1039`); limiter shed from the returned `*limiter.LimitError`; post-dispatch blocks as `tool_call blocked` β€” `internal/server/{mcp.go,mcp_routing.go,mcp_code_execution.go,output_sanitisation.go,server.go}`, `internal/transport/context.go`, `internal/serveredition/registry.go`, `cmd/mcpproxy/main.go` +- [ ] T104 [US3] `jsruntime.ExecutionContext` authorization-decision observer invoked from `checkDispatchGates`; the wrapper installs it with the parent attempt; a refusal it reports is not re-reported by the completion emitters β€” `internal/jsruntime/runtime.go`, `internal/server/mcp_code_execution.go` +- [ ] T105a [P] [US3] **[compile-red until T105 (`audit.ErrorClassOf`)]** Write failing `internal/audit/error_class_test.go`: a table over the typed upstream errors (`context.Canceled`/`DeadlineExceeded`, `*limiter.LimitError`, the upstream transport/timeout/unavailable error types, validation and sanitisation errors, an unknown error β†’ `internal`) β†’ `upstream_error|upstream_timeout|upstream_unavailable|validation|sanitisation|internal|cancelled` β€” `internal/audit/error_class_test.go` +- [ ] T105 [P] [US3] (after T105a) `audit.ErrorClassOf(err error) ErrorClass` β€” the one helper β€” `internal/audit/error_class.go` + +## Phase D.3 β€” `auth_event` lines (FR-017) + +- [ ] T106 [US3] **[compile-red until T107]** Write failing `internal/serveredition/auth/auth_event_test.go`: exactly one line per terminal login attempt (every FR-013 reason incl. `authorization_denied` via the PR-B refusal fixtures + fault injection β†’ `internal_error`), one per logout, none for an abandoned redirect; identity rule is **stage-dependent** (`user_id` when the store was reached and a record exists / `email_hash` when a verified email is known and the store was not yet consulted β€” `domain_not_allowed`, `userinfo_subject_mismatch`, and `provider_error` raised by the userinfo fetch after a verified ID token / neither before identity): `provider_error` is driven at **both** stages β€” the `UserinfoUnavailable`/`UserinfoRedirect`/`UserinfoNonJSON` knobs after a verified token (line carries `email_hash`, never `user_id`, even for a returning user) and a discovery/token-endpoint failure (line carries neither) β€” and every line validates against the schema; `flags` (`provider_rebound`, `redirect_rejected`, `groups_claim_missing`); token mint/rotate/revoke write no line; `session_user` appears only here β€” `internal/serveredition/auth/auth_event_test.go` +- [ ] T107 [US3] Emit `auth_event` lines by installing the emitter on the handler's `LoginResultObserver` hook (T044 β€” one terminal `LoginResult` per attempt, so the count invariant is the hook's, not re-derived from branches) and from `HandleLogout`, through the `audit.Sink` carried in `serveredition.Dependencies.AuditSink` (the typed `Dependencies.AuditSink` field T103 adds to `registry.go`, set by `serveredition_wire.go` from the server's sink; nil = no-op) and handed to the OAuth handler by `setup.go` (edition-neutral sink, server-only emitter) β€” `internal/serveredition/auth/oauth_handler.go`, `internal/serveredition/setup.go`, `internal/server/serveredition_wire.go` + +## Phase D.4 β€” Config, doctor, metrics (FR-014, FR-018, FR-039 part 4) + +- [ ] T108 [US3] **[compile-red until T109]** Write failing `internal/config/audit_log_config_test.go`: defaults per edition (`EffectiveAuditLog`; **the server-edition stdout default applies to the HTTP transport only** β€” in native stdio mode standard output *is* the MCP transport, `server.go:968-989`: with the block **absent** the server-edition default resolves to `{enabled:false}` under stdio with one WARN line naming `audit_log.path` as the stdio-compatible sink (only the *default* is suppressed β€” FR-014 "an explicit value always wins" holds); an **explicit** `enabled: true, stdout: true` with no `path` under stdio is a sink-construction failure β€” typed `config.StartupError{ExitCode: 4}` `audit_log.stdout cannot be used under the stdio transport (stdout carries JSON-RPC); set audit_log.path` β€” never a silent disable; an explicit `path` is honoured and `stdout: true` beside it is dropped with the WARN), **omitted vs explicit** for every field (`compress` omitted β†’ `true`, explicit `false` β†’ `false`; `max_size_mb` omitted β†’ 50, explicit `0` β†’ validation error β€” the wire struct uses pointer fields so presence is observable, data-model Β§9), env overrides, validation messages (`no sink`, non-positive rotation values), `DetectConfigChanges` restart-pinned clause; `cmd/mcpproxy/audit_boot_test.go`: unwritable path β†’ exit code 4 with the message β€” via a typed `config.StartupError{ExitCode: ExitCodeConfigError}` that `classifyError` (`main.go:846ff`) checks with `errors.As` **before** its string heuristics (today a `permission denied` message classifies as exit 5, `:869-873`); stdio + absent block β†’ the WARN line and a disabled sink; stdio + explicit `{enabled:true, stdout:true}` and no `path` β†’ exit code 4 with the message; stdio + explicit `path` β†’ file sink, stdout dropped with the WARN; `enabled: false` under the server edition β†’ startup warning; absent block under the server edition β†’ one startup line β€” files above +- [ ] T109 [US3] Implement `AuditLogConfig` (`internal/config/audit_log.go`), accessor `EffectiveAuditLog(cfg, transport)` (stdio rule of T108), `validateAuditLog(cfg)` **called from both `Config.Validate()` and `ValidateDetailed()`** (`config.go`; the "no sink" and non-positive rotation messages of `contracts/config-keys.md`, so boot, PATCH and `/config/apply` agree β€” T108 drives all four doors), env in `applyTLSEnvOverrides`, `DetectConfigChanges` clause, sink construction in `cmd/mcpproxy/main.go` returning the typed `StartupError` (exit 4 through `classifyError`'s new `errors.As` branch, `exit_codes.go`), `mcpproxy_audit_write_failures_total` **counter** (monotonic `_total`, never a gauge) in `internal/observability` (metrics enabled only) **mirroring the sink's always-on atomic counter** (`Sink.WriteFailures()`, T098), `doctor` finding reading the sink's counter (works with metrics disabled) β€” registered from `internal/server` as a second `management.AddRuntimeWarningSource` (T052 seam) that reports `audit_log: N write failures since start (last: )` when `Sink.WriteFailures() > 0`; asserted through `management.(*service).Doctor` with a stub sink β€” `internal/config/{audit_log.go,config.go,loader.go,serveredition_accessors{,_stub}.go}`, `internal/runtime/config_hotreload.go`, `cmd/mcpproxy/main.go`, `internal/server/server.go`, `internal/observability/*.go`, `internal/management/diagnostics_audit_test.go` +- [ ] T110a [P] [US3] **[behaviour-red]** (no `AUDIT_LOG_FIELDS` export exists) Write failing `frontend/tests/unit/settings-audit-log.spec.ts`: the `audit_log.*` accordion rows of `contracts/config-keys.md` exist with `restart: true` and no secret control β€” `frontend/tests/unit/settings-audit-log.spec.ts` +- [ ] T110 [P] [US3] (after T110a) Wiring checklist: `make swagger` (`audit_log` on `Config`), `swagger-verify`; the `fields.ts` `AUDIT_LOG_FIELDS` accordion; Swift catalogue read-only rows and `python3 scripts/check-settings-parity.py` + `swift test` (`native-tests.yml:83,97`) green locally; `docs/configuration/config-file.md` + `environment-variables.md` + root `docs/configuration.md` rows; `TestContractsInSync` β€” `oas/*`, `frontend/src/views/settings/fields.ts`, `native/macos/MCPProxy/.../SettingsCatalog.swift`, docs + +## Phase D.5 β€” Docs, E2E, benchmark, delivery + +- [ ] T111 [P] [US3] `docs/features/audit-log.md` (schema table from `contracts/audit-line-events.md`, every vocabulary, versioning rule, crash window, Docker/stdout recipe, one vendor-neutral file/stdout scrape example) + `docs/schemas/audit-line-v1.schema.json` + `website/sidebars.js` entry; `docs/features/sensitive-data-detection.md:360` SIEM recipe points at it β€” files above +- [ ] T112 [P] [US3] `docs/operations/deploying-for-a-team.md` (Kubernetes + Keycloak walkthrough: Deployment `strategy: Recreate`, Secret via `${env:}`, Ingress, `public_url`, `trusted_proxies`, `access`, stdout audit, single-replica contract, `--config`+`--data-dir` volume rule, the open 105 items, the staleness bound and the upgrade-state table of T091) + sidebar entry; its config example stored at `docs/operations/examples/deploying-for-a-team.json` and loaded+validated by `internal/config/deploy_guide_example_test.go` (`-tags server`) β€” files above +- [ ] T113 [P] [US3] `scripts/test-api-e2e.sh`: after the activity tests (`:960-977`) assert that a personal instance with `audit_log.enabled=true, path=` wrote one `authz` + one `tool_call` line for the fixture call and none for `retrieve_tools`; keep the script's default run unchanged otherwise β€” `scripts/test-api-e2e.sh`, `test/e2e-config.json` (only if a second config is needed; restore afterwards) +- [ ] T114 [P] [US3] `.github/RELEASE_NOTICE.md` PR-D bullets: server-edition audit-to-stdout default, exit code 4 on an unwritable path β€” `.github/RELEASE_NOTICE.md` +- [ ] T115 [US3] SC-009 benchmark: Spec 105 FR-011 method (frozen 527-tool snapshot, 20 warm-ups, 200 calls, merge-base vs branch) for `call_tool_read`, `retrieve_tools`, `tools/list` with `audit_log` on, administrator and scoped tenant; record p95s in verification.md (quote USD beside tokens if any token figure is reported β€” `feedback_quote_usd_with_tokens`) β€” `bench/` (existing harness), `verification.md` +- [ ] T116 [US3] Real-instance verification: quickstart Β§6 (`--phase d`: three lines for `a:echo` allow + `tool_call`, `b:echo` deny with `disclosed:false`; sentinel absent; `auth_event ok` for Alice; schema validation of the tail through `MCPPROXY_AUDIT_JSONL=$ROOT/audit.jsonl go test ./internal/audit -run TestExternalJSONLValidates` β€” no `npx ajv-cli`, T097), stdout mode in a `docker run` of the built image with `--entrypoint` checks, a stdio serve with the block absent β†’ the WARN line and clean JSON-RPC on stdout, a stdio serve with an explicit stdout-only block β†’ exit 4, unwritable path β†’ exit 4, disk-full simulation (`/dev/full`) β†’ call proceeds + counter; record β€” `verification.md` +- [ ] T117 [X] Full gate set + goldens unregenerated + `test-api-e2e.sh` with the new assertion β€” `verification.md` +- [ ] T118 [X] Cross-model review (briefs: audit package / funnels+attempt / nested observer+limiter / auth_event / config+docs), ≀ 10 rounds β€” `verification.md` +- [ ] T119 [X] Open PR-D; roadmap tick (`status: in_review` on the epic when all four are open); `gen-roadmap.py --check`; CI green; no merge without instruction β€” `roadmap.yaml`, `ROADMAP.md` + +--- + +## Dependencies & Execution Order + +- **Phase 0** precedes everything (roadmap render changes the moment `specs/107-*/` exists β€” `agent1` Β§4). +- **PR-A** (T005–T031) has no code dependency; A.1 (CI/lint) first so A.2's deletions are linted under the tag; A.2 and A.3 are independent (A.3 touches `storage/agent_tokens.go`, A.2 does not); A.4 last. +- **PR-B** (T032–T066) depends on PR-A's non-mutating `Validate` (T019) and carriers (T020). B.1 (rig) precedes B.2 (inside B.1, T032 β†’ T033 β†’ T034a β†’ T034 β€” T034 is not parallel); B.2 and B.3 both edit `oauth_handler.go` β†’ sequential (B.2 then B.3); T054a β†’ T054; B.4 after B.3 (keys exist); B.5 independent of B.2–B.4 except the accessor file (T052 before T061); B.6 last. +- **PR-C** (T067–T095) depends on PR-B (stored groups T044/T045, forced MCP auth T052, probe T053). C.1 first; C.2 before C.3 (the tenant principal's `AllowedServers` is `entitledServerNames`); inside C.2, T074 β†’ T075 β†’ T076 (config β†’ predicate β†’ storage; T076 also defines `auth.CredentialKind`), T077 parallel with T076, **T078 after T076** (it records `CredentialKind`); C.3's T083/T084 sequential (hook then wiring), T085/T086 parallel, T087 (specs, red) β†’ T088 (UI) β†’ T089 after T084; T070a (red) β†’ T075a inside C.2, parallel with T074–T076 (different files). +- **PR-D** (T096–T119) depends on PR-C (owner identity T076, `session_admin` T083) and PR-B (`trusted_proxies` + the `reqcontext.RequestMeta` client-IP/mount tag, T050). D.1 first; D.2's T103 after T099; T104 parallel with T103 after T102; T105a β†’ T105; D.3 after T099 and PR-B's `LoginResultObserver` hook (T044); D.4 parallel with D.2/D.3 (T110a β†’ T110; T109 registers its doctor source on the T052 seam); D.5 last. +- **Within every story**: failing tests β†’ implementation β†’ real-instance verification; models before services before endpoints. + +## Parallel Example: PR-C Phase C.2 tests + +``` +T069 internal/config/server_edition_access_test.go +T070 internal/serveredition/api/entitlement_group_test.go +T071 internal/storage/agent_tokens_owner_resolution_test.go + httpapi/preflight_restricted_test.go +T072 internal/server/mcp_group_scope_test.go + httpapi/sse_scope_group_test.go +T073 internal/serveredition/api/freshness_bound_test.go +``` +All five touch different files and can be written concurrently (each in its own worktree if dispatched to subagents β€” `feedback_subagent_worktree_isolation`). + +## Implementation Strategy + +1. **PR-A is the MVP of honesty**: after it, no doc or CLI sentence claims injection, the personal binary no longer carries the brokered seam, and old configs load intact in both editions. It is mergeable on its own. +2. **PR-B makes the door real**: Okta/Keycloak/Authentik/Auth0/Entra teams can sign in with verified tokens behind an ingress; the container has no anonymous administrator; the maintainer can measure enablement. Mergeable on its own (groups are stored but not yet enforced). +3. **PR-C delivers the promise**: group grants everywhere, a tenant Web UI with no API key, one predicate, empty = deny-all, a real freshness bound. +4. **PR-D writes it down**: audit lines with a schema security approves once, the deploy guide, and the benchmark. + +Stop at any PR boundary; each is a coherent release. + +## Task counts + +| PR | Tasks | Test-first tasks | Verification tasks | +|---|---|---|---| +| Phase 0 | T001–T004 (4) | β€” | β€” | +| PR-A | T005–T031 (28 incl. T022a) | T008–T013, T022a, T026 (8) | T025, T028, T029 (3) | +| PR-B | T032–T066 (37 incl. T034a, T054a) | T032, T034a, T036–T040, T047–T049, T054a, T055, T060 (13) | T062, T063, T064 (3) | +| PR-C | T067–T095 (31 incl. T070a, T075a; T067 is fixture construction, T068's second file is a pin) | T068–T073 incl. T070a, T080–T082, T087 (11) | T079, T090, T093 (3) | +| PR-D | T096–T119 (26 incl. T105a, T110a) | T096–T098, T100–T102, T105a, T106, T108, T110a (10) | T115, T116, T117 (3) | +| **Total** | **126** | 42 | 12 | + +## Notes + +- Anchors in `internal/server/mcp.go:2190-2425` and `tool_gate.go` move when PR #1279 merges; re-derive before T101/T103. +- Never regenerate a tool-surface golden; never add an MCP tool argument (FR-044). +- `gofmt` only the files you touched; a broad `gofmt -w` churns unrelated files. +- Commit convention: conventional prefix, `Related #N`, no AI attribution lines (spec "Commit Message Conventions" overrides the session reminder). diff --git a/specs/107-server-edition-sso-hardening/verification.md b/specs/107-server-edition-sso-hardening/verification.md new file mode 100644 index 000000000..e9d9b2a3c --- /dev/null +++ b/specs/107-server-edition-sso-hardening/verification.md @@ -0,0 +1,35 @@ +# Verification β€” Spec 107 Server Edition SSO Front Door + +Evidence per PR. Each section records the real-instance run, the automated gate set, and the cross-model review rounds (reviewer: codex CLI `gpt-5.6-sol`, static-only; tests are run locally and recorded here). + +## PR-A β€” freeze/cut + honesty + hygiene + +### Real instance + +### Automated checks + +### Cross-review + +## PR-B β€” generic OIDC + front door + telemetry + +### Real instance + +### Automated checks + +### Cross-review + +## PR-C β€” entitlement predicate, group grants, tenant Web UI session + +### Real instance + +### Automated checks + +### Cross-review + +## PR-D β€” JSONL audit line + +### Real instance + +### Automated checks + +### Cross-review diff --git a/specs/README.md b/specs/README.md index e622c786e..7f41aa7e6 100644 --- a/specs/README.md +++ b/specs/README.md @@ -82,6 +82,7 @@ Brainstormed design docs that feed future specs live under [`docs/superpowers/sp | [043-linux-package-repos](./043-linux-package-repos/) | Linux Package Repositories (apt/yum) | `shipped` | 39/41 (95%) | | [104-auto-routing-mode](./104-auto-routing-mode/) | Auto Routing Mode β€” Budget-Fitted Tool Surface Per Session | `drafted` | β€” | | [105-agent-scope-hardening](./105-agent-scope-hardening/) | Agent-Token Scope Hardening β€” Every MCP Request Authorized by Its Own Scope | `drafted` | β€” | +| [107-server-edition-sso-hardening](./107-server-edition-sso-hardening/) | Server Edition SSO Front Door Hardened for Real IdPs β€” Generic OIDC, Group Allowlist, JSONL Audit, Freeze | `drafted` | 4/126 (3%) | ## Updating this index From 9fdc58d09236efdc859d193de87b9271e18b01ff Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 10:57:13 +0300 Subject: [PATCH 02/35] test(spec-107): PR-A red tests, server-tag lint job and release notice Related #1177 --- .github/RELEASE_NOTICE.md | 44 ++ .github/workflows/unit-tests.yml | 31 +- CLAUDE.md | 8 +- cmd/mcpproxy/credential_cmd_wording_test.go | 131 ++++++ cmd/release-gate/docs_claims_test.go | 304 +++++++++++++ internal/config/latent_symbols_guard_test.go | 403 ++++++++++++++++++ internal/config/legacy_keys_load_test.go | 391 +++++++++++++++++ internal/config/personal_roundtrip_test.go | 272 ++++++++++++ .../config/server_edition_validate_test.go | 202 +++++++++ .../testdata/legacy_server_edition.json | 45 ++ .../httpapi/config_patch_removed_keys_test.go | 243 +++++++++++ .../httpapi/config_patch_roundtrip_test.go | 259 +++++++++++ 12 files changed, 2331 insertions(+), 2 deletions(-) create mode 100644 .github/RELEASE_NOTICE.md create mode 100644 cmd/mcpproxy/credential_cmd_wording_test.go create mode 100644 cmd/release-gate/docs_claims_test.go create mode 100644 internal/config/latent_symbols_guard_test.go create mode 100644 internal/config/legacy_keys_load_test.go create mode 100644 internal/config/personal_roundtrip_test.go create mode 100644 internal/config/server_edition_validate_test.go create mode 100644 internal/config/testdata/legacy_server_edition.json create mode 100644 internal/httpapi/config_patch_removed_keys_test.go create mode 100644 internal/httpapi/config_patch_roundtrip_test.go diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md new file mode 100644 index 000000000..91ad6f5a6 --- /dev/null +++ b/.github/RELEASE_NOTICE.md @@ -0,0 +1,44 @@ +## Server edition: configuration keys and modes that never did anything are gone + +This release removes the server-edition knobs and `auth_broker` modes that were accepted by the validator but had no reader in production. An old `mcp_config.json` still loads; what changes is how the removed keys are treated. Personal-edition users are not affected unless the file carries a `server_edition` or `auth_broker` block. + +**Removed keys and modes** (spec 107, FR-032): + +- `server_edition.max_user_servers` and `server_edition.workspace_idle_timeout` β€” never enforced. +- Per-server `auth_broker.header` and `auth_broker.header_format` β€” no request was ever rewritten with them. +- `auth_broker.mode: token_exchange` and `auth_broker.mode: entra_obo` β€” never implemented. `oauth_connect` is now the only accepted mode. + +**What the server edition (`mcpproxy-server`) does with an old file** + +- Loading succeeds. Each removed key is dropped with one warning naming it β€” `server_edition.max_user_servers is no longer supported and was ignored`, `auth_broker.header is no longer supported and was ignored`, and so on. A server whose `auth_broker.mode` is `token_exchange` or `entra_obo` loses its **whole** `auth_broker` block: `auth_broker.mode "token_exchange" was never implemented; the auth_broker block for server "" was ignored`. The next write-back of the file omits the dropped keys. +- Writing them is refused. `PATCH /api/v1/config` and `/api/v1/config/apply` reject a document that carries any removed key or mode with the same message, so a script that still sends `max_user_servers` now gets a validation error instead of a silent accept. +- Remove the keys from your file, and change any `token_exchange`/`entra_obo` server to `oauth_connect` if you want its connect flow to keep working β€” otherwise its `auth_broker` block is dropped on the next load. + +**What the personal edition (`mcpproxy`) does with the same file** + +- Nothing. The `server_edition` block and every server's `auth_broker` block now pass through the personal binary as opaque JSON β€” every key and value preserved, removed keys included, no warning and no validation. Earlier releases wrote both blocks back as `{}`, so an API-key bootstrap or a `PATCH /api/v1/config` from the personal binary could erase a team's SSO or broker configuration; that is fixed here (FR-040). + +## `store_idp_tokens` is now a no-op + +`server_edition.store_idp_tokens` no longer stores anything. The identity-provider access and refresh tokens it used to persist at login existed only to feed the never-implemented `token_exchange`/`entra_obo` modes, which left a long-lived IdP refresh token at rest with nothing reading it. The writer, the reader and the offline-access scope and authorization parameters that asked the IdP for a refresh token (`offline_access`, `access_type=offline`) are removed (FR-033), so a fresh login no longer requests a refresh token from the IdP. + +- The key is still accepted so an old file loads. `"store_idp_tokens": true` logs one warning at boot β€” `server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it` β€” and does nothing else. +- Remove it from your configuration. Nothing in this release reads the IdP tokens an earlier release stored. +- The former [IdP Token Storage](https://docs.mcpproxy.app/features/idp-token-storage/) page is now a tombstone. + +## Auth broker: a stored credential is stored, not injected + +The `oauth_connect` connect flow, its REST routes, the `mcpproxy credential` commands, the encrypted credential store and `MCPPROXY_CRED_KEY` / `credential_encryption_key` all stay. What changes is the promise attached to them: a credential a user connects through the broker is **kept for a future broker and is not injected into upstream tool calls**. It never was β€” the injection, resolution and per-user connection-keying code paths that the documentation described had no production caller and are deleted in this release (FR-031, FR-034). + +- `mcpproxy credential list` and `mcpproxy credential status` now open with the line `Stored credentials are kept for a future broker and are NOT injected into upstream calls in this release.` +- The [auth broker](https://docs.mcpproxy.app/features/auth-broker/) and [credential commands](https://docs.mcpproxy.app/cli/credential-commands/) pages are rewritten accordingly; the "Credential resolution", "Header injection" and "Per-(user, server) connection keying" sections are gone. +- Upstream calls keep using whatever the server's own configuration provides (static headers, the server's own OAuth). If you deployed the broker expecting per-user credentials on upstream calls, that expectation was never met, and this release says so rather than fixing it. +- Historical `credential_broker` activity rows remain readable and labelled. + +## Agent tokens: the cap is now per owner + +The 100-token limit on agent tokens is now counted **per owner** instead of across the whole deployment ([#1177](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1177), FR-037). Tokens with the same owner count together; operator tokens with no owner form one owner of their own. + +- Server edition: one user reaching 100 tokens no longer blocks every other user from creating theirs, and the `409 Conflict` body from `POST /user/tokens` now refers only to the caller's own count β€” deleting your own tokens does free a slot. +- Personal edition: every token is ownerless, so the limit is unchanged in practice (100), and the `409` body keeps its meaning. +- No configuration change is needed. Details: [agent tokens](https://docs.mcpproxy.app/features/agent-tokens/). diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index c49d2742e..a7dbabf7b 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -303,6 +303,18 @@ jobs: # cache-invalidation fix. skip-cache: true + # Second pass under the server build tag (Spec 107 FR-047). Everything in + # internal/serveredition/ and every *_serveredition.go file is behind + # `//go:build server`, so the bare run above never sees it; without this + # step the server edition ships unlinted. Same config, same version β€” the + # only difference is the tag set. + - name: golangci-lint (server edition) + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.9.0 + args: --config .github/.golangci.yml --build-tags server --timeout=10m + skip-cache: true + server-edition: name: Server Edition runs-on: ubuntu-latest @@ -328,6 +340,14 @@ jobs: - name: Build server edition run: go build -tags server ./cmd/mcpproxy + # internal/server is now in the list below and constructs the tiktoken + # tokenizer; warm the vocabulary once so parallel packages hit the cache + # (same reason as the unit-tests job above). + - name: Warm the tiktoken vocabulary cache + run: go run ./bench/cmd/warmtiktoken + env: + TIKTOKEN_CACHE_DIR: ${{ runner.temp }}/tiktoken + # internal/oauth is where the redaction rules live, and half of them are # build-tagged: serverfields_serveredition.go holds the mask decisions for # the `auth_broker` block, whose fields do not exist in the personal @@ -336,8 +356,17 @@ jobs: # `-tags server`. Without this package in the list they never ran, and the # guarantee the #1161 fix rests on (a field added to the tagged block is # masked because the walk reaches it) was unenforced in CI. + # + # internal/server, internal/httpapi and internal/storage carry the + # server-edition wiring (serveredition_wire.go, the tagged handlers, the + # agent-token store) and were only ever *built* under the tag, never + # race-tested (Spec 107 FR-047). The -skip regex is the one the unit-test + # job uses (see "Run unit tests" above): it drops the binary-spawning + # internal/server tests that otherwise hang to the package timeout. - name: Test server edition packages - run: go test -race -tags server ./internal/serveredition/... ./internal/config/... ./internal/oauth/... + run: go test -race -tags server -timeout 20m -skip "E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint" ./internal/serveredition/... ./internal/config/... ./internal/oauth/... ./internal/server/... ./internal/httpapi/... ./internal/storage/... + env: + TIKTOKEN_CACHE_DIR: ${{ runner.temp }}/tiktoken build: name: Build diff --git a/CLAUDE.md b/CLAUDE.md index 0243092b3..0d8fe3e5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,8 +78,14 @@ go test -tags server ./internal/serveredition/... -race # server edition # Lint β€” CI uses golangci-lint v2 with .github/.golangci.yml, which is STRICTER # than the local scripts/run-linter.sh (v1.x) and catches things it misses. -# Run the v2 binary before pushing: +# CI runs it TWICE: bare, and with --build-tags server (server-edition code is +# invisible to the bare run). Run both before pushing: /opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./... +/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml --build-tags server ./... +# CI also race-tests internal/server, httpapi and storage under -tags server +# with the unit-tests.yml -skip regex (bare `go test ./internal/server/...` +# hangs to the timeout on the binary-spawning tests): +go test -race -tags server -timeout 20m -skip "E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint" ./internal/serveredition/... ./internal/config/... ./internal/oauth/... ./internal/server/... ./internal/httpapi/... ./internal/storage/... # Run ./mcpproxy serve [--listen :8080] [--log-level=debug] # core (localhost:8080) diff --git a/cmd/mcpproxy/credential_cmd_wording_test.go b/cmd/mcpproxy/credential_cmd_wording_test.go new file mode 100644 index 000000000..d511d480d --- /dev/null +++ b/cmd/mcpproxy/credential_cmd_wording_test.go @@ -0,0 +1,131 @@ +//go:build server + +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// wantCredentialBanner is the honesty line of Spec 107 FR-034 / contracts/ +// rest-endpoints.md Β§12: a stored credential is kept for a future broker and +// is NOT injected into upstream calls. It is spelled out here, independently +// of any production constant, so the test pins the contract wording. +const wantCredentialBanner = "Stored credentials are kept for a future broker and are NOT injected into upstream calls in this release." + +// newCredentialWordingServer serves GET /api/v1/user/credentials with the +// given list so the real run functions (not just the render helpers) can be +// driven end-to-end without a server-edition instance. +func newCredentialWordingServer(t *testing.T, body string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/credentials" || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + })) + t.Cleanup(srv.Close) + + // Point the CLI at the fake server and force the human-readable format. + oldURL, oldToken := credServerURL, credToken + oldFormat, oldJSON := globalOutputFormat, globalJSONOutput + credServerURL, credToken = srv.URL, "test-jwt" + globalOutputFormat, globalJSONOutput = "", false + t.Setenv("MCPPROXY_OUTPUT", "") + t.Cleanup(func() { + credServerURL, credToken = oldURL, oldToken + globalOutputFormat, globalJSONOutput = oldFormat, oldJSON + }) +} + +// captureCredentialStdout runs fn and returns everything it wrote to os.Stdout. +func captureCredentialStdout(t *testing.T, fn func() error) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + fnErr := fn() + _ = w.Close() + os.Stdout = old + out := <-done + if fnErr != nil { + t.Fatalf("command returned error: %v\noutput so far:\n%s", fnErr, out) + } + return out +} + +// firstLine returns the first line of out (without the trailing newline). +func firstLine(out string) string { + line, _, _ := strings.Cut(out, "\n") + return line +} + +const credentialWordingFixture = `{"credentials":[ + {"server":"github","mode":"oauth_connect","status":"connected","token_type":"Bearer","scopes":["repo"],"obtained_via":"connect_flow"}, + {"server":"jira","mode":"oauth_connect","status":"not_connected","connect_path":"/api/v1/user/credentials/jira/connect"} +]}` + +// FR-034 / contracts/rest-endpoints.md Β§12: `mcpproxy credential list` output +// begins with the "stored … NOT injected" line. +func TestCredentialList_OutputBeginsWithStoredNotInjected(t *testing.T) { + newCredentialWordingServer(t, credentialWordingFixture) + + out := captureCredentialStdout(t, func() error { return runCredentialList(nil, nil) }) + + if got := firstLine(out); got != wantCredentialBanner { + t.Errorf("credential list: first output line = %q, want %q\nfull output:\n%s", got, wantCredentialBanner, out) + } + // The banner must be a prefix, not a replacement: the table still renders. + if !strings.Contains(out, "github") || !strings.Contains(out, "not_connected") { + t.Errorf("credential list: table body missing after banner:\n%s", out) + } +} + +// The banner is unconditional (Β§12 says "output begins with"), so it precedes +// the empty-state message as well. +func TestCredentialList_EmptyOutputBeginsWithStoredNotInjected(t *testing.T) { + newCredentialWordingServer(t, `{"credentials":[]}`) + + out := captureCredentialStdout(t, func() error { return runCredentialList(nil, nil) }) + + if got := firstLine(out); got != wantCredentialBanner { + t.Errorf("credential list (empty): first output line = %q, want %q\nfull output:\n%s", got, wantCredentialBanner, out) + } + if !strings.Contains(out, "No brokered upstreams") { + t.Errorf("credential list (empty): empty-state line missing after banner:\n%s", out) + } +} + +// FR-034 / contracts/rest-endpoints.md Β§12: `mcpproxy credential status ` +// output begins with the same line; the REST status vocabulary +// (connected|expired|not_connected|unavailable) is unchanged in the body. +func TestCredentialStatus_OutputBeginsWithStoredNotInjected(t *testing.T) { + newCredentialWordingServer(t, credentialWordingFixture) + + out := captureCredentialStdout(t, func() error { return runCredentialStatus(nil, []string{"github"}) }) + + if got := firstLine(out); got != wantCredentialBanner { + t.Errorf("credential status: first output line = %q, want %q\nfull output:\n%s", got, wantCredentialBanner, out) + } + for _, want := range []string{"Server:", "github", "Status:", "connected"} { + if !strings.Contains(out, want) { + t.Errorf("credential status: detail body missing %q after banner:\n%s", want, out) + } + } +} diff --git a/cmd/release-gate/docs_claims_test.go b/cmd/release-gate/docs_claims_test.go new file mode 100644 index 000000000..8b68f30cb --- /dev/null +++ b/cmd/release-gate/docs_claims_test.go @@ -0,0 +1,304 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" +) + +// Spec 107 FR-035 / FR-036 docs guard. +// +// The server edition stores per-user brokered credentials but never injects +// them into an upstream call (research D1, D9–D11). The published docs still +// promise the opposite. This test fails while any of the FR-036 +// injection/keying/exchange claims survives in the published docs tree (the +// docusaurus `include` list of website/docusaurus.config.js, plus CLAUDE.md, +// which FR-036 names explicitly), and while any of the four sidebar entries +// FR-036 keeps (`website/sidebars.js` lines 55, 129, 130, 263 at origin/main +// b39800a89) stops resolving to a real page. The rewrite is T023; this guard +// stays red until it lands and keeps the claims from creeping back. +// +// Sentences are matched case-sensitively on whitespace-collapsed text so a +// claim wrapped across a line break (`injects it at\ncall time`) is still +// caught. Table rows are matched on their first cell so that a later +// "removed keys" compatibility note that merely *mentions* `token_exchange` +// in prose is not flagged β€” only a row that documents the mode or the +// package as a live thing is. + +// injectionClaimSentences are the FR-036 free-text claims, verbatim from +// tasks.md T013. +var injectionClaimSentences = []string{ + "injects it at call time", + "Credential resolution", + "Header injection", + "Per-(user, server) connection keying", + "JWT bearer token for MCP", + "nothing re-validates at use time", + "per-user connections", +} + +// removedTableRowCells are the first-cell literals of the Markdown table rows +// FR-036 removes: the `token_exchange` and `entra_obo` mode rows in +// docs/features/auth-broker.md and the `internal/serveredition/workspace/` +// package row in docs/development/server-edition-multiuser-auth.md. +var removedTableRowCells = []string{ + "token_exchange", + "entra_obo", + "workspace/", +} + +// keptSidebarEntries are the sidebar doc ids FR-036 says must keep resolving +// (tombstoned or rewritten, never deleted). Matched by id rather than by line +// number so an unrelated sidebar edit does not move the goalposts. +var keptSidebarEntries = []string{ + "cli/credential-commands", // sidebars.js:55 + "features/auth-broker", // sidebars.js:129 + "features/idp-token-storage", // sidebars.js:130 + "development/server-edition-multiuser-auth", // sidebars.js:263 +} + +// repoRootFromTest is the repository root relative to the package directory; +// go test runs with cmd/release-gate as the working directory. +var repoRootFromTest = filepath.Join("..", "..") + +var whitespaceRun = regexp.MustCompile(`\s+`) + +func collapseWhitespace(s string) string { + return strings.TrimSpace(whitespaceRun.ReplaceAllString(s, " ")) +} + +// TestDocsMakeNoInjectionClaims fails while any FR-036 sentence or table row +// survives in the published docs. +func TestDocsMakeNoInjectionClaims(t *testing.T) { + files := publishedDocFiles(t) + files = append(files, filepath.Join(repoRootFromTest, "CLAUDE.md")) + + var hits []string + for _, path := range files { + hits = append(hits, scanDocForClaims(t, path)...) + } + sort.Strings(hits) + for _, h := range hits { + t.Errorf("FR-036 claim survives: %s", h) + } + if len(hits) > 0 { + t.Logf("%d FR-036 injection/keying/exchange claim(s) still published; T023 rewrites these pages (stored, not injected)", len(hits)) + } +} + +// TestKeptSidebarEntriesResolve fails while any of the four FR-036 sidebar +// entries is missing from website/sidebars.js or names a docs page that does +// not exist. +func TestKeptSidebarEntriesResolve(t *testing.T) { + sidebarPath := filepath.Join(repoRootFromTest, "website", "sidebars.js") + data, err := os.ReadFile(sidebarPath) + if err != nil { + t.Fatalf("read %s: %v", sidebarPath, err) + } + sidebar := string(data) + + for _, id := range keptSidebarEntries { + if !strings.Contains(sidebar, "'"+id+"'") { + t.Errorf("website/sidebars.js no longer lists %q β€” FR-036 keeps this entry (tombstone or rewrite, never delete)", id) + continue + } + if _, ok := resolveDocID(id); !ok { + t.Errorf("website/sidebars.js entry %q names a page that does not exist (expected docs/%s.md or .mdx) β€” the docs build breaks", id, id) + } + } +} + +// resolveDocID maps a docusaurus doc id to the repo docs/ source file. +func resolveDocID(id string) (string, bool) { + for _, ext := range []string{".md", ".mdx"} { + p := filepath.Join(repoRootFromTest, "docs", filepath.FromSlash(id)+ext) + if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() { + return p, true + } + } + return "", false +} + +// scanDocForClaims returns one "path:line: " entry per surviving +// FR-036 sentence or table row in the file. +func scanDocForClaims(t *testing.T, path string) []string { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer f.Close() + + var lines []string + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + lines = append(lines, sc.Text()) + } + if err := sc.Err(); err != nil { + t.Fatalf("read %s: %v", path, err) + } + + rel, relErr := filepath.Rel(repoRootFromTest, path) + if relErr != nil { + rel = path + } + rel = filepath.ToSlash(rel) + + var hits []string + report := func(lineNo int, claim string) { + hits = append(hits, rel+":"+strconv.Itoa(lineNo)+": "+claim) + } + + for i, raw := range lines { + line := collapseWhitespace(raw) + var joined string + if i+1 < len(lines) { + joined = collapseWhitespace(raw + " " + lines[i+1]) + } + for _, s := range injectionClaimSentences { + switch { + case strings.Contains(line, s): + report(i+1, "sentence "+strconv.Quote(s)) + case joined != "" && strings.Contains(joined, s) && !strings.Contains(collapseWhitespace(lines[i+1]), s): + // the claim wraps across this line and the next + report(i+1, "sentence "+strconv.Quote(s)+" (wrapped)") + } + } + if cell, ok := firstTableCell(raw); ok { + for _, c := range removedTableRowCells { + if strings.Contains(cell, c) { + report(i+1, "table row "+strconv.Quote(c)) + } + } + } + } + return hits +} + +// firstTableCell returns the trimmed, backtick-stripped first cell of a +// Markdown table row, or false when the line is not a table row (or is the +// header separator). +func firstTableCell(line string) (string, bool) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") { + return "", false + } + rest := strings.TrimPrefix(trimmed, "|") + end := strings.Index(rest, "|") + if end < 0 { + return "", false + } + cell := strings.TrimSpace(rest[:end]) + cell = strings.Trim(cell, "`") + if cell == "" || strings.Trim(cell, "-: ") == "" { + return "", false + } + return cell, true +} + +// publishedDocFiles returns every docs/ source file the docusaurus site +// publishes, derived from the `include`/`exclude` lists in +// website/docusaurus.config.js so a newly published directory is scanned +// without editing this test. +func publishedDocFiles(t *testing.T) []string { + t.Helper() + cfgPath := filepath.Join(repoRootFromTest, "website", "docusaurus.config.js") + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read %s: %v", cfgPath, err) + } + include := jsStringArray(t, string(data), "include") + exclude := jsStringArray(t, string(data), "exclude") + if len(include) == 0 { + t.Fatalf("%s: no docs `include:` globs parsed β€” the docs-claims guard scans nothing", cfgPath) + } + + excluded := map[string]bool{} + for _, e := range exclude { + excluded[filepath.ToSlash(e)] = true + } + + docsRoot := filepath.Join(repoRootFromTest, "docs") + seen := map[string]bool{} + var files []string + add := func(p string) { + rel, err := filepath.Rel(docsRoot, p) + if err != nil { + return + } + rel = filepath.ToSlash(rel) + if excluded[rel] || seen[rel] { + return + } + seen[rel] = true + files = append(files, p) + } + + for _, glob := range include { + glob = filepath.ToSlash(glob) + if dir, ok := strings.CutSuffix(glob, "/**/*.{md,mdx}"); ok { + root := filepath.Join(docsRoot, filepath.FromSlash(dir)) + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + switch filepath.Ext(p) { + case ".md", ".mdx": + add(p) + } + return nil + }) + if err != nil { + t.Fatalf("walk published docs dir %s (from include glob %q): %v", root, glob, err) + } + continue + } + p := filepath.Join(docsRoot, filepath.FromSlash(glob)) + if st, err := os.Stat(p); err != nil || !st.Mode().IsRegular() { + t.Errorf("%s include entry %q names a missing docs file %s", cfgPath, glob, p) + continue + } + add(p) + } + sort.Strings(files) + return files +} + +// jsStringArray extracts the single-quoted string literals of the first +// `: [ ... ]` array in a JS source, ignoring `//` line comments. +func jsStringArray(t *testing.T, src, key string) []string { + t.Helper() + start := strings.Index(src, key+": [") + if start < 0 { + return nil + } + body := src[start+len(key)+3:] + end := strings.Index(body, "]") + if end < 0 { + t.Fatalf("docusaurus.config.js: unterminated `%s: [` array", key) + } + body = body[:end] + + var out []string + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "//") { + continue + } + for _, m := range jsSingleQuoted.FindAllStringSubmatch(line, -1) { + out = append(out, m[1]) + } + } + return out +} + +var jsSingleQuoted = regexp.MustCompile(`'([^']+)'`) diff --git a/internal/config/latent_symbols_guard_test.go b/internal/config/latent_symbols_guard_test.go new file mode 100644 index 000000000..9d668ad0b --- /dev/null +++ b/internal/config/latent_symbols_guard_test.go @@ -0,0 +1,403 @@ +package config + +// Spec 107 FR-035 latent-symbols guard (task T008). +// +// FR-031/FR-033 delete the never-wired credential-brokering code (router, +// tool filter, workspace manager, token exchanger, credential resolver, header +// injector, IdP subject-token capture, the brokered transport seam) and FR-032 +// removes the dead config knobs and the never-implemented auth_broker modes. +// This test walks the NON-TEST Go AST of the packages that hosted that code +// and fails while any of the removed declarations still exists, so the cut +// cannot silently regress. +// +// The walk uses go/parser directly, so build tags are ignored: the +// //go:build server files are inspected under both `go test ./internal/config` +// and `go test -tags server ./internal/config`, and the verdict is the same in +// both editions. +// +// Compatibility literals the same FRs REQUIRE to exist are exempt by name and +// must never be flagged here (they are proven by the SC-005 load/write-back +// fixtures instead): the retained `StoreIDPTokens` decoder field and its +// deprecation warning (FR-033), and the key/mode strings inside the +// server-build normaliser and its warnings (FR-032). The mode-literal check +// is therefore confined to the validator functions that carry the accepted +// mode set, never applied package-wide. + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// latentGuardRoots are the directories the guard walks, relative to the repo +// root. Recursive entries end in "/...". +var latentGuardRoots = []string{ + "internal/serveredition/...", + "internal/transport", + "internal/upstream/core", + "internal/config", +} + +// removedDecl names one declaration FR-031/FR-032/FR-033 delete. An empty Pkg +// or Recv matches any package / any receiver (or struct) in the walked set. +// Recv scopes methods to their receiver type and struct fields to their +// struct; the task text names the scoped ones explicitly +// (`workspace.Manager`, `(*OAuthConnector).Refresh`, "the fields ..."). +type removedDecl struct { + Pkg string + Recv string + Name string + Why string +} + +var removedDecls = []removedDecl{ + // FR-031 class A: multi-user router / tool filter / workspaces. Scoped to + // package multiuser: the live `serveredition.Dependencies.Router` field is + // the chi.Router every feature mounts on (registry.go) and must stay. + {Pkg: "multiuser", Name: "Router", Why: "multiuser.Router never wired (FR-031)"}, + {Pkg: "multiuser", Name: "NewRouter", Why: "multiuser.NewRouter never wired (FR-031)"}, + {Pkg: "multiuser", Name: "ToolFilter", Why: "multiuser.ToolFilter never wired (FR-031)"}, + {Pkg: "multiuser", Name: "NewToolFilter", Why: "multiuser.NewToolFilter never wired (FR-031)"}, + {Pkg: "workspace", Name: "Manager", Why: "package internal/serveredition/workspace deleted (FR-031)"}, + // FR-031 class A: broker exchange / resolve / inject chain. + {Name: "TokenExchanger", Why: "broker.TokenExchanger deleted (FR-031)"}, + {Name: "CredentialResolver", Why: "broker.CredentialResolver deleted (FR-031)"}, + {Name: "HeaderInjector", Why: "broker.HeaderInjector deleted (FR-031)"}, + {Name: "ConnectionKey", Why: "broker.ConnectionKey deleted (FR-031)"}, + {Name: "AuditActionInject", Why: "dead audit constant (FR-031)"}, + {Name: "AuditActionAcquire", Why: "dead audit constant (FR-031)"}, + {Name: "AuditActionRefresh", Why: "dead audit constant (FR-031)"}, + {Name: "AuditMethodTokenExchange", Why: "dead audit constant (FR-031)"}, + {Name: "AuditMethodEntraOBO", Why: "dead audit constant (FR-031)"}, + {Name: "auditMethodForMode", Why: "dead audit mapping (FR-031)"}, + // FR-031/FR-033: IdP subject-token capture and refresh. + {Name: "GetValidIDPSubjectToken", Why: "IdP subject-token reader deleted (FR-033)"}, + {Name: "ErrReauthRequired", Why: "IdP subject-token reader deleted (FR-033)"}, + {Name: "RefreshAccessToken", Why: "OAuthProvider.RefreshAccessToken deleted (FR-031)"}, + {Recv: "OAuthConnector", Name: "Refresh", Why: "(*OAuthConnector).Refresh deleted (FR-031)"}, + // FR-031: resolver-only seams on the credential handlers. + {Name: "ConnectorProvider", Why: "broker.ConnectorProvider interface + (*CredentialHandlers).ConnectorProvider deleted (FR-031)"}, + {Name: "ConnectorFor", Why: "connectorProvider.ConnectorFor deleted (FR-031)"}, + // FR-031: brokered transport seam compiled into the personal binary. + {Name: "SetBrokeredAuth", Why: "core.(*Client).SetBrokeredAuth deleted (FR-031)"}, + {Name: "brokeredAuth", Why: "core.Client.brokeredAuth field and its branches deleted (FR-031)"}, + {Name: "BrokeredAuth", Why: "transport.BrokeredAuth type + HTTPTransportConfig.BrokeredAuth deleted (FR-031)"}, + {Name: "EffectiveHeaders", Why: "transport.EffectiveHeaders deleted (FR-031)"}, + {Name: "refuseBrokeredOAuth", Why: "transport brokered branch deleted (FR-031)"}, + // FR-032: dead config knobs. + {Pkg: "config", Recv: "ServerEditionConfig", Name: "MaxUserServers", Why: "dead knob removed (FR-032)"}, + {Pkg: "config", Recv: "ServerEditionConfig", Name: "WorkspaceIdleTimeout", Why: "dead knob removed (FR-032)"}, + {Pkg: "config", Recv: "AuthBrokerConfig", Name: "Header", Why: "injection header removed (FR-032)"}, + {Pkg: "config", Recv: "AuthBrokerConfig", Name: "HeaderFormat", Why: "injection header format removed (FR-032)"}, +} + +// removedAuthBrokerModes are the never-implemented modes FR-032 removes from +// the validator's accepted set. They are only forbidden INSIDE the validator +// functions below; the server-build normaliser (T018) legitimately carries +// the same strings in its key/mode table and warnings and is exempt. +var removedAuthBrokerModes = []string{"token_exchange", "entra_obo"} + +// removedAuthBrokerModeIdents are the constant identifiers the validator uses +// today to spell the accepted set (auth_broker.go). Their presence inside a +// validator body is the same violation as the literal. +var removedAuthBrokerModeIdents = []string{"AuthBrokerModeTokenExchange", "AuthBrokerModeEntraOBO"} + +// authBrokerValidatorFuncs are the functions whose bodies define the accepted +// mode set. The task text names validateServerAuthBroker; on HEAD that +// function delegates to (*AuthBrokerConfig).Validate, where the switch +// actually lives, so both are inspected. +var authBrokerValidatorFuncs = []removedDecl{ + {Pkg: "config", Name: "validateServerAuthBroker"}, + {Pkg: "config", Recv: "AuthBrokerConfig", Name: "Validate"}, +} + +// exemptDeclNames are compatibility declarations the guard must never flag +// (FR-033 retained decoder field). Listed by name so a future edit to the +// forbidden table cannot accidentally catch them. +var exemptDeclNames = map[string]string{ + "StoreIDPTokens": "retained decoder field; `true` logs one deprecation warning (FR-033)", +} + +// latentDecl is one declaration found by the AST walk. +type latentDecl struct { + Pkg string + Kind string // type, func, method, field, const, var + Recv string // receiver type for methods, struct/interface name for fields + Name string + Pos token.Position +} + +func (d latentDecl) String() string { + owner := d.Name + if d.Recv != "" { + owner = d.Recv + "." + d.Name + } + return fmt.Sprintf("%s %s.%s at %s:%d", d.Kind, d.Pkg, owner, d.Pos.Filename, d.Pos.Line) +} + +func latentGuardRepoRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("repo root %s has no go.mod: %v", root, err) + } + return root +} + +// latentGuardFiles lists every non-test .go file under the guard roots. +func latentGuardFiles(t *testing.T, root string) []string { + t.Helper() + var files []string + for _, entry := range latentGuardRoots { + recursive := strings.HasSuffix(entry, "/...") + dir := filepath.Join(root, filepath.FromSlash(strings.TrimSuffix(entry, "/..."))) + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + t.Fatalf("guard root %s is not a directory: %v", dir, err) + } + err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if path == dir { + return nil + } + if !recursive || d.Name() == "testdata" { + return filepath.SkipDir + } + return nil + } + name := d.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } + } + sort.Strings(files) + if len(files) == 0 { + t.Fatal("guard walked no files; roots are wrong") + } + return files +} + +func latentTypeName(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.StarExpr: + return latentTypeName(e.X) + case *ast.Ident: + return e.Name + case *ast.IndexExpr: // generic receiver T[P] + return latentTypeName(e.X) + case *ast.IndexListExpr: + return latentTypeName(e.X) + case *ast.SelectorExpr: + return e.Sel.Name + } + return "" +} + +// latentCollectDecls collects top-level declarations plus struct fields and +// interface methods from one parsed file. +func latentCollectDecls(fset *token.FileSet, f *ast.File) ([]latentDecl, map[string]*ast.FuncDecl) { + pkg := f.Name.Name + var out []latentDecl + funcs := map[string]*ast.FuncDecl{} + add := func(kind, recv, name string, pos token.Pos) { + out = append(out, latentDecl{Pkg: pkg, Kind: kind, Recv: recv, Name: name, Pos: fset.Position(pos)}) + } + for _, decl := range f.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + recv := "" + kind := "func" + if d.Recv != nil && len(d.Recv.List) > 0 { + recv = latentTypeName(d.Recv.List[0].Type) + kind = "method" + } + add(kind, recv, d.Name.Name, d.Name.Pos()) + funcs[recv+"."+d.Name.Name] = d + case *ast.GenDecl: + for _, spec := range d.Specs { + switch s := spec.(type) { + case *ast.TypeSpec: + add("type", "", s.Name.Name, s.Name.Pos()) + switch tt := s.Type.(type) { + case *ast.StructType: + for _, field := range tt.Fields.List { + for _, n := range field.Names { + add("field", s.Name.Name, n.Name, n.Pos()) + } + } + case *ast.InterfaceType: + for _, field := range tt.Methods.List { + for _, n := range field.Names { + add("method", s.Name.Name, n.Name, n.Pos()) + } + } + } + case *ast.ValueSpec: + kind := "var" + if d.Tok == token.CONST { + kind = "const" + } + for _, n := range s.Names { + add(kind, "", n.Name, n.Pos()) + } + } + } + } + } + return out, funcs +} + +func (r removedDecl) matches(d latentDecl) bool { + if r.Name != d.Name { + return false + } + if r.Pkg != "" && r.Pkg != d.Pkg { + return false + } + if r.Recv != "" && r.Recv != d.Recv { + return false + } + return true +} + +// TestLatentSymbolsGuard_RemovedDeclarationsAbsent fails while any FR-031 / +// FR-032 / FR-033 declaration still exists in the walked packages, or while +// the auth_broker validator still accepts token_exchange / entra_obo. +func TestLatentSymbolsGuard_RemovedDeclarationsAbsent(t *testing.T) { + root := latentGuardRepoRoot(t) + files := latentGuardFiles(t, root) + fset := token.NewFileSet() + + var violations []string + for _, path := range files { + f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + decls, funcs := latentCollectDecls(fset, f) + + for _, d := range decls { + if _, exempt := exemptDeclNames[d.Name]; exempt { + continue + } + for _, r := range removedDecls { + if r.matches(d) { + violations = append(violations, fmt.Sprintf("%s β€” %s", latentRel(root, d), r.Why)) + } + } + } + + for _, vf := range authBrokerValidatorFuncs { + if f.Name.Name != vf.Pkg { + continue + } + fn, ok := funcs[vf.Recv+"."+vf.Name] + if !ok || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.BasicLit: + if x.Kind != token.STRING { + return true + } + val, err := strconv.Unquote(x.Value) + if err != nil { + val = x.Value + } + for _, mode := range removedAuthBrokerModes { + if strings.Contains(val, mode) { + pos := fset.Position(x.Pos()) + violations = append(violations, fmt.Sprintf("literal %q in %s at %s:%d β€” mode %q removed from the accepted set (FR-032)", + val, latentFuncLabel(vf), latentRelPath(root, pos.Filename), pos.Line, mode)) + } + } + case *ast.Ident: + for _, id := range removedAuthBrokerModeIdents { + if x.Name == id { + pos := fset.Position(x.Pos()) + violations = append(violations, fmt.Sprintf("identifier %s in %s at %s:%d β€” mode constant removed from the accepted set (FR-032)", + id, latentFuncLabel(vf), latentRelPath(root, pos.Filename), pos.Line)) + } + } + } + return true + }) + } + } + + if len(violations) > 0 { + sort.Strings(violations) + t.Errorf("Spec 107 FR-035: %d removed declaration(s)/branch(es) still present (FR-031/FR-032/FR-033):\n %s", + len(violations), strings.Join(violations, "\n ")) + } +} + +// TestLatentSymbolsGuard_CompatibilityDeclarationsRetained pins the exemption: +// the `store_idp_tokens` decoder field stays (FR-033) so old configs load, and +// the guard above must never list it. +func TestLatentSymbolsGuard_CompatibilityDeclarationsRetained(t *testing.T) { + root := latentGuardRepoRoot(t) + fset := token.NewFileSet() + path := filepath.Join(root, "internal", "config", "server_edition_config.go") + f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + decls, _ := latentCollectDecls(fset, f) + found := false + for _, d := range decls { + if d.Kind == "field" && d.Recv == "ServerEditionConfig" && d.Name == "StoreIDPTokens" { + found = true + } + for _, r := range removedDecls { + if r.Name == d.Name { + if _, exempt := exemptDeclNames[d.Name]; exempt { + t.Errorf("exempt declaration %s is also in the removed table; fix the table", d.Name) + } + } + } + } + if !found { + t.Errorf("ServerEditionConfig.StoreIDPTokens must remain as a retained decoder field (FR-033); it is missing from %s", latentRelPath(root, path)) + } +} + +func latentRel(root string, d latentDecl) string { + d.Pos.Filename = latentRelPath(root, d.Pos.Filename) + return d.String() +} + +func latentRelPath(root, path string) string { + if rel, err := filepath.Rel(root, path); err == nil { + return filepath.ToSlash(rel) + } + return path +} + +func latentFuncLabel(vf removedDecl) string { + if vf.Recv != "" { + return "(*" + vf.Recv + ")." + vf.Name + } + return vf.Name +} diff --git a/internal/config/legacy_keys_load_test.go b/internal/config/legacy_keys_load_test.go new file mode 100644 index 000000000..f732fde56 --- /dev/null +++ b/internal/config/legacy_keys_load_test.go @@ -0,0 +1,391 @@ +//go:build server + +package config_test + +// Spec 107 T009 (US5, FR-035/FR-039): a config written against the pre-107 +// server edition β€” carrying server_edition.max_user_servers, +// workspace_idle_timeout, store_idp_tokens: true and per-server auth_broker +// blocks using the never-implemented token_exchange mode or the removed +// header key β€” must still BOOT. The removed keys/modes are dropped by the +// server-build normaliser on the raw map before the typed decode and recorded +// as exactly one LoadDiagnostic each on the returned Config. The loader has no +// logger, so the diagnostics are emitted later by config.LogLoadDiagnostics, +// which this test drives through a zap observer. +// +// This file is an external test package (config_test) because it also asserts +// runtime.DetectConfigChanges is blind to the dropped keys, and internal/runtime +// imports internal/config. +// +// Sibling: internal/httpapi/config_patch_removed_keys_test.go covers the two +// write doors (PATCH /api/v1/config and /config/apply); this file covers the +// boot door and the raw-document check itself. + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" +) + +const legacyServerEditionFixture = "testdata/legacy_server_edition.json" + +// Exact strings from specs/107-server-edition-sso-hardening/contracts/config-keys.md. +const ( + msgMaxUserServersIgnored = "server_edition.max_user_servers is no longer supported and was ignored" + msgWorkspaceIdleTimeoutIgnored = "server_edition.workspace_idle_timeout is no longer supported and was ignored" + msgStoreIDPTokensDeprecated = "server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it" + msgTokenExchangeIgnored = `auth_broker.mode "token_exchange" was never implemented; the auth_broker block for server "legacy-exchange" was ignored` + msgAuthBrokerHeaderIgnored = "auth_broker.header is no longer supported and was ignored" +) + +// removedKeyMarkers are the JSON key/value tokens that must never survive a +// write-back of the legacy fixture. store_idp_tokens is NOT in this list: it is +// a retained (deprecated, no-op) decoder field, not a removed key. +var removedKeyMarkers = []string{ + `"max_user_servers"`, + `"workspace_idle_timeout"`, + `"token_exchange"`, + `"header"`, + `"header_format"`, +} + +// readLegacyFixture returns the fixture as a generic document. +func readLegacyFixture(t *testing.T) map[string]any { + t.Helper() + data, err := os.ReadFile(legacyServerEditionFixture) + require.NoError(t, err) + var doc map[string]any + require.NoError(t, json.Unmarshal(data, &doc)) + return doc +} + +// writeDocument persists a generic document under dir with data_dir pinned to +// dir (LoadFromFile creates the data directory) and returns its path. +func writeDocument(t *testing.T, dir, name string, doc map[string]any) string { + t.Helper() + doc["data_dir"] = dir + data, err := json.MarshalIndent(doc, "", " ") + require.NoError(t, err) + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, data, 0600)) + return path +} + +// cleanSibling strips every removed key/mode from the legacy document the way +// the normaliser is specified to: the whole auth_broker block of a +// token_exchange/entra_obo server goes, only the header/header_format leaves +// go from an oauth_connect server, and the two server_edition keys go. +func cleanSibling(t *testing.T, doc map[string]any) map[string]any { + t.Helper() + raw, err := json.Marshal(doc) + require.NoError(t, err) + var clean map[string]any + require.NoError(t, json.Unmarshal(raw, &clean)) + + se, ok := clean["server_edition"].(map[string]any) + require.True(t, ok, "fixture must carry a server_edition object") + delete(se, "max_user_servers") + delete(se, "workspace_idle_timeout") + + servers, ok := clean["mcpServers"].([]any) + require.True(t, ok, "fixture must carry mcpServers") + for _, s := range servers { + server, ok := s.(map[string]any) + require.True(t, ok) + broker, ok := server["auth_broker"].(map[string]any) + if !ok { + continue + } + switch broker["mode"] { + case "token_exchange", "entra_obo": + delete(server, "auth_broker") + default: + delete(broker, "header") + delete(broker, "header_format") + } + } + return clean +} + +func loadLegacyFixture(t *testing.T) (*config.Config, map[string]any) { + t.Helper() + doc := readLegacyFixture(t) + path := writeDocument(t, t.TempDir(), "legacy.json", doc) + cfg, err := config.LoadFromFile(path) + require.NoError(t, err, "a pre-107 config must still boot the server edition") + require.NotNil(t, cfg) + return cfg, doc +} + +// Boot door: the legacy fixture loads, the block survives, and exactly one +// LoadDiagnostic is recorded per removed key/mode plus one for the deprecated +// store_idp_tokens: true. +func TestLegacyKeys_BootLoadRecordsOneDiagnosticPerKey(t *testing.T) { + cfg, _ := loadLegacyFixture(t) + + require.NotNil(t, cfg.ServerEdition, "the server_edition block must survive normalisation") + assert.True(t, cfg.ServerEdition.Enabled, "dropping removed keys must not disable the block") + assert.Equal(t, []string{"admin@example.com"}, cfg.ServerEdition.AdminEmails) + assert.True(t, cfg.ServerEdition.StoreIDPTokens, + "store_idp_tokens keeps its decoder field (deprecated no-op, FR-035 exemption)") + + require.Len(t, cfg.Servers, 2) + byName := map[string]*config.ServerConfig{} + for _, s := range cfg.Servers { + byName[s.Name] = s + } + require.Contains(t, byName, "legacy-exchange") + require.Contains(t, byName, "legacy-header") + assert.Nil(t, byName["legacy-exchange"].AuthBroker, + "a token_exchange block was never implemented: the WHOLE auth_broker block is dropped") + require.NotNil(t, byName["legacy-header"].AuthBroker, + "an oauth_connect block loses only its header leaf, not the block") + assert.Equal(t, "oauth_connect", byName["legacy-header"].AuthBroker.Mode) + + diags := cfg.LoadDiagnostics() + require.Len(t, diags, 5, "one diagnostic per removed key/mode + one for store_idp_tokens: %+v", diags) + + messages := make([]string, 0, len(diags)) + keys := make([]string, 0, len(diags)) + for _, d := range diags { + messages = append(messages, d.Message) + keys = append(keys, d.Key) + assert.NotEmpty(t, d.Key, "every diagnostic names the key it dropped: %+v", d) + } + assert.ElementsMatch(t, []string{ + msgMaxUserServersIgnored, + msgWorkspaceIdleTimeoutIgnored, + msgStoreIDPTokensDeprecated, + msgTokenExchangeIgnored, + msgAuthBrokerHeaderIgnored, + }, messages, "messages must match contracts/config-keys.md byte for byte") + + // The three server_edition diagnostics name their key exactly; the two + // per-server ones end in the leaf they dropped (the contract fixes the + // message, not the per-server key path). + assert.Contains(t, keys, "server_edition.max_user_servers") + assert.Contains(t, keys, "server_edition.workspace_idle_timeout") + assert.Contains(t, keys, "server_edition.store_idp_tokens") + var sawMode, sawHeader bool + for _, k := range keys { + if strings.HasSuffix(k, "auth_broker.mode") { + sawMode = true + } + if strings.HasSuffix(k, "auth_broker.header") { + sawHeader = true + } + } + assert.True(t, sawMode, "the token_exchange diagnostic keys on auth_broker.mode: %v", keys) + assert.True(t, sawHeader, "the header diagnostic keys on auth_broker.header: %v", keys) +} + +// A config with none of the removed keys records no diagnostics at all β€” +// the slice is not a catch-all for every load. +func TestLegacyKeys_CleanConfigRecordsNoDiagnostics(t *testing.T) { + doc := readLegacyFixture(t) + clean := cleanSibling(t, doc) + delete(clean["server_edition"].(map[string]any), "store_idp_tokens") + path := writeDocument(t, t.TempDir(), "clean.json", clean) + + cfg, err := config.LoadFromFile(path) + require.NoError(t, err) + assert.Empty(t, cfg.LoadDiagnostics()) +} + +// Emit helper: the loader has no logger (loadConfigFile returns only an error +// and main.go builds the logger after the load), so the recorded diagnostics +// are emitted once by config.LogLoadDiagnostics β€” one WARN line each. +func TestLegacyKeys_LogLoadDiagnosticsEmitsOneWarnPerDiagnostic(t *testing.T) { + cfg, _ := loadLegacyFixture(t) + diags := cfg.LoadDiagnostics() + require.NotEmpty(t, diags) + + core, logs := observer.New(zapcore.DebugLevel) + config.LogLoadDiagnostics(cfg, zap.New(core)) + + warns := logs.FilterLevelExact(zapcore.WarnLevel).All() + require.Len(t, warns, len(diags), "exactly one WARN line per diagnostic; got: %+v", logs.All()) + assert.Equal(t, len(diags), logs.Len(), "nothing but the WARN lines is emitted") + + joined := make([]string, 0, len(warns)) + for _, entry := range warns { + line := entry.Message + for _, f := range entry.Context { + line += " " + f.Key + "=" + fieldString(f) + } + joined = append(joined, line) + } + for _, d := range diags { + found := false + for _, line := range joined { + if strings.Contains(line, d.Message) { + found = true + break + } + } + assert.True(t, found, "diagnostic %q must appear in one WARN line: %v", d.Message, joined) + } + + // Emitting from an empty config is a no-op, not a panic and not a line. + core2, logs2 := observer.New(zapcore.DebugLevel) + config.LogLoadDiagnostics(config.DefaultConfig(), zap.New(core2)) + assert.Zero(t, logs2.Len()) +} + +// fieldString renders a zap field's value the way a test can grep it. +func fieldString(f zapcore.Field) string { + enc := zapcore.NewMapObjectEncoder() + f.AddTo(enc) + if v, ok := enc.Fields[f.Key]; ok { + if s, ok := v.(string); ok { + return s + } + b, _ := json.Marshal(v) + return string(b) + } + return "" +} + +// Hot-reload reporting: the dropped keys are never compared, so a legacy file +// and its clean sibling are the SAME config to DetectConfigChanges. +func TestLegacyKeys_DetectConfigChangesIsBlindToDroppedKeys(t *testing.T) { + doc := readLegacyFixture(t) + dir := t.TempDir() + legacyPath := writeDocument(t, dir, "legacy.json", doc) + cleanPath := writeDocument(t, dir, "clean.json", cleanSibling(t, doc)) + + legacyCfg, err := config.LoadFromFile(legacyPath) + require.NoError(t, err) + cleanCfg, err := config.LoadFromFile(cleanPath) + require.NoError(t, err) + + for name, pair := range map[string][2]*config.Config{ + "cleanβ†’legacy": {cleanCfg, legacyCfg}, + "legacyβ†’clean": {legacyCfg, cleanCfg}, + } { + result := runtime.DetectConfigChanges(pair[0], pair[1]) + require.NotNil(t, result, name) + assert.True(t, result.Success, name) + assert.False(t, result.RequiresRestart, "%s: %+v", name, result) + assert.Empty(t, result.ChangedFields, "%s: dropped keys must not surface as a change", name) + for _, f := range result.ChangedFields { + for _, marker := range []string{"max_user_servers", "workspace_idle_timeout", "auth_broker", "store_idp_tokens"} { + assert.NotContains(t, f, marker, name) + } + } + } +} + +// Write-back: SaveConfig of the loaded legacy config omits every removed +// key/mode β€” the file is rewritten clean, so the diagnostics fire once, not on +// every subsequent boot. +func TestLegacyKeys_WriteBackOmitsRemovedKeys(t *testing.T) { + cfg, _ := loadLegacyFixture(t) + + out := filepath.Join(t.TempDir(), "written.json") + require.NoError(t, config.SaveConfig(cfg, out)) + written, err := os.ReadFile(out) + require.NoError(t, err) + text := string(written) + for _, marker := range removedKeyMarkers { + assert.NotContains(t, text, marker, "write-back must not resurrect a removed key/mode") + } + + // And the rewritten file loads with no diagnostics except the retained + // store_idp_tokens deprecation (its decoder field survives by design). + reloaded, err := config.LoadFromFile(out) + require.NoError(t, err) + for _, d := range reloaded.LoadDiagnostics() { + assert.Equal(t, msgStoreIDPTokensDeprecated, d.Message, + "only the deprecated store_idp_tokens may still be reported after a clean write-back") + } +} + +// Raw-document check: the refusal the two HTTP write doors use. It walks the +// generic map, so it sees keys json.Unmarshal into Config silently drops. +// store_idp_tokens is deprecated, not removed, and is NOT refused here. +func TestLegacyKeys_ValidateRemovedKeysWalksTheRawDocument(t *testing.T) { + doc := readLegacyFixture(t) + + errs := config.ValidateRemovedKeys(doc) + require.Len(t, errs, 4, "max_user_servers, workspace_idle_timeout, token_exchange mode, header: %+v", errs) + + messages := make([]string, 0, len(errs)) + for _, e := range errs { + messages = append(messages, e.Message) + assert.NotEmpty(t, e.Field, "%+v", e) + assert.NotContains(t, e.Message, "store_idp_tokens", "deprecated is not removed") + } + assert.ElementsMatch(t, []string{ + msgMaxUserServersIgnored, + msgWorkspaceIdleTimeoutIgnored, + msgTokenExchangeIgnored, + msgAuthBrokerHeaderIgnored, + }, messages) + + fields := make([]string, 0, len(errs)) + for _, e := range errs { + fields = append(fields, e.Field) + } + assert.Contains(t, fields, "server_edition.max_user_servers") + assert.Contains(t, fields, "server_edition.workspace_idle_timeout") + + // The clean sibling passes; a nil/empty document passes. + assert.Empty(t, config.ValidateRemovedKeys(cleanSibling(t, doc))) + assert.Empty(t, config.ValidateRemovedKeys(nil)) + assert.Empty(t, config.ValidateRemovedKeys(map[string]any{})) + + // entra_obo and header_format are refused the same way. + obo := map[string]any{ + "mcpServers": []any{map[string]any{ + "name": "obo", + "url": "https://obo.example.com/mcp", + "protocol": "http", + "auth_broker": map[string]any{"mode": "entra_obo", "header_format": "Token {token}"}, + }}, + } + oboErrs := config.ValidateRemovedKeys(obo) + require.Len(t, oboErrs, 2, "%+v", oboErrs) + var sawOBO, sawHeaderFormat bool + for _, e := range oboErrs { + if e.Message == `auth_broker.mode "entra_obo" was never implemented; the auth_broker block for server "obo" was ignored` { + sawOBO = true + } + if e.Message == "auth_broker.header_format is no longer supported and was ignored" { + sawHeaderFormat = true + } + } + assert.True(t, sawOBO, "%+v", oboErrs) + assert.True(t, sawHeaderFormat, "%+v", oboErrs) +} + +// Config.Validate alone cannot see the removed keys: a bare json.Unmarshal into +// the typed struct drops them without a trace (no field, no diagnostic), which +// is exactly why the write doors need the raw-document check above rather +// than a Validate rule. +func TestLegacyKeys_TypedDecodeDropsThemWithoutTrace(t *testing.T) { + data, err := os.ReadFile(legacyServerEditionFixture) + require.NoError(t, err) + + var typed config.Config + require.NoError(t, json.Unmarshal(data, &typed)) + assert.Empty(t, typed.LoadDiagnostics(), + "a bare Unmarshal is not the loader: it records nothing") + + back, err := json.Marshal(&typed) + require.NoError(t, err) + for _, marker := range []string{`"max_user_servers"`, `"workspace_idle_timeout"`, `"header"`, `"header_format"`} { + assert.NotContains(t, string(back), marker, + "the typed struct has no field for a removed key, so nothing downstream of the decode can refuse it") + } +} diff --git a/internal/config/personal_roundtrip_test.go b/internal/config/personal_roundtrip_test.go new file mode 100644 index 000000000..30fef8527 --- /dev/null +++ b/internal/config/personal_roundtrip_test.go @@ -0,0 +1,272 @@ +//go:build !server + +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Spec 107 FR-040 (T010, personal build): the `server_edition` block and every +// server's `auth_broker` block must survive load β†’ save β†’ load β†’ save +// semantically intact β€” every key and every value, numbers by their decimal +// text β€” with no warning emitted. On HEAD both stubs are `struct{}`, so the +// personal binary writes `{}` back for each block (critic G3); this test is +// red until T020 lands the json.RawMessage carriers. +// +// The comparator is deliberately independent of the FR-015 canonicaliser: it +// decodes both documents with UseNumber and compares the trees structurally, +// so the planted 9007199254740993 (2^53+1) and +// 0.1000000000000000055511151231257827 cannot pass through a float64 unnoticed. + +const ( + legacyFixturePath = "testdata/legacy_server_edition.json" + + // Probe keys planted into every opaque block. They are unknown to the + // server-build decoders, which is fine: the personal build must carry + // them through untouched. + probeIntKey = "roundtrip_probe_int" + probeDecKey = "roundtrip_probe_dec" + probeInt = "9007199254740993" + probeDec = "0.1000000000000000055511151231257827" +) + +// TestPersonalBuild_OpaqueBlocksRoundTrip is the load β†’ save β†’ load β†’ save +// leg of FR-040. The PATCH-merge leg (which needs httpapi.MergeConfigPatch, +// T020) lives in internal/httpapi/config_patch_roundtrip_test.go. +func TestPersonalBuild_OpaqueBlocksRoundTrip(t *testing.T) { + dir := t.TempDir() + original, planted := plantRoundTripProbes(t, legacyFixturePath, dir) + + src := filepath.Join(dir, "planted.json") + require.NoError(t, os.WriteFile(src, planted, 0o600)) + + var stderr bytes.Buffer + firstSave := filepath.Join(dir, "after_first_save.json") + secondSave := filepath.Join(dir, "after_second_save.json") + captureStderr(t, &stderr, func() { + cfg, err := LoadFromFile(src) + require.NoError(t, err) + require.NoError(t, SaveConfig(cfg, firstSave)) + + reloaded, err := LoadFromFile(firstSave) + require.NoError(t, err) + require.NoError(t, SaveConfig(reloaded, secondSave)) + }) + require.NotContains(t, stderr.String(), "WARN", "the personal build must not warn about the opaque blocks") + + for _, path := range []string{firstSave, secondSave} { + saved, err := os.ReadFile(path) + require.NoError(t, err) + assertOpaqueBlocksEqual(t, original, saved, filepath.Base(path)) + } +} + +// plantRoundTripProbes reads the shared fixture, plants the precision probes +// into `server_edition` and every `mcpServers[].auth_broker`, points data_dir +// at a temp dir, and returns (original tree, planted document bytes). +func plantRoundTripProbes(t *testing.T, fixture, dataDir string) (map[string]any, []byte) { + t.Helper() + raw, err := os.ReadFile(fixture) + require.NoError(t, err, "shared fixture %s (created by T009) must exist", fixture) + + doc := decodeUseNumber(t, raw) + doc["data_dir"] = filepath.ToSlash(dataDir) + + se, ok := doc["server_edition"].(map[string]any) + require.True(t, ok, "fixture must carry a server_edition object") + se[probeIntKey] = json.Number(probeInt) + se[probeDecKey] = json.Number(probeDec) + + servers, _ := doc["mcpServers"].([]any) + planted := 0 + for _, s := range servers { + server, ok := s.(map[string]any) + if !ok { + continue + } + broker, ok := server["auth_broker"].(map[string]any) + if !ok { + continue + } + broker[probeIntKey] = json.Number(probeInt) + broker[probeDecKey] = json.Number(probeDec) + planted++ + } + require.Greater(t, planted, 0, "fixture must carry at least one mcpServers[].auth_broker object") + + // json.Number marshals verbatim, so the planted text survives this write. + out, err := json.MarshalIndent(doc, "", " ") + require.NoError(t, err) + return decodeUseNumber(t, out), out +} + +// assertOpaqueBlocksEqual compares server_edition and every auth_broker block +// (keyed by server name) between the original tree and a saved document. +func assertOpaqueBlocksEqual(t *testing.T, original map[string]any, saved []byte, label string) { + t.Helper() + got := decodeUseNumber(t, saved) + + if diff := structuralDiff("server_edition", original["server_edition"], got["server_edition"]); diff != "" { + t.Errorf("%s: server_edition block changed on personal write-back: %s", label, diff) + } + + wantBrokers := authBrokersByName(t, original) + gotBrokers := authBrokersByName(t, got) + names := make([]string, 0, len(wantBrokers)) + for name := range wantBrokers { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if diff := structuralDiff("mcpServers["+name+"].auth_broker", wantBrokers[name], gotBrokers[name]); diff != "" { + t.Errorf("%s: auth_broker block for server %q changed on personal write-back: %s", label, name, diff) + } + } +} + +func authBrokersByName(t *testing.T, doc map[string]any) map[string]any { + t.Helper() + out := map[string]any{} + servers, _ := doc["mcpServers"].([]any) + for _, s := range servers { + server, ok := s.(map[string]any) + if !ok { + continue + } + name, _ := server["name"].(string) + if broker, present := server["auth_broker"]; present { + out[name] = broker + } + } + return out +} + +func decodeUseNumber(t *testing.T, data []byte) map[string]any { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var doc map[string]any + require.NoError(t, dec.Decode(&doc)) + return doc +} + +// structuralDiff is the independent comparator: maps by key set and value, +// arrays by position, json.Number by decimal text, everything else by ==. +// It returns "" when equal, otherwise a path-qualified description. +func structuralDiff(path string, want, got any) string { + switch w := want.(type) { + case map[string]any: + g, ok := got.(map[string]any) + if !ok { + return fmt.Sprintf("%s: want object, got %s", path, describe(got)) + } + keys := map[string]struct{}{} + for k := range w { + keys[k] = struct{}{} + } + for k := range g { + keys[k] = struct{}{} + } + sorted := make([]string, 0, len(keys)) + for k := range keys { + sorted = append(sorted, k) + } + sort.Strings(sorted) + for _, k := range sorted { + wv, inWant := w[k] + gv, inGot := g[k] + switch { + case !inWant: + return fmt.Sprintf("%s.%s: unexpected key (value %s)", path, k, describe(gv)) + case !inGot: + return fmt.Sprintf("%s.%s: key missing (want %s)", path, k, describe(wv)) + } + if d := structuralDiff(path+"."+k, wv, gv); d != "" { + return d + } + } + return "" + case []any: + g, ok := got.([]any) + if !ok { + return fmt.Sprintf("%s: want array, got %s", path, describe(got)) + } + if len(w) != len(g) { + return fmt.Sprintf("%s: want %d elements, got %d", path, len(w), len(g)) + } + for i := range w { + if d := structuralDiff(fmt.Sprintf("%s[%d]", path, i), w[i], g[i]); d != "" { + return d + } + } + return "" + case json.Number: + g, ok := got.(json.Number) + if !ok { + return fmt.Sprintf("%s: want number %s, got %s", path, w.String(), describe(got)) + } + if w.String() != g.String() { + return fmt.Sprintf("%s: want number %s, got %s (decimal text differs)", path, w.String(), g.String()) + } + return "" + default: + if want != got { + return fmt.Sprintf("%s: want %s, got %s", path, describe(want), describe(got)) + } + return "" + } +} + +func describe(v any) string { + switch x := v.(type) { + case nil: + return "absent/null" + case json.Number: + return "number " + x.String() + case string: + return fmt.Sprintf("string %q", x) + case map[string]any: + return "object" + case []any: + return "array" + default: + return fmt.Sprintf("%T %v", v, v) + } +} + +// captureStderr redirects os.Stderr for the duration of fn (the loader has no +// logger; its warnings are fmt.Fprintf(os.Stderr, "WARN: ...") lines). +func captureStderr(t *testing.T, into *bytes.Buffer, fn func()) { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + orig := os.Stderr + os.Stderr = w + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = io.Copy(into, r) + }() + func() { + defer func() { + os.Stderr = orig + _ = w.Close() + <-done + _ = r.Close() + }() + fn() + }() + if s := into.String(); s != "" { + t.Logf("stderr during load/save:\n%s", strings.TrimSpace(s)) + } +} diff --git a/internal/config/server_edition_validate_test.go b/internal/config/server_edition_validate_test.go new file mode 100644 index 000000000..71fe7d415 --- /dev/null +++ b/internal/config/server_edition_validate_test.go @@ -0,0 +1,202 @@ +//go:build server + +package config + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Spec 107 FR-039 part 1 (T011 / T019): Config.Validate() and +// ValidateDetailed() must reach a NON-mutating ServerEditionConfig.Validate +// under the server build, so PATCH /api/v1/config and /config/apply refuse a +// broken block at write time instead of persisting it for the next restart +// (critic G4: today the only caller is setup.go:96 and it mutates). +// +// Defaults (TTLs, Microsoft TenantID) and the MCPPROXY_CRED_KEY fallback move +// to a separate ApplyDefaults() step that setup runs before Validate(). + +// minimalServerEditionConfig returns a Config that passes Config.Validate() +// on its own, carrying the given server_edition block. +func minimalServerEditionConfig(se *ServerEditionConfig) *Config { + return &Config{ + Listen: "127.0.0.1:8080", + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(time.Minute), + Servers: []*ServerConfig{}, + ServerEdition: se, + } +} + +func bogusProviderBlock() *ServerEditionConfig { + return &ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &ServerEditionOAuthConfig{ + Provider: "bogus", + ClientID: "cid", + ClientSecret: "csec", + }, + } +} + +// TestConfigValidate_ReachesServerEditionValidate: an enabled block with +// provider "bogus" fails at Config.Validate. Today it passes because +// Config.Validate never looks at c.ServerEdition (behaviour-red). +func TestConfigValidate_ReachesServerEditionValidate(t *testing.T) { + cfg := minimalServerEditionConfig(bogusProviderBlock()) + + err := cfg.Validate() + require.Error(t, err, "Config.Validate must refuse an enabled server_edition block with provider=bogus") + assert.Contains(t, err.Error(), "server_edition.oauth.provider", + "the refusal must name the offending key so PATCH/apply callers see the same message as boot") +} + +// TestConfigValidateDetailed_ReachesServerEditionValidate: the same block is +// reported by ValidateDetailed() (the write-surface path used by PATCH and +// /config/apply), not only by the boot-compatible Validate(). +func TestConfigValidateDetailed_ReachesServerEditionValidate(t *testing.T) { + cfg := minimalServerEditionConfig(bogusProviderBlock()) + + errs := cfg.ValidateDetailed() + require.NotEmpty(t, errs, "ValidateDetailed must report the invalid server_edition.oauth.provider") + + found := false + for _, ve := range errs { + if strings.Contains(ve.Error(), "server_edition.oauth.provider") { + found = true + break + } + } + assert.True(t, found, "expected a ValidationError naming server_edition.oauth.provider, got %v", errs) +} + +// A nil block and a disabled block keep passing: the bridge must not refuse +// the common (personal-like) case. +func TestConfigValidate_ServerEditionNilOrDisabledPasses(t *testing.T) { + require.NoError(t, minimalServerEditionConfig(nil).Validate(), "nil server_edition block must validate") + + disabled := bogusProviderBlock() + disabled.Enabled = false + require.NoError(t, minimalServerEditionConfig(disabled).Validate(), + "a disabled block is not validated (no rule applies when server_edition.enabled=false)") + assert.Empty(t, minimalServerEditionConfig(disabled).ValidateDetailed()) +} + +// TestServerEditionConfig_ValidateIsNonMutating: Validate() leaves TenantID, +// the TTLs and CredentialEncryptionKey exactly as it found them. Today it +// fills all three (server_edition_config.go: CRED_KEY fallback, "common" +// tenant, 24h TTLs), which is the bug that lets a write door persist derived +// values (behaviour-red). +func TestServerEditionConfig_ValidateIsNonMutating(t *testing.T) { + t.Setenv("MCPPROXY_CRED_KEY", "from-env-key") + + cfg := &ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &ServerEditionOAuthConfig{ + Provider: "microsoft", + ClientID: "cid", + ClientSecret: "csec", + // TenantID deliberately empty: today Validate writes "common". + }, + // TTLs deliberately zero (unset): today Validate writes 24h. + // CredentialEncryptionKey deliberately empty: today Validate copies + // MCPPROXY_CRED_KEY into it. + } + + require.NoError(t, cfg.Validate(), "an otherwise valid block must validate; unset values are defaulted by ApplyDefaults, not refused") + + assert.Equal(t, "", cfg.OAuth.TenantID, "Validate must not fill TenantID") + assert.Equal(t, Duration(0), cfg.SessionTTL, "Validate must not fill SessionTTL") + assert.Equal(t, Duration(0), cfg.BearerTokenTTL, "Validate must not fill BearerTokenTTL") + assert.Equal(t, "", cfg.CredentialEncryptionKey, "Validate must not apply the MCPPROXY_CRED_KEY fallback") +} + +// The same guarantee through the Config-level bridge: a valid block passes +// Config.Validate()/ValidateDetailed() untouched. This is the path a PATCH +// takes before persisting, so nothing derived may leak into the saved file. +func TestConfigValidate_DoesNotMutateServerEditionBlock(t *testing.T) { + t.Setenv("MCPPROXY_CRED_KEY", "from-env-key") + + se := &ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &ServerEditionOAuthConfig{ + Provider: "microsoft", + ClientID: "cid", + ClientSecret: "csec", + }, + } + cfg := minimalServerEditionConfig(se) + + require.NoError(t, cfg.Validate()) + assert.Empty(t, cfg.ValidateDetailed()) + + assert.Equal(t, "", se.OAuth.TenantID) + assert.Equal(t, Duration(0), se.SessionTTL) + assert.Equal(t, Duration(0), se.BearerTokenTTL) + assert.Equal(t, "", se.CredentialEncryptionKey) +} + +// TestServerEditionConfig_ApplyDefaultsFills: ApplyDefaults() is where the +// TTLs, the Microsoft "common" tenant and the MCPPROXY_CRED_KEY fallback now +// land (compile-red until T019 adds ApplyDefaults). +func TestServerEditionConfig_ApplyDefaultsFills(t *testing.T) { + t.Setenv("MCPPROXY_CRED_KEY", "from-env-key") + + cfg := &ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &ServerEditionOAuthConfig{ + Provider: "microsoft", + ClientID: "cid", + ClientSecret: "csec", + }, + } + + cfg.ApplyDefaults() + + assert.Equal(t, "common", cfg.OAuth.TenantID, "ApplyDefaults fills the Microsoft multi-tenant default") + assert.Equal(t, Duration(24*time.Hour), cfg.SessionTTL, "ApplyDefaults fills session_ttl") + assert.Equal(t, Duration(24*time.Hour), cfg.BearerTokenTTL, "ApplyDefaults fills bearer_token_ttl") + assert.Equal(t, "from-env-key", cfg.CredentialEncryptionKey, "ApplyDefaults applies the MCPPROXY_CRED_KEY fallback") + + // The filled block validates, still without mutation. + require.NoError(t, cfg.Validate()) + assert.Equal(t, "common", cfg.OAuth.TenantID) + assert.Equal(t, Duration(24*time.Hour), cfg.SessionTTL) + assert.Equal(t, "from-env-key", cfg.CredentialEncryptionKey) +} + +// Explicit values survive ApplyDefaults: config wins over env for the key, +// and set TTLs/tenant are left alone. +func TestServerEditionConfig_ApplyDefaultsKeepsExplicitValues(t *testing.T) { + t.Setenv("MCPPROXY_CRED_KEY", "from-env-key") + + cfg := &ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + SessionTTL: Duration(2 * time.Hour), + BearerTokenTTL: Duration(3 * time.Hour), + CredentialEncryptionKey: "from-config", + OAuth: &ServerEditionOAuthConfig{ + Provider: "microsoft", + ClientID: "cid", + ClientSecret: "csec", + TenantID: "contoso.onmicrosoft.com", + }, + } + + cfg.ApplyDefaults() + + assert.Equal(t, "contoso.onmicrosoft.com", cfg.OAuth.TenantID) + assert.Equal(t, Duration(2*time.Hour), cfg.SessionTTL) + assert.Equal(t, Duration(3*time.Hour), cfg.BearerTokenTTL) + assert.Equal(t, "from-config", cfg.CredentialEncryptionKey, "explicit config key wins over MCPPROXY_CRED_KEY") +} diff --git a/internal/config/testdata/legacy_server_edition.json b/internal/config/testdata/legacy_server_edition.json new file mode 100644 index 000000000..0e97d0918 --- /dev/null +++ b/internal/config/testdata/legacy_server_edition.json @@ -0,0 +1,45 @@ +{ + "listen": "127.0.0.1:18107", + "api_key": "legacy-fixture-key", + "server_edition": { + "enabled": true, + "admin_emails": ["admin@example.com"], + "oauth": { + "provider": "google", + "client_id": "legacy-client-id", + "client_secret": "legacy-client-secret" + }, + "session_ttl": "24h", + "bearer_token_ttl": "24h", + "max_user_servers": 7, + "workspace_idle_timeout": "45m", + "store_idp_tokens": true + }, + "mcpServers": [ + { + "name": "legacy-exchange", + "url": "https://exchange.example.com/mcp", + "protocol": "http", + "enabled": true, + "created": "2026-01-01T00:00:00Z", + "auth_broker": { + "mode": "token_exchange", + "token_endpoint": "https://idp.example.com/token", + "resource": "https://exchange.example.com" + } + }, + { + "name": "legacy-header", + "url": "https://header.example.com/mcp", + "protocol": "http", + "enabled": true, + "created": "2026-01-01T00:00:00Z", + "auth_broker": { + "mode": "oauth_connect", + "token_endpoint": "https://idp.example.com/token", + "authorization_endpoint": "https://idp.example.com/authorize", + "header": "X-Auth" + } + } + ] +} diff --git a/internal/httpapi/config_patch_removed_keys_test.go b/internal/httpapi/config_patch_removed_keys_test.go new file mode 100644 index 000000000..fd2560071 --- /dev/null +++ b/internal/httpapi/config_patch_removed_keys_test.go @@ -0,0 +1,243 @@ +//go:build server + +package httpapi + +// Spec 107 T009 (US5, FR-039): the two HTTP write doors refuse a document that +// carries a removed server-edition key or a never-implemented auth_broker mode, +// with the exact messages of contracts/config-keys.md, BEFORE the typed decode. +// +// Config.Validate cannot do this job: json.Unmarshal into config.Config +// silently drops unknown keys, so by the time ApplyConfig runs the offending +// keys are gone and the write would be persisted clean with a success toast. +// The refusal therefore comes from config.ValidateRemovedKeys run on the +// generic map β€” in handlePatchConfig on the merged map before +// json.Unmarshal(mergedBytes, &merged), and in handleApplyConfig on the decoded +// raw document. The boot door (normalise + record a LoadDiagnostic instead of +// refusing) is covered by internal/config/legacy_keys_load_test.go. + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + runtime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" +) + +const removedKeysFixture = "../config/testdata/legacy_server_edition.json" + +// Exact strings from contracts/config-keys.md (the same ones the boot door +// records as LoadDiagnostics). +var removedKeyMessages = []string{ + "server_edition.max_user_servers is no longer supported and was ignored", + "server_edition.workspace_idle_timeout is no longer supported and was ignored", + `auth_broker.mode "token_exchange" was never implemented; the auth_broker block for server "legacy-exchange" was ignored`, + "auth_broker.header is no longer supported and was ignored", +} + +// removedKeysController serves a clean live config and fails the test if a +// write door reaches ApplyConfig with a document that should have been refused. +type removedKeysController struct { + baseController + live *config.Config + applied int +} + +func (m *removedKeysController) GetCurrentConfig() any { return &config.Config{APIKey: "test-key"} } +func (m *removedKeysController) GetConfig() (*config.Config, error) { + return m.live, nil +} +func (m *removedKeysController) GetConfigPath() string { return "/tmp/mcp_config.json" } +func (m *removedKeysController) ApplyConfig(cfg *config.Config, _ string) (*runtime.ConfigApplyResult, error) { + m.applied++ + return &runtime.ConfigApplyResult{Success: true, AppliedImmediately: true}, nil +} + +func newRemovedKeysServer(t *testing.T) (*Server, *removedKeysController) { + t.Helper() + ctrl := &removedKeysController{ + live: &config.Config{ + Listen: "127.0.0.1:8080", + APIKey: "test-key", + ServerEdition: &config.ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &config.ServerEditionOAuthConfig{ + Provider: "google", + ClientID: "live-client-id", + ClientSecret: "live-client-secret", + }, + }, + }, + } + return NewServer(ctrl, zap.NewNop().Sugar(), nil), ctrl +} + +func removedKeysDo(t *testing.T, srv *Server, method, path string, body []byte) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + return w +} + +// legacyDocument returns the shared fixture as a generic document with no +// data_dir (the write doors never create directories). +func legacyDocument(t *testing.T) map[string]any { + t.Helper() + data, err := os.ReadFile(filepath.Clean(removedKeysFixture)) + require.NoError(t, err) + var doc map[string]any + require.NoError(t, json.Unmarshal(data, &doc)) + return doc +} + +// assertRefusedWithRemovedKeyMessages checks a 400 whose payload names every +// removed key/mode by its contract message and carries them structurally under +// data.validation_errors (the #1084 shape a client points at a field with). +func assertRefusedWithRemovedKeyMessages(t *testing.T, w *httptest.ResponseRecorder, door string) { + t.Helper() + require.Equal(t, http.StatusBadRequest, w.Code, "%s: body=%s", door, w.Body.String()) + + var envelope map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope), "%s: body=%s", door, w.Body.String()) + assert.Equal(t, false, envelope["success"], door) + + data, ok := envelope["data"].(map[string]any) + require.True(t, ok, "%s: the refusal must carry a structured payload: %s", door, w.Body.String()) + rawErrs, ok := data["validation_errors"].([]any) + require.True(t, ok, "%s: validation_errors must be present: %s", door, w.Body.String()) + + messages := make([]string, 0, len(rawErrs)) + for _, e := range rawErrs { + entry, ok := e.(map[string]any) + require.True(t, ok, door) + msg, _ := entry["message"].(string) + messages = append(messages, msg) + field, _ := entry["field"].(string) + assert.NotEmpty(t, field, "%s: every refusal names its field: %v", door, entry) + } + assert.ElementsMatch(t, removedKeyMessages, messages, + "%s: one refusal per removed key/mode, byte-identical to contracts/config-keys.md", door) + assert.NotContains(t, w.Body.String(), "store_idp_tokens", + "%s: store_idp_tokens is deprecated, not removed β€” it is not refused", door) +} + +// PATCH door: a partial document carrying the removed keys/modes is refused by +// the raw-document check on the MERGED map, and ApplyConfig is never reached. +func TestPatchConfig_RemovedServerEditionKeysAreRefusedBeforeTypedDecode(t *testing.T) { + srv, ctrl := newRemovedKeysServer(t) + doc := legacyDocument(t) + patch := map[string]any{ + "server_edition": doc["server_edition"], + "mcpServers": doc["mcpServers"], + } + body, err := json.Marshal(patch) + require.NoError(t, err) + + w := removedKeysDo(t, srv, http.MethodPatch, "/api/v1/config", body) + assertRefusedWithRemovedKeyMessages(t, w, "PATCH /api/v1/config") + assert.Zero(t, ctrl.applied, "PATCH must refuse before ApplyConfig: the keys would already be gone from the typed struct") +} + +// PATCH door, narrow patch: a single removed key inside server_edition β€” the +// shape the raw-JSON editor produces for one field β€” is enough to refuse. +func TestPatchConfig_SingleRemovedKeyIsRefused(t *testing.T) { + srv, ctrl := newRemovedKeysServer(t) + body := []byte(`{"server_edition":{"max_user_servers":5}}`) + + w := removedKeysDo(t, srv, http.MethodPatch, "/api/v1/config", body) + require.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), removedKeyMessages[0]) + assert.Zero(t, ctrl.applied) +} + +// PATCH door, control: the deprecated store_idp_tokens is NOT a removed key +// and an oauth_connect block without header/header_format is accepted. +func TestPatchConfig_DeprecatedAndRetainedKeysStillPass(t *testing.T) { + srv, ctrl := newRemovedKeysServer(t) + body := []byte(`{ + "server_edition": {"store_idp_tokens": true}, + "mcpServers": [{ + "name": "connect", + "url": "https://connect.example.com/mcp", + "protocol": "http", + "enabled": true, + "auth_broker": { + "mode": "oauth_connect", + "token_endpoint": "https://idp.example.com/token", + "authorization_endpoint": "https://idp.example.com/authorize" + } + }] + }`) + + w := removedKeysDo(t, srv, http.MethodPatch, "/api/v1/config", body) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + assert.Equal(t, 1, ctrl.applied, "a document with no removed key reaches ApplyConfig") +} + +// Apply door: the whole legacy document POSTed to /config/apply is refused on +// the decoded raw document, before UnmaskLiveConfigDocument types it. +func TestApplyConfig_RemovedServerEditionKeysAreRefusedBeforeTypedDecode(t *testing.T) { + srv, ctrl := newRemovedKeysServer(t) + body, err := json.Marshal(legacyDocument(t)) + require.NoError(t, err) + + w := removedKeysDo(t, srv, http.MethodPost, "/api/v1/config/apply", body) + assertRefusedWithRemovedKeyMessages(t, w, "POST /api/v1/config/apply") + assert.Zero(t, ctrl.applied, "/config/apply must refuse before ApplyConfig") +} + +// Apply door, control: the same document with the removed keys stripped is +// accepted β€” the check refuses the keys, not the block. +func TestApplyConfig_CleanServerEditionDocumentPasses(t *testing.T) { + srv, ctrl := newRemovedKeysServer(t) + doc := legacyDocument(t) + require.Empty(t, config.ValidateRemovedKeys(stripRemovedKeys(t, doc)), "the control document must itself be clean") + body, err := json.Marshal(doc) + require.NoError(t, err) + + w := removedKeysDo(t, srv, http.MethodPost, "/api/v1/config/apply", body) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + assert.Equal(t, 1, ctrl.applied) +} + +// stripRemovedKeys mutates doc in place the way the boot normaliser is +// specified to (whole block for token_exchange/entra_obo, leaves otherwise) +// and returns it. +func stripRemovedKeys(t *testing.T, doc map[string]any) map[string]any { + t.Helper() + if se, ok := doc["server_edition"].(map[string]any); ok { + delete(se, "max_user_servers") + delete(se, "workspace_idle_timeout") + } + servers, _ := doc["mcpServers"].([]any) + for _, s := range servers { + server, ok := s.(map[string]any) + if !ok { + continue + } + broker, ok := server["auth_broker"].(map[string]any) + if !ok { + continue + } + switch broker["mode"] { + case "token_exchange", "entra_obo": + delete(server, "auth_broker") + default: + delete(broker, "header") + delete(broker, "header_format") + } + } + return doc +} diff --git a/internal/httpapi/config_patch_roundtrip_test.go b/internal/httpapi/config_patch_roundtrip_test.go new file mode 100644 index 000000000..0d73cda33 --- /dev/null +++ b/internal/httpapi/config_patch_roundtrip_test.go @@ -0,0 +1,259 @@ +//go:build !server + +package httpapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// Spec 107 FR-040 (T010, personal build, PATCH leg): load the shared fixture, +// save it, drive an UNRELATED key through the handlePatchConfig merge path +// (marshal base β†’ UseNumber map β†’ MergeConfigPatch β†’ marshal β†’ typed decode), +// save again, and assert the `server_edition` block and every `auth_broker` +// block are still semantically identical to the originals β€” numbers by +// decimal text, with 9007199254740993 and 0.1000000000000000055511151231257827 +// planted in every block. +// +// Compile-red until T020 exports MergeConfigPatch (a one-line wrapper over +// deepMergeJSON, server.go) and lands the json.RawMessage carriers; the +// UseNumber decode below mirrors what T020 makes handlePatchConfig do. + +const ( + patchRoundTripFixture = "../config/testdata/legacy_server_edition.json" + patchProbeIntKey = "roundtrip_probe_int" + patchProbeDecKey = "roundtrip_probe_dec" + patchProbeInt = "9007199254740993" + patchProbeDec = "0.1000000000000000055511151231257827" +) + +func TestConfigPatch_OpaqueBlocksSurviveUnrelatedPatch(t *testing.T) { + dir := t.TempDir() + original, planted := plantPatchProbes(t, patchRoundTripFixture, dir) + + src := filepath.Join(dir, "planted.json") + require.NoError(t, os.WriteFile(src, planted, 0o600)) + firstSave := filepath.Join(dir, "after_first_save.json") + secondSave := filepath.Join(dir, "after_patch_save.json") + + const patchedListen = "127.0.0.1:19107" + var stderr bytes.Buffer + captureLoaderStderr(t, &stderr, func() { + cfg, err := config.LoadFromFile(src) + require.NoError(t, err) + require.NoError(t, config.SaveConfig(cfg, firstSave)) + + // handlePatchConfig: base = desired (on-disk) config marshalled and + // decoded as a generic map; patch = the client body. Both decodes use + // UseNumber so every number rides through the merge as decimal text. + base, err := config.LoadFromFile(firstSave) + require.NoError(t, err) + baseBytes, err := json.Marshal(base) + require.NoError(t, err) + baseMap := decodePatchUseNumber(t, baseBytes) + patchMap := decodePatchUseNumber(t, []byte(`{"listen":"`+patchedListen+`"}`)) + + merged := MergeConfigPatch(baseMap, patchMap) + mergedBytes, err := json.Marshal(merged) + require.NoError(t, err) + var mergedCfg config.Config + require.NoError(t, json.Unmarshal(mergedBytes, &mergedCfg)) + require.NoError(t, config.SaveConfig(&mergedCfg, secondSave)) + }) + require.NotContains(t, stderr.String(), "WARN", "the personal build must not warn about the opaque blocks") + + saved, err := os.ReadFile(secondSave) + require.NoError(t, err) + got := decodePatchUseNumber(t, saved) + require.Equal(t, patchedListen, got["listen"], "the unrelated patch must have been applied") + + if diff := patchStructuralDiff("server_edition", original["server_edition"], got["server_edition"]); diff != "" { + t.Errorf("server_edition block damaged by an unrelated PATCH: %s", diff) + } + want := patchAuthBrokersByName(original) + have := patchAuthBrokersByName(got) + names := make([]string, 0, len(want)) + for name := range want { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if diff := patchStructuralDiff("mcpServers["+name+"].auth_broker", want[name], have[name]); diff != "" { + t.Errorf("auth_broker block for server %q damaged by an unrelated PATCH: %s", name, diff) + } + } +} + +func plantPatchProbes(t *testing.T, fixture, dataDir string) (map[string]any, []byte) { + t.Helper() + raw, err := os.ReadFile(fixture) + require.NoError(t, err, "shared fixture %s (created by T009) must exist", fixture) + + doc := decodePatchUseNumber(t, raw) + doc["data_dir"] = filepath.ToSlash(dataDir) + + se, ok := doc["server_edition"].(map[string]any) + require.True(t, ok, "fixture must carry a server_edition object") + se[patchProbeIntKey] = json.Number(patchProbeInt) + se[patchProbeDecKey] = json.Number(patchProbeDec) + + servers, _ := doc["mcpServers"].([]any) + planted := 0 + for _, s := range servers { + server, ok := s.(map[string]any) + if !ok { + continue + } + broker, ok := server["auth_broker"].(map[string]any) + if !ok { + continue + } + broker[patchProbeIntKey] = json.Number(patchProbeInt) + broker[patchProbeDecKey] = json.Number(patchProbeDec) + planted++ + } + require.Greater(t, planted, 0, "fixture must carry at least one mcpServers[].auth_broker object") + + out, err := json.MarshalIndent(doc, "", " ") + require.NoError(t, err) + return decodePatchUseNumber(t, out), out +} + +func patchAuthBrokersByName(doc map[string]any) map[string]any { + out := map[string]any{} + servers, _ := doc["mcpServers"].([]any) + for _, s := range servers { + server, ok := s.(map[string]any) + if !ok { + continue + } + name, _ := server["name"].(string) + if broker, present := server["auth_broker"]; present { + out[name] = broker + } + } + return out +} + +func decodePatchUseNumber(t *testing.T, data []byte) map[string]any { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var doc map[string]any + require.NoError(t, dec.Decode(&doc)) + return doc +} + +// patchStructuralDiff is the independent UseNumber comparator (mirrors the +// one in internal/config/personal_roundtrip_test.go; test helpers are not +// importable across packages). +func patchStructuralDiff(path string, want, got any) string { + switch w := want.(type) { + case map[string]any: + g, ok := got.(map[string]any) + if !ok { + return fmt.Sprintf("%s: want object, got %s", path, patchDescribe(got)) + } + keys := map[string]struct{}{} + for k := range w { + keys[k] = struct{}{} + } + for k := range g { + keys[k] = struct{}{} + } + sorted := make([]string, 0, len(keys)) + for k := range keys { + sorted = append(sorted, k) + } + sort.Strings(sorted) + for _, k := range sorted { + wv, inWant := w[k] + gv, inGot := g[k] + switch { + case !inWant: + return fmt.Sprintf("%s.%s: unexpected key (value %s)", path, k, patchDescribe(gv)) + case !inGot: + return fmt.Sprintf("%s.%s: key missing (want %s)", path, k, patchDescribe(wv)) + } + if d := patchStructuralDiff(path+"."+k, wv, gv); d != "" { + return d + } + } + return "" + case []any: + g, ok := got.([]any) + if !ok { + return fmt.Sprintf("%s: want array, got %s", path, patchDescribe(got)) + } + if len(w) != len(g) { + return fmt.Sprintf("%s: want %d elements, got %d", path, len(w), len(g)) + } + for i := range w { + if d := patchStructuralDiff(fmt.Sprintf("%s[%d]", path, i), w[i], g[i]); d != "" { + return d + } + } + return "" + case json.Number: + g, ok := got.(json.Number) + if !ok { + return fmt.Sprintf("%s: want number %s, got %s", path, w.String(), patchDescribe(got)) + } + if w.String() != g.String() { + return fmt.Sprintf("%s: want number %s, got %s (decimal text differs)", path, w.String(), g.String()) + } + return "" + default: + if want != got { + return fmt.Sprintf("%s: want %s, got %s", path, patchDescribe(want), patchDescribe(got)) + } + return "" + } +} + +func patchDescribe(v any) string { + switch x := v.(type) { + case nil: + return "absent/null" + case json.Number: + return "number " + x.String() + case string: + return fmt.Sprintf("string %q", x) + case map[string]any: + return "object" + case []any: + return "array" + default: + return fmt.Sprintf("%T %v", v, v) + } +} + +func captureLoaderStderr(t *testing.T, into *bytes.Buffer, fn func()) { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + orig := os.Stderr + os.Stderr = w + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = io.Copy(into, r) + }() + defer func() { + os.Stderr = orig + _ = w.Close() + <-done + _ = r.Close() + }() + fn() +} From 43d301d3ecdead9654804f45b6dc19ffae322930 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 12:55:14 +0300 Subject: [PATCH 03/35] refactor(serveredition): freeze the latent credential-injection chain and normalise removed config keys (Spec 107 PR-A) Related #1177 --- CLAUDE.md | 2 +- cmd/mcpproxy/credential_cmd.go | 54 +- cmd/mcpproxy/main.go | 5 + docs/cli/credential-commands.md | 40 +- docs/configuration/upstream-servers.md | 2 +- .../server-edition-multiuser-auth.md | 73 ++- docs/features/activity-log.md | 4 +- docs/features/auth-broker.md | 80 ++- docs/features/idp-token-storage.md | 157 +---- frontend/src/views/settings/fields.ts | 1 - .../settings-server-edition-wording.spec.ts | 8 + internal/config/auth_broker.go | 94 ++- internal/config/auth_broker_stub.go | 43 +- internal/config/auth_broker_test.go | 136 ++-- internal/config/config.go | 26 +- .../config/config_validate_serveredition.go | 18 + .../config_validate_serveredition_stub.go | 9 + internal/config/load_diagnostics.go | 44 ++ internal/config/loader.go | 15 +- internal/config/loader_serveredition.go | 50 ++ internal/config/merge.go | 12 +- internal/config/personal_carriers_test.go | 86 +++ internal/config/removed_keys.go | 156 +++++ internal/config/removed_keys_stub.go | 16 + internal/config/server_edition_config.go | 103 +++- internal/config/server_edition_config_stub.go | 42 +- internal/config/server_edition_config_test.go | 94 ++- internal/config/teams_credential_test.go | 5 + .../config/zero_value_preservation_test.go | 14 +- internal/httpapi/server.go | 58 +- internal/oauth/serverfields_serveredition.go | 5 +- internal/runtime/lifecycle.go | 7 + internal/runtime/server_sharing_test.go | 3 +- .../serveredition/api/broker_audit_test.go | 21 +- .../serveredition/api/connector_provider.go | 12 +- .../serveredition/api/credential_handlers.go | 7 - .../api/credential_handlers_test.go | 25 +- .../api/user_handlers_redaction_test.go | 9 +- .../serveredition/auth/idp_subject_token.go | 165 ----- .../auth/idp_subject_token_test.go | 236 ------- internal/serveredition/auth/oauth_handler.go | 26 +- .../serveredition/auth/oauth_handler_test.go | 55 +- .../serveredition/auth/oauth_providers.go | 90 +-- .../auth/oauth_providers_test.go | 52 +- internal/serveredition/broker/audit.go | 43 +- .../broker/audit_connector_test.go | 32 + .../broker/audit_resolver_test.go | 255 -------- .../broker/credential_resolver.go | 429 ------------- .../broker/credential_resolver_test.go | 579 ------------------ .../broker/credential_store_test.go | 2 +- internal/serveredition/broker/injector.go | 108 ---- .../serveredition/broker/injector_test.go | 163 ----- .../serveredition/broker/oauth_connector.go | 51 +- .../broker/oauth_connector_test.go | 64 -- .../serveredition/broker/token_exchanger.go | 233 ------- .../broker/token_exchanger_test.go | 261 -------- .../multiuser/activity_agent_tier_test.go | 8 + .../serveredition/multiuser/isolation_test.go | 177 ------ internal/serveredition/multiuser/router.go | 272 -------- .../multiuser/router_broker_agent_test.go | 100 --- .../multiuser/router_broker_test.go | 45 -- .../serveredition/multiuser/router_test.go | 503 --------------- .../serveredition/multiuser/tool_filter.go | 86 --- internal/serveredition/setup.go | 15 +- internal/serveredition/setup_wiring_test.go | 2 +- .../workspace/integration_test.go | 200 ------ internal/serveredition/workspace/manager.go | 174 ------ .../serveredition/workspace/manager_test.go | 257 -------- internal/serveredition/workspace/workspace.go | 179 ------ .../serveredition/workspace/workspace_test.go | 255 -------- internal/transport/broker_auth.go | 56 -- .../transport/broker_auth_integration_test.go | 140 ----- internal/transport/broker_auth_test.go | 116 ---- internal/transport/http.go | 50 +- .../transport/oauth_static_headers_test.go | 18 - .../core/auth_strategy_oauth_block_test.go | 22 +- internal/upstream/core/client.go | 8 - internal/upstream/core/connection_http.go | 53 +- .../core/connection_http_broker_test.go | 86 --- 79 files changed, 1125 insertions(+), 6077 deletions(-) create mode 100644 internal/config/config_validate_serveredition.go create mode 100644 internal/config/config_validate_serveredition_stub.go create mode 100644 internal/config/load_diagnostics.go create mode 100644 internal/config/loader_serveredition.go create mode 100644 internal/config/personal_carriers_test.go create mode 100644 internal/config/removed_keys.go create mode 100644 internal/config/removed_keys_stub.go delete mode 100644 internal/serveredition/auth/idp_subject_token.go delete mode 100644 internal/serveredition/auth/idp_subject_token_test.go delete mode 100644 internal/serveredition/broker/audit_resolver_test.go delete mode 100644 internal/serveredition/broker/credential_resolver.go delete mode 100644 internal/serveredition/broker/credential_resolver_test.go delete mode 100644 internal/serveredition/broker/injector.go delete mode 100644 internal/serveredition/broker/injector_test.go delete mode 100644 internal/serveredition/broker/token_exchanger.go delete mode 100644 internal/serveredition/broker/token_exchanger_test.go delete mode 100644 internal/serveredition/multiuser/router.go delete mode 100644 internal/serveredition/multiuser/router_broker_agent_test.go delete mode 100644 internal/serveredition/multiuser/router_broker_test.go delete mode 100644 internal/serveredition/multiuser/router_test.go delete mode 100644 internal/serveredition/multiuser/tool_filter.go delete mode 100644 internal/serveredition/workspace/integration_test.go delete mode 100644 internal/serveredition/workspace/manager.go delete mode 100644 internal/serveredition/workspace/manager_test.go delete mode 100644 internal/serveredition/workspace/workspace.go delete mode 100644 internal/serveredition/workspace/workspace_test.go delete mode 100644 internal/transport/broker_auth.go delete mode 100644 internal/transport/broker_auth_integration_test.go delete mode 100644 internal/transport/broker_auth_test.go delete mode 100644 internal/upstream/core/connection_http_broker_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0d8fe3e5d..ac0b03d99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ Built in two editions from one codebase via Go build tags: | Edition | Build | Binary | Distribution | |---------|-------|--------|--------------| | **Personal** (default) | `go build ./cmd/mcpproxy` | `mcpproxy` | macOS DMG, Windows installer, Linux tar.gz | -| **Server** | `go build -tags server ./cmd/mcpproxy` | `mcpproxy-server` | Docker image, .deb, Linux tar.gz | +| **Server** | `go build -tags server -o mcpproxy-server ./cmd/mcpproxy` | `mcpproxy-server` | Docker image (`ghcr.io`) only β€” no .deb / tar.gz | All server code is behind `//go:build server` in `internal/serveredition/`; the personal edition is unaffected. The binary self-identifies (`mcpproxy version`, `/api/v1/status` β†’ `"edition"`). Server multi-user OAuth (Spec 024): see [docs/development/server-edition-multiuser-auth.md](docs/development/server-edition-multiuser-auth.md). diff --git a/cmd/mcpproxy/credential_cmd.go b/cmd/mcpproxy/credential_cmd.go index 383025696..e963e9acd 100644 --- a/cmd/mcpproxy/credential_cmd.go +++ b/cmd/mcpproxy/credential_cmd.go @@ -24,14 +24,27 @@ var ( credToken string ) +// credentialStoredNotInjectedBanner is the honesty line of Spec 107 FR-034 +// (contracts/rest-endpoints.md Β§12). The oauth_connect chain stores a +// per-user credential for a future broker, but nothing reads it on the +// upstream call path, so every human-readable `credential list|status` +// output begins with this line. Keep it byte-identical to the contract. +const credentialStoredNotInjectedBanner = "Stored credentials are kept for a future broker and are NOT injected into upstream calls in this release." + // newCredentialCommand builds the `mcpproxy credential` command tree (server -// edition only). It manages per-user brokered upstream credentials via the -// T8 REST surfaces and never prints secret values (FR-026). +// edition only). It manages per-user stored upstream credentials via the +// T8 REST surfaces and never prints secret values (FR-026). The credentials +// are stored, not injected (FR-034): upstream calls keep using the server's +// shared identity in this release. func newCredentialCommand() *cobra.Command { credentialCmd := &cobra.Command{ Use: "credential", - Short: "Manage per-user brokered upstream credentials (server edition)", - Long: `Inspect and manage your brokered credentials for shared upstream servers. + Short: "Manage per-user stored upstream credentials β€” stored, NOT injected (server edition)", + Long: `Inspect and manage your stored credentials for shared upstream servers. + +` + credentialStoredNotInjectedBanner + ` +Connecting an upstream records a credential tied to your user; upstream tool +calls still go out with the server's shared identity. These commands talk to a running server-edition mcpproxy and require a user token (a JWT obtained from the Web UI or POST /api/v1/auth/token). Provide it @@ -61,8 +74,10 @@ Examples: func newCredentialListCmd() *cobra.Command { return &cobra.Command{ Use: "list", - Short: "List brokered upstreams with connection status (no secrets)", - Long: `List every brokered upstream and your connection status for it. + Short: "List brokered upstreams with stored-credential status (no secrets)", + Long: `List every brokered upstream and whether a credential is stored for it. + +` + credentialStoredNotInjectedBanner + ` Examples: mcpproxy credential list @@ -75,8 +90,10 @@ Examples: func newCredentialStatusCmd() *cobra.Command { return &cobra.Command{ Use: "status ", - Short: "Show one upstream's connection detail (no secrets)", - Long: `Show the connection detail for a single brokered upstream. + Short: "Show one upstream's stored-credential detail (no secrets)", + Long: `Show the stored-credential detail for a single brokered upstream. + +` + credentialStoredNotInjectedBanner + ` Examples: mcpproxy credential status github @@ -210,6 +227,7 @@ func runCredentialConnect(_ *cobra.Command, args []string) error { fmt.Printf("Open this URL in a browser where you are signed in to mcpproxy:\n\n %s\n\n", connectURL) fmt.Println("After authorizing, the credential is stored server-side and tied to your user.") + fmt.Println(credentialStoredNotInjectedBanner) return nil } @@ -325,22 +343,40 @@ func truncateCell(s string, maxLen int) string { return s[:maxLen-3] + "..." } -// emitCredentials prints the list in the resolved output format. +// printCredentialBanner emits the FR-034 honesty line. Human-readable output +// begins with it on stdout (contracts/rest-endpoints.md Β§12); structured +// json/yaml output must stay machine-parseable, so there it goes to stderr. +func printCredentialBanner(structured bool) { + if structured { + fmt.Fprintln(os.Stderr, credentialStoredNotInjectedBanner) + return + } + fmt.Println(credentialStoredNotInjectedBanner) +} + +// emitCredentials prints the list in the resolved output format, preceded by +// the "stored, not injected" banner (FR-034). func emitCredentials(creds []cliclient.CredentialStatus) error { switch ResolveOutputFormat() { case "json", "yaml": + printCredentialBanner(true) return emitFormatted(creds) default: + printCredentialBanner(false) fmt.Println(renderCredentialsTable(creds)) return nil } } +// emitCredentialDetail prints one upstream's detail in the resolved output +// format, preceded by the "stored, not injected" banner (FR-034). func emitCredentialDetail(c cliclient.CredentialStatus) error { switch ResolveOutputFormat() { case "json", "yaml": + printCredentialBanner(true) return emitFormatted(c) default: + printCredentialBanner(false) fmt.Println(renderCredentialDetail(c)) return nil } diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 794e7c50e..2a5953f89 100644 --- a/cmd/mcpproxy/main.go +++ b/cmd/mcpproxy/main.go @@ -504,6 +504,11 @@ func runServer(cmd *cobra.Command, _ []string) error { _ = logger.Sync() }() + // Spec 107 FR-035: the loader has no logger, so the removed-key / + // deprecated-key findings it recorded are emitted here, once, now that + // one exists (server build only; the personal build records none). + config.LogLoadDiagnostics(cfg, logger) + // Log startup information including log directory info logDirInfo, err := logs.GetLogDirInfo() if err != nil { diff --git a/docs/cli/credential-commands.md b/docs/cli/credential-commands.md index bc1b31009..08c19d8b2 100644 --- a/docs/cli/credential-commands.md +++ b/docs/cli/credential-commands.md @@ -1,19 +1,21 @@ --- title: "Credential Commands" sidebar_label: "Credential Commands" -description: "Server edition: manage per-user brokered credentials for shared upstream servers." +description: "Server edition: manage per-user stored credentials for shared upstream servers β€” stored, not injected." --- # Credential CLI Commands (Server Edition) +**A stored credential is kept for a future broker and is NOT injected into upstream calls in this release.** Connecting an upstream records a credential tied to your user; tool calls to that upstream still go out with the server's shared identity. Every human-readable `credential list` / `credential status` output begins with this statement. + > **Server edition only.** These commands are built into `mcpproxy-server` > (`go build -tags server`). They are not present in the personal edition. -The `mcpproxy credential` command group manages your **per-user brokered -credentials** for shared upstream servers that use the credential broker -(spec 074). Brokered upstreams carry an `auth_broker` block in the server -config; each user connects their own credential, and the proxy injects it at -call time. +The `mcpproxy credential` command group manages your **per-user stored +credentials** for shared upstream servers that carry an `auth_broker` block +with `mode: oauth_connect` (see [Auth Broker](../features/auth-broker.md)). +Each user connects their own credential through a browser consent flow; the +proxy stores it encrypted, per user, and reports its status here. Secret values (access/refresh tokens) are **never displayed** by these commands (FR-026). The CLI decodes responses into a non-secret view, so even a @@ -42,7 +44,7 @@ export MCPPROXY_TOKEN=eyJ... # your user JWT ### `credential list` -List every brokered upstream with your connection status. No secrets. +List every connectable upstream with your stored-credential status. No secrets. ```bash mcpproxy credential list @@ -50,6 +52,8 @@ mcpproxy credential list -o json ``` ``` +Stored credentials are kept for a future broker and are NOT injected into upstream calls in this release. + SERVER MODE STATUS TOKEN EXPIRES ------------------------------------------------------------------------------------------ github oauth_connect connected Bearer 2026-07-01 12:00 @@ -58,12 +62,15 @@ jira oauth_connect not_connected * - - * connectable: run 'mcpproxy credential connect ' ``` -Status values: `connected`, `expired`, `not_connected`, `unavailable` -(the latter means the server's credential store is disabled). +Status values: `connected` (a valid, non-expired credential is stored β€” it +says nothing about what upstream calls carry), `expired`, `not_connected`, +`unavailable` (the server's credential store is disabled because no +`credential_encryption_key` / `MCPPROXY_CRED_KEY` is configured). ### `credential status ` -Show the connection detail for one brokered upstream. +Show the stored-credential detail for one upstream. The output opens with the +same "stored, not injected" statement. ```bash mcpproxy credential status github @@ -74,7 +81,8 @@ mcpproxy credential status github -o yaml Print the browser URL that starts the per-user OAuth connect flow. Open it in a browser where you are signed in to mcpproxy; the proxy binds the flow to your -user and stores the resulting credential server-side. +user and stores the resulting credential server-side. A stored credential is +not refreshed β€” once it reports `expired`, run `connect` again. ```bash mcpproxy credential connect github @@ -82,7 +90,7 @@ mcpproxy credential connect github ### `credential rm ` -Disconnect (revoke) your stored credential for an upstream. Aliases: `remove`, +Delete your stored credential for an upstream. Aliases: `remove`, `disconnect`. ```bash @@ -92,13 +100,15 @@ mcpproxy credential rm github ## Output formatting All read commands honor the global `-o table|json|yaml` flag and the -`MCPPROXY_OUTPUT` environment variable (table is the default). +`MCPPROXY_OUTPUT` environment variable (table is the default). With `json` or +`yaml` the "stored, not injected" statement goes to **stderr** so stdout stays +machine-parseable; the payload itself is the REST response unchanged. ## Related REST endpoints (spec 074 T8) | Endpoint | Description | |----------|-------------| -| `GET /api/v1/user/credentials` | List connection status (no secrets) | -| `DELETE /api/v1/user/credentials/{server}` | Disconnect/revoke | +| `GET /api/v1/user/credentials` | List stored-credential status (no secrets) | +| `DELETE /api/v1/user/credentials/{server}` | Delete the stored credential | | `GET /api/v1/user/credentials/{server}/connect` | Start the browser connect flow | | `GET /api/v1/user/credentials/{server}/callback` | OAuth callback (browser) | diff --git a/docs/configuration/upstream-servers.md b/docs/configuration/upstream-servers.md index 15515e098..6b6275ed4 100644 --- a/docs/configuration/upstream-servers.md +++ b/docs/configuration/upstream-servers.md @@ -109,7 +109,7 @@ than stopping the daemon from booting. | `working_dir` | string | No | Working directory for stdio servers | | `env` | object | No | Environment variables to pass | | `oauth` | object | No | OAuth configuration | -| `auth_broker` | object | No | Server-edition per-user token brokering. See [Auth Broker](../features/auth-broker.md). The `authorization_endpoint` key is required when `auth_broker.mode` is `oauth_connect`. | +| `auth_broker` | object | No | Server-edition per-user `oauth_connect` credential store β€” the stored credential is **not** injected into upstream calls. See [Auth Broker](../features/auth-broker.md). `mode` must be `oauth_connect`; `authorization_endpoint` and `token_endpoint` are required. | | `health_check_interval` | duration | No | Per-server override for the liveness `ping` cadence (`0s` disables; falls back to the global value, then the `30s` default). No-op for Docker-isolated servers. | | `tool_discovery_interval` | duration | No | Per-server override for the `tools/list` re-index sweep (`0s` disables; falls back to the global value, then the `5m` default). | diff --git a/docs/development/server-edition-multiuser-auth.md b/docs/development/server-edition-multiuser-auth.md index c0a42541d..50d242bbf 100644 --- a/docs/development/server-edition-multiuser-auth.md +++ b/docs/development/server-edition-multiuser-auth.md @@ -23,13 +23,22 @@ Server edition supports OAuth-based multi-user authentication with Google, GitHu "allowed_domains": ["company.com"] }, "session_ttl": "24h", - "bearer_token_ttl": "24h", - "workspace_idle_timeout": "30m", - "max_user_servers": 20 + "bearer_token_ttl": "24h" } } ``` +The former knobs `workspace_idle_timeout` and `max_user_servers` never +controlled anything and were removed (Spec 107). A config file that still +carries them loads: the server edition drops each with one startup warning +(`server_edition.max_user_servers is no longer supported and was ignored`, +likewise for `workspace_idle_timeout`) and the next write-back omits them; +`PATCH /api/v1/config` and `/config/apply` refuse them with the same text. The +personal edition carries the whole `server_edition` block as opaque JSON and +neither warns nor validates it. `store_idp_tokens` is likewise accepted and +ignored (one deprecation warning when `true`); see +[IdP Token Storage](../features/idp-token-storage.md). + ## Server API Endpoints | Endpoint | Auth | Description | @@ -37,7 +46,7 @@ Server edition supports OAuth-based multi-user authentication with Google, GitHu | `GET /api/v1/auth/login` | Public | Initiate OAuth login flow | | `GET /api/v1/auth/callback` | Public | OAuth callback (creates session) | | `GET /api/v1/auth/me` | Session/JWT | Get current user profile | -| `POST /api/v1/auth/token` | Session | Generate JWT bearer token for MCP | +| `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/logout` | Session | Invalidate session | | `GET /api/v1/user/servers` | Session/JWT | List user's servers (personal + shared) | | `POST /api/v1/user/servers` | Session/JWT | Add personal upstream server | @@ -50,9 +59,9 @@ Server edition supports OAuth-based multi-user authentication with Google, GitHu ## Server Architecture -- **Auth flow**: OAuth 2.0 + PKCE β†’ Session cookie (Web UI) + JWT bearer (MCP/API) -- **Server types**: Shared (config file, single connection) + Personal (DB, per-user connections) -- **Isolation**: Users see only shared + own personal servers. Activity logs user-scoped. +- **Auth flow**: OAuth 2.0 + PKCE β†’ Session cookie (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. - **Admin**: Identified by `admin_emails` config. Sees all activity, manages users. - **Build tag**: All server code behind `//go:build server`. Personal edition unaffected. @@ -203,32 +212,36 @@ themselves a token over the admin's whole inventory. inventory) closes only when enforcement resolves a server name against an owner rather than as a bare string. -#### Scope is a snapshot, not a live check +#### Scope is stored at mint time and narrowed on every use -`resolveTokenServerScope` runs at mint time and **nothing re-validates at use -time**. Un-sharing a server, or removing a personal one, does **not** revoke the -grants already written into live tokens β€” to actually withdraw access, revoke or -delete the tokens. +`resolveTokenServerScope` runs at mint time and writes the entitled list into +the token record. Since #1272 that stored list is **not the last word**: every +authentication of an *owned* token intersects the stored `allowed_servers` with +the owner's current entitlement (narrow-only, fail-closed), so un-sharing a +server or removing a personal one takes effect on the token's next request +without a revoke. The stored list is still worth keeping tight β€” it is the upper +bound the per-request narrowing starts from β€” and revoking or deleting the token +remains the way to withdraw *all* access at once. -Rotation is the one re-check: `POST /api/v1/user/tokens/{name}/regenerate` -re-runs the entitlement predicate and persists the **narrowed** list +Rotation persists the re-check: `POST /api/v1/user/tokens/{name}/regenerate` +re-runs the entitlement predicate and stores the **narrowed** list (`narrowScopeToEntitled`), echoing it back in the response. It only ever narrows, -and it never rejects β€” refusing to rotate would leave the over-broad grant in -place and working. A token that is never rotated is never re-checked. +and it never rejects β€” refusing to rotate would leave the wider stored list in +place (harmless at use time, since narrowing happens per request, but +misleading when read back). **Tokens minted with a literal `"*"` before this constraint existed.** The -enforcement layer honours `"*"` unconditionally, so any such token is a standing -grant over the whole deployment. There is no migration and no admin-facing -report: `GET /api/v1/user/tokens` is per-caller, and the server edition has no -cross-tenant token listing (that belongs in `admin_handlers`, as its own -feature). Until one exists, an operator audits them straight from storage β€” -every record in the `agent_tokens` bucket whose `allowed_servers` contains `"*"` -and whose `user_id` is non-empty β€” and revokes them; a tenant's own rotation -also materialises the star into their entitled set. **This does not block the -release**: the escalation route is closed for every token minted from here on, -and a pre-existing `"*"` token could only have been minted by a tenant who was -already able to mint one, i.e. it is not a new exposure. It is a cleanup item to -carry into the cross-tenant token administration feature. +enforcement layer honours `"*"` unconditionally, but a tenant-owned token no +longer reaches it with a star: the per-authentication narrowing above +materialises a tenant's `"*"` into their current entitled set on every request +(an administrator's star stays literal), and rotation persists that bounded +list. Such records still read as `"*"` in storage until rotated. There is no +migration and no admin-facing report: `GET /api/v1/user/tokens` is per-caller, +and the server edition has no cross-tenant token listing (that belongs in +`admin_handlers`, as its own feature). An operator who wants the stored lists +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. ## Key Directories @@ -239,8 +252,8 @@ carry into the cross-tenant token administration feature. | `cmd/mcpproxy/serveredition_register.go` | Server feature registration entry point | | `internal/serveredition/auth/` | OAuth, sessions, JWT tokens, middleware | | `internal/serveredition/users/` | User/session models, BBolt store | -| `internal/serveredition/workspace/` | Per-user workspace for personal upstreams | -| `internal/serveredition/multiuser/` | Multi-user router (**not yet wired** β€” `NewRouter` has no production caller), tool filtering, activity isolation | +| `internal/serveredition/multiuser/` | Activity isolation (user-scoped activity queries). The per-user router, tool filter and workspace packages that once lived here had no production caller and were deleted in Spec 107. | +| `internal/serveredition/broker/` | Per-user `oauth_connect` credential store (stored, not injected β€” see [Auth Broker](../features/auth-broker.md)) | | `internal/serveredition/api/` | Server REST API endpoints (user, admin, auth) | ## Server Testing diff --git a/docs/features/activity-log.md b/docs/features/activity-log.md index 620cabec3..aab977dba 100644 --- a/docs/features/activity-log.md +++ b/docs/features/activity-log.md @@ -25,7 +25,7 @@ The activity log captures: | `policy_decision` | Tool calls blocked by policy rules | | `quarantine_change` | Server quarantine/unquarantine events | | `server_change` | Server enable/disable/restart events | -| `credential_broker` | Per-user credential brokering events (acquire/refresh/inject/connect) β€” server edition only | +| `credential_broker` | Per-user `oauth_connect` consent/callback outcomes (`connect` is the only action ever recorded; the credential is stored, never injected) β€” server edition only | ### System Lifecycle Events @@ -297,7 +297,7 @@ GET /api/v1/activity | Parameter | Type | Description | |-----------|------|-------------| -| `type` | string | Filter by type (comma-separated for multiple): `tool_call`, `system_start`, `system_stop`, `internal_tool_call`, `config_change`, `policy_decision`, `quarantine_change`, `server_change`, `credential_broker` | +| `type` | string | Filter by type (comma-separated for multiple): `tool_call`, `system_start`, `system_stop`, `internal_tool_call`, `config_change`, `policy_decision`, `quarantine_change`, `server_change`, `credential_broker` (server edition; connect-flow outcomes only) | | `server` | string | Filter by server name | | `tool` | string | Filter by tool name | | `session_id` | string | Filter by MCP session ID | diff --git a/docs/features/auth-broker.md b/docs/features/auth-broker.md index 335ed2982..f91e79588 100644 --- a/docs/features/auth-broker.md +++ b/docs/features/auth-broker.md @@ -3,27 +3,27 @@ id: auth-broker title: Per-User Auth Broker sidebar_label: Auth Broker sidebar_position: 11 -description: Server-edition per-upstream token brokering β€” token exchange, Entra OBO, and per-user OAuth connect flow -keywords: [auth broker, token exchange, entra obo, oauth connect, per-user, server edition, spec 074] +description: Server-edition per-user OAuth connect flow β€” credentials are stored encrypted per user for a future broker and are not injected into upstream calls +keywords: [auth broker, oauth connect, per-user, credential store, server edition, spec 074, spec 107] --- # Per-User Auth Broker +**A stored credential is kept for a future broker and is NOT injected into upstream calls in this release.** The `auth_broker` block lets a user of the server edition connect their *own* OAuth credential for a shared upstream through a per-user consent flow; the proxy persists that credential encrypted, per user, and shows its status through the REST API, the CLI and the Web UI. Nothing on the tool-call path reads it: a proxied request to that upstream still carries whatever the upstream's own `headers` / `oauth` configuration says, exactly as it would without the block. + :::info Server edition only -The auth broker is part of the **server edition** (`//go:build server`). It is opt-in **per upstream** β€” servers without an `auth_broker` block behave exactly as before. Brokering applies only to HTTP-family upstreams (`http`, `sse`, `streamable-http`); configuring it on a `stdio` upstream is rejected at config validation in this phase. +The auth broker is part of the **server edition** (`//go:build server`). It is opt-in **per upstream** β€” servers without an `auth_broker` block behave exactly as before. The connect flow applies only to HTTP-family upstreams (`http`, `sse`, `streamable-http`); configuring it on a `stdio` upstream is rejected at config validation. The personal edition keeps the block in the config file as opaque JSON so a file shared between editions round-trips unchanged; it never validates or acts on it. ::: -The auth broker lets the gateway acquire an **upstream credential on behalf of the calling user** instead of sharing a single static token. Each upstream server can declare how its credential is obtained via an `auth_broker` block on its server config (spec 074). - ## Modes -The `auth_broker.mode` field selects the credential-acquisition strategy: +`auth_broker.mode` accepts one value: | Mode | Description | |------|-------------| -| `token_exchange` | RFC 8693 OAuth 2.0 Token Exchange β€” swaps the caller's IdP token for an upstream-scoped token at the IdP token endpoint. | -| `entra_obo` | Microsoft Entra **On-Behalf-Of** flow. | -| `oauth_connect` | **Path B** β€” a per-user authorization-code + PKCE *connect* flow against an upstream authorization server that does not support token exchange. The user is redirected to the upstream's consent screen once; the resulting per-user credential is persisted encrypted and refreshed transparently. | +| `oauth_connect` | **Path B** β€” a per-user authorization-code + PKCE *connect* flow against the upstream's authorization server. The user is redirected to the upstream's consent screen once; the resulting per-user credential is persisted encrypted. | + +Earlier releases also validated the modes `token_exchange` (RFC 8693) and `entra_obo` (Microsoft On-Behalf-Of). Neither was ever performed by any code path, and they are no longer accepted: a config file that still names one loads with **that server's whole `auth_broker` block ignored** and one warning (`auth_broker.mode "token_exchange" was never implemented; the auth_broker block for server "" was ignored`), and `PATCH /api/v1/config` / `/config/apply` refuse it with the same text. The same applies to the removed `header` and `header_format` keys (`auth_broker.header is no longer supported and was ignored`). ## Configuration @@ -54,58 +54,54 @@ The block lives under a server entry in the config file: | Key | Required | Description | |-----|----------|-------------| -| `mode` | yes | One of `token_exchange`, `entra_obo`, `oauth_connect`. | -| `token_endpoint` | yes | IdP/upstream token endpoint used to mint (and refresh) the upstream credential. | -| `authorization_endpoint` | **only for `oauth_connect`** | Upstream authorization-server *authorize* URL the user is redirected to for consent. Required when `mode` is `oauth_connect`; ignored by `token_exchange` and `entra_obo`. | +| `mode` | yes | Must be `oauth_connect`. | +| `token_endpoint` | yes | Upstream authorization-server token endpoint where the authorization code is exchanged. | +| `authorization_endpoint` | yes | Upstream authorization-server *authorize* URL the user is redirected to for consent. | | `resource` | no | RFC 8707 audience the resulting token is scoped to. | | `scopes` | no | Scopes requested for the upstream credential. | | `client_id` | noΒΉ | Identifies the gateway to the token/authorization endpoint. | -| `client_secret` | no | Authenticates a confidential client. A public client may omit it β€” PKCE still protects the `oauth_connect` code exchange. | -| `header` | no | Outbound header the resolved credential is injected into (default `Authorization`). | -| `header_format` | no | Value template; `{token}` is replaced with the resolved credential (default `Bearer {token}`). | +| `client_secret` | no | Authenticates a confidential client. A public client may omit it β€” PKCE still protects the code exchange. Supports `${env:VAR}` so the secret stays out of the file. | -ΒΉ `client_id` is required at runtime for the `oauth_connect` flow (the connector rejects an empty client ID); it is validated when the connect flow is assembled. +ΒΉ `client_id` is required at runtime for the connect flow (the connector rejects an empty client ID); it is validated when the connect flow is assembled. -:::warning `authorization_endpoint` is mandatory for `oauth_connect` -Config validation fails with `auth_broker.authorization_endpoint is required for mode "oauth_connect"` if the key is missing while `mode` is `oauth_connect`. The other two modes never read it. +:::warning `authorization_endpoint` is mandatory +Config validation fails with `auth_broker.authorization_endpoint is required for mode "oauth_connect"` if the key is missing. ::: -## The `oauth_connect` flow (Path B) - -1. The gateway builds an authorize URL from `authorization_endpoint` with a per-user opaque `state` and a PKCE `S256` challenge, and redirects the user there. -2. On the upstream's callback, `state` is validated as a **known, unexpired, single-use** pending flow (10-minute TTL) bound to the initiating user β€” confused-deputy / replay hardening. -3. The authorization code is exchanged at `token_endpoint` using the bound PKCE verifier; the resulting credential is stored **encrypted, per user**, tagged `ObtainedVia=connect_flow`. -4. Tokens are refreshed transparently from the stored refresh token; a non-rotating authorization server keeps its prior refresh token. - -A denied consent (`error=access_denied`) clears the pending flow and stores nothing. +## Credential storage and the encryption key -## Credential resolution +Per-user credentials are encrypted with **AES-256-GCM** before they are written to BBolt (`config.db`), keyed by `server_edition.credential_encryption_key` β€” a base64-encoded 32-byte key. The environment variable **`MCPPROXY_CRED_KEY`** supplies the key when the config value is empty and is the recommended way to provide it in container or systemd deployments; an explicit config value always wins over the variable. -On each proxied request the broker resolves the per-user credential to inject, in a strict **per-user-only** order. There is **no shared or static fallback** β€” a request that cannot produce a per-user credential fails rather than borrowing another identity: +```bash +# Generate a fresh 32-byte key and base64-encode it +export MCPPROXY_CRED_KEY="$(openssl rand -base64 32)" +``` -1. A valid cached per-user credential is injected directly; if it is within the near-expiry window it is refreshed first (re-minted for `token_exchange`/`entra_obo`, or renewed from the stored refresh token for `oauth_connect`). -2. Otherwise, for `token_exchange`/`entra_obo`, a credential is minted from the user's stored IdP subject token. -3. Otherwise, for `oauth_connect` upstreams the user has not connected β€” or whose stored credential expired and could not be refreshed β€” the request fails with an **actionable error carrying the connect URL**, so the user is told to (re)connect rather than being silently denied. -4. Otherwise the request fails with "no per-user credential available". +Store the value in a secret manager (Vault, AWS Secrets Manager, a Kubernetes Secret, …) and inject it at runtime. The key is never written to disk by MCPProxy itself. When no key is configured the credential store is **disabled**: every credential reports `unavailable` and nothing is persisted. Key rotation is not supported; rotating the key means clearing the `user_upstream_credentials` bucket and asking users to connect again. -Concurrent requests for the same `(user, upstream)` are coalesced (single-flight) so a burst does not trigger duplicate upstream token flows. A policy-decision hook is evaluated per call immediately before the credential is returned; no policy engine ships yet, so it permits every injection by default. +Stored credentials are scoped per user β€” one user's credential is never visible to another β€” and secret values (access/refresh tokens) are never serialised by any REST, CLI or UI surface. -## Header injection +## The `oauth_connect` flow (Path B) -The resolved per-user credential is injected into the configured outbound header (`header`, default `Authorization`) using the value template (`header_format`, default `Bearer {token}`), then the request is forwarded to the upstream. +1. The gateway builds an authorize URL from `authorization_endpoint` with a per-user opaque `state` and a PKCE `S256` challenge, and redirects the user there. +2. On the upstream's callback, `state` is validated as a **known, unexpired, single-use** pending flow (10-minute TTL) bound to the initiating user β€” confused-deputy / replay hardening. +3. The authorization code is exchanged at `token_endpoint` using the bound PKCE verifier; the resulting credential is stored **encrypted, per user**, tagged `ObtainedVia=connect_flow`. +4. That is where the flow ends. The credential is **not** refreshed and **not** injected into any request. Once its access token expires the status becomes `expired`; the user runs the connect flow again to store a fresh one. -Injection is a **replacement**, not a merge: +A denied consent (`error=access_denied`) clears the pending flow and stores nothing. -- Any header on the upstream config whose name matches `header` (case-insensitively) is **removed** before the resolved credential is set, so a brokered upstream presents exactly one value for that header. -- The inbound gateway/IdP token is **never forwarded** to the upstream. Brokering exists precisely so the upstream sees a credential minted *for it*, scoped to the calling user β€” not the token the user presented to the gateway. +## Status surfaces -Injection applies only to **HTTP-family** upstreams (`http`, `sse`, `streamable-http`). Brokering on a `stdio` upstream is rejected β€” at config validation, and again as a runtime guard at the injection boundary β€” with a clear "unsupported in this phase" message. +| Surface | Reference | +|---------|-----------| +| REST | `GET /api/v1/user/credentials`, `DELETE /api/v1/user/credentials/{server}`, `GET /api/v1/user/credentials/{server}/connect`, `GET /api/v1/user/credentials/{server}/callback` | +| CLI | [`mcpproxy credential list\|status\|connect\|rm`](../cli/credential-commands.md) | -## Per-(user, server) connection keying +Status values are `connected` (a valid, non-expired credential is stored), `expired`, `not_connected` and `unavailable` (no encryption key, store disabled). `connected` describes the *stored* credential only; it does not mean any upstream call carries it. -A **shared** upstream that is brokered per-user must carry **each user's own** credential. Brokered upstream connections are therefore keyed by `(user, server)`, never by server alone: one user's connection (and the credential injected on it) is never reused for another user. The server-component of the key reuses the same `name + URL` scheme as the credential store, so a connection and its cached credential stay in lockstep. +Connect-flow consent and callback outcomes are recorded as `credential_broker` activity records (see [Activity Log](./activity-log.md)). ## See also - [OAuth Authentication](./oauth-authentication.md) β€” upstream OAuth for the personal edition. -- Server multi-user authentication is covered in the project `CLAUDE.md` (Spec 024). +- [Server Multi-User Authentication](../development/server-edition-multiuser-auth.md) β€” the server-edition login front door. diff --git a/docs/features/idp-token-storage.md b/docs/features/idp-token-storage.md index 4a46a4aca..6aed909ad 100644 --- a/docs/features/idp-token-storage.md +++ b/docs/features/idp-token-storage.md @@ -1,158 +1,9 @@ --- -title: "IdP Subject Token Storage" +title: "IdP Subject Token Storage (removed)" sidebar_label: "IdP Token Storage" -description: "Server edition: encrypted persistence of identity-provider tokens for on-behalf-of token exchange." +description: "Server edition: the store_idp_tokens feature was removed; the key is accepted and ignored." --- -# IdP Subject Token Storage (Server Edition) +# IdP Subject Token Storage (removed) -MCPProxy Server edition can persist the IdP (identity-provider) access and refresh -tokens obtained during a user's OAuth login so that downstream services can use them -for on-behalf-of (OBO) token exchange (RFC 8693, spec 074 TokenExchanger). This -feature is **off by default** and requires an encryption key to activate. - -## Prerequisites - -- Server edition (`go build -tags server`) -- A 32-byte, base64-encoded AES-256 master key - -## Configuration - -Two settings control the feature, both under the `server_edition` block (configs -that still use the legacy `teams` key are accepted as a back-compat alias): - -```json -{ - "server_edition": { - "enabled": true, - "store_idp_tokens": true, - "credential_encryption_key": "", - "oauth": { ... } - } -} -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `store_idp_tokens` | bool | `false` | Enable IdP subject token persistence | -| `credential_encryption_key` | string | `""` | Base64-encoded AES-256 master key for at-rest encryption | - -### Environment variable override - -`MCPPROXY_CRED_KEY` overrides `credential_encryption_key` at startup and is the -recommended way to supply the key in container or systemd deployments (keeps -secrets out of the config file): - -```bash -export MCPPROXY_CRED_KEY="$(openssl rand -base64 32)" -``` - -The env var takes precedence over the config file value when both are set. - -## Key generation - -```bash -# Generate a fresh 32-byte key and base64-encode it -openssl rand -base64 32 -# Example output: 7h3K...== (44 characters) -``` - -Store this value in a secret manager (Vault, AWS Secrets Manager, Kubernetes -Secret, etc.) and inject it as `MCPPROXY_CRED_KEY` at runtime. - -## Security model - -- Tokens are encrypted with **AES-256-GCM** before being written to BBolt - (`~/.mcpproxy/config.db`). -- The master key is never written to disk by MCPProxy itself; it lives only in - memory after startup. -- When the master key is absent or empty, `store_idp_tokens` has no effect: the - credential store is disabled and a warning is logged at each login. No tokens - are persisted and the feature degrades gracefully to the pre-feature behaviour. -- Stored tokens are scoped per user. One user's credentials cannot be read by - another user. - -## Token lifecycle - -1. **Login** β€” when a user completes the OAuth flow and `store_idp_tokens: true`, - the provider's `access_token` and `refresh_token` are encrypted and stored. -2. **Use** β€” `GetValidIDPSubjectToken` returns the stored access token if it is - valid and not within 60 s of expiry. -3. **Refresh** β€” when the access token is near-expiry, MCPProxy automatically - exchanges the refresh token for a new access token using the provider's token - endpoint. The refreshed token is re-persisted. -4. **Re-auth** β€” when no refresh token is available, or the refresh fails, the - user is required to sign in again (`ErrReauthRequired`). - -## Per-user credentials REST API (server edition) - -Brokered upstreams expose a per-user credential surface under the session/JWT -auth middleware. Every endpoint is scoped to the authenticated caller β€” a user -can only see and manage their own credentials, never another user's. - -| Endpoint | Description | -|----------|-------------| -| `GET /api/v1/user/credentials` | List the connection status of every brokered upstream for the caller. | -| `DELETE /api/v1/user/credentials/{server}` | Disconnect (revoke) the caller's credential for an upstream. | -| `GET /api/v1/user/credentials/{server}/connect` | Initiate the per-user OAuth connect flow (Path B); 302-redirects to the upstream authorization server. | -| `GET /api/v1/user/credentials/{server}/callback` | OAuth connect callback; exchanges the code, stores the per-user credential, and redirects back to the Web UI. | - -### Connection status (`GET /api/v1/user/credentials`) - -The list returns **non-secret metadata only** β€” access and refresh tokens are -never serialized. Each entry carries a `status`: - -- `connected` β€” a valid, non-expired per-user credential exists. -- `expired` β€” a credential exists but its access token has expired. -- `not_connected` β€” no per-user credential exists for this upstream. -- `unavailable` β€” the credential store is disabled (no encryption key configured). - -For `oauth_connect` upstreams that are `not_connected` or `expired`, the entry -includes an actionable `connect_path` pointing at the connect endpoint. - -```json -{ - "credentials": [ - { - "server": "github-shared", - "mode": "oauth_connect", - "status": "not_connected", - "connect_path": "/api/v1/user/credentials/github-shared/connect" - }, - { - "server": "internal-api", - "mode": "token_exchange", - "status": "connected", - "token_type": "Bearer", - "scopes": ["read"], - "expires_at": "2026-06-15T20:00:00Z" - } - ] -} -``` - -### Connect flow (Path B) - -`connect` builds an authorization-code + PKCE URL bound to the authenticated -user and redirects there. After consent, the upstream redirects to `callback`, -which validates the one-time `state`, exchanges the code, and persists the -per-user credential (encrypted, `obtained_via=connect_flow`). The credential is -always stored under the **initiating** user, so the callback cannot be used to -write into another user's record. The browser then lands on `/ui/` with a -`credential_connected` / `credential_error` query flag. The `credential_error` -value is always a coerced, secret-free label (e.g. `access_denied`, -`authorization_denied`); the raw, authorization-server-controlled error string is -never logged or reflected back into the redirect, and token-endpoint failures -surface only the HTTP status plus an allowlisted OAuth error code β€” never the raw -response body (FR-029 / SC-005). - -## Operational notes - -- **Key rotation** is not yet supported. Rotating the key requires clearing the - `user_upstream_credentials` BBolt bucket and asking all users to sign in again. -- If `store_idp_tokens` is disabled after tokens have been stored, the stored data - remains encrypted in the database but is never read. A future cleanup command - will be added to purge it. -- The refresh token is only available when the OAuth provider issues one. Google - and Microsoft both require explicit `offline_access` / `access_type=offline` - parameters, which MCPProxy adds automatically when `store_idp_tokens: true`. +The server edition no longer persists identity-provider access or refresh tokens after login. The feature existed only to feed an on-behalf-of token exchange that was never wired to any upstream call, so a long-lived IdP refresh token at rest had no reader and was a leak surface rather than a capability. `server_edition.store_idp_tokens` is still **accepted** by the config loader for compatibility, but it is a **no-op**: a value of `true` logs one deprecation warning at startup (`server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it`) and nothing is stored. Per-upstream credentials obtained through the `oauth_connect` flow are unaffected β€” they are still stored encrypted under `credential_encryption_key` / `MCPPROXY_CRED_KEY`, as described in [Auth Broker](./auth-broker.md) and [Credential Commands](../cli/credential-commands.md). diff --git a/frontend/src/views/settings/fields.ts b/frontend/src/views/settings/fields.ts index 7b37585e3..4c87c40c4 100644 --- a/frontend/src/views/settings/fields.ts +++ b/frontend/src/views/settings/fields.ts @@ -324,7 +324,6 @@ export const SERVER_EDITION_SECTION_TITLE = 'πŸ‘₯ Server Edition' export const SERVER_EDITION_FIELDS: SettingField[] = [ { key: 'server_edition.enabled', label: 'Enable multi-user mode', control: 'toggle', restart: true }, { key: 'server_edition.oauth.provider', label: 'OAuth provider', control: 'select', options: ['', 'google', 'github', 'microsoft'].map((v) => ({ value: v, label: v || '(none)' })) }, - { key: 'server_edition.max_user_servers', label: 'Max servers per user', control: 'number', min: 0 }, ] // isBlankInstructions returns true when a saved instructions value is empty / diff --git a/frontend/tests/unit/settings-server-edition-wording.spec.ts b/frontend/tests/unit/settings-server-edition-wording.spec.ts index 6e9af2c76..49acbaa9c 100644 --- a/frontend/tests/unit/settings-server-edition-wording.spec.ts +++ b/frontend/tests/unit/settings-server-edition-wording.spec.ts @@ -26,4 +26,12 @@ describe('Settings server-edition wording (MCP-1087)', () => { expect(f.key).toMatch(/^server_edition\./) } }) + + // Spec 107 (FR-035, US5): `max_user_servers` is a removed key. The backend + // no longer reads it (it only emits a one-line startup warning when present), + // so the Settings form must not offer a control that writes a dead key. + it('does not expose the removed `server_edition.max_user_servers` key (Spec 107 T022)', () => { + const keys = SERVER_EDITION_FIELDS.map((f) => f.key) + expect(keys).not.toContain('server_edition.max_user_servers') + }) }) diff --git a/internal/config/auth_broker.go b/internal/config/auth_broker.go index 63f92f993..e7537abb6 100644 --- a/internal/config/auth_broker.go +++ b/internal/config/auth_broker.go @@ -4,36 +4,33 @@ package config import "fmt" -// Auth-broker modes (spec 074, FR-001/FR-003). Each names the upstream -// credential-acquisition strategy the gateway uses on behalf of the caller. +// Auth-broker modes (spec 074, FR-001/FR-003). +// +// Spec 107 FR-032 reduced the accepted set to the one mode that has a live +// implementation. `token_exchange` and `entra_obo` were validated but never +// performed by any code path; a config that still carries one of them loads +// with the whole auth_broker block of that server dropped and a LoadDiagnostic +// recorded (see the server-build normaliser), and the write doors refuse it +// (ValidateRemovedKeys). const ( - // AuthBrokerModeTokenExchange uses RFC 8693 OAuth 2.0 Token Exchange. - AuthBrokerModeTokenExchange = "token_exchange" - // AuthBrokerModeEntraOBO uses Microsoft Entra On-Behalf-Of flow. - AuthBrokerModeEntraOBO = "entra_obo" // AuthBrokerModeOAuthConnect uses a per-user OAuth connect/authorize flow. AuthBrokerModeOAuthConnect = "oauth_connect" ) -// Default header injection settings (FR-016). -const ( - defaultAuthBrokerHeader = "Authorization" - defaultAuthBrokerHeaderFormat = "Bearer {token}" -) - -// AuthBrokerConfig is the per-upstream token-brokering block (server edition). -// It is opt-in per server (FR-003); upstreams without it behave exactly as -// today. Brokering applies only to HTTP-family upstreams in this phase -// (FR-002). +// AuthBrokerConfig is the per-upstream credential-connect block (server +// edition). It is opt-in per server (FR-003); upstreams without it behave +// exactly as today. A user who completes the connect flow gets their +// credential STORED (encrypted) for that upstream β€” nothing injects it into a +// proxied request (Spec 107 FR-034); the `header`/`header_format` injection +// leaves were removed with the never-wired injector (FR-032). type AuthBrokerConfig struct { - // Mode selects the credential-acquisition strategy: token_exchange, - // entra_obo, or oauth_connect. + // Mode selects the credential-acquisition strategy: oauth_connect. Mode string `json:"mode" mapstructure:"mode"` // TokenEndpoint is the IdP token endpoint used to mint the upstream credential. TokenEndpoint string `json:"token_endpoint" mapstructure:"token_endpoint"` // AuthorizationEndpoint is the upstream AS authorize URL the user is // redirected to for consent. Required for the oauth_connect mode (Path B, - // spec 074 FR-011); unused by token_exchange/entra_obo. + // spec 074 FR-011). AuthorizationEndpoint string `json:"authorization_endpoint,omitempty" mapstructure:"authorization_endpoint"` // Resource is the RFC 8707 audience the resulting token is scoped to. Resource string `json:"resource,omitempty" mapstructure:"resource"` @@ -42,28 +39,22 @@ type AuthBrokerConfig struct { // ClientID / ClientSecret authenticate the gateway to the token endpoint. ClientID string `json:"client_id,omitempty" mapstructure:"client_id"` ClientSecret string `json:"client_secret,omitempty" mapstructure:"client_secret"` - // Header is the outbound header name the resolved credential is injected - // into (FR-016, default "Authorization"). - Header string `json:"header,omitempty" mapstructure:"header"` - // HeaderFormat is the value template; "{token}" is replaced with the - // resolved credential (default "Bearer {token}"). - HeaderFormat string `json:"header_format,omitempty" mapstructure:"header_format"` } -// ApplyDefaults fills the optional header-injection fields when unset (FR-016). -func (a *AuthBrokerConfig) ApplyDefaults() { +// Clone returns a deep copy of the block (nil-safe). CopyServerConfig uses it +// so a copied server never shares the Scopes backing array with its source. +func (a *AuthBrokerConfig) Clone() *AuthBrokerConfig { if a == nil { - return - } - if a.Header == "" { - a.Header = defaultAuthBrokerHeader + return nil } - if a.HeaderFormat == "" { - a.HeaderFormat = defaultAuthBrokerHeaderFormat + out := *a + if a.Scopes != nil { + out.Scopes = append([]string(nil), a.Scopes...) } + return &out } -// Validate checks the broker block's own fields (mode + required endpoint). +// Validate checks the broker block's own fields (mode + required endpoints). // Protocol-family enforcement is handled by validateServerAuthBroker, which has // the surrounding ServerConfig context. func (a *AuthBrokerConfig) Validate() error { @@ -71,27 +62,27 @@ func (a *AuthBrokerConfig) Validate() error { return nil } switch a.Mode { - case AuthBrokerModeTokenExchange, AuthBrokerModeEntraOBO, AuthBrokerModeOAuthConnect: + case AuthBrokerModeOAuthConnect: // ok case "": - return fmt.Errorf("auth_broker.mode is required (one of token_exchange, entra_obo, oauth_connect)") + return fmt.Errorf("auth_broker.mode is required (must be %s)", AuthBrokerModeOAuthConnect) default: - return fmt.Errorf("invalid auth_broker.mode: %q (must be token_exchange, entra_obo, or oauth_connect)", a.Mode) + return fmt.Errorf("invalid auth_broker.mode: %q (must be %s)", a.Mode, AuthBrokerModeOAuthConnect) } if a.TokenEndpoint == "" { return fmt.Errorf("auth_broker.token_endpoint is required") } // The connect flow (Path B) additionally needs the upstream authorize URL // to redirect the user to for consent. - if a.Mode == AuthBrokerModeOAuthConnect && a.AuthorizationEndpoint == "" { + if a.AuthorizationEndpoint == "" { return fmt.Errorf("auth_broker.authorization_endpoint is required for mode %q", AuthBrokerModeOAuthConnect) } return nil } // serverIsHTTPFamily reports whether the server is an HTTP/SSE/streamable-HTTP -// upstream, the only kinds that support brokering in this phase (FR-002). A -// server with an explicit stdio protocol, or a bare Command with no URL, is not +// upstream, the only kinds that support the connect flow (FR-002). A server +// with an explicit stdio protocol, or a bare Command with no URL, is not // HTTP-family. func serverIsHTTPFamily(server *ServerConfig) bool { switch server.Protocol { @@ -107,32 +98,27 @@ func serverIsHTTPFamily(server *ServerConfig) bool { } } -// validateServerAuthBroker applies broker defaults and validates the block in -// the context of its server. It rejects brokering on non-HTTP-family upstreams -// (FR-002) with a clear "unsupported in this phase" message. +// validateServerAuthBroker validates the block in the context of its server. +// It rejects the connect flow on non-HTTP-family upstreams (FR-002) with a +// clear "unsupported in this phase" message. func validateServerAuthBroker(server *ServerConfig, fieldPrefix string) []ValidationError { if server == nil || server.AuthBroker == nil { return nil } - var errs []ValidationError if !serverIsHTTPFamily(server) { - errs = append(errs, ValidationError{ + // The protocol error is the actionable one; field validation is skipped. + return []ValidationError{{ Field: fieldPrefix + ".auth_broker", Message: "auth_broker is only supported on HTTP-family upstreams (http, sse, streamable-http); brokering for stdio/non-HTTP upstreams is unsupported in this phase", - }) - // Still apply defaults so a later edition flip surfaces a complete block, - // but skip field validation β€” the protocol error is the actionable one. - server.AuthBroker.ApplyDefaults() - return errs + }} } - server.AuthBroker.ApplyDefaults() if err := server.AuthBroker.Validate(); err != nil { - errs = append(errs, ValidationError{ + return []ValidationError{{ Field: fieldPrefix + ".auth_broker", Message: err.Error(), - }) + }} } - return errs + return nil } diff --git a/internal/config/auth_broker_stub.go b/internal/config/auth_broker_stub.go index 4b91a96ee..6e458b52e 100644 --- a/internal/config/auth_broker_stub.go +++ b/internal/config/auth_broker_stub.go @@ -2,11 +2,44 @@ package config -// AuthBrokerConfig is a stub for the personal edition. Per-upstream token -// brokering is a server-edition feature (spec 074); the personal edition keeps -// the field on ServerConfig so configs round-trip, but carries no behavior and -// performs no validation β€” personal-edition behavior is unaffected. -type AuthBrokerConfig struct{} +import "encoding/json" + +// AuthBrokerConfig is the personal-edition carrier for a server's +// `auth_broker` block. The connect flow is a server-edition feature (spec +// 074); the personal edition keeps the field on ServerConfig so configs round +// trip (Spec 107 FR-040): the block is held as the raw JSON it was read from, +// never decoded, never validated and never warned about. +// +// Omission is the parent pointer's job: ServerConfig.AuthBroker is +// `*T,omitempty`, so a server without the key leaves the pointer nil and the +// key stays absent on write. A non-nil carrier with no raw bytes marshals as +// `{}`. +type AuthBrokerConfig struct { + raw json.RawMessage +} + +// UnmarshalJSON stores the document verbatim. +func (a *AuthBrokerConfig) UnmarshalJSON(data []byte) error { + a.raw = append(json.RawMessage(nil), data...) + return nil +} + +// MarshalJSON emits the stored document verbatim; an empty carrier is `{}`. +func (a AuthBrokerConfig) MarshalJSON() ([]byte, error) { + if len(a.raw) == 0 { + return []byte("{}"), nil + } + return append([]byte(nil), a.raw...), nil +} + +// Clone returns a deep copy of the carrier (nil-safe). CopyServerConfig uses +// it so a copied server never aliases the source's backing array. +func (a *AuthBrokerConfig) Clone() *AuthBrokerConfig { + if a == nil { + return nil + } + return &AuthBrokerConfig{raw: append(json.RawMessage(nil), a.raw...)} +} // validateServerAuthBroker is a no-op in the personal edition. func validateServerAuthBroker(_ *ServerConfig, _ string) []ValidationError { diff --git a/internal/config/auth_broker_test.go b/internal/config/auth_broker_test.go index ee146088b..f4287c9d8 100644 --- a/internal/config/auth_broker_test.go +++ b/internal/config/auth_broker_test.go @@ -22,25 +22,13 @@ func baseValidConfig(server *ServerConfig) *Config { } } -func TestAuthBrokerConfig_ApplyDefaults(t *testing.T) { - t.Run("fills header and header_format when empty", func(t *testing.T) { - b := &AuthBrokerConfig{Mode: AuthBrokerModeTokenExchange, TokenEndpoint: "https://idp/token"} - b.ApplyDefaults() - assert.Equal(t, "Authorization", b.Header) - assert.Equal(t, "Bearer {token}", b.HeaderFormat) - }) - - t.Run("preserves custom header and header_format", func(t *testing.T) { - b := &AuthBrokerConfig{ - Mode: AuthBrokerModeTokenExchange, - TokenEndpoint: "https://idp/token", - Header: "X-Upstream-Auth", - HeaderFormat: "token {token}", - } - b.ApplyDefaults() - assert.Equal(t, "X-Upstream-Auth", b.Header) - assert.Equal(t, "token {token}", b.HeaderFormat) - }) +// connectBroker returns a complete oauth_connect block. +func connectBroker() *AuthBrokerConfig { + return &AuthBrokerConfig{ + Mode: AuthBrokerModeOAuthConnect, + AuthorizationEndpoint: "https://idp/authorize", + TokenEndpoint: "https://idp/token", + } } func TestAuthBroker_OAuthConnectRequiresAuthorizationEndpoint(t *testing.T) { @@ -55,20 +43,7 @@ func TestAuthBroker_OAuthConnectRequiresAuthorizationEndpoint(t *testing.T) { }) t.Run("authorization_endpoint present is accepted", func(t *testing.T) { - b := &AuthBrokerConfig{ - Mode: AuthBrokerModeOAuthConnect, - AuthorizationEndpoint: "https://idp/authorize", - TokenEndpoint: "https://idp/token", - } - require.NoError(t, b.Validate()) - }) - - t.Run("authorization_endpoint is not required for token_exchange", func(t *testing.T) { - b := &AuthBrokerConfig{ - Mode: AuthBrokerModeTokenExchange, - TokenEndpoint: "https://idp/token", - } - require.NoError(t, b.Validate()) + require.NoError(t, connectBroker().Validate()) }) } @@ -78,32 +53,38 @@ func TestAuthBroker_ValidHTTPBroker(t *testing.T) { Protocol: "http", URL: "https://api.github.com/mcp", AuthBroker: &AuthBrokerConfig{ - Mode: AuthBrokerModeTokenExchange, - TokenEndpoint: "https://idp.example.com/token", - Resource: "https://api.github.com", - Scopes: []string{"repo"}, - ClientID: "client-123", - ClientSecret: "secret-xyz", + Mode: AuthBrokerModeOAuthConnect, + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + Resource: "https://api.github.com", + Scopes: []string{"repo"}, + ClientID: "client-123", + ClientSecret: "secret-xyz", }, } cfg := baseValidConfig(server) require.NoError(t, cfg.Validate()) +} - // Defaults applied to the in-place broker after Validate(). - assert.Equal(t, "Authorization", server.AuthBroker.Header) - assert.Equal(t, "Bearer {token}", server.AuthBroker.HeaderFormat) +// Spec 107 FR-039: Validate never mutates the block. There is no default to +// apply any more (the header/header_format leaves are gone), so the block +// must come out of Validate exactly as it went in. +func TestAuthBroker_ValidateIsNonMutating(t *testing.T) { + broker := connectBroker() + broker.Scopes = []string{"repo"} + before := *broker.Clone() + server := &ServerConfig{Name: "github", Protocol: "http", URL: "https://api.github.com/mcp", AuthBroker: broker} + require.NoError(t, baseValidConfig(server).Validate()) + assert.Equal(t, before, *server.AuthBroker) } func TestAuthBroker_RejectedOnStdio(t *testing.T) { server := &ServerConfig{ - Name: "local", - Protocol: "stdio", - Command: "npx", - Args: []string{"some-mcp"}, - AuthBroker: &AuthBrokerConfig{ - Mode: AuthBrokerModeTokenExchange, - TokenEndpoint: "https://idp.example.com/token", - }, + Name: "local", + Protocol: "stdio", + Command: "npx", + Args: []string{"some-mcp"}, + AuthBroker: connectBroker(), } cfg := baseValidConfig(server) err := cfg.Validate() @@ -114,12 +95,9 @@ func TestAuthBroker_RejectedOnStdio(t *testing.T) { func TestAuthBroker_RejectedOnImpliedStdio(t *testing.T) { // No protocol + Command set => stdio by inference; broker must be rejected. server := &ServerConfig{ - Name: "local-implied", - Command: "npx", - AuthBroker: &AuthBrokerConfig{ - Mode: AuthBrokerModeTokenExchange, - TokenEndpoint: "https://idp.example.com/token", - }, + Name: "local-implied", + Command: "npx", + AuthBroker: connectBroker(), } cfg := baseValidConfig(server) err := cfg.Validate() @@ -147,34 +125,26 @@ func TestAuthBroker_MissingRequiredFields(t *testing.T) { t.Run("missing mode", func(t *testing.T) { cfg := baseValidConfig(&ServerConfig{ Name: "github", Protocol: "http", URL: "https://api.github.com/mcp", - AuthBroker: &AuthBrokerConfig{TokenEndpoint: "https://idp/token"}, + AuthBroker: &AuthBrokerConfig{TokenEndpoint: "https://idp/token", AuthorizationEndpoint: "https://idp/authorize"}, }) require.Error(t, cfg.Validate()) }) t.Run("missing token_endpoint", func(t *testing.T) { cfg := baseValidConfig(&ServerConfig{ Name: "github", Protocol: "http", URL: "https://api.github.com/mcp", - AuthBroker: &AuthBrokerConfig{Mode: AuthBrokerModeEntraOBO}, + AuthBroker: &AuthBrokerConfig{Mode: AuthBrokerModeOAuthConnect, AuthorizationEndpoint: "https://idp/authorize"}, }) require.Error(t, cfg.Validate()) }) } -func TestAuthBroker_AllValidModes(t *testing.T) { - for _, mode := range []string{AuthBrokerModeTokenExchange, AuthBrokerModeEntraOBO, AuthBrokerModeOAuthConnect} { - t.Run(mode, func(t *testing.T) { - broker := &AuthBrokerConfig{Mode: mode, TokenEndpoint: "https://idp/token"} - // The connect flow additionally requires the authorize endpoint. - if mode == AuthBrokerModeOAuthConnect { - broker.AuthorizationEndpoint = "https://idp/authorize" - } - cfg := baseValidConfig(&ServerConfig{ - Name: "s", Protocol: "streamable-http", URL: "https://x/mcp", - AuthBroker: broker, - }) - require.NoError(t, cfg.Validate()) - }) - } +// The accepted mode set is exactly {oauth_connect} (Spec 107 FR-032). +func TestAuthBroker_AcceptedModeSet(t *testing.T) { + cfg := baseValidConfig(&ServerConfig{ + Name: "s", Protocol: "streamable-http", URL: "https://x/mcp", + AuthBroker: connectBroker(), + }) + require.NoError(t, cfg.Validate()) } func TestAuthBroker_NoBrokerUnaffected(t *testing.T) { @@ -189,21 +159,29 @@ func TestAuthBroker_JSONRoundTrip(t *testing.T) { "protocol": "http", "url": "https://api.github.com/mcp", "auth_broker": { - "mode": "entra_obo", + "mode": "oauth_connect", + "authorization_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", "token_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", "resource": "api://upstream", "scopes": ["user.read"], "client_id": "abc", - "client_secret": "def", - "header": "X-Auth", - "header_format": "Bearer {token}" + "client_secret": "def" } }` var sc ServerConfig require.NoError(t, json.Unmarshal([]byte(raw), &sc)) require.NotNil(t, sc.AuthBroker) - assert.Equal(t, AuthBrokerModeEntraOBO, sc.AuthBroker.Mode) + assert.Equal(t, AuthBrokerModeOAuthConnect, sc.AuthBroker.Mode) assert.Equal(t, "api://upstream", sc.AuthBroker.Resource) assert.Equal(t, []string{"user.read"}, sc.AuthBroker.Scopes) - assert.Equal(t, "X-Auth", sc.AuthBroker.Header) +} + +// Clone must not alias the Scopes backing array (CopyServerConfig relies on it). +func TestAuthBroker_CloneDoesNotAlias(t *testing.T) { + src := connectBroker() + src.Scopes = []string{"a", "b"} + dst := src.Clone() + dst.Scopes[0] = "changed" + assert.Equal(t, "a", src.Scopes[0]) + assert.Nil(t, (*AuthBrokerConfig)(nil).Clone()) } diff --git a/internal/config/config.go b/internal/config/config.go index 065564a22..a61785bc7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -390,6 +390,12 @@ type Config struct { // empty servers) captured during Validate(), for the boot path to log. profileWarnings []string `json:"-"` + // loadDiagnostics holds the non-fatal Spec 107 findings the server-build + // loader recorded while normalising the raw document (removed keys / + // modes dropped, deprecated keys retained), for LogLoadDiagnostics to + // emit once a logger exists. See load_diagnostics.go. + loadDiagnostics []LoadDiagnostic `json:"-"` + // Prompts settings EnablePrompts bool `json:"enable_prompts" mapstructure:"enable-prompts"` @@ -743,12 +749,15 @@ type ServerConfig struct { // β€” and no longer gates quarantine or skip_quarantine. SourceRegistryProvenance string `json:"source_registry_provenance,omitempty" mapstructure:"source_registry_provenance"` - // AuthBroker holds per-upstream token-brokering configuration (spec 074, - // server edition only). When set, the gateway exchanges the caller's IdP - // subject token for an upstream-scoped credential and injects it into the - // outbound request. The concrete type is build-tagged: a full struct in the - // server edition, an empty stub in the personal edition (which ignores it), - // so personal-edition behavior is unaffected. swaggerignore mirrors ServerEdition. + // AuthBroker holds the per-upstream `oauth_connect` credential-connect + // block (spec 074, server edition only). When set, a user can complete a + // per-user consent flow and have their credential STORED encrypted for this + // upstream β€” nothing injects it into the outbound request (Spec 107 + // FR-034); the call path keeps using the server's own headers/oauth + // settings. The concrete type is build-tagged: a validated struct in the + // server edition, an opaque json.RawMessage carrier in the personal + // edition (preserved verbatim through load β†’ save β†’ PATCH, FR-040), so + // personal-edition behavior is unaffected. swaggerignore mirrors ServerEdition. AuthBroker *AuthBrokerConfig `json:"auth_broker,omitempty" mapstructure:"auth_broker" swaggerignore:"true"` } @@ -2605,6 +2614,11 @@ func (c *Config) validateDetailedCore() []ValidationError { } } + // Spec 107 FR-039: the server_edition block is validated (never mutated) + // on every door β€” boot, PATCH and /config/apply. No-op in the personal + // edition (stub); enforced in the server edition. + errors = append(errors, validateServerEditionConfig(c)...) + return errors } diff --git a/internal/config/config_validate_serveredition.go b/internal/config/config_validate_serveredition.go new file mode 100644 index 000000000..fe37ed24c --- /dev/null +++ b/internal/config/config_validate_serveredition.go @@ -0,0 +1,18 @@ +//go:build server + +package config + +// validateServerEditionConfig is the *Config-level bridge that makes +// Config.Validate() and ValidateDetailed() reach the non-mutating +// ServerEditionConfig.Validate (Spec 107 FR-039): boot, PATCH /api/v1/config +// and /config/apply refuse a broken block identically instead of persisting it +// for the next restart. A nil or disabled block has no rule. +func validateServerEditionConfig(cfg *Config) []ValidationError { + if cfg == nil || cfg.ServerEdition == nil { + return nil + } + if err := cfg.ServerEdition.Validate(); err != nil { + return []ValidationError{{Field: "server_edition", Message: err.Error()}} + } + return nil +} diff --git a/internal/config/config_validate_serveredition_stub.go b/internal/config/config_validate_serveredition_stub.go new file mode 100644 index 000000000..efef087ec --- /dev/null +++ b/internal/config/config_validate_serveredition_stub.go @@ -0,0 +1,9 @@ +//go:build !server + +package config + +// validateServerEditionConfig is a no-op in the personal edition: the +// server_edition block is an opaque JSON carrier there (Spec 107 FR-040). +func validateServerEditionConfig(_ *Config) []ValidationError { + return nil +} diff --git a/internal/config/load_diagnostics.go b/internal/config/load_diagnostics.go new file mode 100644 index 000000000..809825a54 --- /dev/null +++ b/internal/config/load_diagnostics.go @@ -0,0 +1,44 @@ +package config + +import "go.uber.org/zap" + +// LoadDiagnostic is one non-fatal finding recorded while a configuration file +// was loaded: a key the server edition no longer supports and dropped, or a +// deprecated key it still decodes (Spec 107 FR-032/FR-033/FR-035). +// +// The loader has no logger (loadConfigFile returns only an error and main.go +// builds the logger after the load), and once the typed decode has dropped the +// keys they cannot be reconstructed later, so the loader records them on the +// Config and LogLoadDiagnostics emits them once a logger exists. The personal +// build carries the server-edition blocks as opaque JSON and records nothing. +type LoadDiagnostic struct { + // Key is the JSON path of the offending key (e.g. + // "server_edition.max_user_servers", "mcpServers[0].auth_broker.mode"). + Key string + // Message is the exact operator-facing text from contracts/config-keys.md. + Message string +} + +// LoadDiagnostics returns the diagnostics recorded by the load that produced +// this Config, in file order. A Config built in code, or one decoded with a +// bare json.Unmarshal, has none. +func (c *Config) LoadDiagnostics() []LoadDiagnostic { + if c == nil { + return nil + } + return c.loadDiagnostics +} + +// LogLoadDiagnostics emits one WARN line per recorded diagnostic. It is called +// once after the logger exists (cmd/mcpproxy/main.go right after +// logs.SetupLogger) and after each successful hot reload β€” never by the +// loader, which has no logger. A nil config or logger, or no diagnostics, is a +// silent no-op. +func LogLoadDiagnostics(cfg *Config, logger *zap.Logger) { + if cfg == nil || logger == nil { + return + } + for _, d := range cfg.loadDiagnostics { + logger.Warn(d.Message, zap.String("key", d.Key)) + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 8e8e62a28..a6a0d322f 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -252,6 +252,17 @@ func loadConfigFile(path string, cfg *Config) error { return nil } + // Spec 107 FR-032/FR-035: the server build drops the removed + // server-edition keys / auth_broker modes from the RAW document before the + // typed decode and records one LoadDiagnostic each (the loader has no + // logger; LogLoadDiagnostics emits them once one exists). The personal + // build returns the bytes untouched and records nothing (opaque carriers). + data, diagnostics, err := normalizeLoadedDocument(data) + if err != nil { + return err + } + cfg.loadDiagnostics = diagnostics + // First check if api_key is present in the JSON to distinguish between // "not set" vs "explicitly set to empty" var rawConfig map[string]interface{} @@ -272,8 +283,8 @@ func loadConfigFile(path string, cfg *Config) error { // legacy "teams" key to "server_edition". An existing config that still uses // "teams" is normalized onto ServerEdition on read. The new key always wins; // only fall back to the legacy key when "server_edition" is absent. This - // compiles in both editions because ServerEditionConfig is a struct{} stub - // in the personal build (it simply unmarshals to an empty value there). + // compiles in both editions because ServerEditionConfig is a raw-JSON + // carrier in the personal build (it stores the block verbatim there). if _, hasNew := rawConfig["server_edition"]; !hasNew { if legacy, hasLegacy := rawConfig["teams"]; hasLegacy { if raw, err := json.Marshal(legacy); err == nil { diff --git a/internal/config/loader_serveredition.go b/internal/config/loader_serveredition.go new file mode 100644 index 000000000..9a8602255 --- /dev/null +++ b/internal/config/loader_serveredition.go @@ -0,0 +1,50 @@ +//go:build server + +package config + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// normalizeLoadedDocument is the server-build boot normaliser (Spec 107 +// FR-032/FR-033/FR-035). It runs on the RAW document before the typed decode: +// the removed server_edition keys and auth_broker leaves are dropped, the +// whole auth_broker block of a server using a never-implemented mode is +// dropped, and one LoadDiagnostic is recorded per drop (plus one for a +// `store_idp_tokens: true`, which is retained). A document with nothing to +// drop is returned untouched, so the common case never re-encodes the file. +// +// Numbers ride through the re-encode as json.Number, so their decimal text is +// preserved exactly; a boot never rewrites a value it did not drop. +func normalizeLoadedDocument(data []byte) ([]byte, []LoadDiagnostic, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var raw map[string]any + if err := dec.Decode(&raw); err != nil { + return nil, nil, fmt.Errorf("failed to parse config file: %w", err) + } + + diags := dropRemovedKeys(raw) + if len(diags) == 0 { + return data, nil, nil + } + // The deprecation-only case drops nothing; keep the original bytes. + dropped := false + for _, d := range diags { + if d.Message != deprecatedStoreIDPTokensMessage { + dropped = true + break + } + } + if !dropped { + return data, diags, nil + } + + normalized, err := json.Marshal(raw) + if err != nil { + return nil, nil, fmt.Errorf("failed to re-encode normalised config: %w", err) + } + return normalized, diags, nil +} diff --git a/internal/config/merge.go b/internal/config/merge.go index d155f215d..787c53ba6 100644 --- a/internal/config/merge.go +++ b/internal/config/merge.go @@ -675,13 +675,11 @@ func CopyServerConfig(src *ServerConfig) *ServerConfig { dst.QueueTimeout = &v } - // Copy the per-upstream auth-broker block by value (spec 074, server edition). - // In the personal edition AuthBrokerConfig is an empty stub struct, so this is - // a no-op there; copying by value keeps the pointer from being shared. - if src.AuthBroker != nil { - broker := *src.AuthBroker - dst.AuthBroker = &broker - } + // Deep-copy the per-upstream auth-broker block (spec 074, server edition). + // In the personal edition AuthBrokerConfig is a raw-JSON carrier (Spec 107 + // FR-040); a value copy would alias its backing array, so both editions + // clone through the build-tagged Clone(). + dst.AuthBroker = src.AuthBroker.Clone() // Copy *bool by value (not pointer) to avoid shared state if src.ExposePrompts != nil { diff --git a/internal/config/personal_carriers_test.go b/internal/config/personal_carriers_test.go new file mode 100644 index 000000000..5cda7d46c --- /dev/null +++ b/internal/config/personal_carriers_test.go @@ -0,0 +1,86 @@ +//go:build !server + +package config + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Spec 107 FR-040 (T020, personal build): the opaque `server_edition` and +// `auth_broker` carriers are json.RawMessage holders. These tests pin the +// carrier contract itself β€” omission belongs to the parent pointer, an empty +// carrier is `{}`, and Clone() never aliases the source's backing array (the +// value copy CopyServerConfig used to make would have). + +const carrierProbe = `{"mode":"oauth_connect","n":9007199254740993,"d":0.1000000000000000055511151231257827}` + +func TestPersonalCarriers_CopyServerConfigDoesNotAliasAuthBroker(t *testing.T) { + var src ServerConfig + require.NoError(t, json.Unmarshal([]byte(`{"name":"s","url":"https://x/mcp","protocol":"http","auth_broker":`+carrierProbe+`}`), &src)) + require.NotNil(t, src.AuthBroker) + + dst := CopyServerConfig(&src) + require.NotNil(t, dst.AuthBroker) + require.NotSame(t, src.AuthBroker, dst.AuthBroker, "the copy must own its carrier") + + before, err := json.Marshal(dst.AuthBroker) + require.NoError(t, err) + assert.Equal(t, carrierProbe, string(before)) + + // Mutate the SOURCE carrier's bytes in place; an aliased backing array + // would leak the change into the copy. + src.AuthBroker.raw[2] = 'X' + after, err := json.Marshal(dst.AuthBroker) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "mutating the source carrier must leave the copy unchanged") +} + +func TestPersonalCarriers_ServerEditionCloneDoesNotAlias(t *testing.T) { + var src Config + require.NoError(t, json.Unmarshal([]byte(`{"server_edition":`+carrierProbe+`}`), &src)) + require.NotNil(t, src.ServerEdition) + + dst := src.ServerEdition.Clone() + require.NotNil(t, dst) + require.NotSame(t, src.ServerEdition, dst) + + before, err := json.Marshal(dst) + require.NoError(t, err) + assert.Equal(t, carrierProbe, string(before)) + + src.ServerEdition.raw[2] = 'X' + after, err := json.Marshal(dst) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) + + assert.Nil(t, (*ServerEditionConfig)(nil).Clone()) + assert.Nil(t, (*AuthBrokerConfig)(nil).Clone()) +} + +// A carrier only Go code can construct (non-nil pointer, no raw bytes) must +// still emit valid JSON; a JSON null must leave the parent pointer nil so the +// key is omitted on write. +func TestPersonalCarriers_EmptyAndNull(t *testing.T) { + out, err := json.Marshal(&Config{ServerEdition: &ServerEditionConfig{}}) + require.NoError(t, err) + assert.Contains(t, string(out), `"server_edition":{}`) + + out, err = json.Marshal(&ServerConfig{Name: "s", AuthBroker: &AuthBrokerConfig{}}) + require.NoError(t, err) + assert.Contains(t, string(out), `"auth_broker":{}`) + + var cfg Config + require.NoError(t, json.Unmarshal([]byte(`{"server_edition":null,"mcpServers":[{"name":"s","auth_broker":null}]}`), &cfg)) + assert.Nil(t, cfg.ServerEdition) + require.Len(t, cfg.Servers, 1) + assert.Nil(t, cfg.Servers[0].AuthBroker) + + out, err = json.Marshal(&cfg) + require.NoError(t, err) + assert.NotContains(t, string(out), "server_edition") + assert.NotContains(t, string(out), "auth_broker") +} diff --git a/internal/config/removed_keys.go b/internal/config/removed_keys.go new file mode 100644 index 000000000..f8916ec1b --- /dev/null +++ b/internal/config/removed_keys.go @@ -0,0 +1,156 @@ +//go:build server + +package config + +import "fmt" + +// Spec 107 FR-032/FR-035: keys and auth_broker modes the server edition no +// longer supports. Both the boot normaliser (loader_serveredition.go) and the +// write-time refusal (ValidateRemovedKeys) walk the GENERIC document with the +// same visitor, because json.Unmarshal into the typed Config silently drops an +// unknown key β€” by the time Config.Validate runs, nothing is left to refuse. +// +// The strings below are the contract (contracts/config-keys.md) and are the +// one place they may legitimately appear in this package: the FR-035 guard +// test forbids them inside the auth_broker validator, not here. + +const ( + removedKeyMaxUserServers = "max_user_servers" + removedKeyWorkspaceIdleTimeout = "workspace_idle_timeout" + removedKeyAuthBrokerHeader = "header" + removedKeyAuthBrokerHeaderFmt = "header_format" + deprecatedKeyStoreIDPTokens = "store_idp_tokens" +) + +// retiredAuthBrokerModes are the never-implemented modes whose whole +// auth_broker block is dropped (FR-032). +var retiredAuthBrokerModes = map[string]struct{}{ + "token_exchange": {}, + "entra_obo": {}, +} + +// serverEditionBlockKeys are the top-level keys that carry the server-edition +// block: the canonical one and the legacy alias the loader normalises. +var serverEditionBlockKeys = []string{"server_edition", "teams"} + +func removedServerEditionKeyMessage(key string) string { + return fmt.Sprintf("server_edition.%s is no longer supported and was ignored", key) +} + +func removedAuthBrokerModeMessage(mode, serverName string) string { + return fmt.Sprintf("auth_broker.mode %q was never implemented; the auth_broker block for server %q was ignored", mode, serverName) +} + +func removedAuthBrokerLeafMessage(key string) string { + return fmt.Sprintf("auth_broker.%s is no longer supported and was ignored", key) +} + +const deprecatedStoreIDPTokensMessage = "server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it" + +// removedKeyVisitor receives one call per removed key/mode found in a generic +// document. path is the JSON path of the key; message is the contract text. +type removedKeyVisitor func(path, message string) + +// walkRemovedKeys visits every removed key/mode in raw, in document order: +// server_edition.{max_user_servers,workspace_idle_timeout}, then for each +// mcpServers[i].auth_broker the mode (when token_exchange/entra_obo) and the +// header/header_format leaves. It never mutates raw. +func walkRemovedKeys(raw map[string]any, visit removedKeyVisitor) { + if raw == nil { + return + } + for _, blockKey := range serverEditionBlockKeys { + block, ok := raw[blockKey].(map[string]any) + if !ok { + continue + } + for _, key := range []string{removedKeyMaxUserServers, removedKeyWorkspaceIdleTimeout} { + if _, present := block[key]; present { + visit(blockKey+"."+key, removedServerEditionKeyMessage(key)) + } + } + } + + servers, _ := raw["mcpServers"].([]any) + for i, entry := range servers { + server, ok := entry.(map[string]any) + if !ok { + continue + } + broker, ok := server["auth_broker"].(map[string]any) + if !ok { + continue + } + prefix := fmt.Sprintf("mcpServers[%d].auth_broker", i) + if mode, _ := broker["mode"].(string); mode != "" { + if _, removed := retiredAuthBrokerModes[mode]; removed { + name, _ := server["name"].(string) + visit(prefix+".mode", removedAuthBrokerModeMessage(mode, name)) + } + } + for _, key := range []string{removedKeyAuthBrokerHeader, removedKeyAuthBrokerHeaderFmt} { + if _, present := broker[key]; present { + visit(prefix+"."+key, removedAuthBrokerLeafMessage(key)) + } + } + } +} + +// ValidateRemovedKeys is the write-time refusal for the removed keys/modes +// (FR-039): it walks a generic configuration document β€” the merged map in +// PATCH /api/v1/config, the decoded body of /config/apply β€” and returns one +// ValidationError per removed key, with the same message the boot path +// records as a LoadDiagnostic. It must run BEFORE the document is decoded +// into Config, and it never runs on the boot path (boot normalises + records +// instead of refusing). The deprecated store_idp_tokens is not a removed key +// and is never refused. A nil or empty document passes. +func ValidateRemovedKeys(raw map[string]any) []ValidationError { + var errs []ValidationError + walkRemovedKeys(raw, func(path, message string) { + errs = append(errs, ValidationError{Field: path, Message: message}) + }) + return errs +} + +// dropRemovedKeys removes every removed key/mode from raw IN PLACE and returns +// one LoadDiagnostic per removal plus the store_idp_tokens deprecation when +// that key is `true`. It is the boot normaliser's core. +func dropRemovedKeys(raw map[string]any) []LoadDiagnostic { + var diags []LoadDiagnostic + walkRemovedKeys(raw, func(path, message string) { + diags = append(diags, LoadDiagnostic{Key: path, Message: message}) + }) + + for _, blockKey := range serverEditionBlockKeys { + block, ok := raw[blockKey].(map[string]any) + if !ok { + continue + } + delete(block, removedKeyMaxUserServers) + delete(block, removedKeyWorkspaceIdleTimeout) + if v, ok := block[deprecatedKeyStoreIDPTokens].(bool); ok && v { + diags = append(diags, LoadDiagnostic{Key: blockKey + "." + deprecatedKeyStoreIDPTokens, Message: deprecatedStoreIDPTokensMessage}) + } + } + + servers, _ := raw["mcpServers"].([]any) + for _, entry := range servers { + server, ok := entry.(map[string]any) + if !ok { + continue + } + broker, ok := server["auth_broker"].(map[string]any) + if !ok { + continue + } + if mode, _ := broker["mode"].(string); mode != "" { + if _, removed := retiredAuthBrokerModes[mode]; removed { + delete(server, "auth_broker") + continue + } + } + delete(broker, removedKeyAuthBrokerHeader) + delete(broker, removedKeyAuthBrokerHeaderFmt) + } + return diags +} diff --git a/internal/config/removed_keys_stub.go b/internal/config/removed_keys_stub.go new file mode 100644 index 000000000..38dfdf438 --- /dev/null +++ b/internal/config/removed_keys_stub.go @@ -0,0 +1,16 @@ +//go:build !server + +package config + +// ValidateRemovedKeys is a no-op in the personal edition: the server-edition +// blocks are opaque JSON carriers there (Spec 107 FR-040) and nothing is +// normalised, validated or refused. +func ValidateRemovedKeys(_ map[string]any) []ValidationError { + return nil +} + +// normalizeLoadedDocument is a no-op in the personal edition: the file bytes +// are decoded exactly as read and no LoadDiagnostic is ever recorded. +func normalizeLoadedDocument(data []byte) ([]byte, []LoadDiagnostic, error) { + return data, nil, nil +} diff --git a/internal/config/server_edition_config.go b/internal/config/server_edition_config.go index 168b5fb63..4223a706e 100644 --- a/internal/config/server_edition_config.go +++ b/internal/config/server_edition_config.go @@ -10,20 +10,25 @@ import ( ) // ServerEditionConfig holds configuration for the server edition multi-user features. +// +// Spec 107 FR-032 removed the never-enforced `max_user_servers` and +// `workspace_idle_timeout` knobs. A config file that still carries them loads +// (the server-build normaliser drops them from the raw document and records a +// LoadDiagnostic per key); the write doors refuse them (ValidateRemovedKeys). type ServerEditionConfig struct { - Enabled bool `json:"enabled" mapstructure:"enabled"` - AdminEmails []string `json:"admin_emails" mapstructure:"admin-emails"` - OAuth *ServerEditionOAuthConfig `json:"oauth,omitempty" mapstructure:"oauth"` - SessionTTL Duration `json:"session_ttl,omitempty" mapstructure:"session-ttl"` - BearerTokenTTL Duration `json:"bearer_token_ttl,omitempty" mapstructure:"bearer-token-ttl"` - WorkspaceIdleTimeout Duration `json:"workspace_idle_timeout,omitempty" mapstructure:"workspace-idle-timeout"` - MaxUserServers int `json:"max_user_servers,omitempty" mapstructure:"max-user-servers"` + Enabled bool `json:"enabled" mapstructure:"enabled"` + AdminEmails []string `json:"admin_emails" mapstructure:"admin-emails"` + OAuth *ServerEditionOAuthConfig `json:"oauth,omitempty" mapstructure:"oauth"` + SessionTTL Duration `json:"session_ttl,omitempty" mapstructure:"session-ttl"` + BearerTokenTTL Duration `json:"bearer_token_ttl,omitempty" mapstructure:"bearer-token-ttl"` // CredentialEncryptionKey encrypts per-user upstream credentials at rest - // (spec 074). When empty, it falls back to the MCPPROXY_CRED_KEY env var. + // (spec 074). When empty, ApplyDefaults falls back to the MCPPROXY_CRED_KEY + // env var. CredentialEncryptionKey string `json:"credential_encryption_key,omitempty" mapstructure:"credential-encryption-key"` - // StoreIDPTokens controls whether caller IdP subject tokens are persisted. - // Privacy-preserving default: false (FR-006). + // StoreIDPTokens is a deprecated no-op retained so pre-107 configs keep + // loading (Spec 107 FR-033). IdP tokens are no longer persisted at login; + // `true` records one deprecation LoadDiagnostic at load time. StoreIDPTokens bool `json:"store_idp_tokens" mapstructure:"store-idp-tokens"` } @@ -36,14 +41,15 @@ type ServerEditionOAuthConfig struct { AllowedDomains []string `json:"allowed_domains,omitempty" mapstructure:"allowed-domains"` } +// defaultServerEditionTTL is the default for session_ttl and bearer_token_ttl. +const defaultServerEditionTTL = Duration(24 * time.Hour) + // DefaultServerEditionConfig returns a ServerEditionConfig with sensible defaults. func DefaultServerEditionConfig() *ServerEditionConfig { return &ServerEditionConfig{ - Enabled: false, - SessionTTL: Duration(24 * time.Hour), - BearerTokenTTL: Duration(24 * time.Hour), - WorkspaceIdleTimeout: Duration(30 * time.Minute), - MaxUserServers: 20, + Enabled: false, + SessionTTL: defaultServerEditionTTL, + BearerTokenTTL: defaultServerEditionTTL, } } @@ -57,16 +63,38 @@ func (c *ServerEditionConfig) IsAdminEmail(email string) bool { return false } -// Validate checks that the ServerEditionConfig is valid for operation. -func (c *ServerEditionConfig) Validate() error { - if !c.Enabled { - return nil // disabled, no validation needed +// ApplyDefaults fills the derived values a running server edition needs: the +// TTLs, the Microsoft multi-tenant "common" tenant, and the MCPPROXY_CRED_KEY +// fallback for credential_encryption_key (an explicit config value always wins +// over the environment). It is the boot-time companion of Validate (Spec 107 +// FR-039): setup calls ApplyDefaults then Validate, while the write doors call +// only Validate so nothing derived is ever persisted into the config file. +func (c *ServerEditionConfig) ApplyDefaults() { + if c == nil { + return } - // Spec 074: fall back to MCPPROXY_CRED_KEY when no explicit key is set. - // An explicit config value always wins over the environment. if c.CredentialEncryptionKey == "" { c.CredentialEncryptionKey = os.Getenv("MCPPROXY_CRED_KEY") } + if c.OAuth != nil && c.OAuth.Provider == "microsoft" && c.OAuth.TenantID == "" { + c.OAuth.TenantID = "common" + } + if c.SessionTTL.Duration() <= 0 { + c.SessionTTL = defaultServerEditionTTL + } + if c.BearerTokenTTL.Duration() <= 0 { + c.BearerTokenTTL = defaultServerEditionTTL + } +} + +// Validate checks that the ServerEditionConfig is valid for operation. It is +// non-mutating: unset TTLs, an unset Microsoft tenant and an unset encryption +// key are defaulted by ApplyDefaults, never refused here, so the same rules +// apply at boot, on PATCH /api/v1/config and on /config/apply (FR-039). +func (c *ServerEditionConfig) Validate() error { + if c == nil || !c.Enabled { + return nil // disabled, no validation needed + } if len(c.AdminEmails) == 0 { return fmt.Errorf("server_edition.admin_emails must contain at least one admin email") } @@ -83,21 +111,30 @@ func (c *ServerEditionConfig) Validate() error { if c.OAuth.ClientSecret == "" { return fmt.Errorf("server_edition.oauth.client_secret is required") } - if c.OAuth.Provider == "microsoft" && c.OAuth.TenantID == "" { - // Default to "common" for multi-tenant - c.OAuth.TenantID = "common" + if c.SessionTTL.Duration() < 0 { + return fmt.Errorf("server_edition.session_ttl must be positive") } - if c.SessionTTL.Duration() <= 0 { - c.SessionTTL = Duration(24 * time.Hour) + if c.BearerTokenTTL.Duration() < 0 { + return fmt.Errorf("server_edition.bearer_token_ttl must be positive") } - if c.BearerTokenTTL.Duration() <= 0 { - c.BearerTokenTTL = Duration(24 * time.Hour) + return nil +} + +// Clone returns a deep copy of the block (nil-safe). +func (c *ServerEditionConfig) Clone() *ServerEditionConfig { + if c == nil { + return nil } - if c.WorkspaceIdleTimeout.Duration() <= 0 { - c.WorkspaceIdleTimeout = Duration(30 * time.Minute) + out := *c + if c.AdminEmails != nil { + out.AdminEmails = append([]string(nil), c.AdminEmails...) } - if c.MaxUserServers <= 0 { - c.MaxUserServers = 20 + if c.OAuth != nil { + oauth := *c.OAuth + if c.OAuth.AllowedDomains != nil { + oauth.AllowedDomains = append([]string(nil), c.OAuth.AllowedDomains...) + } + out.OAuth = &oauth } - return nil + return &out } diff --git a/internal/config/server_edition_config_stub.go b/internal/config/server_edition_config_stub.go index 16565b2b6..afc8abf38 100644 --- a/internal/config/server_edition_config_stub.go +++ b/internal/config/server_edition_config_stub.go @@ -2,5 +2,43 @@ package config -// ServerEditionConfig is a stub for personal edition. Server edition features are not available. -type ServerEditionConfig struct{} +import "encoding/json" + +// ServerEditionConfig is the personal-edition carrier for the `server_edition` +// block. Server edition features are not available here, but the block must +// survive load β†’ save β†’ PATCH β†’ save byte-for-byte in meaning (Spec 107 +// FR-040): it is held as the raw JSON it was read from, never decoded, never +// validated, never normalised and never warned about. +// +// Omission is the parent pointer's job: Config.ServerEdition is `*T,omitempty`, +// so a document without the key leaves the pointer nil and the key stays +// absent on write, and a JSON `null` also decodes to a nil pointer. A non-nil +// carrier with no raw bytes (only constructible from Go code) marshals as `{}`. +type ServerEditionConfig struct { + raw json.RawMessage +} + +// UnmarshalJSON stores the document verbatim. +func (c *ServerEditionConfig) UnmarshalJSON(data []byte) error { + c.raw = append(json.RawMessage(nil), data...) + return nil +} + +// MarshalJSON emits the stored document verbatim; an empty carrier is `{}`. +func (c ServerEditionConfig) MarshalJSON() ([]byte, error) { + if len(c.raw) == 0 { + return []byte("{}"), nil + } + return append([]byte(nil), c.raw...), nil +} + +// Clone returns a deep copy of the carrier (nil-safe). It is provided for +// symmetry with the server build and pinned by a unit test; nothing copies the +// top-level block today (the config snapshot shares the pointer and nothing +// mutates it in the personal build). +func (c *ServerEditionConfig) Clone() *ServerEditionConfig { + if c == nil { + return nil + } + return &ServerEditionConfig{raw: append(json.RawMessage(nil), c.raw...)} +} diff --git a/internal/config/server_edition_config_test.go b/internal/config/server_edition_config_test.go index cc409154e..cef5e543e 100644 --- a/internal/config/server_edition_config_test.go +++ b/internal/config/server_edition_config_test.go @@ -21,8 +21,6 @@ func TestTeamsDefaultServerEditionConfig(t *testing.T) { assert.Nil(t, cfg.OAuth, "OAuth config should be nil by default") assert.Equal(t, Duration(24*time.Hour), cfg.SessionTTL, "session TTL should default to 24h") assert.Equal(t, Duration(24*time.Hour), cfg.BearerTokenTTL, "bearer token TTL should default to 24h") - assert.Equal(t, Duration(30*time.Minute), cfg.WorkspaceIdleTimeout, "workspace idle timeout should default to 30m") - assert.Equal(t, 20, cfg.MaxUserServers, "max user servers should default to 20") } func TestTeamsIsAdminEmail(t *testing.T) { @@ -143,10 +141,8 @@ func TestTeamsValidate_ValidGoogleConfig(t *testing.T) { ClientID: "my-client-id.apps.googleusercontent.com", ClientSecret: "GOCSPX-secret", }, - SessionTTL: Duration(8 * time.Hour), - BearerTokenTTL: Duration(1 * time.Hour), - WorkspaceIdleTimeout: Duration(15 * time.Minute), - MaxUserServers: 10, + SessionTTL: Duration(8 * time.Hour), + BearerTokenTTL: Duration(1 * time.Hour), } err := cfg.Validate() assert.NoError(t, err) @@ -166,7 +162,7 @@ func TestTeamsValidate_ValidGitHubConfig(t *testing.T) { assert.NoError(t, err) } -func TestTeamsValidate_MicrosoftDefaultsTenantID(t *testing.T) { +func TestTeamsApplyDefaults_MicrosoftDefaultsTenantID(t *testing.T) { cfg := &ServerEditionConfig{ Enabled: true, AdminEmails: []string{"admin@example.com"}, @@ -177,8 +173,10 @@ func TestTeamsValidate_MicrosoftDefaultsTenantID(t *testing.T) { TenantID: "", // empty should default to "common" }, } - err := cfg.Validate() - assert.NoError(t, err) + // Spec 107 FR-039: Validate is non-mutating; ApplyDefaults fills the tenant. + require.NoError(t, cfg.Validate()) + assert.Equal(t, "", cfg.OAuth.TenantID, "Validate must not fill the tenant") + cfg.ApplyDefaults() assert.Equal(t, "common", cfg.OAuth.TenantID, "Microsoft tenant ID should default to 'common'") } @@ -193,12 +191,12 @@ func TestTeamsValidate_MicrosoftExplicitTenantID(t *testing.T) { TenantID: "my-tenant-id", }, } - err := cfg.Validate() - assert.NoError(t, err) + require.NoError(t, cfg.Validate()) + cfg.ApplyDefaults() assert.Equal(t, "my-tenant-id", cfg.OAuth.TenantID, "explicit tenant ID should be preserved") } -func TestTeamsValidate_DefaultsAppliedForZeroValues(t *testing.T) { +func TestTeamsApplyDefaults_DefaultsAppliedForZeroValues(t *testing.T) { cfg := &ServerEditionConfig{ Enabled: true, AdminEmails: []string{"admin@example.com"}, @@ -207,14 +205,30 @@ func TestTeamsValidate_DefaultsAppliedForZeroValues(t *testing.T) { ClientID: "id", ClientSecret: "secret", }, - // All duration/limit fields left at zero + // All duration fields left at zero } - err := cfg.Validate() - assert.NoError(t, err) + // Spec 107 FR-039: zero TTLs are valid (unset) and Validate leaves them. + require.NoError(t, cfg.Validate()) + assert.Equal(t, Duration(0), cfg.SessionTTL, "Validate must not fill SessionTTL") + cfg.ApplyDefaults() assert.Equal(t, Duration(24*time.Hour), cfg.SessionTTL, "zero SessionTTL should default to 24h") assert.Equal(t, Duration(24*time.Hour), cfg.BearerTokenTTL, "zero BearerTokenTTL should default to 24h") - assert.Equal(t, Duration(30*time.Minute), cfg.WorkspaceIdleTimeout, "zero WorkspaceIdleTimeout should default to 30m") - assert.Equal(t, 20, cfg.MaxUserServers, "zero MaxUserServers should default to 20") +} + +func TestTeamsValidate_NegativeTTLRefused(t *testing.T) { + cfg := &ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &ServerEditionOAuthConfig{ + Provider: "google", + ClientID: "id", + ClientSecret: "secret", + }, + SessionTTL: Duration(-1), + } + err := cfg.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "server_edition.session_ttl must be positive") } func TestTeamsValidate_AllProviders(t *testing.T) { @@ -246,10 +260,8 @@ func TestServerEditionConfig_JSONRoundTrip(t *testing.T) { ClientSecret: "GOCSPX-secret", AllowedDomains: []string{"example.com", "corp.io"}, }, - SessionTTL: Duration(8 * time.Hour), - BearerTokenTTL: Duration(1 * time.Hour), - WorkspaceIdleTimeout: Duration(15 * time.Minute), - MaxUserServers: 50, + SessionTTL: Duration(8 * time.Hour), + BearerTokenTTL: Duration(1 * time.Hour), } data, err := json.Marshal(original) @@ -268,8 +280,6 @@ func TestServerEditionConfig_JSONRoundTrip(t *testing.T) { assert.Equal(t, original.OAuth.AllowedDomains, restored.OAuth.AllowedDomains) assert.Equal(t, original.SessionTTL, restored.SessionTTL) assert.Equal(t, original.BearerTokenTTL, restored.BearerTokenTTL) - assert.Equal(t, original.WorkspaceIdleTimeout, restored.WorkspaceIdleTimeout) - assert.Equal(t, original.MaxUserServers, restored.MaxUserServers) } func TestServerEditionConfig_JSONRoundTrip_MicrosoftWithTenant(t *testing.T) { @@ -322,8 +332,7 @@ func TestServerEditionConfig_EmbeddedInConfig(t *testing.T) { ClientID: "id", ClientSecret: "secret", }, - SessionTTL: Duration(12 * time.Hour), - MaxUserServers: 30, + SessionTTL: Duration(12 * time.Hour), }, } @@ -339,7 +348,6 @@ func TestServerEditionConfig_EmbeddedInConfig(t *testing.T) { assert.Equal(t, []string{"admin@example.com"}, restored.ServerEdition.AdminEmails) assert.Equal(t, "google", restored.ServerEdition.OAuth.Provider) assert.Equal(t, Duration(12*time.Hour), restored.ServerEdition.SessionTTL) - assert.Equal(t, 30, restored.ServerEdition.MaxUserServers) } func TestServerEditionConfig_OmittedFromConfig(t *testing.T) { @@ -371,9 +379,7 @@ func TestServerEditionConfig_UnmarshalFromJSON(t *testing.T) { "client_secret": "ghp_secret" }, "session_ttl": "4h", - "bearer_token_ttl": "30m", - "workspace_idle_timeout": "10m", - "max_user_servers": 5 + "bearer_token_ttl": "30m" } }` @@ -387,8 +393,6 @@ func TestServerEditionConfig_UnmarshalFromJSON(t *testing.T) { assert.Equal(t, "Iv1.abc", cfg.ServerEdition.OAuth.ClientID) assert.Equal(t, Duration(4*time.Hour), cfg.ServerEdition.SessionTTL) assert.Equal(t, Duration(30*time.Minute), cfg.ServerEdition.BearerTokenTTL) - assert.Equal(t, Duration(10*time.Minute), cfg.ServerEdition.WorkspaceIdleTimeout) - assert.Equal(t, 5, cfg.ServerEdition.MaxUserServers) } // writeServerEditionConfigFile writes a config JSON to a temp file and returns @@ -463,3 +467,31 @@ func TestLoadFromFile_BothKeysNewWins(t *testing.T) { "new server_edition key must win over legacy teams key") assert.Equal(t, "google", cfg.ServerEdition.OAuth.Provider) } + +// Clone must deep-copy the slices (AdminEmails, OAuth.AllowedDomains) and the +// nested OAuth block so a copy never shares a backing array with its source. +func TestServerEditionConfig_CloneDoesNotAlias(t *testing.T) { + src := &ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"a@example.com"}, + OAuth: &ServerEditionOAuthConfig{ + Provider: "google", + ClientID: "id", + ClientSecret: "secret", + AllowedDomains: []string{"example.com"}, + }, + } + dst := src.Clone() + require.NotNil(t, dst) + require.NotSame(t, src, dst) + require.NotSame(t, src.OAuth, dst.OAuth) + + dst.AdminEmails[0] = "changed" + dst.OAuth.AllowedDomains[0] = "changed" + dst.OAuth.ClientID = "changed" + assert.Equal(t, "a@example.com", src.AdminEmails[0]) + assert.Equal(t, "example.com", src.OAuth.AllowedDomains[0]) + assert.Equal(t, "id", src.OAuth.ClientID) + + assert.Nil(t, (*ServerEditionConfig)(nil).Clone()) +} diff --git a/internal/config/teams_credential_test.go b/internal/config/teams_credential_test.go index d513cbcac..e74a94648 100644 --- a/internal/config/teams_credential_test.go +++ b/internal/config/teams_credential_test.go @@ -44,7 +44,11 @@ func TestServerEditionConfig_CredentialEncryptionKeyEnvFallback(t *testing.T) { ClientSecret: "csec", }, } + // Spec 107 FR-039: the fallback moved from Validate to ApplyDefaults so a + // write door never persists the environment's key into the config file. require.NoError(t, cfg.Validate()) + assert.Equal(t, "", cfg.CredentialEncryptionKey, "Validate must not apply the MCPPROXY_CRED_KEY fallback") + cfg.ApplyDefaults() assert.Equal(t, "from-env-key", cfg.CredentialEncryptionKey, "env MCPPROXY_CRED_KEY should fill an empty key") } @@ -61,5 +65,6 @@ func TestServerEditionConfig_CredentialEncryptionKeyConfigWins(t *testing.T) { }, } require.NoError(t, cfg.Validate()) + cfg.ApplyDefaults() assert.Equal(t, "from-config", cfg.CredentialEncryptionKey, "explicit config key should win over env") } diff --git a/internal/config/zero_value_preservation_test.go b/internal/config/zero_value_preservation_test.go index 48864b821..2bb8bffb8 100644 --- a/internal/config/zero_value_preservation_test.go +++ b/internal/config/zero_value_preservation_test.go @@ -173,13 +173,13 @@ var numericOmitemptyAllowlist = map[string]string{ // Server edition (//go:build server). These are invisible to an untagged // run of this test, which is why it must also be run as - // `go test -tags server ./internal/config`. All four are normalised by - // ServerEditionConfig.Validate, which treats <= 0 as "use the default" - // (internal/config/server_edition_config.go:90-101). - "Config.ServerEdition.SessionTTL": "Validate: <= 0 becomes the 24h default", - "Config.ServerEdition.BearerTokenTTL": "Validate: <= 0 becomes the 24h default", - "Config.ServerEdition.WorkspaceIdleTimeout": "Validate: <= 0 becomes the 30m default", - "Config.ServerEdition.MaxUserServers": "Validate: <= 0 becomes the 20 default", + // `go test -tags server ./internal/config`. Both are normalised by + // ServerEditionConfig.ApplyDefaults at boot, which treats <= 0 as "use the + // default" (Spec 107 FR-039 moved that out of the non-mutating Validate); + // the removed max_user_servers / workspace_idle_timeout knobs (FR-032) no + // longer exist on the struct. + "Config.ServerEdition.SessionTTL": "ApplyDefaults: <= 0 becomes the 24h default", + "Config.ServerEdition.BearerTokenTTL": "ApplyDefaults: <= 0 becomes the 24h default", // 0 and absent both fall back to the DefaultConfig value; there is no // documented meaning for an explicit 0. diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 749651e0d..0a19ecce7 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -1,6 +1,7 @@ package httpapi import ( + "bytes" "context" "encoding/json" "errors" @@ -5203,6 +5204,13 @@ func (s *Server) handleApplyConfig(w http.ResponseWriter, r *http.Request) { return } + // Spec 107 FR-039: the removed server-edition keys / auth_broker modes + // are refused on the raw document, before it is typed (see + // handlePatchConfig). No-op in the personal build. + if s.refuseRemovedConfigKeys(w, r, document, "Invalid configuration") { + return + } + stored, err := s.desiredConfigForPatch() if err != nil { s.logger.Errorw("Failed to read configuration for apply", "error", err) @@ -5328,8 +5336,13 @@ func (s *Server) handlePatchDockerIsolation(w http.ResponseWriter, r *http.Reque // @Failure 403 {object} contracts.ErrorResponse "Forbidden (agent tokens cannot mutate configuration)" // @Router /api/v1/config [patch] func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) { + // UseNumber: every number rides through the merge as its decimal text, so + // the personal build's opaque server_edition / auth_broker carriers (Spec + // 107 FR-040) and any large integer survive the round trip exactly. + patchDecoder := json.NewDecoder(r.Body) + patchDecoder.UseNumber() var patchMap map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&patchMap); err != nil { + if err := patchDecoder.Decode(&patchMap); err != nil { s.writeError(w, r, http.StatusBadRequest, "Invalid JSON payload") return } @@ -5372,8 +5385,10 @@ func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) { s.writeError(w, r, http.StatusInternalServerError, "Failed to read configuration") return } + baseDecoder := json.NewDecoder(bytes.NewReader(baseBytes)) + baseDecoder.UseNumber() var baseMap map[string]interface{} - if err := json.Unmarshal(baseBytes, &baseMap); err != nil { + if err := baseDecoder.Decode(&baseMap); err != nil { s.logger.Errorw("Failed to unmarshal live configuration", "error", err) s.writeError(w, r, http.StatusInternalServerError, "Failed to read configuration") return @@ -5397,6 +5412,15 @@ func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) { deepMergeJSON(baseMap, patchMap) + // Spec 107 FR-039: refuse the removed server-edition keys / auth_broker + // modes on the MERGED generic map, before the typed decode drops them + // without a trace (json.Unmarshal into config.Config ignores unknown + // keys, so Config.Validate can never see them). No-op in the personal + // build. + if s.refuseRemovedConfigKeys(w, r, baseMap, "Invalid configuration patch") { + return + } + mergedBytes, err := json.Marshal(baseMap) if err != nil { s.logger.Errorw("Failed to marshal merged configuration", "error", err) @@ -5505,6 +5529,36 @@ func withLiveUpstreamStats(ctx context.Context, status interface{}, live map[str return refreshed } +// refuseRemovedConfigKeys runs config.ValidateRemovedKeys on a generic +// configuration document and, when it reports anything, answers 400 with the +// structured validation_errors payload (the #1084 shape) and returns true. +// It is the Spec 107 FR-039 write-time gate for keys the typed decode would +// otherwise drop silently; the boot path normalises + records instead. +func (s *Server) refuseRemovedConfigKeys(w http.ResponseWriter, r *http.Request, document map[string]interface{}, msg string) bool { + errs := config.ValidateRemovedKeys(document) + if len(errs) == 0 { + return false + } + s.writeApplyConfigError(w, r, msg, &internalRuntime.ConfigApplyResult{ + Success: false, + ValidationErrors: errs, + }, fmt.Errorf("%s", errs[0].Error())) + return true +} + +// MergeConfigPatch deep-merges patch into a copy of base and returns the +// merged document β€” the exact merge handlePatchConfig performs. Exported so +// the personal-build round-trip test (Spec 107 FR-040, T010) can drive the +// PATCH path without an HTTP server. +func MergeConfigPatch(base, patch map[string]interface{}) map[string]interface{} { + merged := make(map[string]interface{}, len(base)) + for k, v := range base { + merged[k] = v + } + deepMergeJSON(merged, patch) + return merged +} + // deepMergeJSON recursively merges patch into base. When both base[k] and // patch[k] are JSON objects (map[string]interface{}), they are merged // recursively; otherwise patch[k] overwrites base[k] (arrays and scalars diff --git a/internal/oauth/serverfields_serveredition.go b/internal/oauth/serverfields_serveredition.go index 41c579186..12a1ade6d 100644 --- a/internal/oauth/serverfields_serveredition.go +++ b/internal/oauth/serverfields_serveredition.go @@ -8,7 +8,8 @@ package oauth // Every leaf is REFUSED. The block is replaced wholesale by a write and nothing // binds a mask back into it, so an echo of a read mask must be rejected rather // than persisted over the brokered credential β€” the same answer `args` and -// `oauth.scopes` get, and the same reason. +// `oauth.scopes` get, and the same reason. The `header` / `header_format` +// leaves left with the never-wired injector (Spec 107 FR-032). var editionServerFieldMaskDecisions = map[string]MaskDecision{ "auth_broker.mode": MaskDecisionRefuse, "auth_broker.token_endpoint": MaskDecisionRefuse, @@ -17,6 +18,4 @@ var editionServerFieldMaskDecisions = map[string]MaskDecision{ "auth_broker.scopes": MaskDecisionRefuse, "auth_broker.client_id": MaskDecisionRefuse, "auth_broker.client_secret": MaskDecisionRefuse, - "auth_broker.header": MaskDecisionRefuse, - "auth_broker.header_format": MaskDecisionRefuse, } diff --git a/internal/runtime/lifecycle.go b/internal/runtime/lifecycle.go index 63d39befb..51b52eb21 100644 --- a/internal/runtime/lifecycle.go +++ b/internal/runtime/lifecycle.go @@ -1466,6 +1466,13 @@ func (r *Runtime) ReloadConfiguration() error { return fmt.Errorf("failed to reload config: %w", err) } + // Spec 107 FR-035: a hot reload re-runs the loader, which records (but + // cannot log) the removed-key / deprecated-key findings; emit them here, + // once per successful reload, with the logger the reload path has. + if newSnapshot != nil { + config.LogLoadDiagnostics(newSnapshot.Config, r.logger) + } + // Sync the legacy r.cfg/r.cfgPath fields too: Runtime.GetConfig() still // backs GET/PATCH /api/v1/config and other httpapi handlers. Without this, // a disk reload only lands in the configsvc snapshot β€” the API keeps diff --git a/internal/runtime/server_sharing_test.go b/internal/runtime/server_sharing_test.go index ed685d48f..fb59ee3c8 100644 --- a/internal/runtime/server_sharing_test.go +++ b/internal/runtime/server_sharing_test.go @@ -34,7 +34,8 @@ func TestServerSharingSurvivesConfigurationSave(t *testing.T) { cfg := rt.ConfigSnapshot().Clone() var broker config.AuthBrokerConfig require.NoError(t, json.Unmarshal([]byte(`{ - "mode":"token_exchange", + "mode":"oauth_connect", + "authorization_endpoint":"https://issuer.example/authorize", "token_endpoint":"https://issuer.example/token", "client_id":"fixture-client", "client_secret":"fixture-secret" diff --git a/internal/serveredition/api/broker_audit_test.go b/internal/serveredition/api/broker_audit_test.go index 52bc68ae8..99a0ce775 100644 --- a/internal/serveredition/api/broker_audit_test.go +++ b/internal/serveredition/api/broker_audit_test.go @@ -36,9 +36,9 @@ func waitForActivities(t *testing.T, m *storage.Manager, n int) []*storage.Activ } } -func findByAction(recs []*storage.ActivityRecord, action string) *storage.ActivityRecord { +func findByUser(recs []*storage.ActivityRecord, userID string) *storage.ActivityRecord { for _, r := range recs { - if r.Metadata != nil && r.Metadata["broker_action"] == action { + if r.UserID == userID { return r } } @@ -57,12 +57,13 @@ func TestActivityAuditSink_PersistsAttributionNoSecret(t *testing.T) { t.Fatal("expected non-nil sink for a real storage manager") } - // A successful acquisition and a failed connect. + // A successful connect and a failed connect (the connect flow is the one + // operation the broker performs since Spec 107 FR-031). sink.RecordBrokerEvent(context.Background(), broker.AuditEvent{ UserID: "alice", ServerName: "grafana", - Method: broker.AuditMethodTokenExchange, - Action: broker.AuditActionAcquire, + Method: broker.AuditMethodConnect, + Action: broker.AuditActionConnect, Outcome: broker.AuditOutcomeSuccess, RequestID: "req-abc", }) @@ -78,9 +79,9 @@ func TestActivityAuditSink_PersistsAttributionNoSecret(t *testing.T) { recs := waitForActivities(t, mgr, 2) - acq := findByAction(recs, broker.AuditActionAcquire) + acq := findByUser(recs, "alice") if acq == nil { - t.Fatal("acquire record not found") + t.Fatal("alice's connect record not found") } if acq.UserID != "alice" || acq.ServerName != "grafana" { t.Fatalf("missing attribution: user=%q server=%q", acq.UserID, acq.ServerName) @@ -91,13 +92,13 @@ func TestActivityAuditSink_PersistsAttributionNoSecret(t *testing.T) { if acq.Status != "success" { t.Fatalf("expected success status, got %q", acq.Status) } - if acq.Metadata["broker_method"] != broker.AuditMethodTokenExchange { + if acq.Metadata["broker_method"] != broker.AuditMethodConnect { t.Fatalf("method metadata missing: %v", acq.Metadata["broker_method"]) } - conn := findByAction(recs, broker.AuditActionConnect) + conn := findByUser(recs, "bob") if conn == nil { - t.Fatal("connect record not found") + t.Fatal("bob's connect record not found") } if conn.Status != "error" { t.Fatalf("expected error status, got %q", conn.Status) diff --git a/internal/serveredition/api/connector_provider.go b/internal/serveredition/api/connector_provider.go index 2b5403efd..e5b075bc3 100644 --- a/internal/serveredition/api/connector_provider.go +++ b/internal/serveredition/api/connector_provider.go @@ -20,9 +20,7 @@ import ( // oauth_connect upstream (keyed by serverKey). The same connector instance must // serve both the connect redirect and the callback because the connector holds // the in-memory PKCE/state for each pending flow; rebuilding it per request -// would lose that state. It satisfies broker.ConnectorProvider so the T6 -// CredentialResolver can reuse the same connectors when it needs to produce a -// connect URL for an unconnected user. +// would lose that state. type connectorProvider struct { store broker.CredentialStore logger *zap.Logger @@ -99,11 +97,6 @@ func (p *connectorProvider) connector(server *config.ServerConfig) (*broker.OAut return conn, nil } -// ConnectorFor satisfies broker.ConnectorProvider for the credential resolver. -func (p *connectorProvider) ConnectorFor(server *config.ServerConfig) (broker.Connector, error) { - return p.connector(server) -} - // callbackURLLocked builds the gateway callback URL for a server. Caller holds p.mu. func (p *connectorProvider) callbackURLLocked(serverName string) string { base := strings.TrimSuffix(p.baseURL, "/") @@ -133,6 +126,3 @@ func baseURLFromRequest(r *http.Request) string { } return scheme + "://" + r.Host } - -// Compile-time assertion that the provider satisfies the resolver's interface. -var _ broker.ConnectorProvider = (*connectorProvider)(nil) diff --git a/internal/serveredition/api/credential_handlers.go b/internal/serveredition/api/credential_handlers.go index cd0ae27e0..fe6da58d2 100644 --- a/internal/serveredition/api/credential_handlers.go +++ b/internal/serveredition/api/credential_handlers.go @@ -60,13 +60,6 @@ func NewCredentialHandlers(store broker.CredentialStore, sharedServers []*config } } -// ConnectorProvider exposes the shared, connector cache so the credential -// resolver (T6) can mint connect URLs through the same connectors that serve the -// REST connect/callback flow. -func (h *CredentialHandlers) ConnectorProvider() broker.ConnectorProvider { - return h.connectors -} - func (h *CredentialHandlers) SetAdminServersProvider(provider AdminServersProvider) { h.adminServers = provider } diff --git a/internal/serveredition/api/credential_handlers_test.go b/internal/serveredition/api/credential_handlers_test.go index bd120c790..52f001db8 100644 --- a/internal/serveredition/api/credential_handlers_test.go +++ b/internal/serveredition/api/credential_handlers_test.go @@ -105,7 +105,7 @@ func serverKeyFor(s *config.ServerConfig) string { func TestCredentialsList_RedactsSecrets(t *testing.T) { store := credTestStore(t) - srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeTokenExchange) + srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeOAuthConnect) require.NoError(t, store.Put(testUserID, serverKeyFor(srv), &broker.UpstreamCredential{ Type: "oauth2", AccessToken: "SECRET-ACCESS-TOKEN", @@ -113,7 +113,7 @@ func TestCredentialsList_RedactsSecrets(t *testing.T) { ExpiresAt: time.Now().Add(time.Hour), Scopes: []string{"repo"}, TokenType: "Bearer", - ObtainedVia: "token_exchange", + ObtainedVia: "connect_flow", })) h := NewCredentialHandlers(store, []*config.ServerConfig{srv}, nil, zap.NewNop().Sugar()) @@ -135,15 +135,15 @@ func TestCredentialsList_RedactsSecrets(t *testing.T) { got := resp.Credentials[0] assert.Equal(t, "shared-gh", got.Server) assert.Equal(t, credStatusConnected, got.Status) - assert.Equal(t, config.AuthBrokerModeTokenExchange, got.Mode) + assert.Equal(t, config.AuthBrokerModeOAuthConnect, got.Mode) assert.Equal(t, []string{"repo"}, got.Scopes) assert.NotNil(t, got.ExpiresAt) } func TestCredentialsList_Statuses(t *testing.T) { store := credTestStore(t) - connected := brokerHTTPServer("connected-srv", config.AuthBrokerModeTokenExchange) - expired := brokerHTTPServer("expired-srv", config.AuthBrokerModeTokenExchange) + connected := brokerHTTPServer("connected-srv", config.AuthBrokerModeOAuthConnect) + expired := brokerHTTPServer("expired-srv", config.AuthBrokerModeOAuthConnect) fresh := brokerHTTPServer("fresh-srv", config.AuthBrokerModeOAuthConnect) require.NoError(t, store.Put(testUserID, serverKeyFor(connected), &broker.UpstreamCredential{ @@ -186,7 +186,7 @@ func TestCredentialsList_StoreDisabled(t *testing.T) { require.NoError(t, err) require.False(t, store.Enabled()) - srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeTokenExchange) + srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeOAuthConnect) h := NewCredentialHandlers(store, []*config.ServerConfig{srv}, nil, zap.NewNop().Sugar()) r := credRouter(h, defaultAuthContext()) @@ -203,7 +203,7 @@ func TestCredentialsList_StoreDisabled(t *testing.T) { func TestCredentialsDelete_Removes(t *testing.T) { store := credTestStore(t) - srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeTokenExchange) + srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeOAuthConnect) sk := serverKeyFor(srv) require.NoError(t, store.Put(testUserID, sk, &broker.UpstreamCredential{ Type: "oauth2", AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), @@ -223,7 +223,7 @@ func TestCredentialsDelete_Removes(t *testing.T) { func TestCredentialsDelete_UnknownServer404(t *testing.T) { store := credTestStore(t) - srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeTokenExchange) + srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeOAuthConnect) h := NewCredentialHandlers(store, []*config.ServerConfig{srv}, nil, zap.NewNop().Sugar()) r := credRouter(h, defaultAuthContext()) @@ -235,7 +235,7 @@ func TestCredentialsDelete_UnknownServer404(t *testing.T) { func TestCredentials_CrossUserIsolation(t *testing.T) { store := credTestStore(t) - srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeTokenExchange) + srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeOAuthConnect) sk := serverKeyFor(srv) // User B has a valid credential. require.NoError(t, store.Put(testUserB, sk, &broker.UpstreamCredential{ @@ -292,7 +292,10 @@ func TestCredentialsConnect_Redirects(t *testing.T) { func TestCredentialsConnect_NonConnectMode400(t *testing.T) { store := credTestStore(t) - srv := brokerHTTPServer("xchg-srv", config.AuthBrokerModeTokenExchange) + // A block whose mode is not oauth_connect never reaches the handler on a + // validated config (Spec 107 FR-032 made oauth_connect the only accepted + // mode); the handler still refuses one defensively. + srv := brokerHTTPServer("xchg-srv", "not-a-connect-mode") h := NewCredentialHandlers(store, []*config.ServerConfig{srv}, nil, zap.NewNop().Sugar()) r := credRouter(h, defaultAuthContext()) @@ -447,7 +450,7 @@ func TestCredentialsCallback_Denied_RedirectSanitized(t *testing.T) { func TestCredentials_Unauthenticated(t *testing.T) { store := credTestStore(t) - srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeTokenExchange) + srv := brokerHTTPServer("shared-gh", config.AuthBrokerModeOAuthConnect) h := NewCredentialHandlers(store, []*config.ServerConfig{srv}, nil, zap.NewNop().Sugar()) // Empty auth context β†’ unauthenticated. r := credRouter(h, &auth.AuthContext{}) diff --git a/internal/serveredition/api/user_handlers_redaction_test.go b/internal/serveredition/api/user_handlers_redaction_test.go index 4c2702bb9..1118a8997 100644 --- a/internal/serveredition/api/user_handlers_redaction_test.go +++ b/internal/serveredition/api/user_handlers_redaction_test.go @@ -75,10 +75,11 @@ func secretBearingSharedServer() *config.ServerConfig { ClientSecret: sharedOAuthSecret, }, AuthBroker: &config.AuthBrokerConfig{ - Mode: config.AuthBrokerModeTokenExchange, - TokenEndpoint: "https://idp.example.com/token", - ClientID: "broker-client-id", - ClientSecret: sharedBrokerSecret, + Mode: config.AuthBrokerModeOAuthConnect, + AuthorizationEndpoint: "https://idp.example.com/authorize", + TokenEndpoint: "https://idp.example.com/token", + ClientID: "broker-client-id", + ClientSecret: sharedBrokerSecret, }, Isolation: &config.IsolationConfig{ ExtraArgs: []string{"-e", "API_KEY=" + sharedIsolationArg}, diff --git a/internal/serveredition/auth/idp_subject_token.go b/internal/serveredition/auth/idp_subject_token.go deleted file mode 100644 index 57eac9d9b..000000000 --- a/internal/serveredition/auth/idp_subject_token.go +++ /dev/null @@ -1,165 +0,0 @@ -//go:build server - -package auth - -import ( - "context" - "errors" - "strings" - "time" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/broker" -) - -// idpSubjectTokenType is the credential Type recorded for persisted IdP subject -// tokens. It mirrors the value documented on broker.UpstreamCredential. -const idpSubjectTokenType = "idp_subject_token" - -// idpRefreshSkew is how far ahead of expiry a stored IdP subject token is -// considered "near-expiry" and proactively refreshed, so a token never goes -// stale mid-use (FR-005). -const idpRefreshSkew = 60 * time.Second - -// ErrReauthRequired signals that no usable IdP subject token can be produced for -// the user: none is stored, the store is disabled, or the token expired and -// cannot be refreshed. Callers MUST re-authenticate the user rather than fall -// back to a stale token (FR-005). -var ErrReauthRequired = errors.New("idp subject token requires re-authentication") - -// persistIDPSubjectToken stores the freshly-obtained provider token for userID -// when capture is enabled. It is best-effort and never returns an error: a -// disabled flag, disabled/absent store, or write failure leaves login behaving -// exactly as before (FR-004/FR-006). -func (h *OAuthHandler) persistIDPSubjectToken(userID string, tokenResp *TokenResponse) { - if h.config == nil || !h.config.StoreIDPTokens { - return // default-off: behave exactly as today - } - if tokenResp == nil { - return - } - if h.credStore == nil || !h.credStore.Enabled() { - h.logger.Warnw("server_edition.store_idp_tokens is enabled but the credential store is disabled; "+ - "IdP subject token not persisted (set MCPPROXY_CRED_KEY or server_edition.credential_encryption_key)", - "user_id", userID) - return - } - - cred := &broker.UpstreamCredential{ - Type: idpSubjectTokenType, - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - TokenType: tokenResp.TokenType, - ExpiresAt: expiryFromExpiresIn(tokenResp.ExpiresIn), - Scopes: splitScopes(tokenResp.Scope), - ObtainedVia: "login", - UpdatedAt: time.Now(), - } - if err := h.credStore.Put(userID, "", cred); err != nil { - h.logger.Errorw("failed to persist IdP subject token", "user_id", userID, "error", err) - return - } - h.logger.Debugw("persisted IdP subject token", - "user_id", userID, "has_refresh", tokenResp.RefreshToken != "") -} - -// GetValidIDPSubjectToken returns a non-expired IdP subject token for the user, -// refreshing it via the provider's refresh_token grant when it is expired or -// near-expiry. It never returns a stale token: when no valid token can be -// produced (none stored, store disabled, expired-and-not-refreshable, or refresh -// failed) it returns ErrReauthRequired (FR-005). This is the prerequisite seam -// consumed by the credential resolver (Path A token exchange). -func (h *OAuthHandler) GetValidIDPSubjectToken(ctx context.Context, userID string) (*broker.UpstreamCredential, error) { - if h.credStore == nil || !h.credStore.Enabled() { - return nil, ErrReauthRequired - } - - cred, err := h.credStore.Get(userID, "") - if err != nil { - // Absent or undecryptable -> require re-auth, never a stale token. - return nil, ErrReauthRequired - } - - // Fast path: valid and not within the refresh skew window. - if cred.IsValid() && !cred.ExpiresWithin(idpRefreshSkew) { - return cred, nil - } - - // Needs refresh. Without a refresh token, re-auth is the only safe option. - if cred.RefreshToken == "" { - return nil, ErrReauthRequired - } - - if h.config == nil || h.config.OAuth == nil { - return nil, ErrReauthRequired - } - provider, err := GetProvider(h.config.OAuth.Provider, h.config.OAuth.TenantID) - if err != nil { - h.logger.Warnw("cannot refresh IdP subject token: provider lookup failed", - "user_id", userID, "error", err) - return nil, ErrReauthRequired - } - - tokenResp, err := provider.RefreshAccessToken(ctx, cred.RefreshToken, - h.config.OAuth.ClientID, h.config.OAuth.ClientSecret) - if err != nil { - h.logger.Warnw("IdP subject token refresh failed; re-auth required", - "user_id", userID, "error", err) - return nil, ErrReauthRequired - } - - refreshed := &broker.UpstreamCredential{ - Type: idpSubjectTokenType, - AccessToken: tokenResp.AccessToken, - RefreshToken: firstNonEmpty(tokenResp.RefreshToken, cred.RefreshToken), - TokenType: firstNonEmpty(tokenResp.TokenType, cred.TokenType), - ExpiresAt: expiryFromExpiresIn(tokenResp.ExpiresIn), - Scopes: chooseScopes(tokenResp.Scope, cred.Scopes), - ObtainedVia: "token_refresh", - UpdatedAt: time.Now(), - } - - // Re-persist the refreshed credential. A write failure is non-fatal: the - // in-hand token is still valid for this call. - if err := h.credStore.Put(userID, "", refreshed); err != nil { - h.logger.Warnw("failed to persist refreshed IdP subject token", - "user_id", userID, "error", err) - } - return refreshed, nil -} - -// expiryFromExpiresIn converts an OAuth expires_in (seconds) into an absolute -// expiry. A non-positive value yields the zero time, matching the -// never-expiring convention used by UpstreamCredential. -func expiryFromExpiresIn(expiresIn int) time.Time { - if expiresIn <= 0 { - return time.Time{} - } - return time.Now().Add(time.Duration(expiresIn) * time.Second) -} - -// splitScopes splits a space-delimited OAuth scope string into a slice. It -// returns nil for an empty input so the field is omitted when serialized. -func splitScopes(scope string) []string { - fields := strings.Fields(scope) - if len(fields) == 0 { - return nil - } - return fields -} - -// chooseScopes prefers freshly-returned scopes, falling back to the previously -// stored scopes when the refresh response omits them. -func chooseScopes(scope string, prev []string) []string { - if s := splitScopes(scope); len(s) > 0 { - return s - } - return prev -} - -// firstNonEmpty returns a if non-empty, otherwise b. -func firstNonEmpty(a, b string) string { - if a != "" { - return a - } - return b -} diff --git a/internal/serveredition/auth/idp_subject_token_test.go b/internal/serveredition/auth/idp_subject_token_test.go deleted file mode 100644 index 1d160c896..000000000 --- a/internal/serveredition/auth/idp_subject_token_test.go +++ /dev/null @@ -1,236 +0,0 @@ -//go:build server - -package auth - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "time" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/broker" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/users" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.etcd.io/bbolt" - "go.uber.org/zap" -) - -// testCredKey is a deterministic base64-encoded 32-byte AES-256 key for tests. -func testCredKey(t *testing.T) string { - t.Helper() - raw := make([]byte, 32) - for i := range raw { - raw[i] = byte(i + 1) - } - return base64.StdEncoding.EncodeToString(raw) -} - -// idpMockProviderServer simulates an OAuth provider whose /token endpoint -// handles BOTH the authorization_code grant (login) and the refresh_token grant. -func idpMockProviderServer(t *testing.T, userEmail, userName, userSub string) *httptest.Server { - t.Helper() - - mux := http.NewServeMux() - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - _ = r.ParseForm() - w.Header().Set("Content-Type", "application/json") - if r.FormValue("grant_type") == "refresh_token" { - // Refresh grant returns a brand-new access token (and rotated refresh). - json.NewEncoder(w).Encode(map[string]interface{}{ //nolint:errcheck - "access_token": "refreshed-access-token", - "refresh_token": "rotated-refresh-token", - "token_type": "Bearer", - "expires_in": 3600, - }) - return - } - // Authorization-code grant (login). - json.NewEncoder(w).Encode(map[string]interface{}{ //nolint:errcheck - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "token_type": "Bearer", - "expires_in": 3600, - "scope": "openid email profile", - }) - }) - mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ //nolint:errcheck - "sub": userSub, - "email": userEmail, - "name": userName, - }) - }) - - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - return server -} - -// setupIDPTestHandler builds a handler wired to a real (enabled) credential -// store on a shared BBolt DB, plus a mock provider. storeIDPTokens toggles the -// capture-at-login flag. -func setupIDPTestHandler(t *testing.T, storeIDPTokens bool) (*OAuthHandler, *users.UserStore, broker.CredentialStore, *httptest.Server) { - t.Helper() - - tmpFile := filepath.Join(t.TempDir(), "test.db") - db, err := bbolt.Open(tmpFile, 0o600, &bbolt.Options{Timeout: time.Second}) - require.NoError(t, err) - t.Cleanup(func() { db.Close() }) - - userStore := users.NewUserStore(db) - require.NoError(t, userStore.EnsureBuckets()) - - credStore, err := broker.NewBBoltAESStore(db, testCredKey(t), zap.NewNop()) - require.NoError(t, err) - require.True(t, credStore.Enabled()) - - mockServer := idpMockProviderServer(t, "user@example.com", "Test User", "sub-123") - - originalFactory := providerRegistry["google"] - providerRegistry["google"] = func(_ string) *OAuthProvider { - return &OAuthProvider{ - Name: "google", - AuthURL: mockServer.URL + "/authorize", - TokenURL: mockServer.URL + "/token", - UserInfoURL: mockServer.URL + "/userinfo", - Scopes: []string{"openid", "email", "profile"}, - SupportsOIDC: false, // force userinfo path so the mock /userinfo is used - SupportsPKCE: true, - } - } - t.Cleanup(func() { providerRegistry["google"] = originalFactory }) - - sessionMgr := NewSessionManager(userStore, time.Hour, false) - teamsCfg := &config.ServerEditionConfig{ - Enabled: true, - AdminEmails: []string{"admin@example.com"}, - OAuth: &config.ServerEditionOAuthConfig{Provider: "google", ClientID: "cid", ClientSecret: "secret"}, - SessionTTL: config.Duration(time.Hour), - BearerTokenTTL: config.Duration(time.Hour), - StoreIDPTokens: storeIDPTokens, - } - handler := NewOAuthHandler(userStore, sessionMgr, teamsCfg, []byte("test-hmac-key-for-jwt-signing-32b"), zap.NewNop().Sugar()) - handler.SetCredentialStore(credStore) - - return handler, userStore, credStore, mockServer -} - -// driveCallback runs a full login callback for the mock user and returns the userID. -func driveCallback(t *testing.T, handler *OAuthHandler, userStore *users.UserStore) string { - t.Helper() - state := "state01state02state03state04state05state06state07state08state09" - handler.statesMu.Lock() - handler.pendingStates[state] = &oauthState{CodeVerifier: "verifier", RedirectURI: "/ui/", CreatedAt: time.Now()} - handler.statesMu.Unlock() - - req := httptest.NewRequest(http.MethodGet, - fmt.Sprintf("/api/v1/auth/callback?code=code&state=%s", state), nil) - req.Host = "localhost:8080" - w := httptest.NewRecorder() - handler.HandleCallback(w, req) - require.Equal(t, http.StatusFound, w.Result().StatusCode) - - user, err := userStore.GetUserByEmail("user@example.com") - require.NoError(t, err) - require.NotNil(t, user) - return user.ID -} - -func TestHandleCallback_StoresIDPSubjectToken_WhenEnabled(t *testing.T) { - handler, userStore, credStore, _ := setupIDPTestHandler(t, true) - userID := driveCallback(t, handler, userStore) - - cred, err := credStore.Get(userID, "") - require.NoError(t, err, "idp subject token should be persisted at login") - assert.Equal(t, "idp_subject_token", cred.Type) - assert.Equal(t, "mock-access-token", cred.AccessToken) - assert.Equal(t, "mock-refresh-token", cred.RefreshToken) - assert.False(t, cred.ExpiresAt.IsZero(), "expiry should be derived from expires_in") - assert.True(t, cred.ExpiresAt.After(time.Now()), "stored token should not already be expired") -} - -func TestHandleCallback_NoStorage_WhenDisabled(t *testing.T) { - handler, userStore, credStore, _ := setupIDPTestHandler(t, false) - userID := driveCallback(t, handler, userStore) - - _, err := credStore.Get(userID, "") - assert.ErrorIs(t, err, broker.ErrNotFound, "disabled flag must not persist any IdP token") -} - -func TestGetValidIDPSubjectToken_ValidReturnsAsIs(t *testing.T) { - handler, _, credStore, _ := setupIDPTestHandler(t, true) - userID := "user-valid" - require.NoError(t, credStore.Put(userID, "", &broker.UpstreamCredential{ - Type: "idp_subject_token", - AccessToken: "still-good", - RefreshToken: "rt", - ExpiresAt: time.Now().Add(time.Hour), - })) - - cred, err := handler.GetValidIDPSubjectToken(context.Background(), userID) - require.NoError(t, err) - assert.Equal(t, "still-good", cred.AccessToken, "a valid token is returned unchanged") -} - -func TestGetValidIDPSubjectToken_RefreshesNearExpiry(t *testing.T) { - handler, _, credStore, _ := setupIDPTestHandler(t, true) - userID := "user-refresh" - require.NoError(t, credStore.Put(userID, "", &broker.UpstreamCredential{ - Type: "idp_subject_token", - AccessToken: "expired-access", - RefreshToken: "mock-refresh-token", - ExpiresAt: time.Now().Add(-time.Minute), // already expired - })) - - cred, err := handler.GetValidIDPSubjectToken(context.Background(), userID) - require.NoError(t, err) - assert.Equal(t, "refreshed-access-token", cred.AccessToken, "expired token must be refreshed") - assert.True(t, cred.ExpiresAt.After(time.Now()), "refreshed token has a fresh expiry") - - // Refreshed token must be re-persisted. - stored, err := credStore.Get(userID, "") - require.NoError(t, err) - assert.Equal(t, "refreshed-access-token", stored.AccessToken) - assert.Equal(t, "rotated-refresh-token", stored.RefreshToken, "rotated refresh token is persisted") -} - -func TestGetValidIDPSubjectToken_ExpiredNotRefreshable_ReauthSignal(t *testing.T) { - handler, _, credStore, _ := setupIDPTestHandler(t, true) - userID := "user-noreauth" - require.NoError(t, credStore.Put(userID, "", &broker.UpstreamCredential{ - Type: "idp_subject_token", - AccessToken: "expired-access", - // no refresh token - ExpiresAt: time.Now().Add(-time.Minute), - })) - - _, err := handler.GetValidIDPSubjectToken(context.Background(), userID) - assert.ErrorIs(t, err, ErrReauthRequired, "expired + not refreshable must signal re-auth") -} - -func TestGetValidIDPSubjectToken_NotStored_ReauthSignal(t *testing.T) { - handler, _, _, _ := setupIDPTestHandler(t, true) - - _, err := handler.GetValidIDPSubjectToken(context.Background(), "unknown-user") - assert.ErrorIs(t, err, ErrReauthRequired, "absent token must signal re-auth, never a stale token") -} - -func TestGetValidIDPSubjectToken_StoreDisabled_ReauthSignal(t *testing.T) { - handler, _, _, _ := setupIDPTestHandler(t, true) - handler.SetCredentialStore(nil) // simulate broker disabled - - _, err := handler.GetValidIDPSubjectToken(context.Background(), "any") - assert.ErrorIs(t, err, ErrReauthRequired) -} diff --git a/internal/serveredition/auth/oauth_handler.go b/internal/serveredition/auth/oauth_handler.go index 07723e1d3..b20127874 100644 --- a/internal/serveredition/auth/oauth_handler.go +++ b/internal/serveredition/auth/oauth_handler.go @@ -17,7 +17,6 @@ import ( "go.uber.org/zap" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/broker" "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/users" ) @@ -29,22 +28,11 @@ type OAuthHandler struct { hmacKey []byte logger *zap.SugaredLogger - // credStore persists IdP subject tokens at login when teams.store_idp_tokens - // is enabled (spec 074, FR-004/FR-006). It may be nil or disabled, in which - // case token capture is silently skipped and login behaves exactly as before. - credStore broker.CredentialStore - // CSRF state storage (in-memory, keyed by state string) pendingStates map[string]*oauthState statesMu sync.Mutex } -// SetCredentialStore wires the credential store used to persist and refresh IdP -// subject tokens. Passing nil disables token capture (default-off behaviour). -func (h *OAuthHandler) SetCredentialStore(store broker.CredentialStore) { - h.credStore = store -} - type oauthState struct { CodeVerifier string // PKCE code verifier RedirectURI string // Where to redirect after login @@ -136,12 +124,10 @@ func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { // Build the callback URL from the request callbackURL := buildCallbackURL(r) - // Request offline access (a durable refresh token) only when the operator - // opted into persisting IdP subject tokens; otherwise login is unchanged. - offlineAccess := h.config != nil && h.config.StoreIDPTokens - - // Build the authorization URL - authURL := provider.BuildAuthURL(h.config.OAuth.ClientID, callbackURL, state, codeChallenge, offlineAccess) + // Build the authorization URL. Login never asks the IdP for offline access: + // the session and the bearer JWT carry the user, and the IdP refresh token + // that `store_idp_tokens` once persisted had no reader (Spec 107 FR-033). + authURL := provider.BuildAuthURL(h.config.OAuth.ClientID, callbackURL, state, codeChallenge) h.logger.Infow("initiating OAuth login", "provider", h.config.OAuth.Provider) http.Redirect(w, r, authURL, http.StatusFound) @@ -247,10 +233,6 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { return } - // Persist the IdP subject token (encrypted) when capture is enabled. This is - // best-effort: a storage failure must never break login (FR-004/FR-006). - h.persistIDPSubjectToken(user.ID, tokenResp) - // Determine role role := "user" if h.config.IsAdminEmail(user.Email) { diff --git a/internal/serveredition/auth/oauth_handler_test.go b/internal/serveredition/auth/oauth_handler_test.go index 37591df42..7d34d0b88 100644 --- a/internal/serveredition/auth/oauth_handler_test.go +++ b/internal/serveredition/auth/oauth_handler_test.go @@ -101,14 +101,13 @@ func registerMockProvider(t *testing.T, mockServer *httptest.Server) { providerRegistry["google"] = func(_ string) *OAuthProvider { return &OAuthProvider{ - Name: "google", - AuthURL: mockServer.URL + "/authorize", - TokenURL: mockServer.URL + "/token", - UserInfoURL: mockServer.URL + "/userinfo", - Scopes: []string{"openid", "email", "profile"}, - OfflineAuthParams: map[string]string{"access_type": "offline", "prompt": "consent"}, - SupportsOIDC: true, - SupportsPKCE: true, + Name: "google", + AuthURL: mockServer.URL + "/authorize", + TokenURL: mockServer.URL + "/token", + UserInfoURL: mockServer.URL + "/userinfo", + Scopes: []string{"openid", "email", "profile"}, + SupportsOIDC: true, + SupportsPKCE: true, } } @@ -151,46 +150,12 @@ func TestHandleLogin_Redirects(t *testing.T) { assert.Equal(t, "code", params.Get("response_type")) assert.Contains(t, params.Get("scope"), "openid") - // Default-off (FR-006): store_idp_tokens unset β†’ no offline-access request, - // so login behaves exactly as before. - assert.Empty(t, params.Get("access_type"), "offline access must not be requested by default") + // Login never requests offline access: the IdP refresh token has no reader + // (Spec 107 FR-033), so the deprecated store_idp_tokens cannot change the URL. + assert.Empty(t, params.Get("access_type"), "offline access must never be requested") assert.Empty(t, params.Get("prompt")) } -// TestHandleLogin_RequestsOfflineAccess verifies that when teams.store_idp_tokens -// is enabled, the login redirect asks the provider for offline access so the -// persisted IdP subject token actually carries a refresh token (Codex review on -// PR #601 / MCP-1036). Without this, the refresh path in GetValidIDPSubjectToken -// would have no refresh token and always return ErrReauthRequired after expiry. -func TestHandleLogin_RequestsOfflineAccess(t *testing.T) { - mockServer := mockOAuthProviderServer(t, "user@example.com", "Test User", "sub-123") - registerMockProvider(t, mockServer) - - handler, _ := setupTestOAuthHandler(t, &config.ServerEditionOAuthConfig{ - Provider: "google", - ClientID: "test-client-id", - ClientSecret: "test-client-secret", - }) - // Operator opted into persisting IdP subject tokens. - handler.config.StoreIDPTokens = true - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/login", nil) - w := httptest.NewRecorder() - handler.HandleLogin(w, req) - - resp := w.Result() - defer resp.Body.Close() - require.Equal(t, http.StatusFound, resp.StatusCode) - - redirectURL, err := url.Parse(resp.Header.Get("Location")) - require.NoError(t, err) - params := redirectURL.Query() - - assert.Equal(t, "offline", params.Get("access_type"), - "login must request offline access when store_idp_tokens is enabled") - assert.Equal(t, "consent", params.Get("prompt")) -} - func TestHandleLogin_StateInURL(t *testing.T) { mockServer := mockOAuthProviderServer(t, "user@example.com", "Test User", "sub-123") registerMockProvider(t, mockServer) diff --git a/internal/serveredition/auth/oauth_providers.go b/internal/serveredition/auth/oauth_providers.go index 28bec4ad1..8451969d2 100644 --- a/internal/serveredition/auth/oauth_providers.go +++ b/internal/serveredition/auth/oauth_providers.go @@ -25,17 +25,6 @@ type OAuthProvider struct { Scopes []string SupportsOIDC bool // If true, ID token contains user info SupportsPKCE bool - - // OfflineAccessScopes are extra scopes appended to the authorization request - // when offline access (a durable refresh token) is required β€” e.g. - // Microsoft's "offline_access". Empty when the provider asks for offline - // access via query parameters instead (see OfflineAuthParams). - OfflineAccessScopes []string - - // OfflineAuthParams are extra authorization-URL query parameters that ask the - // provider to issue a refresh token β€” e.g. Google's access_type=offline and - // prompt=consent. Google returns a refresh token only when these are present. - OfflineAuthParams map[string]string } // TokenResponse represents the OAuth token exchange response. @@ -72,10 +61,6 @@ func newGoogleProvider(_ string) *OAuthProvider { Scopes: []string{"openid", "email", "profile"}, SupportsOIDC: true, SupportsPKCE: true, - // Google issues a refresh token only when the authorization request sets - // access_type=offline; prompt=consent forces re-issuance on repeat logins. - // https://developers.google.com/identity/protocols/oauth2/web-server - OfflineAuthParams: map[string]string{"access_type": "offline", "prompt": "consent"}, } } @@ -104,10 +89,6 @@ func newMicrosoftProvider(tenantID string) *OAuthProvider { Scopes: []string{"openid", "email", "profile", "User.Read"}, SupportsOIDC: true, SupportsPKCE: true, - // Microsoft's v2.0 endpoint returns a refresh token only when the request - // explicitly includes the offline_access scope. - // https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc - OfflineAccessScopes: []string{"offline_access"}, } } @@ -121,32 +102,18 @@ func GetProvider(name string, tenantID string) (*OAuthProvider, error) { return factory(tenantID), nil } -// BuildAuthURL constructs the authorization URL with the required query parameters. -// When offlineAccess is true, the request also asks the provider to issue a -// durable refresh token (extra scopes and/or query parameters per provider), so -// that the persisted IdP subject token can later be refreshed instead of forcing -// re-authentication. offlineAccess is driven by teams.store_idp_tokens; when the -// feature is off, the URL is identical to before (FR-006). -func (p *OAuthProvider) BuildAuthURL(clientID, redirectURI, state, codeChallenge string, offlineAccess bool) string { - scopes := p.Scopes - if offlineAccess && len(p.OfflineAccessScopes) > 0 { - scopes = append(append([]string{}, p.Scopes...), p.OfflineAccessScopes...) - } - +// BuildAuthURL constructs the authorization URL with the required query +// parameters. Offline access (a durable refresh token) is never requested: the +// login flow has no reader for an IdP refresh token (Spec 107 FR-033). +func (p *OAuthProvider) BuildAuthURL(clientID, redirectURI, state, codeChallenge string) string { params := url.Values{ "client_id": {clientID}, "redirect_uri": {redirectURI}, "response_type": {"code"}, - "scope": {strings.Join(scopes, " ")}, + "scope": {strings.Join(p.Scopes, " ")}, "state": {state}, } - if offlineAccess { - for k, v := range p.OfflineAuthParams { - params.Set(k, v) - } - } - if p.SupportsPKCE && codeChallenge != "" { params.Set("code_challenge", codeChallenge) params.Set("code_challenge_method", "S256") @@ -209,53 +176,6 @@ func (p *OAuthProvider) ExchangeCode(ctx context.Context, code, redirectURI, cli return &tokenResp, nil } -// RefreshAccessToken exchanges a refresh token for a fresh access token via the -// refresh_token grant (RFC 6749 Β§6). Providers may or may not rotate the refresh -// token; callers should preserve the previous refresh token when the response -// omits one. -func (p *OAuthProvider) RefreshAccessToken(ctx context.Context, refreshToken, clientID, clientSecret string) (*TokenResponse, error) { - if refreshToken == "" { - return nil, fmt.Errorf("refresh token is empty") - } - - data := url.Values{ - "grant_type": {"refresh_token"}, - "refresh_token": {refreshToken}, - "client_id": {clientID}, - } - if clientSecret != "" { - data.Set("client_secret", clientSecret) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.TokenURL, strings.NewReader(data.Encode())) - if err != nil { - return nil, fmt.Errorf("creating refresh request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("refresh token request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading refresh response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("refresh token failed (status %d): %s", resp.StatusCode, string(body)) - } - - var tokenResp TokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, fmt.Errorf("parsing refresh response: %w", err) - } - return &tokenResp, nil -} - // FetchUserInfo retrieves user profile information from the provider. // For OIDC providers (Google, Microsoft), it first tries to extract info from the ID token, // falling back to the UserInfo endpoint. For GitHub, it calls /user and /user/emails. diff --git a/internal/serveredition/auth/oauth_providers_test.go b/internal/serveredition/auth/oauth_providers_test.go index 0f580fad5..31896424c 100644 --- a/internal/serveredition/auth/oauth_providers_test.go +++ b/internal/serveredition/auth/oauth_providers_test.go @@ -89,7 +89,7 @@ func TestGetProvider_CaseInsensitive(t *testing.T) { func TestBuildAuthURL_Google(t *testing.T) { p, _ := GetProvider("google", "") - authURL := p.BuildAuthURL("client123", "http://localhost:8080/callback", "state-abc", "challenge-xyz", false) + authURL := p.BuildAuthURL("client123", "http://localhost:8080/callback", "state-abc", "challenge-xyz") parsed, err := url.Parse(authURL) require.NoError(t, err) @@ -107,56 +107,16 @@ func TestBuildAuthURL_Google(t *testing.T) { assert.Equal(t, "challenge-xyz", params.Get("code_challenge")) assert.Equal(t, "S256", params.Get("code_challenge_method")) - // Default-off (FR-006): without offline access requested, no Google - // offline-consent parameters are sent, so login is unchanged. + // Offline access is never requested (Spec 107 FR-033): no Google + // offline-consent parameters are ever sent. assert.Empty(t, params.Get("access_type")) assert.Empty(t, params.Get("prompt")) } -// TestBuildAuthURL_OfflineAccess_Google verifies that when offline access is -// requested (teams.store_idp_tokens), Google's authorization request carries -// access_type=offline and prompt=consent β€” the documented contract for Google -// to return a refresh token (Codex review on PR #601 / MCP-1036). -func TestBuildAuthURL_OfflineAccess_Google(t *testing.T) { - p, _ := GetProvider("google", "") - - authURL := p.BuildAuthURL("client123", "http://localhost:8080/callback", "state-abc", "challenge-xyz", true) - - parsed, err := url.Parse(authURL) - require.NoError(t, err) - params := parsed.Query() - - // Google asks for offline access via query params, not an extra scope. - assert.Equal(t, "offline", params.Get("access_type")) - assert.Equal(t, "consent", params.Get("prompt")) - assert.Equal(t, "openid email profile", params.Get("scope"), - "Google offline access must not alter the scope list") -} - -// TestBuildAuthURL_OfflineAccess_Microsoft verifies that when offline access is -// requested, Microsoft's scope list gains offline_access β€” the documented -// contract for the v2.0 endpoint to return a refresh token. -func TestBuildAuthURL_OfflineAccess_Microsoft(t *testing.T) { - p, _ := GetProvider("microsoft", "contoso") - - withOffline := p.BuildAuthURL("ms-client", "http://localhost:8080/callback", "state-ms", "challenge-ms", true) - parsed, err := url.Parse(withOffline) - require.NoError(t, err) - scope := parsed.Query().Get("scope") - assert.Contains(t, strings.Fields(scope), "offline_access", - "Microsoft must request offline_access to receive a refresh token") - - // Without offline access requested, the scope is unchanged (default-off). - noOffline := p.BuildAuthURL("ms-client", "http://localhost:8080/callback", "state-ms", "challenge-ms", false) - parsedNo, err := url.Parse(noOffline) - require.NoError(t, err) - assert.NotContains(t, strings.Fields(parsedNo.Query().Get("scope")), "offline_access") -} - func TestBuildAuthURL_GitHub(t *testing.T) { p, _ := GetProvider("github", "") - authURL := p.BuildAuthURL("gh-client", "http://localhost:8080/callback", "state-123", "challenge-456", false) + authURL := p.BuildAuthURL("gh-client", "http://localhost:8080/callback", "state-123", "challenge-456") parsed, err := url.Parse(authURL) require.NoError(t, err) @@ -178,7 +138,7 @@ func TestBuildAuthURL_GitHub(t *testing.T) { func TestBuildAuthURL_Microsoft(t *testing.T) { p, _ := GetProvider("microsoft", "contoso") - authURL := p.BuildAuthURL("ms-client", "http://localhost:8080/callback", "state-ms", "challenge-ms", false) + authURL := p.BuildAuthURL("ms-client", "http://localhost:8080/callback", "state-ms", "challenge-ms") parsed, err := url.Parse(authURL) require.NoError(t, err) @@ -195,7 +155,7 @@ func TestBuildAuthURL_Microsoft(t *testing.T) { func TestBuildAuthURL_NoPKCE_WhenEmptyChallenge(t *testing.T) { p, _ := GetProvider("google", "") - authURL := p.BuildAuthURL("client", "http://localhost/cb", "state", "", false) + authURL := p.BuildAuthURL("client", "http://localhost/cb", "state", "") parsed, err := url.Parse(authURL) require.NoError(t, err) diff --git a/internal/serveredition/broker/audit.go b/internal/serveredition/broker/audit.go index ecdedef32..0ffa2035c 100644 --- a/internal/serveredition/broker/audit.go +++ b/internal/serveredition/broker/audit.go @@ -5,30 +5,19 @@ package broker import ( "context" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" ) -// Audit vocabulary for per-user credential brokering (spec 074 T10, FR-028). -// These strings are the stable, secret-free attribution values recorded on every -// acquisition / refresh / injection / connect operation. +// Audit vocabulary for the per-user OAuth connect flow (spec 074 T10, FR-028). +// These strings are the stable, secret-free attribution values recorded on +// every connect operation. Spec 107 (FR-031) retired the acquire / refresh / +// inject actions and the token_exchange / entra_obo methods together with the +// never-wired credential resolver that was their only emitter: the connect +// flow is the one operation the broker performs. const ( - // AuditMethodTokenExchange is the RFC 8693 token-exchange acquisition method. - AuditMethodTokenExchange = "token_exchange" - // AuditMethodEntraOBO is the Entra ID on-behalf-of acquisition method. - AuditMethodEntraOBO = "entra_obo" // AuditMethodConnect is the per-user OAuth connect-flow acquisition method. AuditMethodConnect = "connect" - // AuditMethodUnknown is recorded when the broker mode is unrecognised. - AuditMethodUnknown = "unknown" - // AuditActionAcquire is a first-time per-user credential acquisition. - AuditActionAcquire = "acquire" - // AuditActionRefresh is the renewal of a near-expiry per-user credential. - AuditActionRefresh = "refresh" - // AuditActionInject is the use of an already-valid cached credential for - // injection into a proxied request (no new acquisition occurred). - AuditActionInject = "inject" // AuditActionConnect is the per-user OAuth connect-flow consent/callback. AuditActionConnect = "connect" @@ -48,9 +37,9 @@ type AuditEvent struct { UserID string // ServerName is the brokered upstream's configured name. ServerName string - // Method is the acquisition method: token_exchange | entra_obo | connect. + // Method is the acquisition method: connect. Method string - // Action is the operation: acquire | refresh | inject | connect. + // Action is the operation: connect. Action string // Outcome is success | failure. Outcome string @@ -73,22 +62,6 @@ type nopAuditSink struct{} func (nopAuditSink) RecordBrokerEvent(context.Context, AuditEvent) {} -// auditMethodForMode maps a configured auth-broker mode to its audit method -// label. An unrecognised mode maps to AuditMethodUnknown rather than leaking the -// raw value. -func auditMethodForMode(mode string) string { - switch mode { - case config.AuthBrokerModeTokenExchange: - return AuditMethodTokenExchange - case config.AuthBrokerModeEntraOBO: - return AuditMethodEntraOBO - case config.AuthBrokerModeOAuthConnect: - return AuditMethodConnect - default: - return AuditMethodUnknown - } -} - // auditRequestID extracts the correlatable request id from ctx, if present. func auditRequestID(ctx context.Context) string { return reqcontext.GetRequestID(ctx) diff --git a/internal/serveredition/broker/audit_connector_test.go b/internal/serveredition/broker/audit_connector_test.go index ce174399e..3abefe62c 100644 --- a/internal/serveredition/broker/audit_connector_test.go +++ b/internal/serveredition/broker/audit_connector_test.go @@ -6,6 +6,7 @@ import ( "context" "net/http" "strings" + "sync" "testing" "go.uber.org/zap" @@ -13,6 +14,37 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" ) +// recordingSink captures audit events for assertions. It is concurrency-safe. +// (Formerly a helper of audit_resolver_test.go, which Spec 107 FR-031 deleted +// with the never-wired credential resolver.) +type recordingSink struct { + mu sync.Mutex + events []AuditEvent +} + +func (s *recordingSink) RecordBrokerEvent(_ context.Context, ev AuditEvent) { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, ev) +} + +func (s *recordingSink) all() []AuditEvent { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]AuditEvent, len(s.events)) + copy(out, s.events) + return out +} + +func (s *recordingSink) last(t *testing.T) AuditEvent { + t.Helper() + evs := s.all() + if len(evs) == 0 { + t.Fatalf("expected at least one audit event, got none") + } + return evs[len(evs)-1] +} + // connectorWithSink builds a connector wired to a recording audit sink, pointed // at the given mock token server. func connectorWithSink(t *testing.T, m *mockTokenServer) (*OAuthConnector, *recordingSink) { diff --git a/internal/serveredition/broker/audit_resolver_test.go b/internal/serveredition/broker/audit_resolver_test.go deleted file mode 100644 index 86725c3ab..000000000 --- a/internal/serveredition/broker/audit_resolver_test.go +++ /dev/null @@ -1,255 +0,0 @@ -//go:build server - -package broker - -import ( - "context" - "errors" - "strings" - "sync" - "testing" - "time" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" -) - -// recordingSink captures audit events for assertions. It is concurrency-safe so -// it can be used under the resolver's single-flight without data races. -type recordingSink struct { - mu sync.Mutex - events []AuditEvent -} - -func (s *recordingSink) RecordBrokerEvent(_ context.Context, ev AuditEvent) { - s.mu.Lock() - defer s.mu.Unlock() - s.events = append(s.events, ev) -} - -func (s *recordingSink) all() []AuditEvent { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]AuditEvent, len(s.events)) - copy(out, s.events) - return out -} - -func (s *recordingSink) last(t *testing.T) AuditEvent { - t.Helper() - evs := s.all() - if len(evs) == 0 { - t.Fatalf("expected at least one audit event, got none") - } - return evs[len(evs)-1] -} - -// secretToken is a sentinel access-token value asserted to never appear in any -// audit event (FR-029: no secret material in records). -const secretToken = "SUPER-SECRET-ACCESS-TOKEN-do-not-log" - -func assertNoSecret(t *testing.T, evs []AuditEvent) { - t.Helper() - for i, ev := range evs { - for field, val := range map[string]string{ - "Reason": ev.Reason, "Method": ev.Method, "Action": ev.Action, - "Outcome": ev.Outcome, "ServerName": ev.ServerName, "UserID": ev.UserID, - } { - if strings.Contains(val, secretToken) { - t.Fatalf("event %d %s leaked secret material: %q", i, field, val) - } - } - } -} - -func TestAudit_CacheHit_EmitsInjectSuccess(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - store.seed("alice", key, &UpstreamCredential{AccessToken: secretToken, ExpiresAt: time.Now().Add(time.Hour)}) - - sink := &recordingSink{} - r := NewCredentialResolver(ResolverDeps{Store: store, Audit: sink}) - - ctx := reqcontext.WithRequestID(context.Background(), "req-123") - if _, err := r.Resolve(ctx, "alice", server); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - ev := sink.last(t) - if ev.UserID != "alice" || ev.ServerName != "grafana" { - t.Fatalf("missing attribution: %+v", ev) - } - if ev.Method != AuditMethodTokenExchange { - t.Fatalf("expected method %q, got %q", AuditMethodTokenExchange, ev.Method) - } - if ev.Action != AuditActionInject { - t.Fatalf("expected action %q, got %q", AuditActionInject, ev.Action) - } - if ev.Outcome != AuditOutcomeSuccess { - t.Fatalf("expected success, got %q (reason %q)", ev.Outcome, ev.Reason) - } - if ev.RequestID != "req-123" { - t.Fatalf("expected request_id correlation, got %q", ev.RequestID) - } - assertNoSecret(t, sink.all()) -} - -func TestAudit_FreshTokenExchange_EmitsAcquireSuccess(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: secretToken}} - - sink := &recordingSink{} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex, Audit: sink}) - - if _, err := r.Resolve(context.Background(), "bob", server); err != nil { - t.Fatalf("unexpected error: %v", err) - } - ev := sink.last(t) - if ev.Action != AuditActionAcquire || ev.Outcome != AuditOutcomeSuccess { - t.Fatalf("expected acquire/success, got %s/%s", ev.Action, ev.Outcome) - } - if ev.Method != AuditMethodTokenExchange { - t.Fatalf("expected token_exchange method, got %q", ev.Method) - } - assertNoSecret(t, sink.all()) -} - -func TestAudit_NearExpiry_EmitsRefreshSuccess(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - store.seed("carol", key, &UpstreamCredential{AccessToken: "old", ExpiresAt: time.Now().Add(5 * time.Second)}) - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: secretToken}} - - sink := &recordingSink{} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex, Audit: sink}) - - if _, err := r.Resolve(context.Background(), "carol", server); err != nil { - t.Fatalf("unexpected error: %v", err) - } - ev := sink.last(t) - if ev.Action != AuditActionRefresh || ev.Outcome != AuditOutcomeSuccess { - t.Fatalf("expected refresh/success, got %s/%s", ev.Action, ev.Outcome) - } - assertNoSecret(t, sink.all()) -} - -func TestAudit_EntraOBO_MethodMapping(t *testing.T) { - store := newFakeStore() - b := &config.AuthBrokerConfig{Mode: config.AuthBrokerModeEntraOBO, TokenEndpoint: "https://idp/token"} - b.ApplyDefaults() - server := httpServer("graph", b) - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: secretToken}} - - sink := &recordingSink{} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex, Audit: sink}) - - if _, err := r.Resolve(context.Background(), "dave", server); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ev := sink.last(t); ev.Method != AuditMethodEntraOBO { - t.Fatalf("expected entra_obo method, got %q", ev.Method) - } -} - -func TestAudit_ExchangeFailure_EmitsFailureWithReason_NoSecret(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - // The exchanger fails; its error must not contain secret material, and the - // audit reason is derived from it. - ex := &fakeExchanger{err: errors.New("token exchange rejected by authorization server")} - - sink := &recordingSink{} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex, Audit: sink}) - - if _, err := r.Resolve(context.Background(), "erin", server); err == nil { - t.Fatalf("expected error") - } - ev := sink.last(t) - if ev.Action != AuditActionAcquire || ev.Outcome != AuditOutcomeFailure { - t.Fatalf("expected acquire/failure, got %s/%s", ev.Action, ev.Outcome) - } - if ev.Reason == "" { - t.Fatalf("failure event must carry a reason") - } - assertNoSecret(t, sink.all()) -} - -func TestAudit_PolicyDenied_EmitsInjectFailure(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - store.seed("frank", key, &UpstreamCredential{AccessToken: secretToken, ExpiresAt: time.Now().Add(time.Hour)}) - - sink := &recordingSink{} - deny := PolicyHookFunc(func(_ context.Context, _ PolicyInput) (PolicyDecision, error) { - return PolicyDecision{Allow: false, Reason: "blocked by org policy"}, nil - }) - r := NewCredentialResolver(ResolverDeps{Store: store, Policy: deny, Audit: sink}) - - if _, err := r.Resolve(context.Background(), "frank", server); err == nil { - t.Fatalf("expected policy-denied error") - } - ev := sink.last(t) - if ev.Action != AuditActionInject || ev.Outcome != AuditOutcomeFailure { - t.Fatalf("expected inject/failure, got %s/%s", ev.Action, ev.Outcome) - } - if !strings.Contains(ev.Reason, "org policy") { - t.Fatalf("expected policy reason, got %q", ev.Reason) - } - assertNoSecret(t, sink.all()) -} - -func TestAudit_NotConnected_EmitsConnectFailure(t *testing.T) { - store := newFakeStore() - server := httpServer("github", connectBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - conn := &fakeConnector{serverKey: key, authURL: "https://idp/authorize?state=xyz"} - - sink := &recordingSink{} - r := NewCredentialResolver(ResolverDeps{Store: store, Connectors: &fakeConnectorProvider{conn: conn}, Audit: sink}) - - if _, err := r.Resolve(context.Background(), "grace", server); err == nil { - t.Fatalf("expected not-connected error") - } - ev := sink.last(t) - if ev.Action != AuditActionConnect || ev.Outcome != AuditOutcomeFailure { - t.Fatalf("expected connect/failure, got %s/%s", ev.Action, ev.Outcome) - } - if ev.Method != AuditMethodConnect { - t.Fatalf("expected connect method, got %q", ev.Method) - } -} - -func TestAudit_StoreDisabled_EmitsInjectFailure(t *testing.T) { - store := newFakeStore() - store.enabled = false - server := httpServer("grafana", tokenExchangeBroker()) - - sink := &recordingSink{} - r := NewCredentialResolver(ResolverDeps{Store: store, Audit: sink}) - - if _, err := r.Resolve(context.Background(), "heidi", server); err == nil { - t.Fatalf("expected store-disabled error") - } - if ev := sink.last(t); ev.Action != AuditActionInject || ev.Outcome != AuditOutcomeFailure { - t.Fatalf("expected inject/failure, got %s/%s", ev.Action, ev.Outcome) - } -} - -func TestAudit_Unauthenticated_NoEvent(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - sink := &recordingSink{} - r := NewCredentialResolver(ResolverDeps{Store: store, Audit: sink}) - - if _, err := r.Resolve(context.Background(), "", server); !errors.Is(err, ErrUnauthenticated) { - t.Fatalf("expected ErrUnauthenticated, got %v", err) - } - if evs := sink.all(); len(evs) != 0 { - t.Fatalf("expected no audit event for anonymous caller, got %d", len(evs)) - } -} diff --git a/internal/serveredition/broker/credential_resolver.go b/internal/serveredition/broker/credential_resolver.go deleted file mode 100644 index bd7b0c936..000000000 --- a/internal/serveredition/broker/credential_resolver.go +++ /dev/null @@ -1,429 +0,0 @@ -//go:build server - -package broker - -import ( - "context" - "errors" - "fmt" - "time" - - "go.uber.org/zap" - "golang.org/x/sync/singleflight" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" -) - -// defaultRefreshThreshold is how close to expiry a cached credential may be -// before the resolver proactively refreshes it. A credential expiring within -// this window is treated as stale (FR-013). -const defaultRefreshThreshold = 60 * time.Second - -// Sentinel errors returned by the resolver. They are deliberately coarse and -// secret-free so they can be surfaced to callers and audited (FR-014/FR-019). -var ( - // ErrUnauthenticated is returned when Resolve is called without a user - // identity. Brokering is strictly per-user; an anonymous caller is rejected - // before any store or upstream access (FR-014). - ErrUnauthenticated = errors.New("credential resolver: unauthenticated caller") - - // ErrNoCredential is returned when no per-user credential can be produced and - // no actionable connect flow is available. There is deliberately no shared or - // static fallback (FR-014). - ErrNoCredential = errors.New("credential resolver: no per-user credential available") - - // ErrBrokerNotConfigured is returned when the target server has no auth_broker - // block. Such upstreams are not brokered and behave exactly as today. - ErrBrokerNotConfigured = errors.New("credential resolver: server has no auth_broker configuration") -) - -// Exchanger mints an upstream credential by exchanging the user's stored IdP -// subject token (token_exchange / entra_obo). *TokenExchanger satisfies it. -type Exchanger interface { - Exchange(ctx context.Context, userID, serverKey string, cfg *config.AuthBrokerConfig) (*UpstreamCredential, error) -} - -// Connector drives the per-user OAuth connect flow (Path B). *OAuthConnector -// satisfies it. The resolver uses Refresh to renew a near-expiry connect-flow -// credential and BuildAuthorizationURL to produce an actionable connect URL -// when the user has not yet connected the upstream. -type Connector interface { - ServerKey() string - BuildAuthorizationURL(userID string) (authURL, state string, err error) - Refresh(ctx context.Context, userID string) (*UpstreamCredential, error) -} - -// ConnectorProvider resolves the per-upstream OAuthConnector for a server. The -// REST layer (T8) supplies an implementation that assembles a ConnectorConfig -// from the server's auth_broker block plus the gateway's callback URL. It is -// only consulted for oauth_connect-mode upstreams. -type ConnectorProvider interface { - ConnectorFor(server *config.ServerConfig) (Connector, error) -} - -// NotConnectedError is returned when an oauth_connect upstream cannot produce a -// usable per-user credential and the user must (re)consent. It carries the -// authorize URL the caller redirects the user to (FR-013, actionable error) and -// a Reason that distinguishes a first-time connect from an expired credential -// whose refresh failed (so callers do not tell an already-connected user they -// have "never connected"). -type NotConnectedError struct { - ServerName string - ConnectURL string - Reason string -} - -func (e *NotConnectedError) Error() string { - if e.Reason != "" { - return fmt.Sprintf("credential resolver: upstream %q requires connection (%s); connect at: %s", - e.ServerName, e.Reason, e.ConnectURL) - } - return fmt.Sprintf("credential resolver: upstream %q is not connected for this user; connect at: %s", - e.ServerName, e.ConnectURL) -} - -// PolicyDecision is the verdict of the policy-decision seam evaluated before a -// resolved credential is returned. Allow=false blocks the injection. -type PolicyDecision struct { - Allow bool - Reason string -} - -// PolicyInput is the context handed to the policy seam. -type PolicyInput struct { - UserID string - ServerName string - ServerKey string - Credential *UpstreamCredential -} - -// PolicyHook is the policy-decision seam (FR-015). No policy engine ships now; -// the resolver defaults to an allow-all hook. A future engine implements this -// interface without changing the resolver. -type PolicyHook interface { - Evaluate(ctx context.Context, in PolicyInput) (PolicyDecision, error) -} - -// PolicyHookFunc adapts a function to the PolicyHook interface. -type PolicyHookFunc func(ctx context.Context, in PolicyInput) (PolicyDecision, error) - -// Evaluate implements PolicyHook. -func (f PolicyHookFunc) Evaluate(ctx context.Context, in PolicyInput) (PolicyDecision, error) { - return f(ctx, in) -} - -// allowAllPolicy is the default seam implementation: it permits every -// injection. It exists so the resolver always has a non-nil hook (FR-015). -type allowAllPolicy struct{} - -func (allowAllPolicy) Evaluate(_ context.Context, _ PolicyInput) (PolicyDecision, error) { - return PolicyDecision{Allow: true}, nil -} - -// PolicyDeniedError is returned when the policy seam blocks a resolved -// credential from being injected. -type PolicyDeniedError struct { - ServerName string - Reason string -} - -func (e *PolicyDeniedError) Error() string { - if e.Reason != "" { - return fmt.Sprintf("credential resolver: policy denied credential for %q: %s", e.ServerName, e.Reason) - } - return fmt.Sprintf("credential resolver: policy denied credential for %q", e.ServerName) -} - -// ResolverDeps are the collaborators a CredentialResolver needs. Store and -// Exchanger are required for token-exchange upstreams; Connectors is required -// only for oauth_connect upstreams. Policy, Logger, and Audit are optional. -type ResolverDeps struct { - Store CredentialStore - Exchanger Exchanger - Connectors ConnectorProvider - Policy PolicyHook - Logger *zap.Logger - Audit AuditSink - RefreshThreshold time.Duration -} - -// CredentialResolver produces the per-user upstream credential to inject on a -// proxied request. It applies a strict per-user-only ordering (FR-013/FR-014): -// -// 1. a valid cached per-user credential (refreshed if near-expiry); -// 2. else a freshly token-exchanged / OBO credential from the stored IdP -// subject token; -// 3. else, for oauth_connect upstreams the user has not connected, an -// actionable NotConnectedError carrying the connect URL; -// 4. else ErrNoCredential. -// -// There is no shared or static fallback. Concurrent acquisitions for the same -// (user, server) are coalesced via single-flight so the upstream authorization -// server is not hit with duplicate flows. -type CredentialResolver struct { - store CredentialStore - exchanger Exchanger - conns ConnectorProvider - policy PolicyHook - logger *zap.Logger - audit AuditSink - - refreshThreshold time.Duration - group singleflight.Group -} - -// acquisition is the internal result of the per-(user,server) acquire flight. It -// carries the resolved credential (nil on failure) and the audit action that the -// flight performed (acquire / refresh / inject / connect) so Resolve can emit a -// single, correctly-classified audit event for the whole resolution. -type acquisition struct { - cred *UpstreamCredential - action string -} - -// NewCredentialResolver constructs a resolver from its dependencies, applying -// defaults for the optional fields. -func NewCredentialResolver(deps ResolverDeps) *CredentialResolver { - logger := deps.Logger - if logger == nil { - logger = zap.NewNop() - } - policy := deps.Policy - if policy == nil { - policy = allowAllPolicy{} - } - auditSink := deps.Audit - if auditSink == nil { - auditSink = nopAuditSink{} - } - threshold := deps.RefreshThreshold - if threshold <= 0 { - threshold = defaultRefreshThreshold - } - return &CredentialResolver{ - store: deps.Store, - exchanger: deps.Exchanger, - conns: deps.Connectors, - policy: policy, - logger: logger.Named("credential-resolver"), - audit: auditSink, - refreshThreshold: threshold, - } -} - -// Resolve returns the per-user credential to inject for (userID, server), -// applying the ordering described on CredentialResolver. The policy seam is -// evaluated per call after acquisition; credential acquisition itself is -// coalesced per (user, server) via single-flight. -func (r *CredentialResolver) Resolve(ctx context.Context, userID string, server *config.ServerConfig) (*UpstreamCredential, error) { - if userID == "" { - return nil, ErrUnauthenticated - } - if server == nil || server.AuthBroker == nil { - return nil, ErrBrokerNotConfigured - } - - method := auditMethodForMode(server.AuthBroker.Mode) - if r.store == nil || !r.store.Enabled() { - // A brokered upstream whose store is disabled: a genuine injection attempt - // that cannot proceed β€” audit it so the operator sees why injection failed. - r.emitAudit(ctx, userID, server.Name, method, AuditActionInject, ErrStoreDisabled) - return nil, ErrStoreDisabled - } - - serverKey := oauth.GenerateServerKey(server.Name, server.URL) - - // Coalesce concurrent acquisitions for the same (user, server) so duplicate - // upstream token flows are not triggered (reuse the single-flight pattern). - // - // The flight runs the acquisition once for every co-pending caller. Detach - // the caller's cancellation with context.WithoutCancel so the in-flight - // acquisition is not aborted β€” and its error broadcast to all waiters β€” just - // because whichever caller happened to start the flight cancelled (client - // disconnect, timeout). Per-caller cancellation still applies below at the - // policy/return layer, which uses the caller's original ctx. - flightKey := userID + "\x00" + serverKey - v, err, _ := r.group.Do(flightKey, func() (interface{}, error) { - return r.acquire(context.WithoutCancel(ctx), userID, serverKey, server) - }) - - // Recover the action the flight performed so the audit event is classified - // correctly (acquire / refresh / inject / connect) regardless of outcome. - action := AuditActionInject - if acq, ok := v.(*acquisition); ok && acq != nil && acq.action != "" { - action = acq.action - } - if err != nil { - r.emitAudit(ctx, userID, server.Name, method, action, err) - return nil, err - } - acq, _ := v.(*acquisition) - if acq == nil || acq.cred == nil { - r.emitAudit(ctx, userID, server.Name, method, action, ErrNoCredential) - return nil, ErrNoCredential - } - cred := acq.cred - - // Policy-decision seam: evaluated per call, before the credential is handed - // to the caller (FR-015). Default hook allows everything. - decision, perr := r.policy.Evaluate(ctx, PolicyInput{ - UserID: userID, - ServerName: server.Name, - ServerKey: serverKey, - Credential: cred, - }) - if perr != nil { - wrapped := fmt.Errorf("credential resolver: policy evaluation failed: %w", perr) - r.emitAudit(ctx, userID, server.Name, method, AuditActionInject, wrapped) - return nil, wrapped - } - if !decision.Allow { - denied := &PolicyDeniedError{ServerName: server.Name, Reason: decision.Reason} - r.emitAudit(ctx, userID, server.Name, method, AuditActionInject, denied) - return nil, denied - } - - r.emitAudit(ctx, userID, server.Name, method, action, nil) - return cred, nil -} - -// emitAudit records one secret-free credential-brokering audit event. A nil err -// is a success; any other err is recorded as a failure with a secret-free reason -// drawn from the broker's (secret-free) sentinel/actionable errors (FR-028/029). -func (r *CredentialResolver) emitAudit(ctx context.Context, userID, serverName, method, action string, err error) { - ev := AuditEvent{ - UserID: userID, - ServerName: serverName, - Method: method, - Action: action, - Outcome: AuditOutcomeSuccess, - RequestID: auditRequestID(ctx), - } - if err != nil { - ev.Outcome = AuditOutcomeFailure - ev.Reason = auditReason(err) - } - r.audit.RecordBrokerEvent(ctx, ev) -} - -// auditReason renders a secret-free failure reason. NotConnectedError carries a -// purpose-built Reason; all other broker errors are deliberately coarse and -// secret-free, so their Error() text is safe to record. -func auditReason(err error) string { - if err == nil { - return "" - } - var nc *NotConnectedError - if errors.As(err, &nc) && nc.Reason != "" { - return nc.Reason - } - return err.Error() -} - -// acquire runs the per-user-only ordering for a single (user, server). It is -// invoked inside the single-flight group. -// -// Acquisition and refresh share a path per mode so a near-expiry cache miss does -// not trigger a redundant double acquisition. The Exchanger (T4) and Connector -// (T5) persist their results into the store themselves, so the resolver never -// calls store.Put β€” it only reads the cache via store.Get. -func (r *CredentialResolver) acquire(ctx context.Context, userID, serverKey string, server *config.ServerConfig) (*acquisition, error) { - cfg := server.AuthBroker - - // 1. Serve a still-valid, not-near-expiry cached credential directly. - cached, err := r.store.Get(userID, serverKey) - hasCache := err == nil && cached != nil - switch { - case hasCache: - if cached.IsValid() && !cached.ExpiresWithin(r.refreshThreshold) { - // No new acquisition: an existing valid credential is used for injection. - return &acquisition{cred: cached, action: AuditActionInject}, nil - } - // Stale / near-expiry: renewed by the per-mode path below. - case errors.Is(err, ErrNotFound): - // No cache: acquired by the per-mode path below. - default: - // Unexpected store error (not "missing"): surface it. - return &acquisition{action: AuditActionInject}, - fmt.Errorf("credential resolver: load cached credential: %w", err) - } - - switch cfg.Mode { - case config.AuthBrokerModeTokenExchange, config.AuthBrokerModeEntraOBO: - // 2. Token-exchange / OBO: the first-acquisition and refresh paths are - // identical (re-mint from the stored IdP subject token), so a single - // Exchange call covers both the cache-miss and near-expiry cases. A - // near-expiry cache hit is a refresh; a cache miss is a first acquisition. - action := AuditActionAcquire - if hasCache { - action = AuditActionRefresh - } - if r.exchanger == nil { - return &acquisition{action: action}, - fmt.Errorf("credential resolver: no token exchanger configured for mode %q", cfg.Mode) - } - cred, xerr := r.exchanger.Exchange(ctx, userID, serverKey, cfg) - return &acquisition{cred: cred, action: action}, xerr - - case config.AuthBrokerModeOAuthConnect: - conn, cerr := r.connectorFor(server) - if cerr != nil { - return &acquisition{action: AuditActionConnect}, cerr - } - // A cached connect-flow credential means the user already connected: - // renew transparently via the stored refresh token. Only when that - // refresh fails do we ask the (already-connected) user to reconnect. - if hasCache && cached.RefreshToken != "" { - refreshed, rerr := conn.Refresh(ctx, userID) - if rerr == nil { - return &acquisition{cred: refreshed, action: AuditActionRefresh}, nil - } - r.logger.Warn("connect-flow credential refresh failed; user must reconnect", - zap.String("server", server.Name), zap.Error(rerr)) - return &acquisition{action: AuditActionRefresh}, - r.notConnected(conn, server, userID, "stored credential expired and refresh failed; reconnect required") - } - // 3. Never connected, or connected without a usable refresh token and now - // expired β€” both require (re)consent through the connect flow. - reason := "not connected" - if hasCache { - reason = "stored credential expired; reconnect required" - } - return &acquisition{action: AuditActionConnect}, r.notConnected(conn, server, userID, reason) - - default: - // 4. No recognised acquisition strategy and no per-user credential. - return &acquisition{action: AuditActionAcquire}, ErrNoCredential - } -} - -// notConnected builds the actionable NotConnectedError carrying the upstream -// authorize URL the caller must redirect the user to, tagged with reason. -func (r *CredentialResolver) notConnected(conn Connector, server *config.ServerConfig, userID, reason string) error { - authURL, _, aerr := conn.BuildAuthorizationURL(userID) - if aerr != nil { - return fmt.Errorf("credential resolver: build connect URL: %w", aerr) - } - return &NotConnectedError{ServerName: server.Name, ConnectURL: authURL, Reason: reason} -} - -// connectorFor resolves the per-upstream connector, guarding against a missing -// provider (only oauth_connect upstreams need one). -func (r *CredentialResolver) connectorFor(server *config.ServerConfig) (Connector, error) { - if r.conns == nil { - return nil, fmt.Errorf("credential resolver: no connector provider configured for oauth_connect upstream %q", server.Name) - } - conn, err := r.conns.ConnectorFor(server) - if err != nil { - return nil, fmt.Errorf("credential resolver: resolve connector: %w", err) - } - return conn, nil -} - -// Compile-time assertions that the concrete broker types satisfy the resolver's -// collaborator interfaces. -var ( - _ Exchanger = (*TokenExchanger)(nil) - _ Connector = (*OAuthConnector)(nil) -) diff --git a/internal/serveredition/broker/credential_resolver_test.go b/internal/serveredition/broker/credential_resolver_test.go deleted file mode 100644 index 200e62aa6..000000000 --- a/internal/serveredition/broker/credential_resolver_test.go +++ /dev/null @@ -1,579 +0,0 @@ -//go:build server - -package broker - -import ( - "context" - "errors" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" -) - -// --- test doubles ----------------------------------------------------------- - -// fakeStore is an in-memory CredentialStore keyed by (userID, serverKey), -// matching the real backend's keying so resolver key derivation is exercised. -type fakeStore struct { - mu sync.Mutex - enabled bool - data map[string]*UpstreamCredential - getErr error -} - -func newFakeStore() *fakeStore { - return &fakeStore{enabled: true, data: map[string]*UpstreamCredential{}} -} - -func storeKey(userID, serverKey string) string { return userID + "\x00" + serverKey } - -func (s *fakeStore) Enabled() bool { return s.enabled } - -func (s *fakeStore) Get(userID, serverKey string) (*UpstreamCredential, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.getErr != nil { - return nil, s.getErr - } - c, ok := s.data[storeKey(userID, serverKey)] - if !ok { - return nil, ErrNotFound - } - return c, nil -} - -func (s *fakeStore) Put(userID, serverKey string, cred *UpstreamCredential) error { - s.mu.Lock() - defer s.mu.Unlock() - s.data[storeKey(userID, serverKey)] = cred - return nil -} - -func (s *fakeStore) Delete(userID, serverKey string) error { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.data, storeKey(userID, serverKey)) - return nil -} - -func (s *fakeStore) List(userID string) ([]CredentialEntry, error) { return nil, nil } - -func (s *fakeStore) seed(userID, serverKey string, cred *UpstreamCredential) { - s.mu.Lock() - defer s.mu.Unlock() - s.data[storeKey(userID, serverKey)] = cred -} - -// fakeExchanger records calls and returns a programmed credential/error. -type fakeExchanger struct { - calls int32 - cred *UpstreamCredential - err error - delay time.Duration - startWG *sync.WaitGroup - gotCtxErr error -} - -func (e *fakeExchanger) Exchange(ctx context.Context, userID, serverKey string, _ *config.AuthBrokerConfig) (*UpstreamCredential, error) { - e.gotCtxErr = ctx.Err() - if e.startWG != nil { - e.startWG.Done() - } - if e.delay > 0 { - time.Sleep(e.delay) - } - atomic.AddInt32(&e.calls, 1) - if e.err != nil { - return nil, e.err - } - return e.cred, nil -} - -// fakeConnector implements Connector for connect-flow paths. -type fakeConnector struct { - serverKey string - authURL string - buildErr error - refreshCred *UpstreamCredential - refreshErr error - buildCalls int32 - refreshCalls int32 -} - -func (c *fakeConnector) ServerKey() string { return c.serverKey } - -func (c *fakeConnector) BuildAuthorizationURL(_ string) (string, string, error) { - atomic.AddInt32(&c.buildCalls, 1) - if c.buildErr != nil { - return "", "", c.buildErr - } - return c.authURL, "state-xyz", nil -} - -func (c *fakeConnector) Refresh(_ context.Context, _ string) (*UpstreamCredential, error) { - atomic.AddInt32(&c.refreshCalls, 1) - if c.refreshErr != nil { - return nil, c.refreshErr - } - return c.refreshCred, nil -} - -type fakeConnectorProvider struct { - conn *fakeConnector - err error -} - -func (p *fakeConnectorProvider) ConnectorFor(_ *config.ServerConfig) (Connector, error) { - if p.err != nil { - return nil, p.err - } - return p.conn, nil -} - -// --- fixtures ---------------------------------------------------------------- - -func httpServer(name string, broker *config.AuthBrokerConfig) *config.ServerConfig { - return &config.ServerConfig{ - Name: name, - URL: "https://" + name + ".example.com/mcp", - Protocol: "http", - AuthBroker: broker, - } -} - -func tokenExchangeBroker() *config.AuthBrokerConfig { - b := &config.AuthBrokerConfig{Mode: config.AuthBrokerModeTokenExchange, TokenEndpoint: "https://idp/token", Scopes: []string{"api"}} - b.ApplyDefaults() - return b -} - -func connectBroker() *config.AuthBrokerConfig { - b := &config.AuthBrokerConfig{ - Mode: config.AuthBrokerModeOAuthConnect, - TokenEndpoint: "https://idp/token", - AuthorizationEndpoint: "https://idp/authorize", - ClientID: "client", - } - b.ApplyDefaults() - return b -} - -func validCred() *UpstreamCredential { - return &UpstreamCredential{Type: "oauth2", AccessToken: "cached-token", ExpiresAt: time.Now().Add(time.Hour), ObtainedVia: "token_exchange"} -} - -// --- tests ------------------------------------------------------------------- - -func TestResolve_ValidCachedCredential(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - store.seed("alice", key, validCred()) - - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: "fresh"}} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - got, err := r.Resolve(context.Background(), "alice", server) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.AccessToken != "cached-token" { - t.Fatalf("expected cached token, got %q", got.AccessToken) - } - if c := atomic.LoadInt32(&ex.calls); c != 0 { - t.Fatalf("expected no exchange calls for valid cache, got %d", c) - } -} - -func TestResolve_NearExpiryRefresh_TokenExchange(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - nearExpiry := &UpstreamCredential{AccessToken: "old", ExpiresAt: time.Now().Add(10 * time.Second)} - store.seed("alice", key, nearExpiry) - - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: "refreshed"}} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - got, err := r.Resolve(context.Background(), "alice", server) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.AccessToken != "refreshed" { - t.Fatalf("expected refreshed token, got %q", got.AccessToken) - } - if c := atomic.LoadInt32(&ex.calls); c != 1 { - t.Fatalf("expected 1 exchange call, got %d", c) - } -} - -func TestResolve_NearExpiryRefresh_ConnectFlow(t *testing.T) { - store := newFakeStore() - server := httpServer("github", connectBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - store.seed("alice", key, &UpstreamCredential{AccessToken: "old", RefreshToken: "rt", ExpiresAt: time.Now().Add(5 * time.Second)}) - - conn := &fakeConnector{serverKey: key, refreshCred: &UpstreamCredential{AccessToken: "refreshed-connect"}} - r := NewCredentialResolver(ResolverDeps{Store: store, Connectors: &fakeConnectorProvider{conn: conn}}) - - got, err := r.Resolve(context.Background(), "alice", server) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.AccessToken != "refreshed-connect" { - t.Fatalf("expected refreshed-connect, got %q", got.AccessToken) - } - if c := atomic.LoadInt32(&conn.refreshCalls); c != 1 { - t.Fatalf("expected 1 refresh call, got %d", c) - } -} - -func TestResolve_NoCache_TokenExchange(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: "exchanged"}} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - got, err := r.Resolve(context.Background(), "alice", server) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.AccessToken != "exchanged" { - t.Fatalf("expected exchanged token, got %q", got.AccessToken) - } -} - -func TestResolve_NoCache_EntraOBO(t *testing.T) { - store := newFakeStore() - b := &config.AuthBrokerConfig{Mode: config.AuthBrokerModeEntraOBO, TokenEndpoint: "https://login.microsoftonline.com/token"} - b.ApplyDefaults() - server := httpServer("graph", b) - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: "obo"}} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - got, err := r.Resolve(context.Background(), "alice", server) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.AccessToken != "obo" { - t.Fatalf("expected obo token, got %q", got.AccessToken) - } -} - -func TestResolve_ConnectUnconnected_ReturnsActionableConnectURL(t *testing.T) { - store := newFakeStore() - server := httpServer("github", connectBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - conn := &fakeConnector{serverKey: key, authURL: "https://idp/authorize?client_id=client&state=state-xyz"} - r := NewCredentialResolver(ResolverDeps{Store: store, Connectors: &fakeConnectorProvider{conn: conn}}) - - _, err := r.Resolve(context.Background(), "alice", server) - if err == nil { - t.Fatal("expected NotConnectedError, got nil") - } - var nce *NotConnectedError - if !errors.As(err, &nce) { - t.Fatalf("expected *NotConnectedError, got %T: %v", err, err) - } - if nce.ConnectURL != conn.authURL { - t.Fatalf("expected connect URL %q in error, got %q", conn.authURL, nce.ConnectURL) - } - if !strings.Contains(err.Error(), conn.authURL) { - t.Fatalf("error message must surface the connect URL, got %q", err.Error()) - } -} - -func TestResolve_Unauthenticated_Rejected(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - ex := &fakeExchanger{cred: validCred()} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - _, err := r.Resolve(context.Background(), "", server) - if !errors.Is(err, ErrUnauthenticated) { - t.Fatalf("expected ErrUnauthenticated, got %v", err) - } - if c := atomic.LoadInt32(&ex.calls); c != 0 { - t.Fatalf("expected no work for unauthenticated caller, got %d exchange calls", c) - } -} - -func TestResolve_StoreDisabled_DegradesGracefully(t *testing.T) { - store := newFakeStore() - store.enabled = false - server := httpServer("grafana", tokenExchangeBroker()) - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: &fakeExchanger{cred: validCred()}}) - - _, err := r.Resolve(context.Background(), "alice", server) - if !errors.Is(err, ErrStoreDisabled) { - t.Fatalf("expected ErrStoreDisabled, got %v", err) - } -} - -func TestResolve_NoBrokerConfig_Rejected(t *testing.T) { - store := newFakeStore() - server := httpServer("plain", nil) - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: &fakeExchanger{}}) - - _, err := r.Resolve(context.Background(), "alice", server) - if err == nil { - t.Fatal("expected error for server without auth_broker, got nil") - } -} - -func TestResolve_NoStaticFallback_OnExchangeFailure(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - ex := &fakeExchanger{err: errors.New("token exchange failed: status 401, error \"invalid_grant\"")} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - got, err := r.Resolve(context.Background(), "alice", server) - if err == nil { - t.Fatal("expected the exchange error to propagate (no static fallback), got nil") - } - if got != nil { - t.Fatalf("expected no credential on failure (FR-014, no shared fallback), got %+v", got) - } -} - -func TestResolve_PolicyHook_DeniesInjection(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - store.seed("alice", key, validCred()) - - policy := PolicyHookFunc(func(_ context.Context, in PolicyInput) (PolicyDecision, error) { - return PolicyDecision{Allow: false, Reason: "blocked by policy for " + in.ServerName}, nil - }) - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: &fakeExchanger{}, Policy: policy}) - - _, err := r.Resolve(context.Background(), "alice", server) - var pde *PolicyDeniedError - if !errors.As(err, &pde) { - t.Fatalf("expected *PolicyDeniedError, got %T: %v", err, err) - } - if !strings.Contains(pde.Reason, "grafana") { - t.Fatalf("expected reason to include server name, got %q", pde.Reason) - } -} - -func TestResolve_SingleFlight_CoalescesConcurrentAcquisitions(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - - const n = 12 - var start sync.WaitGroup - start.Add(1) - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: "exchanged"}, delay: 40 * time.Millisecond} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - var wg sync.WaitGroup - errs := make([]error, n) - toks := make([]string, n) - for i := 0; i < n; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - start.Wait() - cred, err := r.Resolve(context.Background(), "alice", server) - errs[idx] = err - if cred != nil { - toks[idx] = cred.AccessToken - } - }(i) - } - start.Done() // release all goroutines together - wg.Wait() - - for i := 0; i < n; i++ { - if errs[i] != nil { - t.Fatalf("goroutine %d errored: %v", i, errs[i]) - } - if toks[i] != "exchanged" { - t.Fatalf("goroutine %d got %q", i, toks[i]) - } - } - if c := atomic.LoadInt32(&ex.calls); c != 1 { - t.Fatalf("single-flight should coalesce to 1 upstream acquisition, got %d", c) - } -} - -// TestResolve_SingleFlight_DetachesCallerCancellation proves the must-fix from -// review: the in-flight acquisition must not inherit the calling request's -// cancellation, or a cancelled caller would broadcast its ctx error to every -// co-pending acquisition for the same (user, server). -func TestResolve_SingleFlight_DetachesCallerCancellation(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - ex := &fakeExchanger{cred: &UpstreamCredential{AccessToken: "exchanged"}} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // caller's request is already cancelled before acquisition runs - - got, err := r.Resolve(ctx, "alice", server) - if err != nil { - t.Fatalf("acquisition should run despite caller cancellation, got error: %v", err) - } - if got.AccessToken != "exchanged" { - t.Fatalf("expected exchanged token, got %q", got.AccessToken) - } - if ex.gotCtxErr != nil { - t.Fatalf("flight context must be detached from caller cancellation, got ctx.Err()=%v", ex.gotCtxErr) - } -} - -// TestResolve_TokenExchange_NearExpiry_NoDoubleExchangeOnFailure proves the -// advisory fix: a near-expiry token-exchange credential whose re-mint fails must -// surface that single error, not retry Exchange a second time. -func TestResolve_TokenExchange_NearExpiry_NoDoubleExchangeOnFailure(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - store.seed("alice", key, &UpstreamCredential{AccessToken: "old", ExpiresAt: time.Now().Add(5 * time.Second)}) - - ex := &fakeExchanger{err: errors.New("token exchange failed: status 401, error \"invalid_grant\"")} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - _, err := r.Resolve(context.Background(), "alice", server) - if err == nil { - t.Fatal("expected the exchange error to propagate, got nil") - } - if c := atomic.LoadInt32(&ex.calls); c != 1 { - t.Fatalf("near-expiry exchange failure must not double-call Exchange, got %d calls", c) - } -} - -// TestResolve_ConnectFlow_RefreshFails_ReturnsReconnectError proves the advisory -// fix: an already-connected user whose refresh fails gets an actionable -// reconnect error (with the connect URL), not a misleading "never connected". -func TestResolve_ConnectFlow_RefreshFails_ReturnsReconnectError(t *testing.T) { - store := newFakeStore() - server := httpServer("github", connectBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - store.seed("alice", key, &UpstreamCredential{AccessToken: "old", RefreshToken: "rt", ExpiresAt: time.Now().Add(5 * time.Second)}) - - conn := &fakeConnector{ - serverKey: key, - authURL: "https://idp/authorize?client_id=client&state=state-xyz", - refreshErr: errors.New("oauth connector: token endpoint returned 400: invalid_grant"), - } - r := NewCredentialResolver(ResolverDeps{Store: store, Connectors: &fakeConnectorProvider{conn: conn}}) - - _, err := r.Resolve(context.Background(), "alice", server) - var nce *NotConnectedError - if !errors.As(err, &nce) { - t.Fatalf("expected *NotConnectedError, got %T: %v", err, err) - } - if nce.Reason == "" || !strings.Contains(nce.Reason, "reconnect") { - t.Fatalf("expected a reconnect reason, got %q", nce.Reason) - } - if nce.ConnectURL != conn.authURL { - t.Fatalf("expected connect URL %q, got %q", conn.authURL, nce.ConnectURL) - } - if c := atomic.LoadInt32(&conn.refreshCalls); c != 1 { - t.Fatalf("expected exactly 1 refresh attempt, got %d", c) - } - if c := atomic.LoadInt32(&conn.buildCalls); c != 1 { - t.Fatalf("expected the connect URL to be built once, got %d", c) - } -} - -// TestResolve_CrossUserIsolation_NeverReturnsAnotherUsersCredential is the -// direct cross-user isolation guard (MCP-2578, backlog follow-up to MCP-1039 / -// #688). #688 verified isolation only structurally (every lookup is -// store.Get(userID, serverKey)-keyed with no shared fallback); this asserts the -// behaviour end-to-end: seed user B's credential, Resolve as user A for the -// SAME serverKey, and prove user A gets the fail-closed path and NEVER user B's -// token. -// -// The seeded user B credential is deliberately VALID and not near expiry, so a -// regression that dropped the per-user keying (a shared/static fallback) would -// make acquire() return it directly from cache (FR-014). With correct keying, -// store.Get(userA, key) misses and user A falls through to its own β€” absent β€” -// acquisition path, which fails closed. -func TestResolve_CrossUserIsolation_NeverReturnsAnotherUsersCredential(t *testing.T) { - const userBToken = "userB-secret-token-MUST-NOT-LEAK" - - t.Run("token_exchange mode falls closed, not to user B's cache", func(t *testing.T) { - store := newFakeStore() - server := httpServer("grafana", tokenExchangeBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - - // Seed user B with a valid, long-lived credential for the same serverKey. - store.seed("userB", key, &UpstreamCredential{ - Type: "oauth2", - AccessToken: userBToken, - ExpiresAt: time.Now().Add(time.Hour), - ObtainedVia: "token_exchange", - }) - - // User A has no credential and its own acquisition path fails (fail closed). - ex := &fakeExchanger{err: errors.New("token exchange failed: status 401, error \"invalid_grant\"")} - r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex}) - - got, err := r.Resolve(context.Background(), "userA", server) - if err == nil { - t.Fatal("expected user A to fail closed (no per-user credential), got nil error") - } - if got != nil { - t.Fatalf("user A must receive no credential, got %+v", got) - } - // The acquisition path must have been exercised β€” proving user A did NOT - // short-circuit to user B's cached credential (which a shared fallback - // would do, skipping Exchange entirely). - if c := atomic.LoadInt32(&ex.calls); c != 1 { - t.Fatalf("expected user A to go through its own acquisition (1 Exchange call), got %d β€” a shared cache fallback would skip it", c) - } - - // User B's credential is still intact and retrievable as user B, proving - // the seed was real and the miss for user A is isolation, not absence. - bCred, bErr := store.Get("userB", key) - if bErr != nil || bCred == nil || bCred.AccessToken != userBToken { - t.Fatalf("user B's credential should be intact; got %+v err=%v", bCred, bErr) - } - }) - - t.Run("oauth_connect mode falls closed to NotConnectedError, not user B's cache", func(t *testing.T) { - store := newFakeStore() - server := httpServer("github", connectBroker()) - key := oauth.GenerateServerKey(server.Name, server.URL) - - // Seed user B with a valid connect-flow credential for the same serverKey. - store.seed("userB", key, &UpstreamCredential{ - Type: "oauth2", - AccessToken: userBToken, - RefreshToken: "userB-refresh", - ExpiresAt: time.Now().Add(time.Hour), - ObtainedVia: "oauth_connect", - }) - - conn := &fakeConnector{serverKey: key, authURL: "https://idp/authorize?client_id=client&state=state-xyz"} - r := NewCredentialResolver(ResolverDeps{Store: store, Connectors: &fakeConnectorProvider{conn: conn}}) - - got, err := r.Resolve(context.Background(), "userA", server) - if got != nil { - t.Fatalf("user A must receive no credential, got %+v", got) - } - var nce *NotConnectedError - if !errors.As(err, &nce) { - t.Fatalf("expected user A to fail closed with *NotConnectedError, got %T: %v", err, err) - } - if nce.ConnectURL != conn.authURL { - t.Fatalf("expected the actionable connect URL %q, got %q", conn.authURL, nce.ConnectURL) - } - // User A must be steered into its own connect flow, never handed user B's - // existing connection (no refresh against user B's cached credential). - if c := atomic.LoadInt32(&conn.refreshCalls); c != 0 { - t.Fatalf("user A must not refresh against user B's cached credential, got %d refresh calls", c) - } - if !strings.Contains(err.Error(), conn.authURL) || strings.Contains(err.Error(), userBToken) { - t.Fatalf("error must surface user A's connect URL and never user B's token, got %q", err.Error()) - } - }) -} diff --git a/internal/serveredition/broker/credential_store_test.go b/internal/serveredition/broker/credential_store_test.go index 3125a497a..65b8c1b59 100644 --- a/internal/serveredition/broker/credential_store_test.go +++ b/internal/serveredition/broker/credential_store_test.go @@ -55,7 +55,7 @@ func sampleCred() *UpstreamCredential { Scopes: []string{"read", "write"}, TokenType: "Bearer", Audience: "https://api.example.com", - ObtainedVia: "token_exchange", + ObtainedVia: "connect_flow", UpdatedAt: time.Now().UTC().Truncate(time.Second), } } diff --git a/internal/serveredition/broker/injector.go b/internal/serveredition/broker/injector.go deleted file mode 100644 index 129a599a9..000000000 --- a/internal/serveredition/broker/injector.go +++ /dev/null @@ -1,108 +0,0 @@ -//go:build server - -package broker - -import ( - "context" - "errors" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport" -) - -// ErrBrokerStdioUnsupported is returned when brokering is requested for a -// non-HTTP-family (stdio) upstream. Credential injection only works over -// HTTP/SSE/streamable-HTTP transports in this phase (spec 074 FR-002). This is a -// runtime defense-in-depth check; config validation already rejects such blocks -// at load time. -var ErrBrokerStdioUnsupported = errors.New("auth broker: credential injection is only supported on HTTP-family upstreams (http, sse, streamable-http); stdio brokering is unsupported in this phase") - -// Fallback header/format used when a brokered server's config has not had its -// defaults applied. These mirror config.AuthBrokerConfig.ApplyDefaults (FR-016); -// config validation normally applies them at load time. -const ( - fallbackBrokerHeader = "Authorization" - fallbackBrokerHeaderFormat = "Bearer {token}" -) - -// resolver is the subset of *CredentialResolver the injector depends on. It is -// an interface so tests can substitute a fake without a real store/exchanger. -type resolver interface { - Resolve(ctx context.Context, userID string, server *config.ServerConfig) (*UpstreamCredential, error) -} - -// HeaderInjector turns a per-user resolved upstream credential into the -// transport-layer BrokeredAuth injected on a proxied request. It is the bridge -// between the credential broker (server edition) and the edition-neutral -// transport layer: the transport never imports the broker, it only receives the -// resolved credential as plain data. -// -// The injector enforces the spec-074 brokering invariants at the injection -// boundary: -// - per-user only: an empty userID is rejected (FR-014); -// - HTTP-family only: stdio brokering is rejected (FR-002); -// - replacement, not forwarding: the produced BrokeredAuth replaces any -// configured/inbound auth header (FR-016/FR-017, enforced in transport). -type HeaderInjector struct { - resolver resolver -} - -// NewHeaderInjector builds an injector over a credential resolver. *CredentialResolver -// satisfies the resolver interface. -func NewHeaderInjector(r resolver) *HeaderInjector { - return &HeaderInjector{resolver: r} -} - -// InjectFor resolves the per-user credential for (userID, server) and returns -// the transport.BrokeredAuth to inject. It returns: -// - ErrUnauthenticated if userID is empty (FR-014); -// - ErrBrokerNotConfigured if the server has no auth_broker block; -// - ErrBrokerStdioUnsupported if the server is not HTTP-family (FR-002); -// - any resolver error (e.g. *NotConnectedError carrying a connect URL). -func (h *HeaderInjector) InjectFor(ctx context.Context, userID string, server *config.ServerConfig) (*transport.BrokeredAuth, error) { - if userID == "" { - return nil, ErrUnauthenticated - } - if server == nil || server.AuthBroker == nil { - return nil, ErrBrokerNotConfigured - } - // Defense-in-depth: reject brokering on stdio/non-HTTP upstreams (FR-002). - if transport.DetermineTransportType(server) == transport.TransportStdio { - return nil, ErrBrokerStdioUnsupported - } - - cred, err := h.resolver.Resolve(ctx, userID, server) - if err != nil { - return nil, err - } - if cred == nil || cred.AccessToken == "" { - return nil, ErrNoCredential - } - - header := server.AuthBroker.Header - if header == "" { - header = fallbackBrokerHeader - } - format := server.AuthBroker.HeaderFormat - if format == "" { - format = fallbackBrokerHeaderFormat - } - return &transport.BrokeredAuth{ - Header: header, - Format: format, - Token: cred.AccessToken, - }, nil -} - -// ConnectionKey derives the pooling key for a brokered upstream connection. It -// binds the connection to a single (user, server) pair so a shared upstream -// brokered per-user never reuses one user's credential/connection for another -// (FR-018). The server component reuses the existing oauth.GenerateServerKey -// scheme (name + URL) so it matches the credential store's keying. -func ConnectionKey(userID string, server *config.ServerConfig) string { - if server == nil { - return userID + "\x00" - } - return userID + "\x00" + oauth.GenerateServerKey(server.Name, server.URL) -} diff --git a/internal/serveredition/broker/injector_test.go b/internal/serveredition/broker/injector_test.go deleted file mode 100644 index 3a25f5a68..000000000 --- a/internal/serveredition/broker/injector_test.go +++ /dev/null @@ -1,163 +0,0 @@ -//go:build server - -package broker - -import ( - "context" - "errors" - "testing" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport" -) - -// fakeResolver returns a per-user token so the injector can be exercised without -// a real store/exchanger. It records the per-user resolution so tests can assert -// one user's credential is never produced for another (FR-018). -type fakeResolver struct { - tokens map[string]string // userID -> access token - err error -} - -func (f *fakeResolver) Resolve(_ context.Context, userID string, _ *config.ServerConfig) (*UpstreamCredential, error) { - if f.err != nil { - return nil, f.err - } - tok, ok := f.tokens[userID] - if !ok { - return nil, ErrNoCredential - } - return &UpstreamCredential{AccessToken: tok, TokenType: "Bearer"}, nil -} - -func httpBrokerServer() *config.ServerConfig { - s := &config.ServerConfig{ - Name: "ghe", - URL: "https://ghe.example/mcp", - Protocol: "streamable-http", - AuthBroker: &config.AuthBrokerConfig{ - Mode: config.AuthBrokerModeTokenExchange, - TokenEndpoint: "https://idp.example/token", - }, - } - s.AuthBroker.ApplyDefaults() - return s -} - -// FR-016: the resolved per-user credential is rendered into the configured -// header/format (default Authorization: Bearer {token}). -func TestInjector_InjectFor_ProducesBrokeredAuth(t *testing.T) { - inj := NewHeaderInjector(&fakeResolver{tokens: map[string]string{"alice": "alice-tok"}}) - - ba, err := inj.InjectFor(context.Background(), "alice", httpBrokerServer()) - if err != nil { - t.Fatalf("InjectFor: %v", err) - } - if ba.Header != "Authorization" { - t.Fatalf("header = %q, want Authorization", ba.Header) - } - if ba.HeaderValue() != "Bearer alice-tok" { - t.Fatalf("header value = %q, want %q", ba.HeaderValue(), "Bearer alice-tok") - } -} - -// FR-018 + SC-002/003: two users brokered against the SAME shared upstream get -// two distinct outbound tokens; one user's credential is never reused for the -// other. -func TestInjector_TwoUsers_TwoTokens(t *testing.T) { - inj := NewHeaderInjector(&fakeResolver{tokens: map[string]string{ - "alice": "alice-tok", - "bob": "bob-tok", - }}) - server := httpBrokerServer() - - aliceBA, err := inj.InjectFor(context.Background(), "alice", server) - if err != nil { - t.Fatalf("alice: %v", err) - } - bobBA, err := inj.InjectFor(context.Background(), "bob", server) - if err != nil { - t.Fatalf("bob: %v", err) - } - - if aliceBA.HeaderValue() == bobBA.HeaderValue() { - t.Fatalf("two users produced the same outbound token %q (FR-018 violation)", aliceBA.HeaderValue()) - } - - // And the effective outbound headers must carry each user's own token. - aliceHdr := transport.EffectiveHeaders(nil, aliceBA) - bobHdr := transport.EffectiveHeaders(nil, bobBA) - if aliceHdr["Authorization"] != "Bearer alice-tok" || bobHdr["Authorization"] != "Bearer bob-tok" { - t.Fatalf("cross-user token leak: alice=%q bob=%q", aliceHdr["Authorization"], bobHdr["Authorization"]) - } - - // Per-(user,server) connection keys must differ so connections are never - // pooled across users (FR-018). - if ConnectionKey("alice", server) == ConnectionKey("bob", server) { - t.Fatalf("connection key collided across users (FR-018 violation)") - } -} - -// FR-018: ConnectionKey is stable for the same (user, server) and distinct per -// user and per server so a brokered connection is never reused across either. -func TestConnectionKey_StableAndDistinct(t *testing.T) { - s1 := httpBrokerServer() - s2 := httpBrokerServer() - s2.Name = "other" - s2.URL = "https://other.example/mcp" - - k1 := ConnectionKey("alice", s1) - k1again := ConnectionKey("alice", s1) - if k1 != k1again { - t.Fatal("ConnectionKey must be stable for the same (user, server)") - } - if ConnectionKey("alice", s1) == ConnectionKey("alice", s2) { - t.Fatal("ConnectionKey must differ per server") - } - if ConnectionKey("alice", s1) == ConnectionKey("bob", s1) { - t.Fatal("ConnectionKey must differ per user") - } -} - -// FR-002: brokering on a stdio upstream is rejected with a clear, actionable -// message β€” never silently injected. -func TestInjector_RejectsStdio(t *testing.T) { - inj := NewHeaderInjector(&fakeResolver{tokens: map[string]string{"alice": "x"}}) - stdio := &config.ServerConfig{ - Name: "local", - Protocol: "stdio", - Command: "my-mcp", - AuthBroker: &config.AuthBrokerConfig{ - Mode: config.AuthBrokerModeTokenExchange, - TokenEndpoint: "https://idp.example/token", - }, - } - stdio.AuthBroker.ApplyDefaults() - - _, err := inj.InjectFor(context.Background(), "alice", stdio) - if !errors.Is(err, ErrBrokerStdioUnsupported) { - t.Fatalf("stdio brokering must be rejected with ErrBrokerStdioUnsupported, got %v", err) - } -} - -// A server with no auth_broker block is not brokered: the injector returns -// ErrBrokerNotConfigured and the caller proceeds with today's behaviour. -func TestInjector_NotConfigured(t *testing.T) { - inj := NewHeaderInjector(&fakeResolver{}) - plain := &config.ServerConfig{Name: "plain", URL: "https://x/mcp", Protocol: "streamable-http"} - - _, err := inj.InjectFor(context.Background(), "alice", plain) - if !errors.Is(err, ErrBrokerNotConfigured) { - t.Fatalf("non-brokered server must return ErrBrokerNotConfigured, got %v", err) - } -} - -// An empty userID is rejected before any resolution β€” brokering is strictly -// per-user (FR-014). -func TestInjector_RejectsAnonymous(t *testing.T) { - inj := NewHeaderInjector(&fakeResolver{tokens: map[string]string{"alice": "x"}}) - _, err := inj.InjectFor(context.Background(), "", httpBrokerServer()) - if !errors.Is(err, ErrUnauthenticated) { - t.Fatalf("anonymous caller must be rejected with ErrUnauthenticated, got %v", err) - } -} diff --git a/internal/serveredition/broker/oauth_connector.go b/internal/serveredition/broker/oauth_connector.go index 91480c4a5..ea5f7d0d8 100644 --- a/internal/serveredition/broker/oauth_connector.go +++ b/internal/serveredition/broker/oauth_connector.go @@ -259,46 +259,8 @@ func (c *OAuthConnector) Deny(state, reason string) error { return nil } -// Refresh mints a fresh access token for userID from the stored refresh token -// and re-persists the credential. It is the transparent auto-refresh path -// (FR-012). An absent or empty refresh token is an error. -func (c *OAuthConnector) Refresh(ctx context.Context, userID string) (*UpstreamCredential, error) { - existing, err := c.store.Get(userID, c.serverKey) - if err != nil { - return nil, fmt.Errorf("oauth connector: load credential: %w", err) - } - if existing.RefreshToken == "" { - return nil, fmt.Errorf("oauth connector: no refresh token for user %q", userID) - } - - form := url.Values{ - "grant_type": {"refresh_token"}, - "refresh_token": {existing.RefreshToken}, - "client_id": {c.cfg.ClientID}, - } - if len(c.cfg.Scopes) > 0 { - form.Set("scope", strings.Join(c.cfg.Scopes, " ")) - } - if c.cfg.Resource != "" { - form.Set("resource", c.cfg.Resource) - } - tok, err := c.postToken(ctx, form) - if err != nil { - return nil, err - } - - // Preserve the prior refresh token when the AS does not rotate it. - cred := c.credentialFromToken(tok, existing.RefreshToken) - if err := c.store.Put(userID, c.serverKey, cred); err != nil { - return nil, fmt.Errorf("oauth connector: persist refreshed credential: %w", err) - } - c.logger.Debug("refreshed per-user upstream credential", zap.String("user_id", userID)) - return cred, nil -} - // oauthTokenResponse is the subset of the OAuth token endpoint response we -// consume. Named distinctly from token_exchanger.go's tokenResponse (RFC 8693) -// to avoid a same-package redeclaration in the broker package. +// consume. type oauthTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` @@ -344,6 +306,14 @@ func (c *OAuthConnector) postToken(ctx context.Context, form url.Values) (*oauth return &tok, nil } +// tokenErrorResponse is the OAuth 2.0 error body (RFC 6749 Β§5.2). Only the +// machine-readable error code is ever surfaced; error_description may reflect +// caller-supplied input and is treated as untrusted (never returned to callers). +type tokenErrorResponse struct { + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` +} + // rfc6749TokenErrorCodes is the closed set of error codes a token endpoint may // legitimately return per RFC 6749 Β§5.2. Any value outside this set is treated // as untrusted AS-controlled free text (which could echo secrets or caller @@ -362,8 +332,7 @@ var rfc6749TokenErrorCodes = map[string]struct{}{ // error code. The raw response body and error_description are deliberately // dropped: a malicious or misconfigured authorization server can embed access // tokens, refresh tokens, client details, or echoed request data there, and -// that error string is logged on the connect and refresh paths (FR-029 / -// SC-005). This mirrors the RFC 8693 exchanger's sanitizedError. +// that error string is logged on the connect path (FR-029 / SC-005). func sanitizedTokenEndpointError(status int, body []byte) error { var te tokenErrorResponse _ = json.Unmarshal(body, &te) diff --git a/internal/serveredition/broker/oauth_connector_test.go b/internal/serveredition/broker/oauth_connector_test.go index e018d0861..54f719694 100644 --- a/internal/serveredition/broker/oauth_connector_test.go +++ b/internal/serveredition/broker/oauth_connector_test.go @@ -288,70 +288,6 @@ func TestOAuthConnector_Deny_StoresNothing(t *testing.T) { } } -func TestOAuthConnector_Refresh(t *testing.T) { - m := newMockTokenServer(t) - m.accessToken = "refreshed-access-token" - m.refreshToken = "" // emulate AS that does not rotate the refresh token - store := newConnectorTestStore(t) - c, _ := NewOAuthConnector(store, connectorTestConfig(m.srv.URL), zap.NewNop(), nil) - - // Seed an existing connect-flow credential with a refresh token. - seed := &UpstreamCredential{ - Type: "oauth2", - AccessToken: "old-access-token", - RefreshToken: "seed-refresh-token", - ExpiresAt: time.Now().Add(-time.Minute), // expired - ObtainedVia: "connect_flow", - } - if err := store.Put("user-alice", c.ServerKey(), seed); err != nil { - t.Fatalf("seed Put: %v", err) - } - - cred, err := c.Refresh(context.Background(), "user-alice") - if err != nil { - t.Fatalf("Refresh: %v", err) - } - if cred.AccessToken != "refreshed-access-token" { - t.Errorf("AccessToken = %q, want refreshed-access-token", cred.AccessToken) - } - // Refresh token preserved when the AS omits a new one. - if cred.RefreshToken != "seed-refresh-token" { - t.Errorf("RefreshToken = %q, want preserved seed-refresh-token", cred.RefreshToken) - } - if cred.ObtainedVia != "connect_flow" { - t.Errorf("ObtainedVia = %q", cred.ObtainedVia) - } - if m.lastForm.Get("grant_type") != "refresh_token" { - t.Errorf("grant_type = %q, want refresh_token", m.lastForm.Get("grant_type")) - } - if m.lastForm.Get("refresh_token") != "seed-refresh-token" { - t.Errorf("sent refresh_token = %q", m.lastForm.Get("refresh_token")) - } - - // Persisted. - stored, err := store.Get("user-alice", c.ServerKey()) - if err != nil { - t.Fatalf("store.Get: %v", err) - } - if stored.AccessToken != "refreshed-access-token" { - t.Errorf("stored AccessToken = %q", stored.AccessToken) - } -} - -func TestOAuthConnector_Refresh_NoRefreshToken(t *testing.T) { - m := newMockTokenServer(t) - store := newConnectorTestStore(t) - c, _ := NewOAuthConnector(store, connectorTestConfig(m.srv.URL), zap.NewNop(), nil) - - seed := &UpstreamCredential{AccessToken: "at", ObtainedVia: "connect_flow"} // no refresh token - if err := store.Put("user-alice", c.ServerKey(), seed); err != nil { - t.Fatalf("seed Put: %v", err) - } - if _, err := c.Refresh(context.Background(), "user-alice"); err == nil { - t.Fatal("expected error refreshing a credential with no refresh token") - } -} - func TestOAuthConnector_Complete_TokenEndpointError(t *testing.T) { m := newMockTokenServer(t) m.status = http.StatusBadRequest diff --git a/internal/serveredition/broker/token_exchanger.go b/internal/serveredition/broker/token_exchanger.go deleted file mode 100644 index 67962e98d..000000000 --- a/internal/serveredition/broker/token_exchanger.go +++ /dev/null @@ -1,233 +0,0 @@ -//go:build server - -package broker - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" -) - -// RFC 8693 / Microsoft Entra OAuth grant-type and token-type URNs (FR-007/FR-008). -const ( - grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" - grantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer" - tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" - entraRequestedTokenUse = "on_behalf_of" -) - -// defaultExchangeTimeout bounds a single token-endpoint round trip. -const defaultExchangeTimeout = 30 * time.Second - -// TokenExchanger mints upstream credentials on behalf of a user by exchanging -// that user's stored IdP subject token (T3) at an authorization server, then -// caches the result in the CredentialStore (FR-007/FR-008/FR-009). -// -// Two strategies are supported, selected by AuthBrokerConfig.Mode: -// - token_exchange: RFC 8693 OAuth 2.0 Token Exchange. -// - entra_obo: Microsoft Entra On-Behalf-Of (jwt-bearer + on_behalf_of). -// -// golang.org/x/oauth2 has no native RFC 8693 support (golang/oauth2#409), so -// the exchange request is hand-rolled. -type TokenExchanger struct { - store CredentialStore - httpClient *http.Client - logger *zap.Logger -} - -// NewTokenExchanger constructs an exchanger over the given credential store. A -// nil httpClient gets a default client with a bounded timeout. -func NewTokenExchanger(store CredentialStore, httpClient *http.Client, logger *zap.Logger) *TokenExchanger { - if httpClient == nil { - httpClient = &http.Client{Timeout: defaultExchangeTimeout} - } - if logger == nil { - logger = zap.NewNop() - } - return &TokenExchanger{ - store: store, - httpClient: httpClient, - logger: logger.Named("token-exchanger"), - } -} - -// tokenResponse is the success body of a token-endpoint response (RFC 8693 Β§2.2.1 -// and the standard OAuth token response used by Entra OBO). -type tokenResponse struct { - AccessToken string `json:"access_token"` - IssuedTokenType string `json:"issued_token_type"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - Scope string `json:"scope"` - RefreshToken string `json:"refresh_token"` -} - -// tokenErrorResponse is the OAuth 2.0 error body (RFC 6749 Β§5.2). Only the -// machine-readable error code is ever surfaced; error_description may reflect -// caller-supplied input and is treated as untrusted (never returned to callers). -type tokenErrorResponse struct { - ErrorCode string `json:"error"` - ErrorDescription string `json:"error_description"` -} - -// Exchange reads the user's stored IdP subject token, performs the configured -// token exchange, caches the resulting credential under (userID, serverKey), -// and returns it. Nothing is cached when the exchange fails. -func (e *TokenExchanger) Exchange(ctx context.Context, userID, serverKey string, cfg *config.AuthBrokerConfig) (*UpstreamCredential, error) { - if cfg == nil { - return nil, fmt.Errorf("token exchange: nil auth_broker config") - } - - // Subject token = the stored IdP token from T3 (keyed by userID alone). - subject, err := e.store.Get(userID, "") - if err != nil { - return nil, fmt.Errorf("token exchange: no IdP subject token for user: %w", err) - } - if subject.AccessToken == "" { - return nil, fmt.Errorf("token exchange: stored IdP subject token is empty") - } - - form, err := buildExchangeForm(cfg, subject.AccessToken) - if err != nil { - return nil, err - } - - cred, err := e.post(ctx, cfg, form) - if err != nil { - return nil, err - } - - // Cache only on success (FR-009). - if perr := e.store.Put(userID, serverKey, cred); perr != nil { - return nil, fmt.Errorf("token exchange: cache credential: %w", perr) - } - return cred, nil -} - -// buildExchangeForm assembles the POST body for the configured mode. -func buildExchangeForm(cfg *config.AuthBrokerConfig, subjectToken string) (url.Values, error) { - form := url.Values{} - scope := strings.Join(cfg.Scopes, " ") - - switch cfg.Mode { - case config.AuthBrokerModeTokenExchange: - form.Set("grant_type", grantTypeTokenExchange) - form.Set("subject_token", subjectToken) - form.Set("subject_token_type", tokenTypeAccessToken) - form.Set("requested_token_type", tokenTypeAccessToken) - if cfg.Resource != "" { - form.Set("resource", cfg.Resource) - } - case config.AuthBrokerModeEntraOBO: - form.Set("grant_type", grantTypeJWTBearer) - form.Set("assertion", subjectToken) - form.Set("requested_token_use", entraRequestedTokenUse) - default: - return nil, fmt.Errorf("token exchange: unsupported auth_broker mode %q", cfg.Mode) - } - - if scope != "" { - form.Set("scope", scope) - } - // Client authentication via client_secret_post. - if cfg.ClientID != "" { - form.Set("client_id", cfg.ClientID) - } - if cfg.ClientSecret != "" { - form.Set("client_secret", cfg.ClientSecret) - } - return form, nil -} - -// post executes the token-endpoint round trip and maps the response onto an -// UpstreamCredential. Errors are sanitized: only the HTTP status and OAuth -// error code are surfaced, never the response body or request secrets. -func (e *TokenExchanger) post(ctx context.Context, cfg *config.AuthBrokerConfig, form url.Values) (*UpstreamCredential, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenEndpoint, strings.NewReader(form.Encode())) - if err != nil { - return nil, fmt.Errorf("token exchange: build request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - - resp, err := e.httpClient.Do(req) - if err != nil { - // A transport error may embed the endpoint URL but not request secrets. - return nil, fmt.Errorf("token exchange: request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return nil, fmt.Errorf("token exchange: read response: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, e.sanitizedError(resp.StatusCode, body) - } - - var tr tokenResponse - if err := json.Unmarshal(body, &tr); err != nil { - return nil, fmt.Errorf("token exchange: malformed success response (status %d)", resp.StatusCode) - } - if tr.AccessToken == "" { - return nil, fmt.Errorf("token exchange: response missing access_token (status %d)", resp.StatusCode) - } - - return credentialFromResponse(cfg, &tr), nil -} - -// sanitizedError maps an authorization-server error response onto a safe error -// that names only the HTTP status and the standard OAuth error code. The -// error_description and raw body are deliberately dropped β€” they may reflect -// caller input or secrets (FR-008/FR-009). -func (e *TokenExchanger) sanitizedError(status int, body []byte) error { - var te tokenErrorResponse - _ = json.Unmarshal(body, &te) - if te.ErrorCode != "" { - e.logger.Warn("token exchange rejected by authorization server", - zap.Int("status", status), - zap.String("error", te.ErrorCode)) - return fmt.Errorf("token exchange failed: status %d, error %q", status, te.ErrorCode) - } - e.logger.Warn("token exchange rejected by authorization server", - zap.Int("status", status)) - return fmt.Errorf("token exchange failed: status %d", status) -} - -// credentialFromResponse converts a successful token response into the stored -// credential model, recording how it was obtained and the requested audience. -func credentialFromResponse(cfg *config.AuthBrokerConfig, tr *tokenResponse) *UpstreamCredential { - now := time.Now().UTC() - cred := &UpstreamCredential{ - Type: "oauth2", - AccessToken: tr.AccessToken, - RefreshToken: tr.RefreshToken, - TokenType: tr.TokenType, - Audience: cfg.Resource, - ObtainedVia: cfg.Mode, - UpdatedAt: now, - } - if cred.TokenType == "" { - cred.TokenType = "Bearer" - } - if tr.ExpiresIn > 0 { - cred.ExpiresAt = now.Add(time.Duration(tr.ExpiresIn) * time.Second) - } - // Prefer the granted scope from the response; fall back to the requested set. - if granted := strings.Fields(tr.Scope); len(granted) > 0 { - cred.Scopes = granted - } else { - cred.Scopes = cfg.Scopes - } - return cred -} diff --git a/internal/serveredition/broker/token_exchanger_test.go b/internal/serveredition/broker/token_exchanger_test.go deleted file mode 100644 index 1a6cd19ac..000000000 --- a/internal/serveredition/broker/token_exchanger_test.go +++ /dev/null @@ -1,261 +0,0 @@ -//go:build server - -package broker - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" -) - -const ( - testUserID = "user-42" - testSrvKey = "server-key-abc" - testSubject = "subject-token-SECRET-do-not-leak" -) - -// seedSubjectToken stores an IdP subject token for the user (serverKey == "") -// the way T3 would, so the exchanger can read it. -func seedSubjectToken(t *testing.T, store CredentialStore, token string) { - t.Helper() - if err := store.Put(testUserID, "", &UpstreamCredential{ - Type: "idp_subject_token", - AccessToken: token, - }); err != nil { - t.Fatalf("seed subject token: %v", err) - } -} - -// newExchangeServer spins up a mock token endpoint. handler receives the parsed -// POST form and writes the response. -func newExchangeServer(t *testing.T, handler func(t *testing.T, form url.Values, w http.ResponseWriter)) *httptest.Server { - t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Errorf("expected POST, got %s", r.Method) - } - if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") { - t.Errorf("expected form content-type, got %q", ct) - } - if err := r.ParseForm(); err != nil { - t.Fatalf("parse form: %v", err) - } - handler(t, r.PostForm, w) - })) - t.Cleanup(srv.Close) - return srv -} - -func writeJSON(w http.ResponseWriter, status int, body map[string]interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(body) -} - -func TestTokenExchanger_RFC8693HappyPath(t *testing.T) { - store := newTestStore(t, openTestDB(t), newTestKey(t)) - seedSubjectToken(t, store, testSubject) - - srv := newExchangeServer(t, func(t *testing.T, form url.Values, w http.ResponseWriter) { - t.Helper() - if got := form.Get("grant_type"); got != "urn:ietf:params:oauth:grant-type:token-exchange" { - t.Errorf("grant_type = %q", got) - } - if got := form.Get("subject_token"); got != testSubject { - t.Errorf("subject_token = %q", got) - } - if got := form.Get("subject_token_type"); got != "urn:ietf:params:oauth:token-type:access_token" { - t.Errorf("subject_token_type = %q", got) - } - if got := form.Get("resource"); got != "https://upstream.example.com" { - t.Errorf("resource = %q", got) - } - if got := form.Get("scope"); got != "read write" { - t.Errorf("scope = %q", got) - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "access_token": "exchanged-access-token", - "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", - "token_type": "Bearer", - "expires_in": 3600, - "scope": "read write", - }) - }) - - cfg := &config.AuthBrokerConfig{ - Mode: config.AuthBrokerModeTokenExchange, - TokenEndpoint: srv.URL, - Resource: "https://upstream.example.com", - Scopes: []string{"read", "write"}, - ClientID: "gateway-client", - ClientSecret: "gateway-secret", - } - - ex := NewTokenExchanger(store, nil, zap.NewNop()) - cred, err := ex.Exchange(context.Background(), testUserID, testSrvKey, cfg) - if err != nil { - t.Fatalf("Exchange: %v", err) - } - if cred.AccessToken != "exchanged-access-token" { - t.Errorf("AccessToken = %q", cred.AccessToken) - } - if cred.TokenType != "Bearer" { - t.Errorf("TokenType = %q", cred.TokenType) - } - if cred.Audience != "https://upstream.example.com" { - t.Errorf("Audience = %q", cred.Audience) - } - if cred.ObtainedVia != "token_exchange" { - t.Errorf("ObtainedVia = %q", cred.ObtainedVia) - } - if len(cred.Scopes) != 2 || cred.Scopes[0] != "read" || cred.Scopes[1] != "write" { - t.Errorf("Scopes = %v", cred.Scopes) - } - // expires_in 3600 -> ExpiresAt roughly an hour out. - if d := time.Until(cred.ExpiresAt); d < 55*time.Minute || d > 60*time.Minute { - t.Errorf("ExpiresAt not ~1h out: %v (delta %v)", cred.ExpiresAt, d) - } - - // Result must be cached under (userID, serverKey). - cached, err := store.Get(testUserID, testSrvKey) - if err != nil { - t.Fatalf("expected cached credential: %v", err) - } - if cached.AccessToken != "exchanged-access-token" { - t.Errorf("cached AccessToken = %q", cached.AccessToken) - } -} - -func TestTokenExchanger_EntraOBOMode(t *testing.T) { - store := newTestStore(t, openTestDB(t), newTestKey(t)) - seedSubjectToken(t, store, testSubject) - - srv := newExchangeServer(t, func(t *testing.T, form url.Values, w http.ResponseWriter) { - t.Helper() - if got := form.Get("grant_type"); got != "urn:ietf:params:oauth:grant-type:jwt-bearer" { - t.Errorf("grant_type = %q", got) - } - if got := form.Get("assertion"); got != testSubject { - t.Errorf("assertion = %q", got) - } - if got := form.Get("requested_token_use"); got != "on_behalf_of" { - t.Errorf("requested_token_use = %q", got) - } - if got := form.Get("client_id"); got != "entra-client" { - t.Errorf("client_id = %q", got) - } - if got := form.Get("client_secret"); got != "entra-secret" { - t.Errorf("client_secret = %q", got) - } - if got := form.Get("scope"); got != "api://upstream/.default" { - t.Errorf("scope = %q", got) - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "access_token": "obo-access-token", - "token_type": "Bearer", - "expires_in": 1800, - "scope": "api://upstream/.default", - }) - }) - - cfg := &config.AuthBrokerConfig{ - Mode: config.AuthBrokerModeEntraOBO, - TokenEndpoint: srv.URL, - Scopes: []string{"api://upstream/.default"}, - ClientID: "entra-client", - ClientSecret: "entra-secret", - } - - ex := NewTokenExchanger(store, nil, zap.NewNop()) - cred, err := ex.Exchange(context.Background(), testUserID, testSrvKey, cfg) - if err != nil { - t.Fatalf("Exchange: %v", err) - } - if cred.AccessToken != "obo-access-token" { - t.Errorf("AccessToken = %q", cred.AccessToken) - } - if cred.ObtainedVia != "entra_obo" { - t.Errorf("ObtainedVia = %q", cred.ObtainedVia) - } - if d := time.Until(cred.ExpiresAt); d < 25*time.Minute || d > 30*time.Minute { - t.Errorf("ExpiresAt not ~30m out: %v", cred.ExpiresAt) - } -} - -// Error responses from the AS must be sanitized: the surfaced error names the -// OAuth error code + HTTP status but never leaks error_description (which can -// reflect secrets) or the subject token, and nothing is cached. -func TestTokenExchanger_ErrorSanitizedAndNotCached(t *testing.T) { - store := newTestStore(t, openTestDB(t), newTestKey(t)) - seedSubjectToken(t, store, testSubject) - - const leak = "this-description-leaks-a-SECRET-value" - srv := newExchangeServer(t, func(_ *testing.T, _ url.Values, w http.ResponseWriter) { - writeJSON(w, http.StatusBadRequest, map[string]interface{}{ - "error": "invalid_grant", - "error_description": leak, - }) - }) - - cfg := &config.AuthBrokerConfig{ - Mode: config.AuthBrokerModeTokenExchange, - TokenEndpoint: srv.URL, - Resource: "https://upstream.example.com", - ClientID: "gateway-client", - ClientSecret: "gateway-secret", - } - - ex := NewTokenExchanger(store, nil, zap.NewNop()) - _, err := ex.Exchange(context.Background(), testUserID, testSrvKey, cfg) - if err == nil { - t.Fatal("expected error on 400 response") - } - msg := err.Error() - if !strings.Contains(msg, "invalid_grant") { - t.Errorf("error should name the OAuth error code, got %q", msg) - } - if strings.Contains(msg, leak) || strings.Contains(msg, "SECRET") { - t.Errorf("error leaked error_description: %q", msg) - } - if strings.Contains(msg, testSubject) { - t.Errorf("error leaked subject token: %q", msg) - } - // Nothing cached on failure. - if _, gerr := store.Get(testUserID, testSrvKey); gerr == nil { - t.Error("nothing should be cached after a failed exchange") - } -} - -// A missing IdP subject token is a clean error, not a panic, and surfaces no -// HTTP call (the endpoint is never hit because we have nothing to exchange). -func TestTokenExchanger_MissingSubjectToken(t *testing.T) { - store := newTestStore(t, openTestDB(t), newTestKey(t)) - // No subject token seeded. - - called := false - srv := newExchangeServer(t, func(_ *testing.T, _ url.Values, w http.ResponseWriter) { - called = true - writeJSON(w, http.StatusOK, map[string]interface{}{"access_token": "x"}) - }) - - cfg := &config.AuthBrokerConfig{ - Mode: config.AuthBrokerModeTokenExchange, - TokenEndpoint: srv.URL, - } - ex := NewTokenExchanger(store, nil, zap.NewNop()) - if _, err := ex.Exchange(context.Background(), testUserID, testSrvKey, cfg); err == nil { - t.Fatal("expected error when subject token is missing") - } - if called { - t.Error("token endpoint should not be called without a subject token") - } -} diff --git a/internal/serveredition/multiuser/activity_agent_tier_test.go b/internal/serveredition/multiuser/activity_agent_tier_test.go index 74481bfe6..60b0b91f0 100644 --- a/internal/serveredition/multiuser/activity_agent_tier_test.go +++ b/internal/serveredition/multiuser/activity_agent_tier_test.go @@ -13,6 +13,14 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" ) +// userCtx builds the request context a signed-in user session produces. +// (Formerly a helper of router_test.go, which Spec 107 FR-031 deleted with the +// never-wired multi-user router.) +func userCtx(userID string) context.Context { + ac := auth.UserContext(userID, userID+"@example.com", "Regular User", "google") + return auth.WithAuthContext(context.Background(), ac) +} + // stubActivityProvider is the smallest storage provider GetUserActivity needs. // It records whether it was consulted at all, which is what distinguishes // "refused before reading" from "read and then filtered to nothing". diff --git a/internal/serveredition/multiuser/isolation_test.go b/internal/serveredition/multiuser/isolation_test.go index f8e0a3aa6..18ff87538 100644 --- a/internal/serveredition/multiuser/isolation_test.go +++ b/internal/serveredition/multiuser/isolation_test.go @@ -13,153 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestIntegration_CrossUserIsolation(t *testing.T) { - // Setup: temp BBolt, user store, workspace manager, router - // User A adds servers: "server-a1", "server-a2" - // User B adds servers: "server-b1" - // Shared servers: "shared-1" - router, _ := setupRouter(t, - []string{"shared-1"}, - map[string][]string{ - "userA": {"server-a1", "server-a2"}, - "userB": {"server-b1"}, - }, - ) - - ctxA := auth.WithAuthContext(context.Background(), - auth.UserContext("userA", "a@test.com", "User A", "google")) - ctxB := auth.WithAuthContext(context.Background(), - auth.UserContext("userB", "b@test.com", "User B", "google")) - - // Test 1: User A's accessible servers = shared-1, server-a1, server-a2 - serversA, err := router.GetUserServers(ctxA) - require.NoError(t, err) - assert.Len(t, serversA, 3, "User A should see shared-1 + server-a1 + server-a2") - - namesA := serverInfoNames(serversA) - assert.Contains(t, namesA, "shared-1") - assert.Contains(t, namesA, "server-a1") - assert.Contains(t, namesA, "server-a2") - - // Test 2: User B's accessible servers = shared-1, server-b1 - serversB, err := router.GetUserServers(ctxB) - require.NoError(t, err) - assert.Len(t, serversB, 2, "User B should see shared-1 + server-b1") - - namesB := serverInfoNames(serversB) - assert.Contains(t, namesB, "shared-1") - assert.Contains(t, namesB, "server-b1") - - // Test 3: User A cannot access server-b1 - assert.False(t, router.IsServerAccessible(ctxA, "server-b1"), - "User A must not access User B's server") - - // Test 4: User B cannot access server-a1 - assert.False(t, router.IsServerAccessible(ctxB, "server-a1"), - "User B must not access User A's server") - assert.False(t, router.IsServerAccessible(ctxB, "server-a2"), - "User B must not access User A's server") - - // Test 5: Both can access shared-1 - assert.True(t, router.IsServerAccessible(ctxA, "shared-1"), - "User A should access shared server") - assert.True(t, router.IsServerAccessible(ctxB, "shared-1"), - "User B should access shared server") - - // Test 6: Tool filtering - allTools := []ToolInfo{ - {ToolName: "tool1", ServerName: "server-a1", Ownership: OwnershipPersonal}, - {ToolName: "tool2", ServerName: "server-b1", Ownership: OwnershipPersonal}, - {ToolName: "tool3", ServerName: "shared-1", Ownership: OwnershipShared}, - {ToolName: "tool4", ServerName: "server-a2", Ownership: OwnershipPersonal}, - {ToolName: "tool5", ServerName: "unknown-server", Ownership: OwnershipPersonal}, - } - - toolFilter := NewToolFilter(router, testLogger(t)) - - // User A sees tool1 (server-a1), tool3 (shared-1), tool4 (server-a2) -- not tool2 or tool5 - filteredA := toolFilter.FilterToolsByUser(ctxA, allTools) - require.Len(t, filteredA, 3) - - filteredANames := make([]string, len(filteredA)) - for i, ti := range filteredA { - filteredANames[i] = ti.ToolName - } - assert.Contains(t, filteredANames, "tool1") - assert.Contains(t, filteredANames, "tool3") - assert.Contains(t, filteredANames, "tool4") - assert.NotContains(t, filteredANames, "tool2") - assert.NotContains(t, filteredANames, "tool5") - - // User B sees tool2 (server-b1), tool3 (shared-1) -- not tool1, tool4, or tool5 - filteredB := toolFilter.FilterToolsByUser(ctxB, allTools) - require.Len(t, filteredB, 2) - - filteredBNames := make([]string, len(filteredB)) - for i, ti := range filteredB { - filteredBNames[i] = ti.ToolName - } - assert.Contains(t, filteredBNames, "tool2") - assert.Contains(t, filteredBNames, "tool3") - assert.NotContains(t, filteredBNames, "tool1") - assert.NotContains(t, filteredBNames, "tool4") - assert.NotContains(t, filteredBNames, "tool5") - - // Verify ownership is correctly set on filtered tools - for _, ti := range filteredA { - switch ti.ServerName { - case "shared-1": - assert.Equal(t, OwnershipShared, ti.Ownership) - default: - assert.Equal(t, OwnershipPersonal, ti.Ownership) - } - } -} - -func TestIntegration_CrossUserIsolation_GetServerForUser(t *testing.T) { - router, _ := setupRouter(t, - []string{"shared-1"}, - map[string][]string{ - "userA": {"server-a1"}, - "userB": {"server-b1"}, - }, - ) - - ctxA := userCtx("userA") - ctxB := userCtx("userB") - - // User A can get their own personal server - infoA, err := router.GetServerForUser(ctxA, "server-a1") - require.NoError(t, err) - assert.Equal(t, "server-a1", infoA.Config.Name) - assert.Equal(t, OwnershipPersonal, infoA.Ownership) - - // User A cannot get User B's personal server - _, err = router.GetServerForUser(ctxA, "server-b1") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found or not accessible") - - // User B can get their own personal server - infoB, err := router.GetServerForUser(ctxB, "server-b1") - require.NoError(t, err) - assert.Equal(t, "server-b1", infoB.Config.Name) - assert.Equal(t, OwnershipPersonal, infoB.Ownership) - - // User B cannot get User A's personal server - _, err = router.GetServerForUser(ctxB, "server-a1") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found or not accessible") - - // Both can get shared server - sharedA, err := router.GetServerForUser(ctxA, "shared-1") - require.NoError(t, err) - assert.Equal(t, OwnershipShared, sharedA.Ownership) - - sharedB, err := router.GetServerForUser(ctxB, "shared-1") - require.NoError(t, err) - assert.Equal(t, OwnershipShared, sharedB.Ownership) -} - func TestIntegration_ActivityIsolation(t *testing.T) { now := time.Now().UTC() @@ -329,33 +182,3 @@ func TestIntegration_ActivityIsolation_EnrichAndFilter(t *testing.T) { require.Len(t, recsB, 1) assert.Equal(t, "new-rec-b", recsB[0].ID) } - -func TestIntegration_ToolFilterAccessibleServerNames(t *testing.T) { - router, _ := setupRouter(t, - []string{"shared-a", "shared-b"}, - map[string][]string{ - "userA": {"personal-1", "personal-2"}, - "userB": {"personal-3"}, - }, - ) - - toolFilter := NewToolFilter(router, testLogger(t)) - - // User A should see shared + personal servers, sorted - ctxA := userCtx("userA") - namesA, err := toolFilter.GetAccessibleServerNames(ctxA) - require.NoError(t, err) - assert.Equal(t, []string{"personal-1", "personal-2", "shared-a", "shared-b"}, namesA) - - // User B should see shared + their personal server, sorted - ctxB := userCtx("userB") - namesB, err := toolFilter.GetAccessibleServerNames(ctxB) - require.NoError(t, err) - assert.Equal(t, []string{"personal-3", "shared-a", "shared-b"}, namesB) - - // Verify IsToolAccessible respects isolation - assert.True(t, toolFilter.IsToolAccessible(ctxA, "personal-1")) - assert.False(t, toolFilter.IsToolAccessible(ctxA, "personal-3")) - assert.True(t, toolFilter.IsToolAccessible(ctxB, "personal-3")) - assert.False(t, toolFilter.IsToolAccessible(ctxB, "personal-1")) -} diff --git a/internal/serveredition/multiuser/router.go b/internal/serveredition/multiuser/router.go deleted file mode 100644 index 4c79f6d79..000000000 --- a/internal/serveredition/multiuser/router.go +++ /dev/null @@ -1,272 +0,0 @@ -//go:build server - -package multiuser - -import ( - "context" - "fmt" - "sort" - "sync" - - "go.uber.org/zap" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/broker" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/workspace" -) - -// ServerOwnership indicates who owns a server. -type ServerOwnership string - -const ( - // OwnershipShared indicates a server defined in the shared config file, - // accessible to all authenticated users. - OwnershipShared ServerOwnership = "shared" - - // OwnershipPersonal indicates a server owned by a specific user, - // stored in their workspace. - OwnershipPersonal ServerOwnership = "personal" -) - -// ServerInfo holds a server config with ownership metadata. -type ServerInfo struct { - Config *config.ServerConfig - Ownership ServerOwnership -} - -// Router routes MCP operations to the correct upstream based on user identity. -// It merges shared servers (from the config file) with personal servers (from -// the user's workspace) to provide a unified view of accessible servers. -// -// NOT YET WIRED. NewRouter has no production caller: internal/serveredition/setup.go -// constructs the REST surfaces and the auth middleware, and nothing constructs -// this. Personal servers are consequently reachable through -// /api/v1/user/servers and nowhere else β€” no MCP request is routed through here -// today. -// -// That matters when reasoning about security properties. The guards below -// (brokerPoolIdentity's IsUser() namespacing, the shared-before-personal -// precedence in GetServerForUser) are correct and are kept for when the router -// IS wired, but they are LATENT: no argument about a live exposure may rest on -// them, in either direction. In particular, the personal-server/admin-server -// name collision that internal/serveredition/api/user_handlers.go refuses is a -// real escalation today because of TOKEN SCOPE β€” a bare name is the whole of -// what auth.AuthContext.CanAccessServer compares β€” and not because of any -// routing this file performs. Anyone wiring this router should re-derive those -// properties rather than inherit them from these comments. -type Router struct { - sharedServers []*config.ServerConfig - workspaceManager *workspace.Manager - mu sync.RWMutex - logger *zap.SugaredLogger -} - -// NewRouter creates a new multi-user router. -// -// sharedServers are the servers defined in the main config file, accessible -// to all authenticated users. workspaceManager provides access to per-user -// personal server configurations. -func NewRouter(sharedServers []*config.ServerConfig, workspaceManager *workspace.Manager, logger *zap.SugaredLogger) *Router { - return &Router{ - sharedServers: sharedServers, - workspaceManager: workspaceManager, - logger: logger.With("component", "multiuser_router"), - } -} - -// GetUserServers returns all servers accessible to the user from context. -// Admin users get all shared servers. Regular users get shared servers plus -// their own personal servers. Returns an error if no auth context is present. -func (r *Router) GetUserServers(ctx context.Context) ([]ServerInfo, error) { - ac := auth.AuthContextFromContext(ctx) - if ac == nil { - return nil, fmt.Errorf("no authentication context") - } - - r.mu.RLock() - shared := make([]*config.ServerConfig, len(r.sharedServers)) - copy(shared, r.sharedServers) - r.mu.RUnlock() - - var result []ServerInfo - - // All authenticated users can access shared servers. - for _, s := range shared { - result = append(result, ServerInfo{ - Config: s, - Ownership: OwnershipShared, - }) - } - - // Users (both regular and admin) also get their personal servers. - if ac.IsUser() && ac.GetUserID() != "" { - ws, err := r.workspaceManager.GetOrCreateWorkspace(ac.GetUserID()) - if err != nil { - r.logger.Warnw("Failed to load workspace for user", - "user_id", ac.GetUserID(), "error", err) - // Return shared servers even if workspace loading fails. - return result, nil - } - - for _, s := range ws.GetServers() { - // Skip personal servers that shadow a shared server name to avoid - // ambiguity. Shared servers take precedence. - if r.isSharedServer(s.Name) { - r.logger.Debugw("Skipping personal server that shadows shared server", - "server", s.Name, "user_id", ac.GetUserID()) - continue - } - result = append(result, ServerInfo{ - Config: s, - Ownership: OwnershipPersonal, - }) - } - } - - return result, nil -} - -// GetServerForUser finds a specific server by name, checking both shared and -// personal servers. Returns an error if the server is not found or the user -// is not authorized to access it. -func (r *Router) GetServerForUser(ctx context.Context, serverName string) (*ServerInfo, error) { - ac := auth.AuthContextFromContext(ctx) - if ac == nil { - return nil, fmt.Errorf("no authentication context") - } - - // Check shared servers first. - r.mu.RLock() - for _, s := range r.sharedServers { - if s.Name == serverName { - r.mu.RUnlock() - return &ServerInfo{ - Config: s, - Ownership: OwnershipShared, - }, nil - } - } - r.mu.RUnlock() - - // Check user's personal servers. - if ac.IsUser() && ac.GetUserID() != "" { - ws, err := r.workspaceManager.GetOrCreateWorkspace(ac.GetUserID()) - if err != nil { - return nil, fmt.Errorf("failed to load workspace: %w", err) - } - - if s, ok := ws.GetServer(serverName); ok { - return &ServerInfo{ - Config: s, - Ownership: OwnershipPersonal, - }, nil - } - } - - return nil, fmt.Errorf("server %q not found or not accessible", serverName) -} - -// BrokeredConnectionKey returns the per-(user, server) pooling key for a -// brokered upstream connection (spec 074 FR-018). It resolves the calling user -// and the named server (shared or personal), then keys the connection by both -// so a shared upstream brokered per-user never reuses one user's -// credential/connection for another. The connection pool MUST use this key when -// caching brokered upstream clients. -// -// It errors if there is no auth context or the server is not accessible to the -// user; it does not require the server to actually declare an auth_broker block, -// so callers can key uniformly and decide per server whether to broker. -// -// LATENT, like everything else on this type: nothing constructs the Router -// (see its doc comment), and broker.ConnectionKey has no other caller, so no -// brokered connection is pooled by this key in production today. The rule below -// is correct and must survive to the moment the router IS wired; it is not -// evidence that a live pooling hazard is currently closed. -// -// Only a USER-tier context keys as its own user id. Since issue #1168 an agent -// token's AuthContext carries its OWNER's UserID (so its activity is -// attributable), and keying straight off GetUserID() would therefore pool an -// agent-token request onto the owner's own brokered connection β€” a connection -// carrying the owner's injected IdP credential, which is precisely the sharing -// FR-018 exists to prevent. Every non-user tier is namespaced instead, so it can -// never collide with a user session's entry while two agent tokens of different -// owners still stay apart. This is one place; see brokerPoolIdentity. -func (r *Router) BrokeredConnectionKey(ctx context.Context, serverName string) (string, error) { - ac := auth.AuthContextFromContext(ctx) - if ac == nil { - return "", fmt.Errorf("no authentication context") - } - - info, err := r.GetServerForUser(ctx, serverName) - if err != nil { - return "", err - } - return broker.ConnectionKey(brokerPoolIdentity(ac), info.Config), nil -} - -// nonUserPoolPrefix namespaces the pooling identity of every non-user tier so -// it cannot collide with a user session's. It contains a character no ULID -// carries, so no user id can be forged into this namespace either. -const nonUserPoolPrefix = "non-user:" - -// brokerPoolIdentity returns the identity half of a brokered connection's -// pooling key. -// -// A user session keys as its bare user id, exactly as before. Anything else β€” -// an agent token (which now carries its owner's UserID), the personal-edition -// API key, an anonymous back-compat context β€” keys inside a separate namespace, -// qualified by tier and by whatever user id it does carry. That keeps two -// different owners' agent tokens apart while guaranteeing neither of them ever -// lands on a user session's pool entry. -func brokerPoolIdentity(ac *auth.AuthContext) string { - if ac.IsUser() && ac.GetUserID() != "" { - return ac.GetUserID() - } - return nonUserPoolPrefix + ac.Type + ":" + ac.GetUserID() -} - -// IsServerAccessible returns true if the user from the context can access -// the named server. This is a convenience method that does not return -// detailed error information. -func (r *Router) IsServerAccessible(ctx context.Context, serverName string) bool { - info, err := r.GetServerForUser(ctx, serverName) - return err == nil && info != nil -} - -// UpdateSharedServers replaces the shared server list. This is called on -// config reload to pick up changes to the shared server definitions. -func (r *Router) UpdateSharedServers(servers []*config.ServerConfig) { - r.mu.Lock() - defer r.mu.Unlock() - - r.sharedServers = servers - r.logger.Infow("Updated shared servers", "count", len(servers)) -} - -// GetSharedServerNames returns a sorted list of shared server names. -func (r *Router) GetSharedServerNames() []string { - r.mu.RLock() - defer r.mu.RUnlock() - - names := make([]string, 0, len(r.sharedServers)) - for _, s := range r.sharedServers { - names = append(names, s.Name) - } - sort.Strings(names) - return names -} - -// isSharedServer checks if a server name matches any shared server. -// Caller must not hold r.mu (this method acquires the read lock). -func (r *Router) isSharedServer(name string) bool { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, s := range r.sharedServers { - if s.Name == name { - return true - } - } - return false -} diff --git a/internal/serveredition/multiuser/router_broker_agent_test.go b/internal/serveredition/multiuser/router_broker_agent_test.go deleted file mode 100644 index af22a49ef..000000000 --- a/internal/serveredition/multiuser/router_broker_agent_test.go +++ /dev/null @@ -1,100 +0,0 @@ -//go:build server - -package multiuser - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" -) - -// agentCtx builds the request context an agent token produces. Built through -// auth.AgentToken.AuthContext β€” the single production constructor for the agent -// tier β€” rather than by hand, so this test cannot pass against a hand-written -// context that happens to differ from what authentication actually installs. -func agentCtx(owner, tokenName string) context.Context { - tok := &auth.AgentToken{Name: tokenName, UserID: owner, Permissions: []string{auth.PermRead}} - return auth.WithAuthContext(context.Background(), tok.AuthContext()) -} - -// TestRouter_BrokeredConnectionKey_AgentTokenNeverPoolsOntoItsOwner is the -// regression guard for the second-order cost of issue #1168. -// -// FR-018 keys a brokered upstream connection per (user, server) so one user's -// injected IdP credential is never reused for another. The key's identity half -// came straight from ac.GetUserID() with no tier check. That was harmless while -// only user sessions carried a UserID β€” an agent token keyed as the anonymous -// "" β€” but #1168 made an agent token carry its OWNER's UserID so its activity -// could be attributed. From that moment an agent-token request keyed IDENTICALLY -// to its owner's own session, so it pooled onto the owner's brokered connection -// and rode the owner's injected credential. -// -// The properties, all three of which must hold together: -// -// - an agent token owned by U must NOT key like U's session (the regression); -// - two agent tokens with DIFFERENT owners must still key apart, so the fix -// cannot be "collapse every agent back to anonymous"; -// - a user session's own key is UNCHANGED and still per-user, so the fix does -// not quietly break FR-018 itself. -// -// BITES: on unfixed code the first assertion fails β€” the agent key equals the -// owner's session key exactly. -func TestRouter_BrokeredConnectionKey_AgentTokenNeverPoolsOntoItsOwner(t *testing.T) { - router, _ := setupRouter(t, []string{"shared-ghe"}, nil) - - aliceSession, err := router.BrokeredConnectionKey(userCtx("alice"), "shared-ghe") - require.NoError(t, err) - require.NotEmpty(t, aliceSession, "positive control: a user session must key at all") - - aliceAgent, err := router.BrokeredConnectionKey(agentCtx("alice", "ci"), "shared-ghe") - require.NoError(t, err) - - assert.NotEqual(t, aliceSession, aliceAgent, - "an agent token must not pool onto its owner's brokered connection: that connection carries the owner's injected IdP credential") - - // A second token of the SAME owner is the same principal as the first, so it - // may share a pool entry with it β€” but still not with the session. - aliceAgent2, err := router.BrokeredConnectionKey(agentCtx("alice", "nightly"), "shared-ghe") - require.NoError(t, err) - assert.Equal(t, aliceAgent, aliceAgent2, "agent-token pooling must stay stable for one owner") - assert.NotEqual(t, aliceSession, aliceAgent2, "no agent token may reach its owner's session pool entry") - - // Different owners must still be kept apart β€” the fix must not degenerate - // into keying every agent token as one anonymous principal. - bobAgent, err := router.BrokeredConnectionKey(agentCtx("bob", "ci"), "shared-ghe") - require.NoError(t, err) - assert.NotEqual(t, aliceAgent, bobAgent, - "two owners' agent tokens must not share a brokered connection") - - // And FR-018 itself is intact for sessions. - bobSession, err := router.BrokeredConnectionKey(userCtx("bob"), "shared-ghe") - require.NoError(t, err) - assert.NotEqual(t, aliceSession, bobSession, "FR-018: user sessions stay per-user") - assert.NotEqual(t, bobSession, aliceAgent, "an agent token must not land on ANY user's session entry") -} - -// TestRouter_BrokeredConnectionKey_AdminUserSessionKeysAsItself pins that the -// tier guard reads IsUser(), which covers "admin_user" as well as "user". An -// admin's own browser session is still a user session and must keep keying by -// its bare user id β€” a guard written as `ac.Type == AuthTypeUser` would silently -// shunt every admin into the non-user namespace. -func TestRouter_BrokeredConnectionKey_AdminUserSessionKeysAsItself(t *testing.T) { - router, _ := setupRouter(t, []string{"shared-ghe"}, nil) - - adminKey, err := router.BrokeredConnectionKey(adminCtx("root"), "shared-ghe") - require.NoError(t, err) - userKey, err := router.BrokeredConnectionKey(userCtx("root"), "shared-ghe") - require.NoError(t, err) - - assert.Equal(t, userKey, adminKey, - "an admin_user session is a user session: same identity, same pool entry") - - agentKey, err := router.BrokeredConnectionKey(agentCtx("root", "ci"), "shared-ghe") - require.NoError(t, err) - assert.NotEqual(t, adminKey, agentKey, - "an agent token owned by an admin must still not pool onto the admin's session") -} diff --git a/internal/serveredition/multiuser/router_broker_test.go b/internal/serveredition/multiuser/router_broker_test.go deleted file mode 100644 index e11895a23..000000000 --- a/internal/serveredition/multiuser/router_broker_test.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:build server - -package multiuser - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Spec 074 T7 / FR-018: a shared upstream brokered per-user must key its -// connection by (user, server) so one user's credential/connection is never -// reused for another. The router exposes the per-(user,server) connection key -// for the connection pool to use. - -func TestRouter_BrokeredConnectionKey_DistinctPerUser(t *testing.T) { - router, _ := setupRouter(t, []string{"shared-ghe"}, nil) - - aliceKey, err := router.BrokeredConnectionKey(userCtx("alice"), "shared-ghe") - require.NoError(t, err) - bobKey, err := router.BrokeredConnectionKey(userCtx("bob"), "shared-ghe") - require.NoError(t, err) - - assert.NotEmpty(t, aliceKey) - assert.NotEqual(t, aliceKey, bobKey, - "the same shared brokered upstream must key a distinct connection per user (FR-018)") - - // Stable for the same (user, server). - aliceKey2, err := router.BrokeredConnectionKey(userCtx("alice"), "shared-ghe") - require.NoError(t, err) - assert.Equal(t, aliceKey, aliceKey2, "connection key must be stable for the same (user, server)") -} - -func TestRouter_BrokeredConnectionKey_RequiresAuth(t *testing.T) { - router, _ := setupRouter(t, []string{"shared-ghe"}, nil) - _, err := router.BrokeredConnectionKey(noAuthCtx(), "shared-ghe") - assert.Error(t, err, "no auth context must be rejected") -} - -func TestRouter_BrokeredConnectionKey_UnknownServer(t *testing.T) { - router, _ := setupRouter(t, []string{"shared-ghe"}, nil) - _, err := router.BrokeredConnectionKey(userCtx("alice"), "nope") - assert.Error(t, err, "unknown/inaccessible server must error") -} diff --git a/internal/serveredition/multiuser/router_test.go b/internal/serveredition/multiuser/router_test.go deleted file mode 100644 index ccce95f6d..000000000 --- a/internal/serveredition/multiuser/router_test.go +++ /dev/null @@ -1,503 +0,0 @@ -//go:build server - -package multiuser - -import ( - "context" - "path/filepath" - "testing" - "time" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/users" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/workspace" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.etcd.io/bbolt" - "go.uber.org/zap" -) - -// --- test helpers --- - -func setupTestStore(t *testing.T) *users.UserStore { - t.Helper() - tmpFile := filepath.Join(t.TempDir(), "test.db") - db, err := bbolt.Open(tmpFile, 0600, &bbolt.Options{Timeout: 1 * time.Second}) - require.NoError(t, err) - t.Cleanup(func() { db.Close() }) - - store := users.NewUserStore(db) - require.NoError(t, store.EnsureBuckets()) - return store -} - -func testLogger(t *testing.T) *zap.SugaredLogger { - t.Helper() - logger, _ := zap.NewDevelopment() - t.Cleanup(func() { _ = logger.Sync() }) - return logger.Sugar() -} - -func seedServers(t *testing.T, store *users.UserStore, userID string, servers ...*config.ServerConfig) { - t.Helper() - for _, s := range servers { - require.NoError(t, store.CreateUserServer(userID, s)) - } -} - -func makeServer(name string) *config.ServerConfig { - return &config.ServerConfig{ - Name: name, - URL: "https://" + name + ".example.com/mcp", - Enabled: true, - } -} - -func makeSharedServers(names ...string) []*config.ServerConfig { - servers := make([]*config.ServerConfig, len(names)) - for i, name := range names { - servers[i] = makeServer(name) - } - return servers -} - -func setupRouter(t *testing.T, sharedNames []string, userServers map[string][]string) (*Router, *workspace.Manager) { - t.Helper() - store := setupTestStore(t) - logger := testLogger(t) - - for userID, serverNames := range userServers { - for _, name := range serverNames { - seedServers(t, store, userID, makeServer(name)) - } - } - - wm := workspace.NewManager(store, 10*time.Minute, logger) - t.Cleanup(func() { wm.Stop() }) - - shared := makeSharedServers(sharedNames...) - router := NewRouter(shared, wm, logger) - return router, wm -} - -func adminCtx(userID string) context.Context { - ac := auth.AdminUserContext(userID, userID+"@example.com", "Admin User", "google") - return auth.WithAuthContext(context.Background(), ac) -} - -func userCtx(userID string) context.Context { - ac := auth.UserContext(userID, userID+"@example.com", "Regular User", "google") - return auth.WithAuthContext(context.Background(), ac) -} - -func noAuthCtx() context.Context { - return context.Background() -} - -// --- Router tests --- - -func TestRouter_GetUserServers_Admin(t *testing.T) { - router, _ := setupRouter(t, - []string{"github", "gitlab", "slack"}, - nil, - ) - - ctx := adminCtx("admin-1") - servers, err := router.GetUserServers(ctx) - require.NoError(t, err) - - // Admin gets all shared servers. - assert.Len(t, servers, 3) - for _, s := range servers { - assert.Equal(t, OwnershipShared, s.Ownership) - } - - names := serverInfoNames(servers) - assert.Contains(t, names, "github") - assert.Contains(t, names, "gitlab") - assert.Contains(t, names, "slack") -} - -func TestRouter_GetUserServers_AdminWithPersonal(t *testing.T) { - router, _ := setupRouter(t, - []string{"github", "gitlab"}, - map[string][]string{ - "admin-1": {"my-private-server"}, - }, - ) - - ctx := adminCtx("admin-1") - servers, err := router.GetUserServers(ctx) - require.NoError(t, err) - - // Admin gets shared + their own personal servers. - assert.Len(t, servers, 3) - - names := serverInfoNames(servers) - assert.Contains(t, names, "github") - assert.Contains(t, names, "gitlab") - assert.Contains(t, names, "my-private-server") - - // Verify ownership metadata. - for _, s := range servers { - if s.Config.Name == "my-private-server" { - assert.Equal(t, OwnershipPersonal, s.Ownership) - } else { - assert.Equal(t, OwnershipShared, s.Ownership) - } - } -} - -func TestRouter_GetUserServers_User(t *testing.T) { - router, _ := setupRouter(t, - []string{"github", "gitlab"}, - map[string][]string{ - "alice": {"alice-dev", "alice-staging"}, - }, - ) - - ctx := userCtx("alice") - servers, err := router.GetUserServers(ctx) - require.NoError(t, err) - - // User gets shared + their personal servers. - assert.Len(t, servers, 4) - - names := serverInfoNames(servers) - assert.Contains(t, names, "github") - assert.Contains(t, names, "gitlab") - assert.Contains(t, names, "alice-dev") - assert.Contains(t, names, "alice-staging") -} - -func TestRouter_GetUserServers_UserDoesNotSeeOtherUserServers(t *testing.T) { - router, _ := setupRouter(t, - []string{"github"}, - map[string][]string{ - "alice": {"alice-server"}, - "bob": {"bob-server"}, - }, - ) - - ctx := userCtx("alice") - servers, err := router.GetUserServers(ctx) - require.NoError(t, err) - - names := serverInfoNames(servers) - assert.Contains(t, names, "github") - assert.Contains(t, names, "alice-server") - assert.NotContains(t, names, "bob-server") -} - -func TestRouter_GetUserServers_NoAuth(t *testing.T) { - router, _ := setupRouter(t, []string{"github"}, nil) - - ctx := noAuthCtx() - _, err := router.GetUserServers(ctx) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no authentication context") -} - -func TestRouter_GetUserServers_ApiKeyAdmin(t *testing.T) { - router, _ := setupRouter(t, []string{"github", "gitlab"}, nil) - - // API key admin (non-OAuth) gets shared servers only (no UserID, no workspace). - ac := auth.AdminContext() - ctx := auth.WithAuthContext(context.Background(), ac) - - servers, err := router.GetUserServers(ctx) - require.NoError(t, err) - assert.Len(t, servers, 2) - for _, s := range servers { - assert.Equal(t, OwnershipShared, s.Ownership) - } -} - -func TestRouter_GetServerForUser_Shared(t *testing.T) { - router, _ := setupRouter(t, - []string{"github", "gitlab"}, - nil, - ) - - // Both admin and regular user can access shared servers. - for _, ctx := range []context.Context{adminCtx("admin-1"), userCtx("alice")} { - info, err := router.GetServerForUser(ctx, "github") - require.NoError(t, err) - assert.Equal(t, "github", info.Config.Name) - assert.Equal(t, OwnershipShared, info.Ownership) - } -} - -func TestRouter_GetServerForUser_Personal(t *testing.T) { - router, _ := setupRouter(t, - []string{"github"}, - map[string][]string{ - "alice": {"alice-dev"}, - }, - ) - - ctx := userCtx("alice") - info, err := router.GetServerForUser(ctx, "alice-dev") - require.NoError(t, err) - assert.Equal(t, "alice-dev", info.Config.Name) - assert.Equal(t, OwnershipPersonal, info.Ownership) -} - -func TestRouter_GetServerForUser_OtherUser(t *testing.T) { - router, _ := setupRouter(t, - []string{"github"}, - map[string][]string{ - "alice": {"alice-dev"}, - "bob": {"bob-dev"}, - }, - ) - - // Alice cannot access Bob's personal server. - ctx := userCtx("alice") - _, err := router.GetServerForUser(ctx, "bob-dev") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found or not accessible") -} - -func TestRouter_GetServerForUser_NotFound(t *testing.T) { - router, _ := setupRouter(t, []string{"github"}, nil) - - ctx := userCtx("alice") - _, err := router.GetServerForUser(ctx, "nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found or not accessible") -} - -func TestRouter_GetServerForUser_NoAuth(t *testing.T) { - router, _ := setupRouter(t, []string{"github"}, nil) - - _, err := router.GetServerForUser(noAuthCtx(), "github") - assert.Error(t, err) - assert.Contains(t, err.Error(), "no authentication context") -} - -func TestRouter_IsServerAccessible(t *testing.T) { - router, _ := setupRouter(t, - []string{"github"}, - map[string][]string{ - "alice": {"alice-dev"}, - "bob": {"bob-dev"}, - }, - ) - - ctx := userCtx("alice") - - assert.True(t, router.IsServerAccessible(ctx, "github")) - assert.True(t, router.IsServerAccessible(ctx, "alice-dev")) - assert.False(t, router.IsServerAccessible(ctx, "bob-dev")) - assert.False(t, router.IsServerAccessible(ctx, "nonexistent")) - assert.False(t, router.IsServerAccessible(noAuthCtx(), "github")) -} - -func TestRouter_UpdateSharedServers(t *testing.T) { - router, _ := setupRouter(t, []string{"github"}, nil) - - assert.Equal(t, []string{"github"}, router.GetSharedServerNames()) - - // Update to a new list. - router.UpdateSharedServers(makeSharedServers("gitlab", "slack")) - - names := router.GetSharedServerNames() - assert.Equal(t, []string{"gitlab", "slack"}, names) - - // Old shared server is no longer accessible. - ctx := userCtx("alice") - assert.False(t, router.IsServerAccessible(ctx, "github")) - assert.True(t, router.IsServerAccessible(ctx, "gitlab")) - assert.True(t, router.IsServerAccessible(ctx, "slack")) -} - -func TestRouter_UpdateSharedServers_Empty(t *testing.T) { - router, _ := setupRouter(t, []string{"github"}, nil) - - router.UpdateSharedServers(nil) - assert.Empty(t, router.GetSharedServerNames()) -} - -func TestRouter_GetSharedServerNames_Sorted(t *testing.T) { - router, _ := setupRouter(t, []string{"zulu", "alpha", "mike"}, nil) - - names := router.GetSharedServerNames() - assert.Equal(t, []string{"alpha", "mike", "zulu"}, names) -} - -func TestRouter_PersonalServerShadowingShared(t *testing.T) { - // If a user has a personal server with the same name as a shared server, - // the shared server takes precedence and the personal one is skipped. - router, _ := setupRouter(t, - []string{"github"}, - map[string][]string{ - "alice": {"github", "alice-dev"}, - }, - ) - - ctx := userCtx("alice") - servers, err := router.GetUserServers(ctx) - require.NoError(t, err) - - // Should have shared "github" + personal "alice-dev", not duplicate "github". - names := serverInfoNames(servers) - assert.Len(t, servers, 2) - assert.Contains(t, names, "github") - assert.Contains(t, names, "alice-dev") - - // The "github" should be the shared one. - for _, s := range servers { - if s.Config.Name == "github" { - assert.Equal(t, OwnershipShared, s.Ownership) - } - } -} - -// --- ToolFilter tests --- - -func TestToolFilter_FilterToolsByUser(t *testing.T) { - router, _ := setupRouter(t, - []string{"github", "gitlab"}, - map[string][]string{ - "alice": {"alice-dev"}, - "bob": {"bob-dev"}, - }, - ) - - filter := NewToolFilter(router, testLogger(t)) - - allTools := []ToolInfo{ - {ToolName: "create_issue", ServerName: "github"}, - {ToolName: "list_repos", ServerName: "github"}, - {ToolName: "create_mr", ServerName: "gitlab"}, - {ToolName: "deploy", ServerName: "alice-dev"}, - {ToolName: "build", ServerName: "bob-dev"}, - {ToolName: "run", ServerName: "unknown-server"}, - } - - ctx := userCtx("alice") - filtered := filter.FilterToolsByUser(ctx, allTools) - - // Alice should see tools from github, gitlab, and alice-dev (not bob-dev or unknown). - assert.Len(t, filtered, 4) - - toolNames := make([]string, len(filtered)) - for i, ti := range filtered { - toolNames[i] = ti.ToolName - } - assert.Contains(t, toolNames, "create_issue") - assert.Contains(t, toolNames, "list_repos") - assert.Contains(t, toolNames, "create_mr") - assert.Contains(t, toolNames, "deploy") - assert.NotContains(t, toolNames, "build") - assert.NotContains(t, toolNames, "run") -} - -func TestToolFilter_FilterToolsByUser_PreservesOwnership(t *testing.T) { - router, _ := setupRouter(t, - []string{"github"}, - map[string][]string{ - "alice": {"alice-dev"}, - }, - ) - - filter := NewToolFilter(router, testLogger(t)) - - allTools := []ToolInfo{ - {ToolName: "create_issue", ServerName: "github"}, - {ToolName: "deploy", ServerName: "alice-dev"}, - } - - ctx := userCtx("alice") - filtered := filter.FilterToolsByUser(ctx, allTools) - - require.Len(t, filtered, 2) - for _, ti := range filtered { - switch ti.ServerName { - case "github": - assert.Equal(t, OwnershipShared, ti.Ownership) - case "alice-dev": - assert.Equal(t, OwnershipPersonal, ti.Ownership) - } - } -} - -func TestToolFilter_FilterToolsByUser_NoAuth(t *testing.T) { - router, _ := setupRouter(t, []string{"github"}, nil) - filter := NewToolFilter(router, testLogger(t)) - - allTools := []ToolInfo{ - {ToolName: "create_issue", ServerName: "github"}, - } - - filtered := filter.FilterToolsByUser(noAuthCtx(), allTools) - assert.Nil(t, filtered) -} - -func TestToolFilter_FilterToolsByUser_EmptyInput(t *testing.T) { - router, _ := setupRouter(t, []string{"github"}, nil) - filter := NewToolFilter(router, testLogger(t)) - - ctx := userCtx("alice") - filtered := filter.FilterToolsByUser(ctx, nil) - assert.Nil(t, filtered) - - filtered = filter.FilterToolsByUser(ctx, []ToolInfo{}) - assert.Nil(t, filtered) -} - -func TestToolFilter_GetAccessibleServerNames(t *testing.T) { - router, _ := setupRouter(t, - []string{"github", "gitlab"}, - map[string][]string{ - "alice": {"alice-dev", "alice-staging"}, - }, - ) - - filter := NewToolFilter(router, testLogger(t)) - - ctx := userCtx("alice") - names, err := filter.GetAccessibleServerNames(ctx) - require.NoError(t, err) - - // Should be sorted. - assert.Equal(t, []string{"alice-dev", "alice-staging", "github", "gitlab"}, names) -} - -func TestToolFilter_GetAccessibleServerNames_NoAuth(t *testing.T) { - router, _ := setupRouter(t, []string{"github"}, nil) - filter := NewToolFilter(router, testLogger(t)) - - _, err := filter.GetAccessibleServerNames(noAuthCtx()) - assert.Error(t, err) -} - -func TestToolFilter_IsToolAccessible(t *testing.T) { - router, _ := setupRouter(t, - []string{"github"}, - map[string][]string{ - "alice": {"alice-dev"}, - "bob": {"bob-dev"}, - }, - ) - - filter := NewToolFilter(router, testLogger(t)) - ctx := userCtx("alice") - - assert.True(t, filter.IsToolAccessible(ctx, "github")) - assert.True(t, filter.IsToolAccessible(ctx, "alice-dev")) - assert.False(t, filter.IsToolAccessible(ctx, "bob-dev")) - assert.False(t, filter.IsToolAccessible(ctx, "nonexistent")) -} - -// --- helper --- - -func serverInfoNames(servers []ServerInfo) []string { - names := make([]string, len(servers)) - for i, s := range servers { - names[i] = s.Config.Name - } - return names -} diff --git a/internal/serveredition/multiuser/tool_filter.go b/internal/serveredition/multiuser/tool_filter.go deleted file mode 100644 index 7a1c85931..000000000 --- a/internal/serveredition/multiuser/tool_filter.go +++ /dev/null @@ -1,86 +0,0 @@ -//go:build server - -package multiuser - -import ( - "context" - "sort" - - "go.uber.org/zap" -) - -// ToolInfo represents a tool with its source server. -type ToolInfo struct { - ToolName string - ServerName string - Ownership ServerOwnership -} - -// ToolFilter provides user-scoped tool discovery. -// It wraps a Router to filter global tool lists down to only those tools -// that belong to servers the current user has access to. -type ToolFilter struct { - router *Router - logger *zap.SugaredLogger -} - -// NewToolFilter creates a new ToolFilter backed by the given Router. -func NewToolFilter(router *Router, logger *zap.SugaredLogger) *ToolFilter { - return &ToolFilter{ - router: router, - logger: logger.With("component", "tool_filter"), - } -} - -// FilterToolsByUser filters a global tool list to only include tools from -// servers the user has access to. Tools from inaccessible servers are silently -// dropped. If no auth context is present, an empty list is returned. -func (f *ToolFilter) FilterToolsByUser(ctx context.Context, allTools []ToolInfo) []ToolInfo { - servers, err := f.router.GetUserServers(ctx) - if err != nil { - f.logger.Debugw("Cannot filter tools: no auth context", "error", err) - return nil - } - - // Build a set of accessible server names for O(1) lookup. - accessible := make(map[string]ServerOwnership, len(servers)) - for _, s := range servers { - accessible[s.Config.Name] = s.Ownership - } - - var result []ToolInfo - for _, tool := range allTools { - if ownership, ok := accessible[tool.ServerName]; ok { - result = append(result, ToolInfo{ - ToolName: tool.ToolName, - ServerName: tool.ServerName, - Ownership: ownership, - }) - } - } - - return result -} - -// GetAccessibleServerNames returns a sorted list of all server names the user -// can access. This is useful for scoping BM25 search queries to only include -// tools from accessible servers. -func (f *ToolFilter) GetAccessibleServerNames(ctx context.Context) ([]string, error) { - servers, err := f.router.GetUserServers(ctx) - if err != nil { - return nil, err - } - - names := make([]string, 0, len(servers)) - for _, s := range servers { - names = append(names, s.Config.Name) - } - sort.Strings(names) - return names, nil -} - -// IsToolAccessible checks if a tool's server is accessible to the user. -// This is a convenience method for quick access checks during tool calls. -func (f *ToolFilter) IsToolAccessible(ctx context.Context, serverName string) bool { - return f.router.IsServerAccessible(ctx, serverName) -} diff --git a/internal/serveredition/setup.go b/internal/serveredition/setup.go index aeb798c4b..623890e61 100644 --- a/internal/serveredition/setup.go +++ b/internal/serveredition/setup.go @@ -92,7 +92,10 @@ func setupMultiUserOAuth(deps Dependencies) error { }) } - // Validate server config + // Spec 107 FR-039: defaults (TTLs, Microsoft tenant, MCPPROXY_CRED_KEY + // fallback) are applied at boot only; Validate itself never mutates, so + // the write doors run the same rules without persisting derived values. + cfg.ApplyDefaults() if err := cfg.Validate(); err != nil { return fmt.Errorf("server config validation: %w", err) } @@ -117,15 +120,15 @@ func setupMultiUserOAuth(deps Dependencies) error { // Create OAuth handler oauthHandler := teamsauth.NewOAuthHandler(userStore, sessionManager, cfg, hmacKey, deps.Logger) - // Wire the per-user credential store so IdP subject tokens can be captured at - // login when teams.store_idp_tokens is enabled (spec 074). The store derives - // its key from MCPPROXY_CRED_KEY or teams.credential_encryption_key; with no - // key it is constructed disabled and token capture is silently skipped. + // The per-user credential store backs the oauth_connect flow (spec 074 + // Path B): credentials a user connects are stored here, encrypted under + // MCPPROXY_CRED_KEY or server_edition.credential_encryption_key. With no key + // it is constructed disabled and the connect surface reports so. Nothing + // injects a stored credential into a proxied request (Spec 107 FR-034). credStore, err := broker.NewBBoltAESStore(deps.DB, broker.ResolveMasterKey(cfg.CredentialEncryptionKey), deps.Logger.Desugar()) if err != nil { return fmt.Errorf("creating credential store: %w", err) } - oauthHandler.SetCredentialStore(credStore) // The LIVE view of the server-edition block, read through the same provider // the admin-servers check uses rather than a second mechanism. diff --git a/internal/serveredition/setup_wiring_test.go b/internal/serveredition/setup_wiring_test.go index 64d0e9e6f..1ee195359 100644 --- a/internal/serveredition/setup_wiring_test.go +++ b/internal/serveredition/setup_wiring_test.go @@ -254,7 +254,7 @@ func TestSetupAuxiliarySurfacesFollowLiveSharing(t *testing.T) { token, err := teamsauth.GenerateBearerToken(h.hmacKey, user.ID, user.Email, user.DisplayName, "user", user.Provider, time.Hour) require.NoError(t, err) for _, shared := range []bool{true, false, true} { - h.setLiveServers([]*config.ServerConfig{{Name: "sharing-sentinel", Shared: shared, AuthBroker: &config.AuthBrokerConfig{Mode: "token_exchange"}}}) + h.setLiveServers([]*config.ServerConfig{{Name: "sharing-sentinel", Shared: shared, AuthBroker: &config.AuthBrokerConfig{Mode: config.AuthBrokerModeOAuthConnect}}}) for _, path := range []string{"/api/v1/user/credentials", "/api/v1/user/diagnostics"} { req := httptest.NewRequest(http.MethodGet, path, nil) req.Host = "localhost:8080" diff --git a/internal/serveredition/workspace/integration_test.go b/internal/serveredition/workspace/integration_test.go deleted file mode 100644 index 74bcd30ee..000000000 --- a/internal/serveredition/workspace/integration_test.go +++ /dev/null @@ -1,200 +0,0 @@ -//go:build server - -package workspace - -import ( - "testing" - "time" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestIntegration_UserAddsServerAndDiscoverTools(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - userID := "integ-user-1" - - // Create workspace for user - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - ws, err := mgr.GetOrCreateWorkspace(userID) - require.NoError(t, err) - require.NotNil(t, ws) - - // Initially empty - assert.Empty(t, ws.GetServers()) - assert.Empty(t, ws.ServerNames()) - - // Add a server config to the workspace - server := &config.ServerConfig{ - Name: "github-dev", - URL: "https://github-dev.example.com/mcp", - Protocol: "http", - Enabled: true, - } - err = ws.AddServer(store, server) - require.NoError(t, err) - - // Verify server appears in workspace.GetServers() - servers := ws.GetServers() - require.Len(t, servers, 1) - assert.Equal(t, "github-dev", servers[0].Name) - assert.Equal(t, "https://github-dev.example.com/mcp", servers[0].URL) - assert.True(t, servers[0].Enabled) - - // Verify server name appears in workspace.ServerNames() - names := ws.ServerNames() - assert.Equal(t, []string{"github-dev"}, names) - - // Add a second server - server2 := &config.ServerConfig{ - Name: "gitlab-staging", - URL: "https://gitlab-staging.example.com/mcp", - Protocol: "http", - Enabled: true, - } - err = ws.AddServer(store, server2) - require.NoError(t, err) - - // Verify both servers present - assert.Len(t, ws.GetServers(), 2) - assert.Equal(t, []string{"github-dev", "gitlab-staging"}, ws.ServerNames()) - - // Update server - verify changes persisted - updatedServer := &config.ServerConfig{ - Name: "github-dev", - URL: "https://github-dev-v2.example.com/mcp", - Protocol: "http", - Enabled: false, - } - err = ws.UpdateServer(store, updatedServer) - require.NoError(t, err) - - got, ok := ws.GetServer("github-dev") - require.True(t, ok) - assert.Equal(t, "https://github-dev-v2.example.com/mcp", got.URL) - assert.False(t, got.Enabled) - - // Verify persisted to store - persisted, err := store.GetUserServer(userID, "github-dev") - require.NoError(t, err) - require.NotNil(t, persisted) - assert.Equal(t, "https://github-dev-v2.example.com/mcp", persisted.URL) - assert.False(t, persisted.Enabled) - - // Remove server - verify no longer listed - err = ws.RemoveServer(store, "github-dev") - require.NoError(t, err) - - _, ok = ws.GetServer("github-dev") - assert.False(t, ok) - - assert.Equal(t, []string{"gitlab-staging"}, ws.ServerNames()) - - // Verify removed from store - removed, err := store.GetUserServer(userID, "github-dev") - require.NoError(t, err) - assert.Nil(t, removed) -} - -func TestIntegration_WorkspaceLifecycle(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - // Use a very short idle timeout for test - idleTimeout := 50 * time.Millisecond - mgr := NewManager(store, idleTimeout, logger) - defer mgr.Stop() - - // Create workspace for user A - userID := "lifecycle-user" - ws, err := mgr.GetOrCreateWorkspace(userID) - require.NoError(t, err) - require.NotNil(t, ws) - - // Add servers - require.NoError(t, ws.AddServer(store, makeServer("srv-1"))) - require.NoError(t, ws.AddServer(store, makeServer("srv-2"))) - assert.Len(t, ws.GetServers(), 2) - - // Touch workspace (activity) - ws.Touch() - - // Verify active workspace count - assert.Equal(t, 1, mgr.ActiveWorkspaceCount()) - - // Let it idle (wait past the short timeout) - time.Sleep(70 * time.Millisecond) - - // Run cleanup - mgr.cleanupIdle() - - // Verify workspace removed from manager - assert.Equal(t, 0, mgr.ActiveWorkspaceCount()) - _, exists := mgr.GetWorkspace(userID) - assert.False(t, exists) - - // Re-create workspace loads from store (servers still persisted) - ws2, err := mgr.GetOrCreateWorkspace(userID) - require.NoError(t, err) - assert.Len(t, ws2.GetServers(), 2) - assert.Equal(t, []string{"srv-1", "srv-2"}, ws2.ServerNames()) -} - -func TestIntegration_MultipleUsersIndependentWorkspaces(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - // Create workspaces for user A and user B - wsA, err := mgr.GetOrCreateWorkspace("userA") - require.NoError(t, err) - - wsB, err := mgr.GetOrCreateWorkspace("userB") - require.NoError(t, err) - - // Add different servers to each - require.NoError(t, wsA.AddServer(store, makeServer("server-a1"))) - require.NoError(t, wsA.AddServer(store, makeServer("server-a2"))) - require.NoError(t, wsA.AddServer(store, makeServer("server-a3"))) - - require.NoError(t, wsB.AddServer(store, makeServer("server-b1"))) - require.NoError(t, wsB.AddServer(store, makeServer("server-b2"))) - - // Verify each workspace has only its own servers - assert.Equal(t, []string{"server-a1", "server-a2", "server-a3"}, wsA.ServerNames()) - assert.Equal(t, []string{"server-b1", "server-b2"}, wsB.ServerNames()) - - // User A should not see B's servers - _, okA := wsA.GetServer("server-b1") - assert.False(t, okA, "user A should not see user B's servers") - - // User B should not see A's servers - _, okB := wsB.GetServer("server-a1") - assert.False(t, okB, "user B should not see user A's servers") - - assert.Equal(t, 2, mgr.ActiveWorkspaceCount()) - - // Remove workspace for A - mgr.RemoveWorkspace("userA") - - // Verify B's workspace still active - assert.Equal(t, 1, mgr.ActiveWorkspaceCount()) - wsBAgain, exists := mgr.GetWorkspace("userB") - assert.True(t, exists) - assert.Equal(t, []string{"server-b1", "server-b2"}, wsBAgain.ServerNames()) - - // Verify A's workspace is gone - _, exists = mgr.GetWorkspace("userA") - assert.False(t, exists) - - // Re-creating A's workspace restores from store - wsANew, err := mgr.GetOrCreateWorkspace("userA") - require.NoError(t, err) - assert.Equal(t, []string{"server-a1", "server-a2", "server-a3"}, wsANew.ServerNames()) -} diff --git a/internal/serveredition/workspace/manager.go b/internal/serveredition/workspace/manager.go deleted file mode 100644 index e97932117..000000000 --- a/internal/serveredition/workspace/manager.go +++ /dev/null @@ -1,174 +0,0 @@ -//go:build server - -package workspace - -import ( - "sync" - "time" - - "go.uber.org/zap" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/users" -) - -// Manager manages user workspaces, creating them on demand and cleaning up idle ones. -type Manager struct { - workspaces map[string]*UserWorkspace - mu sync.RWMutex - userStore *users.UserStore - idleTimeout time.Duration - logger *zap.SugaredLogger - done chan struct{} -} - -// NewManager creates a new workspace manager. -func NewManager(userStore *users.UserStore, idleTimeout time.Duration, logger *zap.SugaredLogger) *Manager { - return &Manager{ - workspaces: make(map[string]*UserWorkspace), - userStore: userStore, - idleTimeout: idleTimeout, - logger: logger.With("component", "workspace_manager"), - done: make(chan struct{}), - } -} - -// GetOrCreateWorkspace returns an existing workspace for the user, or creates -// a new one and loads the user's servers from the database. -func (m *Manager) GetOrCreateWorkspace(userID string) (*UserWorkspace, error) { - // Fast path: check with read lock - m.mu.RLock() - ws, exists := m.workspaces[userID] - m.mu.RUnlock() - - if exists { - ws.Touch() - return ws, nil - } - - // Slow path: create with write lock - m.mu.Lock() - defer m.mu.Unlock() - - // Double-check after acquiring write lock - if ws, exists = m.workspaces[userID]; exists { - ws.Touch() - return ws, nil - } - - ws = NewUserWorkspace(userID, m.logger.Desugar().Sugar()) - if err := ws.LoadServers(m.userStore); err != nil { - return nil, err - } - - m.workspaces[userID] = ws - m.logger.Infow("Created workspace", "user_id", userID, "active_workspaces", len(m.workspaces)) - return ws, nil -} - -// GetWorkspace returns the workspace for a user if it exists. -func (m *Manager) GetWorkspace(userID string) (*UserWorkspace, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - - ws, exists := m.workspaces[userID] - return ws, exists -} - -// RemoveWorkspace removes and shuts down a user's workspace. -func (m *Manager) RemoveWorkspace(userID string) { - m.mu.Lock() - ws, exists := m.workspaces[userID] - if exists { - delete(m.workspaces, userID) - } - m.mu.Unlock() - - if exists { - ws.Shutdown() - m.logger.Infow("Removed workspace", "user_id", userID) - } -} - -// ActiveWorkspaceCount returns the number of currently active workspaces. -func (m *Manager) ActiveWorkspaceCount() int { - m.mu.RLock() - defer m.mu.RUnlock() - - return len(m.workspaces) -} - -// StartCleanup starts a background goroutine that periodically removes -// workspaces that have been idle longer than the configured timeout. -func (m *Manager) StartCleanup() { - // Run cleanup at half the idle timeout interval, minimum 30 seconds - interval := m.idleTimeout / 2 - if interval < 30*time.Second { - interval = 30 * time.Second - } - - go func() { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - m.cleanupIdle() - case <-m.done: - return - } - } - }() - - m.logger.Infow("Started workspace cleanup", "interval", interval, "idle_timeout", m.idleTimeout) -} - -// Stop stops the cleanup goroutine and shuts down all active workspaces. -func (m *Manager) Stop() { - close(m.done) - - m.mu.Lock() - workspaces := make(map[string]*UserWorkspace, len(m.workspaces)) - for k, v := range m.workspaces { - workspaces[k] = v - } - m.workspaces = make(map[string]*UserWorkspace) - m.mu.Unlock() - - for userID, ws := range workspaces { - ws.Shutdown() - m.logger.Debugw("Shut down workspace during stop", "user_id", userID) - } - - m.logger.Infow("Workspace manager stopped", "workspaces_shut_down", len(workspaces)) -} - -// cleanupIdle removes workspaces that haven't been accessed within the idle timeout. -func (m *Manager) cleanupIdle() { - cutoff := time.Now().Add(-m.idleTimeout) - - m.mu.Lock() - var toRemove []string - for userID, ws := range m.workspaces { - if ws.LastAccess().Before(cutoff) { - toRemove = append(toRemove, userID) - } - } - - removed := make(map[string]*UserWorkspace, len(toRemove)) - for _, userID := range toRemove { - removed[userID] = m.workspaces[userID] - delete(m.workspaces, userID) - } - m.mu.Unlock() - - // Shutdown outside the lock to avoid holding it during cleanup - for userID, ws := range removed { - ws.Shutdown() - m.logger.Infow("Cleaned up idle workspace", "user_id", userID) - } - - if len(removed) > 0 { - m.logger.Infow("Idle workspace cleanup complete", "removed", len(removed), "remaining", m.ActiveWorkspaceCount()) - } -} diff --git a/internal/serveredition/workspace/manager_test.go b/internal/serveredition/workspace/manager_test.go deleted file mode 100644 index ef752f702..000000000 --- a/internal/serveredition/workspace/manager_test.go +++ /dev/null @@ -1,257 +0,0 @@ -//go:build server - -package workspace - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestManager_GetOrCreateWorkspace(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - userID := "mgr-user-1" - - seedServers(t, store, userID, makeServer("srv1")) - - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - // First access creates the workspace - ws1, err := mgr.GetOrCreateWorkspace(userID) - require.NoError(t, err) - require.NotNil(t, ws1) - assert.Equal(t, userID, ws1.UserID) - assert.Equal(t, []string{"srv1"}, ws1.ServerNames()) - assert.Equal(t, 1, mgr.ActiveWorkspaceCount()) - - // Second access returns the same workspace - ws2, err := mgr.GetOrCreateWorkspace(userID) - require.NoError(t, err) - assert.Same(t, ws1, ws2) - assert.Equal(t, 1, mgr.ActiveWorkspaceCount()) -} - -func TestManager_GetOrCreateWorkspace_MultipleUsers(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - seedServers(t, store, "alice", makeServer("alice-srv")) - seedServers(t, store, "bob", makeServer("bob-srv")) - - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - wsAlice, err := mgr.GetOrCreateWorkspace("alice") - require.NoError(t, err) - - wsBob, err := mgr.GetOrCreateWorkspace("bob") - require.NoError(t, err) - - assert.NotSame(t, wsAlice, wsBob) - assert.Equal(t, 2, mgr.ActiveWorkspaceCount()) - assert.Equal(t, []string{"alice-srv"}, wsAlice.ServerNames()) - assert.Equal(t, []string{"bob-srv"}, wsBob.ServerNames()) -} - -func TestManager_GetWorkspace_NotExists(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - ws, exists := mgr.GetWorkspace("nonexistent") - assert.False(t, exists) - assert.Nil(t, ws) -} - -func TestManager_GetWorkspace_Exists(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - created, err := mgr.GetOrCreateWorkspace("user-x") - require.NoError(t, err) - - got, exists := mgr.GetWorkspace("user-x") - assert.True(t, exists) - assert.Same(t, created, got) -} - -func TestManager_RemoveWorkspace(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - seedServers(t, store, "rm-user", makeServer("srv")) - - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - _, err := mgr.GetOrCreateWorkspace("rm-user") - require.NoError(t, err) - assert.Equal(t, 1, mgr.ActiveWorkspaceCount()) - - mgr.RemoveWorkspace("rm-user") - assert.Equal(t, 0, mgr.ActiveWorkspaceCount()) - - _, exists := mgr.GetWorkspace("rm-user") - assert.False(t, exists) -} - -func TestManager_RemoveWorkspace_NotExists(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - // Should not panic - mgr.RemoveWorkspace("ghost") - assert.Equal(t, 0, mgr.ActiveWorkspaceCount()) -} - -func TestManager_ActiveWorkspaceCount(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - mgr := NewManager(store, 10*time.Minute, logger) - defer mgr.Stop() - - assert.Equal(t, 0, mgr.ActiveWorkspaceCount()) - - _, err := mgr.GetOrCreateWorkspace("u1") - require.NoError(t, err) - assert.Equal(t, 1, mgr.ActiveWorkspaceCount()) - - _, err = mgr.GetOrCreateWorkspace("u2") - require.NoError(t, err) - assert.Equal(t, 2, mgr.ActiveWorkspaceCount()) - - _, err = mgr.GetOrCreateWorkspace("u3") - require.NoError(t, err) - assert.Equal(t, 3, mgr.ActiveWorkspaceCount()) - - mgr.RemoveWorkspace("u2") - assert.Equal(t, 2, mgr.ActiveWorkspaceCount()) -} - -func TestManager_CleanupIdle(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - // Use a very short idle timeout for testing - idleTimeout := 50 * time.Millisecond - mgr := NewManager(store, idleTimeout, logger) - defer mgr.Stop() - - // Create two workspaces - ws1, err := mgr.GetOrCreateWorkspace("idle-user") - require.NoError(t, err) - require.NotNil(t, ws1) - - ws2, err := mgr.GetOrCreateWorkspace("active-user") - require.NoError(t, err) - require.NotNil(t, ws2) - - assert.Equal(t, 2, mgr.ActiveWorkspaceCount()) - - // Wait for idle timeout to pass - time.Sleep(70 * time.Millisecond) - - // Touch the active user to keep it alive - ws2.Touch() - - // Run cleanup directly - mgr.cleanupIdle() - - // idle-user should be removed, active-user should remain - assert.Equal(t, 1, mgr.ActiveWorkspaceCount()) - - _, exists := mgr.GetWorkspace("idle-user") - assert.False(t, exists) - - _, exists = mgr.GetWorkspace("active-user") - assert.True(t, exists) -} - -func TestManager_CleanupIdle_AllIdle(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - idleTimeout := 50 * time.Millisecond - mgr := NewManager(store, idleTimeout, logger) - defer mgr.Stop() - - _, err := mgr.GetOrCreateWorkspace("u1") - require.NoError(t, err) - _, err = mgr.GetOrCreateWorkspace("u2") - require.NoError(t, err) - - time.Sleep(70 * time.Millisecond) - mgr.cleanupIdle() - - assert.Equal(t, 0, mgr.ActiveWorkspaceCount()) -} - -func TestManager_CleanupIdle_NoneIdle(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - // Long timeout so nothing is idle - mgr := NewManager(store, 1*time.Hour, logger) - defer mgr.Stop() - - _, err := mgr.GetOrCreateWorkspace("u1") - require.NoError(t, err) - - mgr.cleanupIdle() - - assert.Equal(t, 1, mgr.ActiveWorkspaceCount()) -} - -func TestManager_Stop(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - seedServers(t, store, "s1", makeServer("a")) - seedServers(t, store, "s2", makeServer("b")) - - mgr := NewManager(store, 10*time.Minute, logger) - - ws1, err := mgr.GetOrCreateWorkspace("s1") - require.NoError(t, err) - ws2, err := mgr.GetOrCreateWorkspace("s2") - require.NoError(t, err) - - // Both workspaces have servers loaded - assert.Len(t, ws1.GetServers(), 1) - assert.Len(t, ws2.GetServers(), 1) - - mgr.Stop() - - // After stop, all workspaces should be shut down (servers cleared) - assert.Equal(t, 0, mgr.ActiveWorkspaceCount()) - assert.Empty(t, ws1.GetServers()) - assert.Empty(t, ws2.GetServers()) -} - -func TestManager_Stop_WithCleanupRunning(t *testing.T) { - store := setupTestStore(t) - logger := testLogger(t) - - mgr := NewManager(store, 1*time.Minute, logger) - mgr.StartCleanup() - - _, err := mgr.GetOrCreateWorkspace("u1") - require.NoError(t, err) - - // Stop should cleanly shut down cleanup goroutine and workspaces - mgr.Stop() - assert.Equal(t, 0, mgr.ActiveWorkspaceCount()) -} diff --git a/internal/serveredition/workspace/workspace.go b/internal/serveredition/workspace/workspace.go deleted file mode 100644 index f76ca74a0..000000000 --- a/internal/serveredition/workspace/workspace.go +++ /dev/null @@ -1,179 +0,0 @@ -//go:build server - -package workspace - -import ( - "fmt" - "sort" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/users" -) - -// UserWorkspace holds a user's personal server configurations and state. -// For the MVP, it acts as a configuration container rather than maintaining -// live MCP connections. The MultiUserRouter uses it to determine which -// servers a user has access to. -type UserWorkspace struct { - UserID string - mu sync.RWMutex - servers map[string]*config.ServerConfig - lastAccess time.Time - logger *zap.SugaredLogger -} - -// NewUserWorkspace creates a new workspace for the given user. -func NewUserWorkspace(userID string, logger *zap.SugaredLogger) *UserWorkspace { - return &UserWorkspace{ - UserID: userID, - servers: make(map[string]*config.ServerConfig), - lastAccess: time.Now(), - logger: logger.With("component", "user_workspace", "user_id", userID), - } -} - -// LoadServers loads the user's server configurations from the persistent store. -func (w *UserWorkspace) LoadServers(store *users.UserStore) error { - servers, err := store.ListUserServers(w.UserID) - if err != nil { - return fmt.Errorf("failed to load servers for user %s: %w", w.UserID, err) - } - - w.mu.Lock() - defer w.mu.Unlock() - - w.servers = make(map[string]*config.ServerConfig, len(servers)) - for _, s := range servers { - w.servers[s.Name] = s - } - - w.logger.Infow("Loaded user servers", "count", len(w.servers)) - return nil -} - -// GetServers returns all personal server configurations. -func (w *UserWorkspace) GetServers() []*config.ServerConfig { - w.mu.RLock() - defer w.mu.RUnlock() - - result := make([]*config.ServerConfig, 0, len(w.servers)) - for _, s := range w.servers { - result = append(result, s) - } - return result -} - -// GetServer returns a specific server configuration by name. -func (w *UserWorkspace) GetServer(name string) (*config.ServerConfig, bool) { - w.mu.RLock() - defer w.mu.RUnlock() - - s, ok := w.servers[name] - return s, ok -} - -// AddServer adds a server configuration and persists it to the store. -func (w *UserWorkspace) AddServer(store *users.UserStore, server *config.ServerConfig) error { - if server.Name == "" { - return fmt.Errorf("server name is required") - } - - w.mu.Lock() - defer w.mu.Unlock() - - if _, exists := w.servers[server.Name]; exists { - return fmt.Errorf("server %q already exists in workspace", server.Name) - } - - if err := store.CreateUserServer(w.UserID, server); err != nil { - return fmt.Errorf("failed to persist server %q: %w", server.Name, err) - } - - w.servers[server.Name] = server - w.logger.Infow("Added server to workspace", "server", server.Name) - return nil -} - -// RemoveServer removes a server configuration and deletes it from the store. -func (w *UserWorkspace) RemoveServer(store *users.UserStore, name string) error { - w.mu.Lock() - defer w.mu.Unlock() - - if _, exists := w.servers[name]; !exists { - return fmt.Errorf("server %q not found in workspace", name) - } - - if err := store.DeleteUserServer(w.UserID, name); err != nil { - return fmt.Errorf("failed to delete server %q: %w", name, err) - } - - delete(w.servers, name) - w.logger.Infow("Removed server from workspace", "server", name) - return nil -} - -// UpdateServer updates an existing server configuration and persists the change. -func (w *UserWorkspace) UpdateServer(store *users.UserStore, server *config.ServerConfig) error { - if server.Name == "" { - return fmt.Errorf("server name is required") - } - - w.mu.Lock() - defer w.mu.Unlock() - - if _, exists := w.servers[server.Name]; !exists { - return fmt.Errorf("server %q not found in workspace", server.Name) - } - - if err := store.UpdateUserServer(w.UserID, server); err != nil { - return fmt.Errorf("failed to update server %q: %w", server.Name, err) - } - - w.servers[server.Name] = server - w.logger.Infow("Updated server in workspace", "server", server.Name) - return nil -} - -// ServerNames returns a sorted list of server names in the workspace. -func (w *UserWorkspace) ServerNames() []string { - w.mu.RLock() - defer w.mu.RUnlock() - - names := make([]string, 0, len(w.servers)) - for name := range w.servers { - names = append(names, name) - } - sort.Strings(names) - return names -} - -// Touch updates the last access time to now. -func (w *UserWorkspace) Touch() { - w.mu.Lock() - defer w.mu.Unlock() - - w.lastAccess = time.Now() -} - -// LastAccess returns the time the workspace was last accessed. -func (w *UserWorkspace) LastAccess() time.Time { - w.mu.RLock() - defer w.mu.RUnlock() - - return w.lastAccess -} - -// Shutdown cleans up any resources held by the workspace. -// For the MVP, this clears the in-memory server map. Future versions -// may close MCP connections here. -func (w *UserWorkspace) Shutdown() { - w.mu.Lock() - defer w.mu.Unlock() - - w.logger.Infow("Shutting down workspace", "server_count", len(w.servers)) - w.servers = make(map[string]*config.ServerConfig) -} diff --git a/internal/serveredition/workspace/workspace_test.go b/internal/serveredition/workspace/workspace_test.go deleted file mode 100644 index 070815bd1..000000000 --- a/internal/serveredition/workspace/workspace_test.go +++ /dev/null @@ -1,255 +0,0 @@ -//go:build server - -package workspace - -import ( - "path/filepath" - "testing" - "time" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/users" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.etcd.io/bbolt" - "go.uber.org/zap" -) - -func setupTestStore(t *testing.T) *users.UserStore { - t.Helper() - tmpFile := filepath.Join(t.TempDir(), "test.db") - db, err := bbolt.Open(tmpFile, 0600, &bbolt.Options{Timeout: 1 * time.Second}) - require.NoError(t, err) - t.Cleanup(func() { db.Close() }) - - store := users.NewUserStore(db) - require.NoError(t, store.EnsureBuckets()) - return store -} - -func testLogger(t *testing.T) *zap.SugaredLogger { - t.Helper() - logger, _ := zap.NewDevelopment() - t.Cleanup(func() { _ = logger.Sync() }) - return logger.Sugar() -} - -func seedServers(t *testing.T, store *users.UserStore, userID string, servers ...*config.ServerConfig) { - t.Helper() - for _, s := range servers { - require.NoError(t, store.CreateUserServer(userID, s)) - } -} - -func makeServer(name string) *config.ServerConfig { - return &config.ServerConfig{ - Name: name, - URL: "https://" + name + ".example.com/mcp", - Enabled: true, - } -} - -func TestUserWorkspace_LoadServers(t *testing.T) { - store := setupTestStore(t) - userID := "user-1" - - seedServers(t, store, userID, makeServer("github"), makeServer("gitlab")) - - ws := NewUserWorkspace(userID, testLogger(t)) - err := ws.LoadServers(store) - require.NoError(t, err) - - servers := ws.GetServers() - assert.Len(t, servers, 2) - - names := ws.ServerNames() - assert.Equal(t, []string{"github", "gitlab"}, names) -} - -func TestUserWorkspace_LoadServers_Empty(t *testing.T) { - store := setupTestStore(t) - - ws := NewUserWorkspace("user-no-servers", testLogger(t)) - err := ws.LoadServers(store) - require.NoError(t, err) - - assert.Empty(t, ws.GetServers()) - assert.Empty(t, ws.ServerNames()) -} - -func TestUserWorkspace_AddServer(t *testing.T) { - store := setupTestStore(t) - userID := "user-2" - - ws := NewUserWorkspace(userID, testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - - server := makeServer("new-server") - err := ws.AddServer(store, server) - require.NoError(t, err) - - // Verify in workspace - got, ok := ws.GetServer("new-server") - require.True(t, ok) - assert.Equal(t, "new-server", got.Name) - - // Verify persisted to store - persisted, err := store.GetUserServer(userID, "new-server") - require.NoError(t, err) - require.NotNil(t, persisted) - assert.Equal(t, "new-server", persisted.Name) -} - -func TestUserWorkspace_AddServer_Duplicate(t *testing.T) { - store := setupTestStore(t) - userID := "user-3" - - ws := NewUserWorkspace(userID, testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - require.NoError(t, ws.AddServer(store, makeServer("dup"))) - - err := ws.AddServer(store, makeServer("dup")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already exists") -} - -func TestUserWorkspace_AddServer_EmptyName(t *testing.T) { - store := setupTestStore(t) - - ws := NewUserWorkspace("user-x", testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - - err := ws.AddServer(store, &config.ServerConfig{Name: ""}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "server name is required") -} - -func TestUserWorkspace_RemoveServer(t *testing.T) { - store := setupTestStore(t) - userID := "user-4" - seedServers(t, store, userID, makeServer("to-remove"), makeServer("keep")) - - ws := NewUserWorkspace(userID, testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - - err := ws.RemoveServer(store, "to-remove") - require.NoError(t, err) - - // Verify removed from workspace - _, ok := ws.GetServer("to-remove") - assert.False(t, ok) - assert.Equal(t, []string{"keep"}, ws.ServerNames()) - - // Verify removed from store - persisted, err := store.GetUserServer(userID, "to-remove") - require.NoError(t, err) - assert.Nil(t, persisted) -} - -func TestUserWorkspace_RemoveServer_NotFound(t *testing.T) { - store := setupTestStore(t) - - ws := NewUserWorkspace("user-5", testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - - err := ws.RemoveServer(store, "nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -func TestUserWorkspace_UpdateServer(t *testing.T) { - store := setupTestStore(t) - userID := "user-6" - seedServers(t, store, userID, makeServer("updatable")) - - ws := NewUserWorkspace(userID, testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - - updated := &config.ServerConfig{ - Name: "updatable", - URL: "https://new-url.example.com/mcp", - Enabled: false, - } - err := ws.UpdateServer(store, updated) - require.NoError(t, err) - - // Verify updated in workspace - got, ok := ws.GetServer("updatable") - require.True(t, ok) - assert.Equal(t, "https://new-url.example.com/mcp", got.URL) - assert.False(t, got.Enabled) - - // Verify persisted - persisted, err := store.GetUserServer(userID, "updatable") - require.NoError(t, err) - require.NotNil(t, persisted) - assert.Equal(t, "https://new-url.example.com/mcp", persisted.URL) -} - -func TestUserWorkspace_UpdateServer_NotFound(t *testing.T) { - store := setupTestStore(t) - - ws := NewUserWorkspace("user-7", testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - - err := ws.UpdateServer(store, makeServer("missing")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -func TestUserWorkspace_GetServer(t *testing.T) { - store := setupTestStore(t) - userID := "user-8" - seedServers(t, store, userID, makeServer("alpha"), makeServer("beta")) - - ws := NewUserWorkspace(userID, testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - - got, ok := ws.GetServer("alpha") - require.True(t, ok) - assert.Equal(t, "alpha", got.Name) - - got, ok = ws.GetServer("beta") - require.True(t, ok) - assert.Equal(t, "beta", got.Name) - - _, ok = ws.GetServer("gamma") - assert.False(t, ok) -} - -func TestUserWorkspace_Touch(t *testing.T) { - ws := NewUserWorkspace("user-9", testLogger(t)) - initial := ws.LastAccess() - - // Small sleep to ensure time difference - time.Sleep(10 * time.Millisecond) - ws.Touch() - - assert.True(t, ws.LastAccess().After(initial)) -} - -func TestUserWorkspace_ServerNames(t *testing.T) { - store := setupTestStore(t) - userID := "user-10" - seedServers(t, store, userID, makeServer("zulu"), makeServer("alpha"), makeServer("mike")) - - ws := NewUserWorkspace(userID, testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - - // ServerNames should be sorted alphabetically - names := ws.ServerNames() - assert.Equal(t, []string{"alpha", "mike", "zulu"}, names) -} - -func TestUserWorkspace_Shutdown(t *testing.T) { - store := setupTestStore(t) - userID := "user-11" - seedServers(t, store, userID, makeServer("srv1"), makeServer("srv2")) - - ws := NewUserWorkspace(userID, testLogger(t)) - require.NoError(t, ws.LoadServers(store)) - assert.Len(t, ws.GetServers(), 2) - - ws.Shutdown() - assert.Empty(t, ws.GetServers()) -} diff --git a/internal/transport/broker_auth.go b/internal/transport/broker_auth.go deleted file mode 100644 index a71512b06..000000000 --- a/internal/transport/broker_auth.go +++ /dev/null @@ -1,56 +0,0 @@ -package transport - -import "strings" - -// BrokeredAuth carries a per-user resolved upstream credential to inject into an -// outbound HTTP/SSE request (spec 074, FR-016/FR-017). The server edition's -// credential broker resolves the per-user token and hands it down as plain data -// so the edition-neutral transport layer can inject it without importing any -// server-only package. -// -// Injection REPLACES any inbound or statically-configured header of the same -// name: the gateway/IdP token is never forwarded to the upstream (FR-017). -type BrokeredAuth struct { - // Header is the outbound header the credential is injected into - // (default "Authorization"). - Header string - // Format is the value template; the literal substring "{token}" is replaced - // with Token (default "Bearer {token}"). - Format string - // Token is the resolved per-user credential. - Token string -} - -// tokenPlaceholder is the substring in Format replaced with the resolved token. -const tokenPlaceholder = "{token}" - -// HeaderValue renders the outbound header value from Format, substituting the -// resolved token for the "{token}" placeholder. -func (b *BrokeredAuth) HeaderValue() string { - return strings.ReplaceAll(b.Format, tokenPlaceholder, b.Token) -} - -// EffectiveHeaders returns the outbound header set for a request, injecting the -// brokered per-user credential when one is supplied. -// -// The returned map is always a fresh copy β€” callers must never mutate the -// server config's header map. When brokered is non-nil, any header in base -// whose name matches brokered.Header case-insensitively is dropped before the -// resolved credential is set, so the configured/inbound auth is REPLACED rather -// than merged or forwarded (FR-017). When brokered is nil, base is returned -// unchanged (as a copy). -func EffectiveHeaders(base map[string]string, brokered *BrokeredAuth) map[string]string { - out := make(map[string]string, len(base)+1) - for k, v := range base { - if brokered != nil && strings.EqualFold(k, brokered.Header) { - // Drop the inbound/configured auth header β€” replaced below so the - // gateway/IdP token is never forwarded to the upstream (FR-017). - continue - } - out[k] = v - } - if brokered != nil { - out[brokered.Header] = brokered.HeaderValue() - } - return out -} diff --git a/internal/transport/broker_auth_integration_test.go b/internal/transport/broker_auth_integration_test.go deleted file mode 100644 index be2438f82..000000000 --- a/internal/transport/broker_auth_integration_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package transport - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/mark3labs/mcp-go/mcp" -) - -// newCapturingMCPServer stands up a minimal streamable-HTTP MCP endpoint that -// records the inbound Authorization header and answers initialize so the -// mcp-go client completes a real request β€” proving the brokered credential -// reaches the wire (spec 074 FR-016/FR-017). -func newCapturingMCPServer(t *testing.T, gotAuth *string) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - *gotAuth = r.Header.Get("Authorization") - - var req struct { - ID json.RawMessage `json:"id"` - Method string `json:"method"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - resp := map[string]any{ - "jsonrpc": "2.0", - "id": json.RawMessage(req.ID), - "result": map[string]any{ - "protocolVersion": mcp.LATEST_PROTOCOL_VERSION, - "serverInfo": map[string]any{"name": "cap", "version": "1"}, - "capabilities": map[string]any{}, - }, - } - _ = json.NewEncoder(w).Encode(resp) - })) -} - -func doInitialize(t *testing.T, cfg *HTTPTransportConfig, sse bool) { - t.Helper() - var ( - c interface{ Start(context.Context) error } - err error - ) - if sse { - cl, e := CreateSSEClient(cfg) - c, err = cl, e - } else { - cl, e := CreateHTTPClient(cfg) - c, err = cl, e - } - if err != nil { - t.Fatalf("create client: %v", err) - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := c.Start(ctx); err != nil { - t.Fatalf("start: %v", err) - } - // Initialize triggers the first POST carrying the headers. - type initer interface { - Initialize(context.Context, mcp.InitializeRequest) (*mcp.InitializeResult, error) - } - if i, ok := c.(initer); ok { - _, _ = i.Initialize(ctx, mcp.InitializeRequest{}) - } -} - -// FR-017 across the SSE path: the brokered per-user token is on the wire of the -// initial SSE GET stream and the inbound/configured gateway token is not. -func TestCreateSSEClient_InjectsBrokeredAuthOnWire(t *testing.T) { - var gotAuth string - mux := http.NewServeMux() - var srv *httptest.Server - mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - w.Header().Set("Content-Type", "text/event-stream") - fl, ok := w.(http.Flusher) - if !ok { - t.Errorf("ResponseWriter is not a Flusher") - return - } - // Tell the client where to POST messages, then hold the stream open. - _, _ = w.Write([]byte("event: endpoint\ndata: " + srv.URL + "/message\n\n")) - fl.Flush() - <-r.Context().Done() - }) - mux.HandleFunc("/message", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - }) - srv = httptest.NewServer(mux) - defer srv.Close() - - cfg := &HTTPTransportConfig{ - URL: srv.URL + "/sse", - Headers: map[string]string{"Authorization": "Bearer INBOUND-GATEWAY"}, - BrokeredAuth: &BrokeredAuth{ - Header: "Authorization", Format: "Bearer {token}", Token: "per-user-SSE", - }, - } - sseClient, err := CreateSSEClient(cfg) - if err != nil { - t.Fatalf("create SSE client: %v", err) - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := sseClient.Start(ctx); err != nil { - t.Fatalf("start SSE client: %v", err) - } - defer sseClient.Close() - - if gotAuth != "Bearer per-user-SSE" { - t.Fatalf("SSE outbound Authorization = %q, want brokered per-user token (inbound must be replaced)", gotAuth) - } -} - -// FR-017 across the HTTP path: the brokered per-user token is on the wire and -// the inbound/configured gateway token is not. -func TestCreateHTTPClient_InjectsBrokeredAuthOnWire(t *testing.T) { - var gotAuth string - srv := newCapturingMCPServer(t, &gotAuth) - defer srv.Close() - - cfg := &HTTPTransportConfig{ - URL: srv.URL, - Headers: map[string]string{"Authorization": "Bearer INBOUND-GATEWAY"}, - BrokeredAuth: &BrokeredAuth{ - Header: "Authorization", Format: "Bearer {token}", Token: "per-user-HTTP", - }, - } - doInitialize(t, cfg, false) - - if gotAuth != "Bearer per-user-HTTP" { - t.Fatalf("HTTP outbound Authorization = %q, want brokered per-user token (inbound must be replaced)", gotAuth) - } -} diff --git a/internal/transport/broker_auth_test.go b/internal/transport/broker_auth_test.go deleted file mode 100644 index 84c95bf35..000000000 --- a/internal/transport/broker_auth_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package transport - -import "testing" - -// Spec 074 T7 (FR-016/FR-017): the per-user resolved credential is injected into -// the configured outbound header, REPLACING any inbound/configured auth header. -// The inbound gateway/IdP token must never be forwarded. - -func TestBrokeredAuth_HeaderValue(t *testing.T) { - cases := []struct { - name string - format string - token string - want string - }{ - {name: "default bearer", format: "Bearer {token}", token: "u1-tok", want: "Bearer u1-tok"}, - {name: "raw token", format: "{token}", token: "abc", want: "abc"}, - {name: "custom prefix", format: "token {token}", token: "xyz", want: "token xyz"}, - {name: "no placeholder", format: "static-value", token: "ignored", want: "static-value"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - b := &BrokeredAuth{Header: "Authorization", Format: tc.format, Token: tc.token} - if got := b.HeaderValue(); got != tc.want { - t.Fatalf("HeaderValue() = %q, want %q", got, tc.want) - } - }) - } -} - -func TestEffectiveHeaders_InjectsBrokeredCredential(t *testing.T) { - base := map[string]string{"X-Trace": "on"} - b := &BrokeredAuth{Header: "Authorization", Format: "Bearer {token}", Token: "user-A-token"} - - got := EffectiveHeaders(base, b) - - if got["Authorization"] != "Bearer user-A-token" { - t.Fatalf("outbound Authorization = %q, want %q", got["Authorization"], "Bearer user-A-token") - } - if got["X-Trace"] != "on" { - t.Fatalf("non-auth header should be preserved, got %q", got["X-Trace"]) - } -} - -// FR-017: the inbound gateway/IdP token configured on the server must be -// REPLACED, never merged or forwarded β€” even if cased differently. -func TestEffectiveHeaders_ReplacesInboundAuthHeader(t *testing.T) { - base := map[string]string{ - "authorization": "Bearer INBOUND-GATEWAY-TOKEN", // lowercase, different casing - "X-Other": "keep", - } - b := &BrokeredAuth{Header: "Authorization", Format: "Bearer {token}", Token: "per-user-token"} - - got := EffectiveHeaders(base, b) - - // Exactly one auth header, carrying the per-user token, never the inbound one. - authCount := 0 - for k, v := range got { - if equalFoldHeader(k, "Authorization") { - authCount++ - if v != "Bearer per-user-token" { - t.Fatalf("auth header = %q, want per-user token, must not forward inbound", v) - } - } - if v == "Bearer INBOUND-GATEWAY-TOKEN" { - t.Fatalf("inbound gateway token was forwarded on outbound header %q (FR-017 violation)", k) - } - } - if authCount != 1 { - t.Fatalf("expected exactly 1 auth header after replacement, got %d", authCount) - } - if got["X-Other"] != "keep" { - t.Fatalf("unrelated header dropped: %q", got["X-Other"]) - } -} - -func TestEffectiveHeaders_NilBrokeredReturnsBaseCopy(t *testing.T) { - base := map[string]string{"Authorization": "Bearer static"} - got := EffectiveHeaders(base, nil) - if got["Authorization"] != "Bearer static" { - t.Fatalf("nil broker must leave base headers intact, got %q", got["Authorization"]) - } - // Must be a copy, not the same map (callers must not mutate the server config). - got["Authorization"] = "mutated" - if base["Authorization"] != "Bearer static" { - t.Fatalf("EffectiveHeaders must not alias/mutate the base map") - } -} - -func TestEffectiveHeaders_InjectsIntoEmptyBase(t *testing.T) { - b := &BrokeredAuth{Header: "Authorization", Format: "Bearer {token}", Token: "t"} - got := EffectiveHeaders(nil, b) - if got["Authorization"] != "Bearer t" { - t.Fatalf("brokered auth must inject even with no base headers, got %q", got["Authorization"]) - } -} - -// equalFoldHeader is a test helper mirroring the case-insensitive header match. -func equalFoldHeader(a, b string) bool { - if len(a) != len(b) { - return false - } - for i := 0; i < len(a); i++ { - ca, cb := a[i], b[i] - if 'A' <= ca && ca <= 'Z' { - ca += 'a' - 'A' - } - if 'A' <= cb && cb <= 'Z' { - cb += 'a' - 'A' - } - if ca != cb { - return false - } - } - return true -} diff --git a/internal/transport/http.go b/internal/transport/http.go index bbbb837ca..23d04713c 100644 --- a/internal/transport/http.go +++ b/internal/transport/http.go @@ -128,15 +128,10 @@ func NewEndpointDeprecatedError(url, message, migrationGuide, newEndpoint string // HTTPTransportConfig holds configuration for HTTP transport type HTTPTransportConfig struct { - URL string - Headers map[string]string - OAuthConfig *client.OAuthConfig - UseOAuth bool - // BrokeredAuth, when set, injects a per-user resolved credential into the - // outbound headers, replacing any configured/inbound auth header (spec 074 - // FR-016/FR-017). It is edition-neutral plain data so the server-edition - // credential broker can drive injection without this package importing it. - BrokeredAuth *BrokeredAuth + URL string + Headers map[string]string + OAuthConfig *client.OAuthConfig + UseOAuth bool TraceEnabled bool // Enable detailed HTTP/SSE frame tracing // RetryAfter, when set, receives the `Retry-After` hints observed on this // upstream's rate-limited responses (#1040). mcp-go flattens non-2xx @@ -170,27 +165,6 @@ func (cfg *HTTPTransportConfig) needsCustomTransport() bool { return cfg.TraceEnabled || cfg.RetryAfter != nil } -// effectiveHeaders returns the outbound header set, applying brokered per-user -// auth injection when configured (spec 074 FR-016/FR-017). -// refuseBrokeredOAuth fails closed when a per-user brokered credential meets -// an OAuth transport (spec 074 FR-014/FR-017). The auth ladder never pairs the -// two β€” a brokered connection is headers-only β€” but the OAuth constructors now -// carry static headers (GH #1271), so this guard keeps a shared OAuth token -// from ever overwriting, or riding beside, a per-user credential. -func (cfg *HTTPTransportConfig) refuseBrokeredOAuth() error { - if cfg.BrokeredAuth != nil { - return fmt.Errorf("brokered per-user auth cannot be combined with an OAuth transport (spec 074)") - } - return nil -} - -func (cfg *HTTPTransportConfig) effectiveHeaders() map[string]string { - if cfg.BrokeredAuth == nil { - return cfg.Headers - } - return EffectiveHeaders(cfg.Headers, cfg.BrokeredAuth) -} - // CreateHTTPClient creates a new MCP client using HTTP transport func CreateHTTPClient(cfg *HTTPTransportConfig) (*client.Client, error) { logger := zap.L().Named("transport") @@ -210,9 +184,6 @@ func CreateHTTPClient(cfg *HTTPTransportConfig) (*client.Client, error) { zap.Bool("has_oauth_config", cfg.OAuthConfig != nil)) if cfg.UseOAuth && cfg.OAuthConfig != nil { - if err := cfg.refuseBrokeredOAuth(); err != nil { - return nil, err - } // Use OAuth-enabled client with Dynamic Client Registration logger.Info("Creating OAuth-enabled streamable HTTP client with Dynamic Client Registration", zap.String("url", cfg.logSafeURL()), @@ -284,10 +255,7 @@ func CreateHTTPClient(cfg *HTTPTransportConfig) (*client.Client, error) { logger.Debug("Creating regular HTTP client", zap.String("url", cfg.logSafeURL())) - // Apply brokered per-user auth injection (spec 074): replaces any configured - // auth header with the resolved per-user credential and never forwards the - // inbound gateway/IdP token (FR-017). - headers := cfg.effectiveHeaders() + headers := cfg.Headers opts := []transport.StreamableHTTPCOption{} if len(headers) > 0 { @@ -343,9 +311,6 @@ func CreateSSEClient(cfg *HTTPTransportConfig) (*client.Client, error) { zap.Bool("has_oauth_config", cfg.OAuthConfig != nil)) if cfg.UseOAuth && cfg.OAuthConfig != nil { - if err := cfg.refuseBrokeredOAuth(); err != nil { - return nil, err - } // Use OAuth-enabled SSE client with Dynamic Client Registration logger.Info("Creating OAuth-enabled SSE client with Dynamic Client Registration", zap.String("url", cfg.logSafeURL()), @@ -406,10 +371,7 @@ func CreateSSEClient(cfg *HTTPTransportConfig) (*client.Client, error) { logger.Debug("Creating regular SSE client", zap.String("url", cfg.logSafeURL())) - // Apply brokered per-user auth injection (spec 074): replaces any configured - // auth header with the resolved per-user credential and never forwards the - // inbound gateway/IdP token (FR-017). - headers := cfg.effectiveHeaders() + headers := cfg.Headers // Create custom HTTP client for SSE - NO Timeout field to allow indefinite streaming // The Timeout field covers the entire request duration, which kills long-lived SSE streams diff --git a/internal/transport/oauth_static_headers_test.go b/internal/transport/oauth_static_headers_test.go index ee1dee33c..96393c5ba 100644 --- a/internal/transport/oauth_static_headers_test.go +++ b/internal/transport/oauth_static_headers_test.go @@ -71,21 +71,3 @@ func TestOAuthClients_CarryStaticHeaders(t *testing.T) { }) } } - -// Spec 074 fail-closed at the transport: a brokered per-user credential must -// never meet an OAuth transport, where the shared token would overwrite or -// ride beside it. -func TestOAuthClients_RefuseBrokeredAuth(t *testing.T) { - cfg := &HTTPTransportConfig{ - URL: "http://127.0.0.1:1/mcp", - OAuthConfig: oauthTestConfig(), - UseOAuth: true, - BrokeredAuth: &BrokeredAuth{Header: "Authorization", Format: "Bearer {token}", Token: "alice"}, - } - if _, err := CreateHTTPClient(cfg); err == nil { - t.Fatal("CreateHTTPClient must refuse brokered auth on an OAuth transport") - } - if _, err := CreateSSEClient(cfg); err == nil { - t.Fatal("CreateSSEClient must refuse brokered auth on an OAuth transport") - } -} diff --git a/internal/upstream/core/auth_strategy_oauth_block_test.go b/internal/upstream/core/auth_strategy_oauth_block_test.go index 2ee369b38..ac9fe66ff 100644 --- a/internal/upstream/core/auth_strategy_oauth_block_test.go +++ b/internal/upstream/core/auth_strategy_oauth_block_test.go @@ -13,6 +13,14 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport" ) +func strategyNames(strategies []authStrategy) []string { + names := make([]string, len(strategies)) + for i, s := range strategies { + names[i] = s.name + } + return names +} + // GH #1271: an upstream that answers initialize/tools/list anonymously but // requires a token for tools/call can never be authenticated when no-auth sits // before OAuth in the ladder β€” no-auth "succeeds" and the ladder stops. A @@ -66,20 +74,6 @@ func TestAuthStrategies_DisableOAuthEnvKeepsHistoricalChain(t *testing.T) { } } -// Spec 074 fail-closed must win over the oauth block: a brokered connection -// stays headers-only even when the server config carries an oauth block. -func TestAuthStrategies_BrokeredWinsOverOAuthBlock(t *testing.T) { - c := &Client{config: &config.ServerConfig{URL: "https://upstream.example/mcp", OAuth: &config.OAuthConfig{}}} - c.SetBrokeredAuth(&transport.BrokeredAuth{Header: "Authorization", Format: "Bearer {token}", Token: "u"}) - want := []string{"headers"} - if got := strategyNames(c.httpAuthStrategies()); !reflect.DeepEqual(got, want) { - t.Fatalf("brokered HTTP strategies = %v, want %v", got, want) - } - if got := strategyNames(c.sseAuthStrategies()); !reflect.DeepEqual(got, want) { - t.Fatalf("brokered SSE strategies = %v, want %v", got, want) - } -} - // GH #1271, manual-login side: the three login paths (getAuthorizationURLQuick, // forceHTTPOAuthFlowWithResult, forceSSEOAuthFlowWithResult) keyed "no // authentication required" on a successful initialize. For a per-method-auth diff --git a/internal/upstream/core/client.go b/internal/upstream/core/client.go index 84f05996c..7c1700314 100644 --- a/internal/upstream/core/client.go +++ b/internal/upstream/core/client.go @@ -90,14 +90,6 @@ type Client struct { // when multiple requests are in-flight simultaneously sseRequestMu sync.Mutex - // brokeredAuth, when set, is the per-user upstream credential the gateway - // resolved for this (user, server) connection. The headers-auth strategy - // injects it into the configured outbound header, replacing any inbound or - // statically-configured auth β€” the gateway/IdP token is never forwarded - // (spec 074 FR-016/FR-017). nil for non-brokered upstreams (unchanged - // behaviour). - brokeredAuth *proxytransport.BrokeredAuth - // retryAfter collects the `Retry-After` hints this upstream's HTTP/SSE // responses carry. mcp-go flattens a 429 into an error string long before // the connection state machine sees it, so the hint is captured by a diff --git a/internal/upstream/core/connection_http.go b/internal/upstream/core/connection_http.go index 0c6a4032f..890ed70cb 100644 --- a/internal/upstream/core/connection_http.go +++ b/internal/upstream/core/connection_http.go @@ -18,16 +18,10 @@ type authStrategy struct { // httpAuthStrategies returns the ordered HTTP auth strategies to attempt. // -// A per-user brokered connection is FAIL-CLOSED (spec 074, security-critical): -// the ONLY permitted strategy is the brokered headers. It must never fall back -// to no-auth or shared OAuth β€” either would connect with the wrong identity and -// defeat per-user isolation (FR-014/FR-017). Non-brokered connections keep the -// historical headers -> no-auth -> OAuth chain, except that a configured oauth -// block makes OAuth the only strategy (see oauthRequiredByConfig). +// Connections keep the historical headers -> no-auth -> OAuth chain, except +// that a configured oauth block makes OAuth the only strategy (see +// oauthRequiredByConfig). func (c *Client) httpAuthStrategies() []authStrategy { - if c.brokeredAuth != nil { - return []authStrategy{{"headers", c.tryHeadersAuth}} - } if c.oauthRequiredByConfig() { return []authStrategy{{"OAuth", c.tryOAuthAuth}} } @@ -39,11 +33,8 @@ func (c *Client) httpAuthStrategies() []authStrategy { } // sseAuthStrategies is the SSE counterpart of httpAuthStrategies, with the same -// fail-closed guarantee for brokered connections and the same oauth-block rule. +// oauth-block rule. func (c *Client) sseAuthStrategies() []authStrategy { - if c.brokeredAuth != nil { - return []authStrategy{{"headers", c.trySSEHeadersAuth}} - } if c.oauthRequiredByConfig() { return []authStrategy{{"OAuth", c.trySSEOAuthAuth}} } @@ -105,14 +96,14 @@ func (c *Client) AuthStrategy() string { // connectHTTP establishes HTTP transport connection with auth fallback func (c *Client) connectHTTP(ctx context.Context) error { - // Strategy order (and, for brokered connections, the fail-closed single + // Strategy order (and, for a configured oauth block, the single OAuth // strategy) is decided by httpAuthStrategies. return c.runAuthStrategies(ctx, c.httpAuthStrategies(), "") } // connectSSE establishes SSE transport connection with auth fallback func (c *Client) connectSSE(ctx context.Context) error { - // Strategy order (and, for brokered connections, the fail-closed single + // Strategy order (and, for a configured oauth block, the single OAuth // strategy) is decided by sseAuthStrategies. return c.runAuthStrategies(ctx, c.sseAuthStrategies(), "SSE ") } @@ -168,37 +159,13 @@ func (c *Client) runAuthStrategies(ctx context.Context, authStrategies []authStr return fmt.Errorf("all "+transportLabel+"authentication strategies failed, last error: %w", lastErr) } -// SetBrokeredAuth sets the per-user resolved upstream credential for this -// connection. When set, the headers-auth strategy injects it into the configured -// outbound header, replacing any inbound/configured auth (spec 074 -// FR-016/FR-017). Pass nil to clear it (non-brokered behaviour). -func (c *Client) SetBrokeredAuth(b *transport.BrokeredAuth) { - c.brokeredAuth = b -} - -// canUseHeadersStrategy reports whether the headers-auth strategy can run: it -// needs either statically-configured headers or a per-user brokered credential -// to inject. A brokered upstream commonly carries no static headers (FR-016). -func (c *Client) canUseHeadersStrategy() bool { - return len(c.config.Headers) > 0 || c.brokeredAuth != nil -} - -// brokeredHTTPConfig builds the HTTP transport config for the headers-auth -// strategy, threading the per-user brokered credential through so the transport -// layer injects it (spec 074 FR-016/FR-017). -func (c *Client) brokeredHTTPConfig() *transport.HTTPTransportConfig { - httpConfig := c.httpTransportConfig(c.config, nil) - httpConfig.BrokeredAuth = c.brokeredAuth - return httpConfig -} - // tryHeadersAuth attempts authentication using configured headers func (c *Client) tryHeadersAuth(ctx context.Context) error { - if !c.canUseHeadersStrategy() { + if len(c.config.Headers) == 0 { return fmt.Errorf("no headers configured") } - httpConfig := c.brokeredHTTPConfig() + httpConfig := c.httpTransportConfig(c.config, nil) httpClient, err := transport.CreateHTTPClient(httpConfig) if err != nil { return fmt.Errorf("failed to create HTTP client with headers: %w", err) @@ -250,11 +217,11 @@ func (c *Client) tryNoAuth(ctx context.Context) error { // trySSEHeadersAuth attempts SSE authentication using configured headers func (c *Client) trySSEHeadersAuth(ctx context.Context) error { - if !c.canUseHeadersStrategy() { + if len(c.config.Headers) == 0 { return fmt.Errorf("no headers configured") } - httpConfig := c.brokeredHTTPConfig() + httpConfig := c.httpTransportConfig(c.config, nil) sseClient, err := transport.CreateSSEClient(httpConfig) if err != nil { return fmt.Errorf("failed to create SSE client with headers: %w", err) diff --git a/internal/upstream/core/connection_http_broker_test.go b/internal/upstream/core/connection_http_broker_test.go deleted file mode 100644 index 464a62c04..000000000 --- a/internal/upstream/core/connection_http_broker_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package core - -import ( - "reflect" - "testing" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport" -) - -func strategyNames(strategies []authStrategy) []string { - names := make([]string, len(strategies)) - for i, s := range strategies { - names[i] = s.name - } - return names -} - -// Spec 074 T7: a per-user brokered credential set on the client drives outbound -// header injection on the headers-auth strategy, replacing any configured auth -// header (FR-016/FR-017). - -func TestClient_BrokeredHTTPConfig_ReplacesConfiguredAuth(t *testing.T) { - c := &Client{ - config: &config.ServerConfig{ - URL: "https://upstream.example/mcp", - Headers: map[string]string{"Authorization": "Bearer INBOUND-GATEWAY"}, - }, - } - c.SetBrokeredAuth(&transport.BrokeredAuth{ - Header: "Authorization", Format: "Bearer {token}", Token: "user-1-token", - }) - - cfg := c.brokeredHTTPConfig() - if cfg.BrokeredAuth == nil || cfg.BrokeredAuth.Token != "user-1-token" { - t.Fatalf("brokered auth not threaded into transport config: %+v", cfg.BrokeredAuth) - } - - eff := transport.EffectiveHeaders(cfg.Headers, cfg.BrokeredAuth) - if eff["Authorization"] != "Bearer user-1-token" { - t.Fatalf("outbound auth = %q, want per-user token (inbound must be replaced)", eff["Authorization"]) - } -} - -// FR-016: a brokered server may carry no static headers; the headers-auth -// strategy must still be usable so the resolved credential gets injected. -func TestClient_CanUseHeadersStrategy_WithBrokeredAuthOnly(t *testing.T) { - c := &Client{config: &config.ServerConfig{URL: "https://upstream.example/mcp"}} - - if c.canUseHeadersStrategy() { - t.Fatalf("no headers and no brokered auth: headers strategy must be skipped") - } - - c.SetBrokeredAuth(&transport.BrokeredAuth{Header: "Authorization", Format: "Bearer {token}", Token: "t"}) - if !c.canUseHeadersStrategy() { - t.Fatalf("brokered auth present: headers strategy must be usable even with no static headers") - } -} - -// Spec 074 T7 (security-critical): a per-user brokered connection must be -// FAIL-CLOSED. The only permitted auth strategy is the brokered headers; on -// failure the connection must be refused β€” it must NEVER fall back to no-auth -// (would connect unauthenticated) or shared OAuth (would borrow another -// identity), either of which defeats per-user isolation (FR-014/FR-017). -func TestClient_BrokeredConnection_FailsClosed_OnlyHeadersStrategy(t *testing.T) { - c := &Client{config: &config.ServerConfig{URL: "https://upstream.example/mcp"}} - - // Non-brokered: the historical full fallback chain is preserved. - wantFull := []string{"headers", "no-auth", "OAuth"} - if got := strategyNames(c.httpAuthStrategies()); !reflect.DeepEqual(got, wantFull) { - t.Fatalf("non-brokered HTTP strategies = %v, want %v", got, wantFull) - } - if got := strategyNames(c.sseAuthStrategies()); !reflect.DeepEqual(got, wantFull) { - t.Fatalf("non-brokered SSE strategies = %v, want %v", got, wantFull) - } - - // Brokered: ONLY the headers strategy β€” no no-auth, no OAuth fallback. - c.SetBrokeredAuth(&transport.BrokeredAuth{Header: "Authorization", Format: "Bearer {token}", Token: "user-1"}) - wantBrokered := []string{"headers"} - if got := strategyNames(c.httpAuthStrategies()); !reflect.DeepEqual(got, wantBrokered) { - t.Fatalf("brokered HTTP strategies = %v, want %v (fail-closed: no no-auth/OAuth fallback)", got, wantBrokered) - } - if got := strategyNames(c.sseAuthStrategies()); !reflect.DeepEqual(got, wantBrokered) { - t.Fatalf("brokered SSE strategies = %v, want %v (fail-closed: no no-auth/OAuth fallback)", got, wantBrokered) - } -} From c2559867ac586a9b11544710dc443399c001cf25 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 13:02:54 +0300 Subject: [PATCH 04/35] fix(storage): enforce the agent-token cap per owner, not per deployment (Spec 107 PR-A) Closes #1177 --- .../server-edition-multiuser-auth.md | 18 +- docs/features/agent-tokens.md | 13 ++ internal/httpapi/tokens.go | 5 +- internal/httpapi/tokens_cap_test.go | 56 ++++++ internal/serveredition/api/user_handlers.go | 19 +- .../serveredition/api/user_token_cap_test.go | 111 +++++++++++ .../api/user_token_mutation_test.go | 92 +-------- internal/storage/agent_tokens.go | 85 +++++++- .../storage/agent_tokens_owner_cap_test.go | 182 ++++++++++++++++++ 9 files changed, 466 insertions(+), 115 deletions(-) create mode 100644 internal/httpapi/tokens_cap_test.go create mode 100644 internal/serveredition/api/user_token_cap_test.go create mode 100644 internal/storage/agent_tokens_owner_cap_test.go diff --git a/docs/development/server-edition-multiuser-auth.md b/docs/development/server-edition-multiuser-auth.md index 50d242bbf..05863cf82 100644 --- a/docs/development/server-edition-multiuser-auth.md +++ b/docs/development/server-edition-multiuser-auth.md @@ -122,12 +122,18 @@ there is cosmetic. Do not grow an authorization check on top of it. back in front: it opens a TOCTOU window on delete-then-recreate, and its fall-through 500 used to interpolate the storage sentinel into the body. - The token cap answers **409** on both editions' surfaces - (`ErrAgentTokenLimitReached`). One storage condition, one status. The **body - differs by edition on purpose**: `auth.MaxTokens` is counted across the whole - `agent_tokens` bucket, so in the server edition it is a *deployment-wide* cap - a tenant may be unable to clear, and the message says so and points at an - administrator. Do not copy the personal edition's "you have reached the - maximum" wording here. A per-owner quota is issue #1177. + (`ErrAgentTokenLimitReached`). One storage condition, one status. The cap is + **per owner** (Spec 107 FR-037, issue #1177): `CreateAgentToken` counts only + the records whose `UserID` matches the new token's, so each tenant gets + `auth.MaxTokens` (100) of their own and the operator's ownerless tokens form + one owner of their own. One tenant can never exhaust another's slots, and the + 409 body names **only the caller's own count** ("you have reached your + maximum of 100 agent tokens; delete one of your tokens") β€” never the + deployment, other users or a fleet total, which the old global + `Stats().KeyN` count leaked as a cross-tenant oracle. There is no owner + index (records are keyed by hash), so the count is a full bucket walk that + decodes every row and stops early only once `MaxTokens` *matches* are found, + never after `MaxTokens` rows. - **A token is only as live as its owner.** `storage.Manager.SetAgentTokenOwnerGate` is installed in `setup.go` over the user store, and `ValidateAgentToken` consults it for every *owned* token (ownerless personal-edition tokens are diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 43e8d890f..76a9a5cfb 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -319,6 +319,19 @@ The pin is shown by `token list` (PROFILE PIN column) and `token show` (Profile ## Managing Tokens +### Token Limit + +Each owner can hold at most **100 agent tokens**. Revoked tokens still occupy a +slot until they are deleted, so once you hit the limit, creating another token +answers `409 Conflict` with a message about *your* count β€” delete one of your +own tokens to free a slot. + +The limit is **per owner**, not per deployment: in the personal edition every +token belongs to the one operator, and in the server edition each signed-in +user gets their own 100 while the operator's tokens are counted as a separate +owner. Another user's tokens never count against yours, and the error never +reveals how many tokens anyone else holds. + ### List All Tokens ```bash diff --git a/internal/httpapi/tokens.go b/internal/httpapi/tokens.go index e1402376b..0cc9bf82d 100644 --- a/internal/httpapi/tokens.go +++ b/internal/httpapi/tokens.go @@ -221,7 +221,10 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) { return } if errors.Is(err, storage.ErrAgentTokenLimitReached) { - s.writeError(w, r, http.StatusConflict, fmt.Sprintf("Maximum number of agent tokens (%d) reached", auth.MaxTokens)) + // The cap is per owner (Spec 107 FR-037, #1177). Every token on + // this surface is ownerless, so the caller owns all of them and + // the body may β€” must β€” speak of the caller's own count only. + s.writeError(w, r, http.StatusConflict, fmt.Sprintf("You have reached your maximum of %d agent tokens; delete one of your tokens to free a slot", auth.MaxTokens)) return } s.logger.Errorf("Failed to create agent token: %v", err) diff --git a/internal/httpapi/tokens_cap_test.go b/internal/httpapi/tokens_cap_test.go new file mode 100644 index 000000000..0dde65fa4 --- /dev/null +++ b/internal/httpapi/tokens_cap_test.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// TestCreateToken_CapReached pins the personal-edition 409 for +// storage.ErrAgentTokenLimitReached (Spec 107 FR-037, issue #1177). +// +// The cap is now enforced per owner in storage. Every personal-edition token is +// ownerless, so on this surface the caller owns every token that counts: the +// body's MEANING is unchanged β€” "you are at the maximum, free one of yours" β€” +// and it must reference only the caller's own count, never a deployment total. +// +// Oracle discipline: a positive control mints through the same server first, +// so the 409 is the classified sentinel and not an unwired store. +// +// BITES: map the sentinel to any other status, drop the limit figure, or +// describe the deployment instead of the caller. +func TestCreateToken_CapReached(t *testing.T) { + store := newMockTokenStore() + srv := newTestTokenServer(t, store, []string{"server1"}) + + body := createTokenRequest{Name: "ci", Permissions: []string{"read"}} + + ok := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body) + require.Equal(t, http.StatusCreated, ok.Code, "positive control: create must work before the cap (%s)", ok.Body.String()) + + store.createErr = storage.ErrAgentTokenLimitReached + body.Name = "one-too-many" + over := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body) + require.Equal(t, http.StatusConflict, over.Code, "the cap must answer 409 (%s)", over.Body.String()) + + var envelope contracts.APIResponse + require.NoError(t, json.Unmarshal(over.Body.Bytes(), &envelope)) + require.False(t, envelope.Success) + msg := envelope.Error + lower := strings.ToLower(msg) + + assert.Contains(t, msg, fmt.Sprintf("%d", auth.MaxTokens), "the body must say what the limit is") + assert.Contains(t, lower, "your", "the body must address the caller's own tokens") + for _, leak := range []string{"deployment", "shared by all", "all users", "administrator"} { + assert.NotContains(t, lower, leak, "the body must reference only the caller's own count") + } +} diff --git a/internal/serveredition/api/user_handlers.go b/internal/serveredition/api/user_handlers.go index 99eb0caa3..e87963697 100644 --- a/internal/serveredition/api/user_handlers.go +++ b/internal/serveredition/api/user_handlers.go @@ -1160,18 +1160,15 @@ func (h *UserHandlers) createUserToken(w http.ResponseWriter, r *http.Request) { // standing conflict with the deployment's state, not a transient // outage a client should sit and retry the way a 503 invites. // - // The WORDING, though, cannot be the personal edition's. There, the - // caller owns every token and "you have reached the maximum" is - // both true and actionable. auth.MaxTokens is a DEPLOYMENT-wide cap - // counted across all tenants (internal/storage/agent_tokens.go), so - // here the caller may hold none of the tokens filling it, and a - // message that reads as their own quota sends them to delete tokens - // that will not free a slot β€” or to look for tokens they are not - // allowed to see. Say whose limit it is and who can act on it. - // A per-owner quota, which would make this the caller's own - // problem to fix, is issue #1177. + // The cap is PER OWNER (Spec 107 FR-037, issue #1177): storage + // counts only records with the caller's own UserID, so this is + // the caller's own quota, and deleting one of their own tokens + // always frees a slot. The body therefore speaks of the caller's + // tokens only. It must never describe the deployment, other + // users or a fleet total β€” that was the cross-tenant oracle the + // old global count and its "shared by all users" wording leaked. writeError(w, http.StatusConflict, - fmt.Sprintf("This deployment has reached its limit of %d agent tokens. The limit is shared by all users, so deleting your own tokens may not free a slot; ask an administrator.", auth.MaxTokens)) + fmt.Sprintf("You have reached your maximum of %d agent tokens; delete one of your tokens to free a slot.", auth.MaxTokens)) default: writeError(w, http.StatusInternalServerError, "Failed to create token") } diff --git a/internal/serveredition/api/user_token_cap_test.go b/internal/serveredition/api/user_token_cap_test.go new file mode 100644 index 000000000..f153fa8fd --- /dev/null +++ b/internal/serveredition/api/user_token_cap_test.go @@ -0,0 +1,111 @@ +//go:build server + +package api + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" +) + +// Spec 107 FR-037 (issue #1177): the agent-token cap is enforced PER OWNER, and +// the 409 body names only the caller's own count. A global cap was a +// cross-tenant oracle twice over β€” one tenant could exhaust every tenant's +// slots, and the body told any tenant that the fleet total had reached +// auth.MaxTokens. +// +// These two tests replace TestCreateUserToken_CapExhaustionIsConflict and +// TestCreateUserToken_CapExhaustionDoesNotBlameTheCaller, which pinned the +// deployment-wide wording ("shared by all users … ask an administrator") that +// the per-owner cap makes false. + +// assertOwnQuotaWording pins what the per-owner 409 body may and may not say: +// it states the limit, it addresses the caller's own tokens, and it never +// describes the deployment, other users or a fleet total. +func assertOwnQuotaWording(t *testing.T, msg string) { + t.Helper() + lower := strings.ToLower(msg) + assert.Contains(t, msg, fmt.Sprintf("%d", auth.MaxTokens), + "the cap message must say what the limit is") + assert.Contains(t, lower, "your", + "the cap is the caller's own quota; the body must say so") + for _, leak := range []string{"deployment", "shared by all", "all users", "administrator", "other users"} { + assert.NotContains(t, lower, leak, + "a per-owner cap body must not describe anything beyond the caller's own tokens") + } +} + +// TestCreateUserToken_CapIsPerOwner: user A at the cap is refused with 409, +// and user B β€” who holds nothing β€” mints their first on the same router. +// +// Oracle discipline: A's 100th token mints through the HTTP route (positive +// control on the same router), so the 409 that follows is exhaustion and not a +// malformed body or an unwired store; B's success proves the cap is A's alone. +// +// BITES: restore the global `Stats().KeyN` count in storage and B's first +// create answers 409; restore the deployment-wide wording and the body +// assertions fail. +func TestCreateUserToken_CapIsPerOwner(t *testing.T) { + rig := newTokenTestRig(t) + rig.actAs(userACtx()) + + // Fill to one below the cap through storage, which is far faster than the + // HTTP route and exercises the same counter. + for i := 0; i < auth.MaxTokens-1; i++ { + rig.seedToken(t, tokenUserA, fmt.Sprintf("filler-%03d", i)) + } + + last := rig.createToken(t, "last-slot", nil) + require.Equal(t, http.StatusCreated, last.Code, + "positive control: the final slot below the cap must still mint (%s)", last.Body.String()) + + over := rig.createToken(t, "one-too-many", nil) + require.Equal(t, http.StatusConflict, over.Code, + "the token cap must answer 409, matching the personal edition (%s)", over.Body.String()) + assertOwnQuotaWording(t, errorMessage(t, over)) + + // The cap is A's, not the deployment's. + rig.actAs(userBCtx()) + first := rig.createToken(t, "b-first", nil) + require.Equal(t, http.StatusCreated, first.Code, + "user B holds no tokens; A's cap must not block B (%s)", first.Body.String()) +} + +// TestCreateUserToken_CapBodyNamesOnlyTheCallersCount is the oracle half on +// its own: user B, holding exactly one token, is told nothing about the 100 +// tokens user A holds. The 409 only ever fires for B once B is at the cap, +// and its body then describes B's own quota. +// +// BITES: with the global count B's second create is refused while B holds one +// token, and with the old body the message describes the deployment. +func TestCreateUserToken_CapBodyNamesOnlyTheCallersCount(t *testing.T) { + rig := newTokenTestRig(t) + + rig.actAs(userBCtx()) + ctrl := rig.createToken(t, "b-first", nil) + require.Equal(t, http.StatusCreated, ctrl.Code, + "positive control: user B must be able to mint before anyone fills anything (%s)", ctrl.Body.String()) + + // User A fills A's OWN quota entirely. + for i := 0; i < auth.MaxTokens; i++ { + rig.seedToken(t, tokenUserA, fmt.Sprintf("a-filler-%03d", i)) + } + + second := rig.createToken(t, "b-second", nil) + require.Equal(t, http.StatusCreated, second.Code, + "A's full quota must not be reported to B as B's problem (%s)", second.Body.String()) + + // Now B reaches B's own cap, and the refusal is about B. + for i := 2; i < auth.MaxTokens; i++ { + rig.seedToken(t, tokenUserB, fmt.Sprintf("b-filler-%03d", i)) + } + over := rig.createToken(t, "b-one-too-many", nil) + require.Equal(t, http.StatusConflict, over.Code, "B at B's own cap must be refused (%s)", over.Body.String()) + assertOwnQuotaWording(t, errorMessage(t, over)) +} diff --git a/internal/serveredition/api/user_token_mutation_test.go b/internal/serveredition/api/user_token_mutation_test.go index 79c8f0928..c24d90b06 100644 --- a/internal/serveredition/api/user_token_mutation_test.go +++ b/internal/serveredition/api/user_token_mutation_test.go @@ -4,10 +4,8 @@ package api import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -94,91 +92,11 @@ func TestUserTokenMutators_NotFoundLeaksNoStorageSentinel(t *testing.T) { } } -// TestCreateUserToken_CapExhaustionIsConflict pins the status the token cap -// answers with. The same storage condition (storage.ErrAgentTokenLimitReached) -// used to map to 409 on the personal-edition surface and 503 on this one, so a -// client's retry behaviour depended on which door it knocked on β€” and 503 -// invites a retry loop against a condition that will never clear on its own. -// -// Oracle discipline: a positive control mints the token immediately BELOW the -// cap on the same router, so the 409 that follows is exhaustion and not a -// malformed body or an unwired store. -// -// BITES: restore http.StatusServiceUnavailable in createUserToken. -func TestCreateUserToken_CapExhaustionIsConflict(t *testing.T) { - rig := newTokenTestRig(t) - rig.actAs(userACtx()) - - // Fill to one below the cap through storage, which is far faster than the - // HTTP route and exercises the same counter. - for i := 0; i < auth.MaxTokens-1; i++ { - rig.seedToken(t, tokenUserA, fmt.Sprintf("filler-%03d", i)) - } - - // Positive control: the last slot below the cap still mints. - last := rig.createToken(t, "last-slot", nil) - require.Equal(t, http.StatusCreated, last.Code, - "positive control: the final slot below the cap must still mint (%s)", last.Body.String()) - - over := rig.createToken(t, "one-too-many", nil) - require.Equal(t, http.StatusConflict, over.Code, - "the token cap must answer 409, matching the personal edition (%s)", over.Body.String()) - - msg := errorMessage(t, over) - assert.Contains(t, msg, fmt.Sprintf("%d", auth.MaxTokens), - "the cap message must say what the limit is") - - // The cap is DEPLOYMENT-wide (auth.MaxTokens is counted across the whole - // agent_tokens bucket, all owners together), and there is no per-owner - // quota β€” that is issue #1177. So the body must not read as the caller's - // own quota: a tenant who holds none of the tokens filling it would go - // hunting for tokens of theirs to delete, and deleting every one of them - // need not free a slot. It has to say whose limit it is and who can act. - assert.Contains(t, strings.ToLower(msg), "administrator", - "the cap body must point the caller at someone who can actually act on it") - assert.NotRegexp(t, `(?i)\byou(r)? have reached|delete (one of )?your`, msg, - "the cap body must not instruct the caller to free a slot they may not control") -} - -// TestCreateUserToken_CapExhaustionDoesNotBlameTheCaller is the Q3 property on -// its own, with the victim being someone who holds NO tokens at all: the cap is -// filled entirely by another tenant. -// -// Oracle discipline: user B mints successfully at the start (positive control on -// the same router), so the 409 that follows is the deployment cap and not an -// unwired store; and the message is asserted for what it must NOT claim, since -// the defect is a true statement about the wrong subject. -// -// BITES: with the old body this fails on "administrator", because the message -// read "Maximum number of agent tokens (100) reached" β€” the personal edition's -// wording, where the caller does own every token. -func TestCreateUserToken_CapExhaustionDoesNotBlameTheCaller(t *testing.T) { - rig := newTokenTestRig(t) - - // Positive control: user B can mint before the cap is filled. - rig.actAs(userBCtx()) - ctrl := rig.createToken(t, "b-first", nil) - require.Equal(t, http.StatusCreated, ctrl.Code, - "positive control: user B must be able to mint before the cap fills (%s)", ctrl.Body.String()) - - // User A fills the rest of the DEPLOYMENT-wide cap. - for i := 0; i < auth.MaxTokens-1; i++ { - rig.seedToken(t, tokenUserA, fmt.Sprintf("a-filler-%03d", i)) - } - - over := rig.createToken(t, "b-second", nil) - require.Equal(t, http.StatusConflict, over.Code, - "the cap must be reported to the blocked tenant (%s)", over.Body.String()) - - // User B holds exactly one token. Deleting it frees one slot out of a cap - // filled by someone else's 99 β€” which is precisely why the body must not - // tell them to. - msg := errorMessage(t, over) - assert.Contains(t, strings.ToLower(msg), "shared by all users", - "the body must say the limit is not the caller's own") - assert.Contains(t, strings.ToLower(msg), "administrator", - "the body must name who can act on a deployment-wide limit") -} +// The two cap-exhaustion tests that used to live here +// (TestCreateUserToken_CapExhaustionIsConflict and +// TestCreateUserToken_CapExhaustionDoesNotBlameTheCaller) pinned the +// deployment-wide 409 wording; the cap is per owner since Spec 107 FR-037 +// (#1177) and their replacements live in user_token_cap_test.go. // TestRegenerateUserToken_ReNarrowsScopeToCurrentEntitlement pins the one // re-check a token's server scope ever gets. diff --git a/internal/storage/agent_tokens.go b/internal/storage/agent_tokens.go index fd4f9b256..981afd023 100644 --- a/internal/storage/agent_tokens.go +++ b/internal/storage/agent_tokens.go @@ -51,8 +51,10 @@ var ( // of a different tenant's token. ErrAgentTokenNameExists = errors.New("agent token with this name already exists") - // ErrAgentTokenLimitReached is returned when the deployment-wide token cap - // is reached. + // ErrAgentTokenLimitReached is returned when the OWNER's token cap + // (auth.MaxTokens) is reached. The cap is per owner (token.UserID), never + // deployment-wide: one tenant cannot exhaust another's slots, and the + // error tells the caller nothing about anyone else's tokens (issue #1177). ErrAgentTokenLimitReached = errors.New("maximum number of agent tokens reached") // ErrAgentTokenOwnerInactive is returned by ValidateAgentToken when a @@ -84,11 +86,13 @@ var ( // consulting the legacy owner-blind name index. Returns (nil, nil) when the // pair does not resolve. // -// A scan is correct and cheap here: the bucket is capped at auth.MaxTokens -// entries and only low-frequency management operations resolve by name (the -// authentication hot path resolves by hash). It is also constant-time with -// respect to ownership β€” the whole bucket is walked regardless β€” so it adds no -// timing oracle for "does another tenant own this name?". +// A scan is correct and cheap here: the bucket holds at most auth.MaxTokens +// entries PER OWNER (the cap is per owner, issue #1177), so it is bounded by +// the number of token owners times the cap, and only low-frequency management +// operations resolve by name (the authentication hot path resolves by hash). +// It is also constant-time with respect to ownership β€” the whole bucket is +// walked regardless β€” so it adds no timing oracle for "does another tenant +// own this name?". // // The caller must complete this scan before mutating the bucket: bbolt forbids // mutating a bucket while iterating it. @@ -151,8 +155,14 @@ func (m *Manager) findAgentTokenHashLocked(tx *bbolt.Tx, userID, name string) ([ // personal-edition token) additionally get a bare-name entry in the legacy // "agent_token_names" index so the personal edition is unchanged on disk. // +// The auth.MaxTokens cap is likewise PER OWNER (issue #1177): tokens with the +// same UserID count together, and the ownerless operator tokens form one owner +// of their own. One tenant can therefore never exhaust another's slots, and +// the personal edition β€” where every token is ownerless β€” behaves exactly as +// it did under the old global count. +// // Returns ErrAgentTokenNameExists if the same owner already has that name, or -// ErrAgentTokenLimitReached if the deployment-wide cap is reached. +// ErrAgentTokenLimitReached if that owner is at the cap. func (m *Manager) CreateAgentToken(token auth.AgentToken, rawToken string, hmacKey []byte) error { if token.Name == "" { return fmt.Errorf("agent token name cannot be empty") @@ -191,8 +201,13 @@ func (m *Manager) CreateAgentToken(token auth.AgentToken, rawToken string, hmacK return ErrAgentTokenNameExists } - // Enforce max token limit - count := tokenBucket.Stats().KeyN + // Enforce the cap for THIS OWNER. This is a second full walk of the + // bucket rather than Stats().KeyN: the key count is the deployment + // total, which is exactly the cross-tenant oracle #1177 removes. + count, err := m.countAgentTokensForOwnerLocked(tx, token.UserID, auth.MaxTokens) + if err != nil { + return err + } if count >= auth.MaxTokens { return ErrAgentTokenLimitReached } @@ -231,6 +246,56 @@ func (m *Manager) CreateAgentToken(token auth.AgentToken, rawToken string, hmacK }) } +// errStopAgentTokenWalk is the sentinel countAgentTokensForOwnerLocked returns +// from its ForEach callback to end the walk early; it never escapes. +var errStopAgentTokenWalk = errors.New("stop agent token walk") + +// countAgentTokensForOwnerLocked counts the records in agent_tokens whose +// UserID equals userID, inside the given transaction, stopping early once +// `limit` matches have been found (the caller only needs to know whether the +// owner is at the cap). Revoked-but-not-deleted tokens still count: they hold +// a slot until deleted, exactly as they did under the old global count. +// +// There is no owner index β€” records are keyed by token hash and UserID lives +// inside the JSON β€” and because the cap is per owner the bucket may hold far +// more than auth.MaxTokens rows. So this is a FULL bucket walk that decodes +// each record; it must never give up after `limit` ROWS, only after `limit` +// MATCHES, or an owner whose rows sort late in key order would be uncapped. +// +// An unparseable row is skipped and logged, for the reason given on +// findAgentTokenHashLocked: aborting would turn every owner's create into a +// 500 over one corrupt record. A skipped row counts for nobody. +func (m *Manager) countAgentTokensForOwnerLocked(tx *bbolt.Tx, userID string, limit int) (int, error) { + tokenBucket := tx.Bucket([]byte(AgentTokensBucket)) + if tokenBucket == nil { + return 0, nil + } + + count := 0 + err := tokenBucket.ForEach(func(k, v []byte) error { + var token auth.AgentToken + if err := json.Unmarshal(v, &token); err != nil { + if m.logger != nil { + m.logger.Warnw("skipping unparseable agent token record", + "bucket", AgentTokensBucket, "key", string(k), "error", err) + } + return nil + } + if token.UserID != userID { + return nil + } + count++ + if count >= limit { + return errStopAgentTokenWalk + } + return nil + }) + if err != nil && !errors.Is(err, errStopAgentTokenWalk) { + return 0, fmt.Errorf("failed to count agent tokens for owner: %w", err) + } + return count, nil +} + // claimAgentTokenNameSlot reports whether the legacy owner-blind name index may // be pointed at a newly created OWNERLESS token. // diff --git a/internal/storage/agent_tokens_owner_cap_test.go b/internal/storage/agent_tokens_owner_cap_test.go new file mode 100644 index 000000000..d3579e2f0 --- /dev/null +++ b/internal/storage/agent_tokens_owner_cap_test.go @@ -0,0 +1,182 @@ +package storage + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.etcd.io/bbolt" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" +) + +// Spec 107 FR-037 (issue #1177): auth.MaxTokens is a PER-OWNER cap, not a +// deployment-wide one. Tokens with the same UserID count together; ownerless +// operator tokens (UserID == "", every personal-edition token) form one owner. +// +// Oracle discipline for every test below: a positive control mints the token +// immediately BELOW the cap for the same owner, so the ErrAgentTokenLimitReached +// that follows is the cap and not a name collision or an unwired store. + +// fillOwnerToCap mints exactly auth.MaxTokens tokens for one owner and +// requires every one of them to land. +func fillOwnerToCap(t *testing.T, mgr *Manager, owner string) { + t.Helper() + for i := 0; i < auth.MaxTokens; i++ { + token, raw := makeOwnedTestToken(t, fmt.Sprintf("%s-fill-%03d", ownerLabel(owner), i), owner) + require.NoError(t, mgr.CreateAgentToken(token, raw, testHMACKey), + "owner %q token %d must land below the cap", owner, i) + } +} + +func ownerLabel(owner string) string { + if owner == "" { + return "ownerless" + } + return owner +} + +// TestAgentTokenCap_PerOwner: owner A at the cap cannot mint a 101st token, +// and owner B β€” who holds nothing β€” can still mint their first. +// +// BITES: with the global `tokenBucket.Stats().KeyN >= auth.MaxTokens` count, +// A's 100 tokens fill the bucket and B's first create fails. +func TestAgentTokenCap_PerOwner(t *testing.T) { + mgr, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + const ownerA = "01HTEST0000000000000USERA" + const ownerB = "01HTEST0000000000000USERB" + + fillOwnerToCap(t, mgr, ownerA) + + over, raw := makeOwnedTestToken(t, "a-one-too-many", ownerA) + err := mgr.CreateAgentToken(over, raw, testHMACKey) + require.ErrorIs(t, err, ErrAgentTokenLimitReached, "A's 101st token must hit A's own cap") + + first, raw := makeOwnedTestToken(t, "b-first", ownerB) + require.NoError(t, mgr.CreateAgentToken(first, raw, testHMACKey), + "B holds no tokens; A's cap must not block B's first") + + // The bucket now holds MaxTokens+1 rows: the cap is per owner, so the + // bucket itself is no longer bounded by auth.MaxTokens. + count, err := mgr.GetAgentTokenCount() + require.NoError(t, err) + assert.Equal(t, auth.MaxTokens+1, count) +} + +// TestAgentTokenCap_OwnerlessTokensAreOneOwner: the operator's ownerless +// tokens count together as ONE owner (so the personal edition is unchanged), +// and they do not count against a user's quota nor a user's against theirs. +// +// BITES: with the global count, 100 ownerless tokens block the user's first. +func TestAgentTokenCap_OwnerlessTokensAreOneOwner(t *testing.T) { + mgr, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + const ownerA = "01HTEST0000000000000USERA" + + fillOwnerToCap(t, mgr, "") + + over, raw := makeOwnedTestToken(t, "ownerless-one-too-many", "") + require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenLimitReached, + "the ownerless owner is capped like any other") + + first, raw := makeOwnedTestToken(t, "a-first", ownerA) + require.NoError(t, mgr.CreateAgentToken(first, raw, testHMACKey), + "the operator's ownerless tokens must not consume a user's quota") + + // And the other direction: A at the cap does not stop an ownerless mint + // once a slot is free in the ownerless quota. + require.NoError(t, mgr.DeleteAgentToken("ownerless-fill-000")) + freed, raw := makeOwnedTestToken(t, "ownerless-after-delete", "") + require.NoError(t, mgr.CreateAgentToken(freed, raw, testHMACKey), + "deleting one of the owner's own tokens must free a slot for that owner") +} + +// TestAgentTokenCap_CountsWholeBucketNotFirstMaxTokensRows pins the walk +// shape. There is no owner index β€” records are keyed by HMAC hash and UserID +// lives inside the JSON β€” and once the cap is per owner the bucket can hold +// far more than auth.MaxTokens rows. So the count must decode EVERY row and +// count only the new token's owner; it may stop early once MaxTokens matches +// are found, never after MaxTokens rows. +// +// The fixture writes more than MaxTokens other-owner rows directly into the +// bucket under keys that sort BEFORE every hex hash key ('!' < '0'), so a +// walk that gives up after MaxTokens rows sees only strangers, counts zero +// for the target owner, and lets a 101st token through; a walk that counts +// rows instead of owners blocks the target owner's first. +func TestAgentTokenCap_CountsWholeBucketNotFirstMaxTokensRows(t *testing.T) { + mgr, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + const ownerA = "01HTEST0000000000000USERA" + const stranger = "01HTEST00000000000STRANGER" + const strangerRows = auth.MaxTokens + 50 + + require.NoError(t, mgr.db.db.Update(func(tx *bbolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists([]byte(AgentTokensBucket)) + if err != nil { + return err + } + for i := 0; i < strangerRows; i++ { + rec := auth.AgentToken{ + Name: fmt.Sprintf("stranger-%03d", i), + UserID: stranger, + TokenHash: fmt.Sprintf("!stranger-%03d", i), + TokenPrefix: "mcp_agt_strang", + Permissions: []string{auth.PermRead}, + CreatedAt: time.Now().UTC(), + } + data, err := json.Marshal(rec) + if err != nil { + return err + } + // '!' (0x21) sorts before every hex digit, so these rows are the + // first MaxTokens+50 the cursor yields. + if err := bucket.Put([]byte(rec.TokenHash), data); err != nil { + return err + } + } + return nil + })) + + // Positive control on the fixture: the strangers really are in the bucket + // and really do outnumber the cap. + count, err := mgr.GetAgentTokenCount() + require.NoError(t, err) + require.Equal(t, strangerRows, count) + + // The target owner's full quota still mints ... + fillOwnerToCap(t, mgr, ownerA) + + // ... and the 101st is refused, even though the first MaxTokens rows in + // key order belong to someone else. + over, raw := makeOwnedTestToken(t, "a-one-too-many", ownerA) + require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenLimitReached) +} + +// TestAgentTokenCap_UnparseableRowIsSkippedNotCounted: a corrupt row must +// neither abort the create for every tenant nor be counted against anyone. +func TestAgentTokenCap_UnparseableRowIsSkippedNotCounted(t *testing.T) { + mgr, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + const ownerA = "01HTEST0000000000000USERA" + + require.NoError(t, mgr.db.db.Update(func(tx *bbolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists([]byte(AgentTokensBucket)) + if err != nil { + return err + } + return bucket.Put([]byte("!corrupt"), []byte("{not json")) + })) + + fillOwnerToCap(t, mgr, ownerA) + + over, raw := makeOwnedTestToken(t, "a-one-too-many", ownerA) + require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenLimitReached) +} From 3bf4394c8b75cef46cc637288fff9e1cf95966f8 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 13:44:17 +0300 Subject: [PATCH 05/35] fix(spec-107): address adversarial review of PR-A --- .github/RELEASE_NOTICE.md | 22 +- .../server-edition-multiuser-auth.md | 21 +- docs/features/agent-tokens.md | 23 +- docs/features/idp-token-storage.md | 2 +- internal/auth/agent_token.go | 12 +- internal/config/latent_symbols_guard_test.go | 4 + internal/config/server_edition_config.go | 6 +- .../httpapi/config_patch_roundtrip_test.go | 60 +++++ internal/httpapi/tokens.go | 12 +- internal/httpapi/tokens_cap_test.go | 64 +++-- internal/serveredition/api/user_handlers.go | 23 +- .../serveredition/api/user_token_cap_test.go | 111 --------- .../api/user_token_mutation_test.go | 120 +++++++++- .../serveredition/auth/oauth_handler_test.go | 41 ++++ internal/serveredition/broker/bbolt_aes.go | 57 ++++- .../broker/legacy_idp_purge_test.go | 139 +++++++++++ internal/serveredition/setup.go | 25 +- internal/serveredition/setup_defaults_test.go | 162 +++++++++++++ .../setup_legacy_idp_purge_test.go | 95 ++++++++ .../storage/agent_token_owner_quota_test.go | 69 ++++++ internal/storage/agent_tokens.go | 138 +++++------ .../storage/agent_tokens_owner_cap_test.go | 224 +++++++++--------- 22 files changed, 1052 insertions(+), 378 deletions(-) delete mode 100644 internal/serveredition/api/user_token_cap_test.go create mode 100644 internal/serveredition/broker/legacy_idp_purge_test.go create mode 100644 internal/serveredition/setup_defaults_test.go create mode 100644 internal/serveredition/setup_legacy_idp_purge_test.go create mode 100644 internal/storage/agent_token_owner_quota_test.go diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md index 91ad6f5a6..9ed3bcca2 100644 --- a/.github/RELEASE_NOTICE.md +++ b/.github/RELEASE_NOTICE.md @@ -1,3 +1,15 @@ +## πŸ”’ Colon-named tools are approved by their exact name β€” one-time review after upgrade + +A tool's approval record, search-index entry and callability are now keyed by the **exact name its server reports**, colons included. Earlier releases filed a namespaced tool such as `ns:erase` under the text after its first colon, so it shared β€” and silently inherited β€” the approval of a sibling `erase` on the same server. Every dispatch path (`call_tool_*`, direct-name dispatch on `/mcp/all`, `call_tool()` inside code execution), preflight and `describe_tool` now resolve exactly the `server:tool` pair they dispatch. + +**What changes for you** + +- **On `manual` (default) and `scan` trust, colon-named tools on a server that already has an approved baseline become pending once, under their own names, on the first discovery after upgrade.** They stay uncallable and out of `retrieve_tools` until you approve them: `mcpproxy upstream inspect ` to review, `mcpproxy upstream approve `, the `quarantine_security` MCP tool, or the Web UI. `trust_mode: auto` servers and installs with `quarantine_enabled: false` auto-approve them; no other tool is affected. +- **Blocks carry over.** A tool you had disabled under the old collapsed name stays disabled under its own name until you enable it there; the log records the carry-over at `WARN` with both names. A tool that was locked pending review is pending under its own name and is unlocked by approving it by that name β€” nothing else is needed, and nothing is deleted. A namespaced tool you had toggled in the UI keeps its old review lock (with the before/after evidence) under its own name until you approve it; if its old record approved a *different* definition than the server reports now, it is held as changed for review, and if it had no old record it is pending under an active gate β€” a toggle never approves a definition nobody reviewed. +- **Unresolved names are refused for everyone.** A call to a tool that a connected server's discovered tool set does not contain is refused before any upstream call, for administrators too β€” while the server's discovery has not completed, retry shortly; afterwards, refresh with `retrieve_tools` and retry with a listed name. Quarantined, disabled and disconnected servers keep their existing answers: a call to a disconnected server still gets the not-connected / `reconnect_on_use` answer, and once the server reconnects and completes discovery, a name that result does not list is refused as unresolved β€” it is never dispatched. + +Details: [Security Quarantine β†’ Namespaced tool names](https://docs.mcpproxy.app/features/security-quarantine#namespaced-tool-names) and [Agent Tokens β†’ Target tool tier](https://docs.mcpproxy.app/features/agent-tokens#target-tool-tier). + ## Server edition: configuration keys and modes that never did anything are gone This release removes the server-edition knobs and `auth_broker` modes that were accepted by the validator but had no reader in production. An old `mcp_config.json` still loads; what changes is how the removed keys are treated. Personal-edition users are not affected unless the file carries a `server_edition` or `auth_broker` block. @@ -23,7 +35,7 @@ This release removes the server-edition knobs and `auth_broker` modes that were `server_edition.store_idp_tokens` no longer stores anything. The identity-provider access and refresh tokens it used to persist at login existed only to feed the never-implemented `token_exchange`/`entra_obo` modes, which left a long-lived IdP refresh token at rest with nothing reading it. The writer, the reader and the offline-access scope and authorization parameters that asked the IdP for a refresh token (`offline_access`, `access_type=offline`) are removed (FR-033), so a fresh login no longer requests a refresh token from the IdP. - The key is still accepted so an old file loads. `"store_idp_tokens": true` logs one warning at boot β€” `server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it` β€” and does nothing else. -- Remove it from your configuration. Nothing in this release reads the IdP tokens an earlier release stored. +- Remove it from your configuration. Nothing in this release reads the IdP tokens an earlier release stored, and the first start of `mcpproxy-server` after upgrading **deletes them** from `config.db` (the rows are removed by key, so this happens whether or not `MCPPROXY_CRED_KEY` is still set; the log line `purged legacy IdP subject-token rows` reports the count). Credentials connected through the `oauth_connect` flow are not touched. - The former [IdP Token Storage](https://docs.mcpproxy.app/features/idp-token-storage/) page is now a tombstone. ## Auth broker: a stored credential is stored, not injected @@ -35,10 +47,10 @@ The `oauth_connect` connect flow, its REST routes, the `mcpproxy credential` com - Upstream calls keep using whatever the server's own configuration provides (static headers, the server's own OAuth). If you deployed the broker expecting per-user credentials on upstream calls, that expectation was never met, and this release says so rather than fixing it. - Historical `credential_broker` activity rows remain readable and labelled. -## Agent tokens: the cap is now per owner +## Agent tokens: a per-user quota inside the deployment cap -The 100-token limit on agent tokens is now counted **per owner** instead of across the whole deployment ([#1177](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1177), FR-037). Tokens with the same owner count together; operator tokens with no owner form one owner of their own. +The server edition now enforces a **25-token quota per signed-in user** on top of the existing 100-record deployment cap ([#1177](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1177)). Revoked tokens keep their slot until they are permanently deleted. -- Server edition: one user reaching 100 tokens no longer blocks every other user from creating theirs, and the `409 Conflict` body from `POST /user/tokens` now refers only to the caller's own count β€” deleting your own tokens does free a slot. -- Personal edition: every token is ownerless, so the limit is unchanged in practice (100), and the `409` body keeps its meaning. +- Server edition: a user at 25 tokens gets a `409 Conflict` from `POST /user/tokens` that names *their* quota β€” permanently deleting one of their unused tokens frees a slot β€” and no longer consumes the slots every other user shares. The 100-record deployment cap remains, and its `409` still says the limit is shared and points at an administrator. A user who already holds more than 25 tokens keeps them; they cannot create another until they are back under the quota. +- Personal edition: every token is ownerless, so the quota does not apply and the 100-token limit is unchanged. - No configuration change is needed. Details: [agent tokens](https://docs.mcpproxy.app/features/agent-tokens/). diff --git a/docs/development/server-edition-multiuser-auth.md b/docs/development/server-edition-multiuser-auth.md index 05863cf82..76c44df8f 100644 --- a/docs/development/server-edition-multiuser-auth.md +++ b/docs/development/server-edition-multiuser-auth.md @@ -121,19 +121,14 @@ there is cosmetic. Do not grow an authorization check on top of it. error (`storage.ErrAgentTokenNotFound` β†’ 404). Do not put a `Get…` preflight back in front: it opens a TOCTOU window on delete-then-recreate, and its fall-through 500 used to interpolate the storage sentinel into the body. -- The token cap answers **409** on both editions' surfaces - (`ErrAgentTokenLimitReached`). One storage condition, one status. The cap is - **per owner** (Spec 107 FR-037, issue #1177): `CreateAgentToken` counts only - the records whose `UserID` matches the new token's, so each tenant gets - `auth.MaxTokens` (100) of their own and the operator's ownerless tokens form - one owner of their own. One tenant can never exhaust another's slots, and the - 409 body names **only the caller's own count** ("you have reached your - maximum of 100 agent tokens; delete one of your tokens") β€” never the - deployment, other users or a fleet total, which the old global - `Stats().KeyN` count leaked as a cross-tenant oracle. There is no owner - index (records are keyed by hash), so the count is a full bucket walk that - decodes every row and stops early only once `MaxTokens` *matches* are found, - never after `MaxTokens` rows. +- The token cap answers **409** on both editions' surfaces. The deployment-wide + `auth.MaxTokens` bound counts every stored record, including revoked records, + and its server-edition message points at an administrator. Server edition also + enforces `auth.MaxTokensPerOwner` for non-empty owners, preventing one tenant + from exhausting the shared pool. That owner-specific message tells the caller + to permanently delete an unused token; soft revocation deliberately does not + free storage or quota. Ownerless personal-edition tokens retain the original + deployment-only limit. - **A token is only as live as its owner.** `storage.Manager.SetAgentTokenOwnerGate` is installed in `setup.go` over the user store, and `ValidateAgentToken` consults it for every *owned* token (ownerless personal-edition tokens are diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 76a9a5cfb..325bf7a61 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -321,16 +321,19 @@ The pin is shown by `token list` (PROFILE PIN column) and `token show` (Profile ### Token Limit -Each owner can hold at most **100 agent tokens**. Revoked tokens still occupy a -slot until they are deleted, so once you hit the limit, creating another token -answers `409 Conflict` with a message about *your* count β€” delete one of your -own tokens to free a slot. - -The limit is **per owner**, not per deployment: in the personal edition every -token belongs to the one operator, and in the server edition each signed-in -user gets their own 100 while the operator's tokens are counted as a separate -owner. Another user's tokens never count against yours, and the error never -reveals how many tokens anyone else holds. +A deployment stores at most **100 agent tokens**, and in the server edition +each signed-in user may hold at most **25** of them. Revoked tokens still +occupy a slot until they are permanently deleted, so once a limit is reached, +creating another token answers `409 Conflict`: + +- **Your own quota (server edition, 25 per user).** The message tells you it is + your limit; permanently delete one of your unused tokens to free a slot. One + user filling their quota never blocks another user from creating theirs. +- **The deployment limit (100 stored records).** In the personal edition every + token belongs to the one operator, so this is the only limit and deleting one + of your tokens frees a slot. In the server edition the message says the limit + is shared and points at an administrator, because deleting your own tokens + may not free a slot that other users' records are filling. ### List All Tokens diff --git a/docs/features/idp-token-storage.md b/docs/features/idp-token-storage.md index 6aed909ad..0161662cf 100644 --- a/docs/features/idp-token-storage.md +++ b/docs/features/idp-token-storage.md @@ -6,4 +6,4 @@ description: "Server edition: the store_idp_tokens feature was removed; the key # IdP Subject Token Storage (removed) -The server edition no longer persists identity-provider access or refresh tokens after login. The feature existed only to feed an on-behalf-of token exchange that was never wired to any upstream call, so a long-lived IdP refresh token at rest had no reader and was a leak surface rather than a capability. `server_edition.store_idp_tokens` is still **accepted** by the config loader for compatibility, but it is a **no-op**: a value of `true` logs one deprecation warning at startup (`server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it`) and nothing is stored. Per-upstream credentials obtained through the `oauth_connect` flow are unaffected β€” they are still stored encrypted under `credential_encryption_key` / `MCPPROXY_CRED_KEY`, as described in [Auth Broker](./auth-broker.md) and [Credential Commands](../cli/credential-commands.md). +The server edition no longer persists identity-provider access or refresh tokens after login. The feature existed only to feed an on-behalf-of token exchange that was never wired to any upstream call, so a long-lived IdP refresh token at rest had no reader and was a leak surface rather than a capability. `server_edition.store_idp_tokens` is still **accepted** by the config loader for compatibility, but it is a **no-op**: a value of `true` logs one deprecation warning at startup (`server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it`) and nothing is stored. Tokens an earlier release persisted are deleted from `config.db` on the first start after upgrading (logged as `purged legacy IdP subject-token rows`); nothing in the current release could read them anyway. Per-upstream credentials obtained through the `oauth_connect` flow are unaffected β€” they are still stored encrypted under `credential_encryption_key` / `MCPPROXY_CRED_KEY`, as described in [Auth Broker](./auth-broker.md) and [Credential Commands](../cli/credential-commands.md). diff --git a/internal/auth/agent_token.go b/internal/auth/agent_token.go index 0f945113a..d420b10ab 100644 --- a/internal/auth/agent_token.go +++ b/internal/auth/agent_token.go @@ -28,9 +28,19 @@ var validPermissions = map[string]bool{ PermDestructive: true, } -// MaxTokens is the maximum number of agent tokens allowed. +// MaxTokens is the maximum number of stored agent tokens allowed deployment-wide. const MaxTokens = 100 +// MaxTokensPerOwner caps how many stored agent tokens a single owner may hold +// in the server edition. Without an owner cap, any authenticated tenant can +// consume the entire deployment-wide pool and prevent every other tenant, +// including an administrator, from minting a token (issue #1177). +// +// Ownerless personal-edition tokens are exempt so their established MaxTokens +// limit remains unchanged. Twenty-five preserves room for at least four fully +// provisioned owners inside the existing deployment cap. +const MaxTokensPerOwner = 25 + // AgentToken represents a stored agent token record. type AgentToken struct { Name string `json:"name"` diff --git a/internal/config/latent_symbols_guard_test.go b/internal/config/latent_symbols_guard_test.go index 9d668ad0b..44869ee2b 100644 --- a/internal/config/latent_symbols_guard_test.go +++ b/internal/config/latent_symbols_guard_test.go @@ -81,6 +81,10 @@ var removedDecls = []removedDecl{ // FR-031/FR-033: IdP subject-token capture and refresh. {Name: "GetValidIDPSubjectToken", Why: "IdP subject-token reader deleted (FR-033)"}, {Name: "ErrReauthRequired", Why: "IdP subject-token reader deleted (FR-033)"}, + {Name: "persistIDPSubjectToken", Why: "IdP subject-token writer deleted (FR-033, T015)"}, + {Recv: "OAuthHandler", Name: "SetCredentialStore", Why: "the login handler no longer holds a credential store to write IdP tokens into (FR-033, T015)"}, + {Name: "OfflineAuthParams", Why: "offline-access authorization parameters deleted; login never requests a refresh token (FR-033, T015)"}, + {Name: "OfflineAccessScopes", Why: "offline_access scope set deleted; login never requests a refresh token (FR-033, T015)"}, {Name: "RefreshAccessToken", Why: "OAuthProvider.RefreshAccessToken deleted (FR-031)"}, {Recv: "OAuthConnector", Name: "Refresh", Why: "(*OAuthConnector).Refresh deleted (FR-031)"}, // FR-031: resolver-only seams on the credential handlers. diff --git a/internal/config/server_edition_config.go b/internal/config/server_edition_config.go index 4223a706e..86958245a 100644 --- a/internal/config/server_edition_config.go +++ b/internal/config/server_edition_config.go @@ -67,8 +67,10 @@ func (c *ServerEditionConfig) IsAdminEmail(email string) bool { // TTLs, the Microsoft multi-tenant "common" tenant, and the MCPPROXY_CRED_KEY // fallback for credential_encryption_key (an explicit config value always wins // over the environment). It is the boot-time companion of Validate (Spec 107 -// FR-039): setup calls ApplyDefaults then Validate, while the write doors call -// only Validate so nothing derived is ever persisted into the config file. +// FR-039): setup calls ApplyDefaults then Validate on a Clone of the live +// block β€” never on the runtime's own pointer, which is the PATCH merge base +// and the next write-back β€” while the write doors call only Validate, so +// nothing derived is ever persisted into the config file. func (c *ServerEditionConfig) ApplyDefaults() { if c == nil { return diff --git a/internal/httpapi/config_patch_roundtrip_test.go b/internal/httpapi/config_patch_roundtrip_test.go index 0d73cda33..90466b851 100644 --- a/internal/httpapi/config_patch_roundtrip_test.go +++ b/internal/httpapi/config_patch_roundtrip_test.go @@ -7,12 +7,16 @@ import ( "encoding/json" "fmt" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "sort" + "strings" "testing" "github.com/stretchr/testify/require" + "go.uber.org/zap" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" ) @@ -94,6 +98,62 @@ func TestConfigPatch_OpaqueBlocksSurviveUnrelatedPatch(t *testing.T) { } } +// TestConfigPatch_HandlerPreservesOpaqueBlocks is the same property driven +// through the PRODUCTION door: an HTTP PATCH of an unrelated key reaches +// handlePatchConfig, whose own decoders (patch body and marshalled base) must +// both be UseNumber for the planted 9007199254740993 / +// 0.1000000000000000055511151231257827 to survive into the *config.Config +// handed to ApplyConfig. The test above calls MergeConfigPatch with its own +// UseNumber decode and so cannot see the handler's decoders at all. +// +// BITES: drop either `.UseNumber()` in handlePatchConfig β€” the base decoder +// turns 9007199254740993 into 9007199254740992 and the decimal into +// 0.1 β€” and the structural diff names the damaged key. +func TestConfigPatch_HandlerPreservesOpaqueBlocks(t *testing.T) { + dir := t.TempDir() + original, planted := plantPatchProbes(t, patchRoundTripFixture, dir) + + src := filepath.Join(dir, "planted.json") + require.NoError(t, os.WriteFile(src, planted, 0o600)) + live, err := config.LoadFromFile(src) + require.NoError(t, err) + + // The mock returns the loaded document as the live config and captures + // exactly what the handler hands to ApplyConfig. + ctrl := &mockPatchConfigController{apiKey: "test-key", live: live} + srv := NewServer(ctrl, zap.NewNop().Sugar(), nil) + + const patchedListen = "127.0.0.1:19108" + req := httptest.NewRequest(http.MethodPatch, "/api/v1/config", strings.NewReader(`{"listen":"`+patchedListen+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + require.NotNil(t, ctrl.captured, "ApplyConfig must have been reached") + require.Equal(t, patchedListen, ctrl.captured.Listen, "the unrelated patch must have been applied") + + // Persist what the handler produced, the way ApplyConfig would, and + // compare the opaque blocks against the planted originals. + saved := filepath.Join(dir, "after_handler_patch.json") + require.NoError(t, config.SaveConfig(ctrl.captured, saved)) + data, err := os.ReadFile(saved) + require.NoError(t, err) + got := decodePatchUseNumber(t, data) + + if diff := patchStructuralDiff("server_edition", original["server_edition"], got["server_edition"]); diff != "" { + t.Errorf("server_edition block damaged by PATCH /api/v1/config: %s", diff) + } + want := patchAuthBrokersByName(original) + have := patchAuthBrokersByName(got) + require.NotEmpty(t, want, "fixture must carry auth_broker blocks") + for name, block := range want { + if diff := patchStructuralDiff("mcpServers["+name+"].auth_broker", block, have[name]); diff != "" { + t.Errorf("auth_broker block for server %q damaged by PATCH /api/v1/config: %s", name, diff) + } + } +} + func plantPatchProbes(t *testing.T, fixture, dataDir string) (map[string]any, []byte) { t.Helper() raw, err := os.ReadFile(fixture) diff --git a/internal/httpapi/tokens.go b/internal/httpapi/tokens.go index 0cc9bf82d..daa692ea9 100644 --- a/internal/httpapi/tokens.go +++ b/internal/httpapi/tokens.go @@ -221,10 +221,14 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) { return } if errors.Is(err, storage.ErrAgentTokenLimitReached) { - // The cap is per owner (Spec 107 FR-037, #1177). Every token on - // this surface is ownerless, so the caller owns all of them and - // the body may β€” must β€” speak of the caller's own count only. - s.writeError(w, r, http.StatusConflict, fmt.Sprintf("You have reached your maximum of %d agent tokens; delete one of your tokens to free a slot", auth.MaxTokens)) + s.writeError(w, r, http.StatusConflict, fmt.Sprintf("Maximum number of agent tokens (%d) reached", auth.MaxTokens)) + return + } + // Personal-edition tokens are ownerless and cannot reach this condition + // today, but classify it so a future owned-token caller does not get a + // misleading 500. + if errors.Is(err, storage.ErrAgentTokenOwnerLimitReached) { + s.writeError(w, r, http.StatusConflict, fmt.Sprintf("Maximum number of agent tokens for this owner (%d) reached", auth.MaxTokensPerOwner)) return } s.logger.Errorf("Failed to create agent token: %v", err) diff --git a/internal/httpapi/tokens_cap_test.go b/internal/httpapi/tokens_cap_test.go index 0dde65fa4..a673ce2aa 100644 --- a/internal/httpapi/tokens_cap_test.go +++ b/internal/httpapi/tokens_cap_test.go @@ -15,42 +15,54 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" ) -// TestCreateToken_CapReached pins the personal-edition 409 for -// storage.ErrAgentTokenLimitReached (Spec 107 FR-037, issue #1177). +// TestCreateToken_CapReached pins the personal-edition 409 classification of +// both storage cap sentinels (issue #1177 / #1286). // -// The cap is now enforced per owner in storage. Every personal-edition token is -// ownerless, so on this surface the caller owns every token that counts: the -// body's MEANING is unchanged β€” "you are at the maximum, free one of yours" β€” -// and it must reference only the caller's own count, never a deployment total. +// Every personal-edition token is ownerless, so only the deployment-wide +// storage.ErrAgentTokenLimitReached can fire on this surface today; the +// per-owner storage.ErrAgentTokenOwnerLimitReached is classified as well so a +// future owned-token caller gets a 409 that names the owner quota rather than +// a misleading 500. Each body states the limit it is about and nothing about +// anyone else's tokens. // // Oracle discipline: a positive control mints through the same server first, // so the 409 is the classified sentinel and not an unwired store. // -// BITES: map the sentinel to any other status, drop the limit figure, or -// describe the deployment instead of the caller. +// BITES: map either sentinel to any other status, or drop the limit figure. func TestCreateToken_CapReached(t *testing.T) { - store := newMockTokenStore() - srv := newTestTokenServer(t, store, []string{"server1"}) + cases := []struct { + name string + err error + limit int + }{ + {name: "deployment cap", err: storage.ErrAgentTokenLimitReached, limit: auth.MaxTokens}, + {name: "owner quota", err: storage.ErrAgentTokenOwnerLimitReached, limit: auth.MaxTokensPerOwner}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newMockTokenStore() + srv := newTestTokenServer(t, store, []string{"server1"}) - body := createTokenRequest{Name: "ci", Permissions: []string{"read"}} + body := createTokenRequest{Name: "ci", Permissions: []string{"read"}} - ok := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body) - require.Equal(t, http.StatusCreated, ok.Code, "positive control: create must work before the cap (%s)", ok.Body.String()) + ok := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body) + require.Equal(t, http.StatusCreated, ok.Code, "positive control: create must work before the cap (%s)", ok.Body.String()) - store.createErr = storage.ErrAgentTokenLimitReached - body.Name = "one-too-many" - over := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body) - require.Equal(t, http.StatusConflict, over.Code, "the cap must answer 409 (%s)", over.Body.String()) + store.createErr = tc.err + body.Name = "one-too-many" + over := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body) + require.Equal(t, http.StatusConflict, over.Code, "the cap must answer 409 (%s)", over.Body.String()) - var envelope contracts.APIResponse - require.NoError(t, json.Unmarshal(over.Body.Bytes(), &envelope)) - require.False(t, envelope.Success) - msg := envelope.Error - lower := strings.ToLower(msg) + var envelope contracts.APIResponse + require.NoError(t, json.Unmarshal(over.Body.Bytes(), &envelope)) + require.False(t, envelope.Success) + msg := envelope.Error + lower := strings.ToLower(msg) - assert.Contains(t, msg, fmt.Sprintf("%d", auth.MaxTokens), "the body must say what the limit is") - assert.Contains(t, lower, "your", "the body must address the caller's own tokens") - for _, leak := range []string{"deployment", "shared by all", "all users", "administrator"} { - assert.NotContains(t, lower, leak, "the body must reference only the caller's own count") + assert.Contains(t, msg, fmt.Sprintf("(%d)", tc.limit), "the body must say what the limit is") + for _, leak := range []string{"other users", "shared by all", "administrator"} { + assert.NotContains(t, lower, leak, "the body must not describe anyone else's tokens") + } + }) } } diff --git a/internal/serveredition/api/user_handlers.go b/internal/serveredition/api/user_handlers.go index e87963697..beca6109f 100644 --- a/internal/serveredition/api/user_handlers.go +++ b/internal/serveredition/api/user_handlers.go @@ -1160,15 +1160,22 @@ func (h *UserHandlers) createUserToken(w http.ResponseWriter, r *http.Request) { // standing conflict with the deployment's state, not a transient // outage a client should sit and retry the way a 503 invites. // - // The cap is PER OWNER (Spec 107 FR-037, issue #1177): storage - // counts only records with the caller's own UserID, so this is - // the caller's own quota, and deleting one of their own tokens - // always frees a slot. The body therefore speaks of the caller's - // tokens only. It must never describe the deployment, other - // users or a fleet total β€” that was the cross-tenant oracle the - // old global count and its "shared by all users" wording leaked. + // The WORDING, though, cannot be the personal edition's. There, the + // caller owns every token and "you have reached the maximum" is + // both true and actionable. auth.MaxTokens is a DEPLOYMENT-wide cap + // counted across all tenants (internal/storage/agent_tokens.go), so + // here the caller may hold none of the tokens filling it, and a + // message that reads as their own quota sends them to delete tokens + // that will not free a slot β€” or to look for tokens they are not + // allowed to see. Say whose limit it is and who can act on it. + // The per-owner quota is handled separately below. Reaching this + // branch means the caller is within their quota but the shared + // deployment storage bound is full. writeError(w, http.StatusConflict, - fmt.Sprintf("You have reached your maximum of %d agent tokens; delete one of your tokens to free a slot.", auth.MaxTokens)) + fmt.Sprintf("This deployment has reached its limit of %d agent tokens. The limit is shared by all users, so deleting your own tokens may not free a slot; ask an administrator.", auth.MaxTokens)) + case errors.Is(err, storage.ErrAgentTokenOwnerLimitReached): + writeError(w, http.StatusConflict, + fmt.Sprintf("You have reached your limit of %d agent tokens. Permanently delete one you no longer use to free a slot.", auth.MaxTokensPerOwner)) default: writeError(w, http.StatusInternalServerError, "Failed to create token") } diff --git a/internal/serveredition/api/user_token_cap_test.go b/internal/serveredition/api/user_token_cap_test.go deleted file mode 100644 index f153fa8fd..000000000 --- a/internal/serveredition/api/user_token_cap_test.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build server - -package api - -import ( - "fmt" - "net/http" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" -) - -// Spec 107 FR-037 (issue #1177): the agent-token cap is enforced PER OWNER, and -// the 409 body names only the caller's own count. A global cap was a -// cross-tenant oracle twice over β€” one tenant could exhaust every tenant's -// slots, and the body told any tenant that the fleet total had reached -// auth.MaxTokens. -// -// These two tests replace TestCreateUserToken_CapExhaustionIsConflict and -// TestCreateUserToken_CapExhaustionDoesNotBlameTheCaller, which pinned the -// deployment-wide wording ("shared by all users … ask an administrator") that -// the per-owner cap makes false. - -// assertOwnQuotaWording pins what the per-owner 409 body may and may not say: -// it states the limit, it addresses the caller's own tokens, and it never -// describes the deployment, other users or a fleet total. -func assertOwnQuotaWording(t *testing.T, msg string) { - t.Helper() - lower := strings.ToLower(msg) - assert.Contains(t, msg, fmt.Sprintf("%d", auth.MaxTokens), - "the cap message must say what the limit is") - assert.Contains(t, lower, "your", - "the cap is the caller's own quota; the body must say so") - for _, leak := range []string{"deployment", "shared by all", "all users", "administrator", "other users"} { - assert.NotContains(t, lower, leak, - "a per-owner cap body must not describe anything beyond the caller's own tokens") - } -} - -// TestCreateUserToken_CapIsPerOwner: user A at the cap is refused with 409, -// and user B β€” who holds nothing β€” mints their first on the same router. -// -// Oracle discipline: A's 100th token mints through the HTTP route (positive -// control on the same router), so the 409 that follows is exhaustion and not a -// malformed body or an unwired store; B's success proves the cap is A's alone. -// -// BITES: restore the global `Stats().KeyN` count in storage and B's first -// create answers 409; restore the deployment-wide wording and the body -// assertions fail. -func TestCreateUserToken_CapIsPerOwner(t *testing.T) { - rig := newTokenTestRig(t) - rig.actAs(userACtx()) - - // Fill to one below the cap through storage, which is far faster than the - // HTTP route and exercises the same counter. - for i := 0; i < auth.MaxTokens-1; i++ { - rig.seedToken(t, tokenUserA, fmt.Sprintf("filler-%03d", i)) - } - - last := rig.createToken(t, "last-slot", nil) - require.Equal(t, http.StatusCreated, last.Code, - "positive control: the final slot below the cap must still mint (%s)", last.Body.String()) - - over := rig.createToken(t, "one-too-many", nil) - require.Equal(t, http.StatusConflict, over.Code, - "the token cap must answer 409, matching the personal edition (%s)", over.Body.String()) - assertOwnQuotaWording(t, errorMessage(t, over)) - - // The cap is A's, not the deployment's. - rig.actAs(userBCtx()) - first := rig.createToken(t, "b-first", nil) - require.Equal(t, http.StatusCreated, first.Code, - "user B holds no tokens; A's cap must not block B (%s)", first.Body.String()) -} - -// TestCreateUserToken_CapBodyNamesOnlyTheCallersCount is the oracle half on -// its own: user B, holding exactly one token, is told nothing about the 100 -// tokens user A holds. The 409 only ever fires for B once B is at the cap, -// and its body then describes B's own quota. -// -// BITES: with the global count B's second create is refused while B holds one -// token, and with the old body the message describes the deployment. -func TestCreateUserToken_CapBodyNamesOnlyTheCallersCount(t *testing.T) { - rig := newTokenTestRig(t) - - rig.actAs(userBCtx()) - ctrl := rig.createToken(t, "b-first", nil) - require.Equal(t, http.StatusCreated, ctrl.Code, - "positive control: user B must be able to mint before anyone fills anything (%s)", ctrl.Body.String()) - - // User A fills A's OWN quota entirely. - for i := 0; i < auth.MaxTokens; i++ { - rig.seedToken(t, tokenUserA, fmt.Sprintf("a-filler-%03d", i)) - } - - second := rig.createToken(t, "b-second", nil) - require.Equal(t, http.StatusCreated, second.Code, - "A's full quota must not be reported to B as B's problem (%s)", second.Body.String()) - - // Now B reaches B's own cap, and the refusal is about B. - for i := 2; i < auth.MaxTokens; i++ { - rig.seedToken(t, tokenUserB, fmt.Sprintf("b-filler-%03d", i)) - } - over := rig.createToken(t, "b-one-too-many", nil) - require.Equal(t, http.StatusConflict, over.Code, "B at B's own cap must be refused (%s)", over.Body.String()) - assertOwnQuotaWording(t, errorMessage(t, over)) -} diff --git a/internal/serveredition/api/user_token_mutation_test.go b/internal/serveredition/api/user_token_mutation_test.go index c24d90b06..f006e78a6 100644 --- a/internal/serveredition/api/user_token_mutation_test.go +++ b/internal/serveredition/api/user_token_mutation_test.go @@ -4,8 +4,10 @@ package api import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -92,11 +94,119 @@ func TestUserTokenMutators_NotFoundLeaksNoStorageSentinel(t *testing.T) { } } -// The two cap-exhaustion tests that used to live here -// (TestCreateUserToken_CapExhaustionIsConflict and -// TestCreateUserToken_CapExhaustionDoesNotBlameTheCaller) pinned the -// deployment-wide 409 wording; the cap is per owner since Spec 107 FR-037 -// (#1177) and their replacements live in user_token_cap_test.go. +// TestCreateUserToken_CapExhaustionIsConflict pins the status the token cap +// answers with. The same storage condition (storage.ErrAgentTokenLimitReached) +// used to map to 409 on the personal-edition surface and 503 on this one, so a +// client's retry behaviour depended on which door it knocked on β€” and 503 +// invites a retry loop against a condition that will never clear on its own. +// +// Oracle discipline: a positive control mints the token immediately BELOW the +// cap on the same router, so the 409 that follows is exhaustion and not a +// malformed body or an unwired store. +// +// BITES: restore http.StatusServiceUnavailable in createUserToken. +func TestCreateUserToken_CapExhaustionIsConflict(t *testing.T) { + rig := newTokenTestRig(t) + rig.actAs(userACtx()) + + // Fill to one below the cap through storage, which is far faster than the + // HTTP route and exercises the same counter. + seedDeploymentTokenFiller(t, rig, auth.MaxTokens-1) + + // Positive control: the last slot below the cap still mints. + last := rig.createToken(t, "last-slot", nil) + require.Equal(t, http.StatusCreated, last.Code, + "positive control: the final slot below the cap must still mint (%s)", last.Body.String()) + + over := rig.createToken(t, "one-too-many", nil) + require.Equal(t, http.StatusConflict, over.Code, + "the token cap must answer 409, matching the personal edition (%s)", over.Body.String()) + + msg := errorMessage(t, over) + assert.Contains(t, msg, fmt.Sprintf("%d", auth.MaxTokens), + "the cap message must say what the limit is") + + // This branch is still the DEPLOYMENT-wide cap, not the new owner quota. + // The body must not tell this caller that deleting one of their own tokens + // necessarily frees a slot. + assert.Contains(t, strings.ToLower(msg), "administrator", + "the cap body must point the caller at someone who can actually act on it") + assert.NotRegexp(t, `(?i)\byou(r)? have reached|delete (one of )?your`, msg, + "the cap body must not instruct the caller to free a slot they may not control") +} + +// TestCreateUserToken_CapExhaustionDoesNotBlameTheCaller is the Q3 property on +// its own, with the victim being someone who holds NO tokens at all: the cap is +// filled entirely by another tenant. +// +// Oracle discipline: user B mints successfully at the start (positive control on +// the same router), so the 409 that follows is the deployment cap and not an +// unwired store; and the message is asserted for what it must NOT claim, since +// the defect is a true statement about the wrong subject. +// +// BITES: with the old body this fails on "administrator", because the message +// read "Maximum number of agent tokens (100) reached" β€” the personal edition's +// wording, where the caller does own every token. +func TestCreateUserToken_CapExhaustionDoesNotBlameTheCaller(t *testing.T) { + rig := newTokenTestRig(t) + + // Positive control: user B can mint before the cap is filled. + rig.actAs(userBCtx()) + ctrl := rig.createToken(t, "b-first", nil) + require.Equal(t, http.StatusCreated, ctrl.Code, + "positive control: user B must be able to mint before the cap fills (%s)", ctrl.Body.String()) + + // Other tenants fill the rest of the DEPLOYMENT-wide cap without any one + // owner reaching the per-owner quota first. + seedDeploymentTokenFiller(t, rig, auth.MaxTokens-1) + + over := rig.createToken(t, "b-second", nil) + require.Equal(t, http.StatusConflict, over.Code, + "the cap must be reported to the blocked tenant (%s)", over.Body.String()) + + // User B holds exactly one token. Deleting it frees one slot out of a cap + // filled by someone else's 99 β€” which is precisely why the body must not + // tell them to. + msg := errorMessage(t, over) + assert.Contains(t, strings.ToLower(msg), "shared by all users", + "the body must say the limit is not the caller's own") + assert.Contains(t, strings.ToLower(msg), "administrator", + "the body must name who can act on a deployment-wide limit") +} + +// Issue #1177: the server-edition door must expose the owner's own quota as +// an actionable 409 while leaving another tenant able to mint. +func TestCreateUserToken_OwnerQuotaDoesNotExhaustOtherTenants(t *testing.T) { + rig := newTokenTestRig(t) + rig.actAs(userACtx()) + for i := 0; i < auth.MaxTokensPerOwner; i++ { + rig.seedToken(t, tokenUserA, fmt.Sprintf("a-owned-%02d", i)) + } + + over := rig.createToken(t, "one-too-many", nil) + require.Equal(t, http.StatusConflict, over.Code, + "the owner quota must be a standing conflict (%s)", over.Body.String()) + msg := errorMessage(t, over) + assert.Contains(t, msg, fmt.Sprintf("%d", auth.MaxTokensPerOwner)) + assert.Contains(t, strings.ToLower(msg), "your limit") + assert.Contains(t, strings.ToLower(msg), "permanently delete") + + rig.actAs(userBCtx()) + other := rig.createToken(t, "b-first", nil) + require.Equal(t, http.StatusCreated, other.Code, + "one tenant's quota must not consume another tenant's slots (%s)", other.Body.String()) +} + +// seedDeploymentTokenFiller fills the shared cap while staying below every +// synthetic owner's quota, ensuring deployment-cap tests exercise that branch. +func seedDeploymentTokenFiller(t *testing.T, rig *tokenTestRig, n int) { + t.Helper() + perOwner := auth.MaxTokensPerOwner - 1 + for i := 0; i < n; i++ { + owner := fmt.Sprintf("deployment-filler-%02d", i/perOwner) + rig.seedToken(t, owner, fmt.Sprintf("filler-%03d", i)) + } +} // TestRegenerateUserToken_ReNarrowsScopeToCurrentEntitlement pins the one // re-check a token's server scope ever gets. diff --git a/internal/serveredition/auth/oauth_handler_test.go b/internal/serveredition/auth/oauth_handler_test.go index 7d34d0b88..497e8a42a 100644 --- a/internal/serveredition/auth/oauth_handler_test.go +++ b/internal/serveredition/auth/oauth_handler_test.go @@ -156,6 +156,47 @@ func TestHandleLogin_Redirects(t *testing.T) { assert.Empty(t, params.Get("prompt")) } +// TestHandleLogin_StoreIDPTokensTrueStillNeverRequestsOfflineAccess pins the +// Spec 107 FR-033 no-op where it matters: with the deprecated flag ON, the +// authorization URL is byte-for-byte free of every offline-access marker the +// retired capture path used to add (`access_type=offline`, `prompt=consent`, +// the `offline_access` scope). TestHandleLogin_Redirects runs with the flag +// off, so on its own it could not catch the capture path coming back behind +// the flag. +// +// BITES: re-add OfflineAuthParams / OfflineAccessScopes gated on +// h.config.StoreIDPTokens in HandleLogin. +func TestHandleLogin_StoreIDPTokensTrueStillNeverRequestsOfflineAccess(t *testing.T) { + mockServer := mockOAuthProviderServer(t, "user@example.com", "Test User", "sub-123") + registerMockProvider(t, mockServer) + + handler, _ := setupTestOAuthHandler(t, &config.ServerEditionOAuthConfig{ + Provider: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }) + handler.config.StoreIDPTokens = true + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/login", nil) + w := httptest.NewRecorder() + handler.HandleLogin(w, req) + + resp := w.Result() + defer resp.Body.Close() + require.Equal(t, http.StatusFound, resp.StatusCode) + + redirectURL, err := url.Parse(resp.Header.Get("Location")) + require.NoError(t, err) + params := redirectURL.Query() + // Positive control: this is the real authorization request. + assert.Equal(t, "test-client-id", params.Get("client_id")) + assert.Equal(t, "code", params.Get("response_type")) + + assert.Empty(t, params.Get("access_type"), "store_idp_tokens must not request offline access") + assert.Empty(t, params.Get("prompt"), "store_idp_tokens must not force a consent prompt") + assert.NotContains(t, params.Get("scope"), "offline_access", "store_idp_tokens must not add the offline_access scope") +} + func TestHandleLogin_StateInURL(t *testing.T) { mockServer := mockOAuthProviderServer(t, "user@example.com", "Test User", "sub-123") registerMockProvider(t, mockServer) diff --git a/internal/serveredition/broker/bbolt_aes.go b/internal/serveredition/broker/bbolt_aes.go index afa3e052c..1ca7f1582 100644 --- a/internal/serveredition/broker/bbolt_aes.go +++ b/internal/serveredition/broker/bbolt_aes.go @@ -21,7 +21,9 @@ import ( // // Key scheme: // - upstream credential: ":" -// - idp subject token: "" (no colon) +// - idp subject token: "" (no colon) β€” RETIRED. The +// writer was removed by Spec 107 FR-033; rows an earlier release left +// behind are deleted at boot by PurgeLegacyIDPSubjectTokens. // // serverKey follows the existing SHA256(name+url) scheme from // internal/oauth.GenerateServerKey. @@ -98,7 +100,8 @@ func NewBBoltAESStore(db *bbolt.DB, base64Key string, logger *zap.Logger) (*BBol func (s *BBoltAESStore) Enabled() bool { return s.enabled } // recordKey builds the BBolt key for a (userID, serverKey) pair. An empty -// serverKey yields the bare userID, used for the idp subject token. +// serverKey yields the bare userID β€” the shape the retired idp subject-token +// writer used; no production caller passes an empty serverKey any more. func recordKey(userID, serverKey string) string { if serverKey == "" { return userID @@ -220,6 +223,56 @@ func (s *BBoltAESStore) List(userID string) ([]CredentialEntry, error) { return entries, nil } +// PurgeLegacyIDPSubjectTokens deletes every record the retired IdP +// subject-token writer (Spec 107 FR-033) left in the credential bucket: the +// rows keyed by a BARE userID, with no ":" suffix. Those rows held +// a user's IdP access token and offline refresh token, encrypted, and this +// release has no reader, no listing and no per-row delete door for them β€” +// List seeks ":" and the credential routes address a resolved server β€” +// so without this sweep an upgraded deployment that ran with +// `store_idp_tokens: true` would keep them at rest indefinitely. +// +// Rows are matched and deleted by KEY, never decrypted, so the sweep runs +// whether or not an encryption key is configured (a deployment that dropped +// its MCPPROXY_CRED_KEY is cleaned too). Upstream credentials always carry a +// colon (user IDs are ULIDs; the server key is appended with ":") and are +// never touched. Returns the number of rows removed; idempotent. +func (s *BBoltAESStore) PurgeLegacyIDPSubjectTokens() (int, error) { + var legacy [][]byte + if err := s.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(credentialBucket)) + if b == nil { + return nil + } + return b.ForEach(func(k, _ []byte) error { + if !strings.Contains(string(k), ":") { + legacy = append(legacy, append([]byte(nil), k...)) + } + return nil + }) + }); err != nil { + return 0, fmt.Errorf("scan credential bucket for legacy idp subject tokens: %w", err) + } + if len(legacy) == 0 { + return 0, nil + } + if err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(credentialBucket)) + if b == nil { + return nil + } + for _, k := range legacy { + if err := b.Delete(k); err != nil { + return err + } + } + return nil + }); err != nil { + return 0, fmt.Errorf("delete legacy idp subject tokens: %w", err) + } + return len(legacy), nil +} + // hasPrefix reports whether b begins with prefix. func hasPrefix(b, prefix []byte) bool { if len(b) < len(prefix) { diff --git a/internal/serveredition/broker/legacy_idp_purge_test.go b/internal/serveredition/broker/legacy_idp_purge_test.go new file mode 100644 index 000000000..25b226841 --- /dev/null +++ b/internal/serveredition/broker/legacy_idp_purge_test.go @@ -0,0 +1,139 @@ +//go:build server + +package broker + +import ( + "errors" + "testing" + + "go.etcd.io/bbolt" + "go.uber.org/zap" +) + +// Spec 107 FR-033 residual: the retired IdP subject-token writer persisted a +// user's IdP access + offline refresh token under the BARE userID (no colon). +// Nothing on this release reads, lists or deletes those rows through any door +// β€” List seeks ":" and Delete needs a resolved server β€” so an upgraded +// deployment that had store_idp_tokens:true would keep them at rest forever. +// PurgeLegacyIDPSubjectTokens is the one-shot sweep setup runs at boot. + +// seedLegacySubjectRow writes an opaque row under a bare userID straight into +// the bucket: the sweep must not need to decrypt anything, so the row is not +// even ciphertext. +func seedLegacySubjectRow(t *testing.T, db *bbolt.DB, userID string) { + t.Helper() + if err := db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(credentialBucket)) + if err != nil { + return err + } + return b.Put([]byte(userID), []byte("legacy-opaque-ciphertext")) + }); err != nil { + t.Fatalf("seed legacy row: %v", err) + } +} + +func bucketKeys(t *testing.T, db *bbolt.DB) map[string]bool { + t.Helper() + keys := map[string]bool{} + if err := db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(credentialBucket)) + if b == nil { + return nil + } + return b.ForEach(func(k, _ []byte) error { + keys[string(k)] = true + return nil + }) + }); err != nil { + t.Fatal(err) + } + return keys +} + +// TestPurgeLegacyIDPSubjectTokens_RemovesOnlyBareUserIDRows: every bare-userID +// row goes, every ":" upstream credential stays readable. +func TestPurgeLegacyIDPSubjectTokens_RemovesOnlyBareUserIDRows(t *testing.T) { + db := openTestDB(t) + store := newTestStore(t, db, newTestKey(t)) + + if err := store.Put("alice", "srv_1234", sampleCred()); err != nil { + t.Fatalf("Put upstream: %v", err) + } + seedLegacySubjectRow(t, db, "alice") + seedLegacySubjectRow(t, db, "bob") + + // Positive control on the fixture: three rows, two of them legacy. + if keys := bucketKeys(t, db); len(keys) != 3 || !keys["alice"] || !keys["bob"] || !keys["alice:srv_1234"] { + t.Fatalf("fixture keys wrong: %v", keys) + } + + n, err := store.PurgeLegacyIDPSubjectTokens() + if err != nil { + t.Fatalf("purge: %v", err) + } + if n != 2 { + t.Errorf("purged %d rows, want 2", n) + } + + keys := bucketKeys(t, db) + if keys["alice"] || keys["bob"] { + t.Errorf("legacy rows survived the sweep: %v", keys) + } + if !keys["alice:srv_1234"] { + t.Errorf("upstream credential was swept: %v", keys) + } + if got, err := store.Get("alice", "srv_1234"); err != nil || got.AccessToken != sampleCred().AccessToken { + t.Errorf("upstream credential must still decrypt after the sweep: %v %+v", err, got) + } + + // Idempotent: a second boot finds nothing. + n, err = store.PurgeLegacyIDPSubjectTokens() + if err != nil || n != 0 { + t.Errorf("second sweep: n=%d err=%v, want 0 nil", n, err) + } +} + +// TestPurgeLegacyIDPSubjectTokens_RunsWithoutAnEncryptionKey: the rows are +// deleted by key, never decrypted, so a deployment that dropped its +// MCPPROXY_CRED_KEY (store disabled) is still cleaned. +func TestPurgeLegacyIDPSubjectTokens_RunsWithoutAnEncryptionKey(t *testing.T) { + db := openTestDB(t) + seedLegacySubjectRow(t, db, "alice") + + store, err := NewBBoltAESStore(db, "", zap.NewNop()) + if err != nil { + t.Fatalf("NewBBoltAESStore: %v", err) + } + if store.Enabled() { + t.Fatal("fixture: store must be disabled without a key") + } + if _, gerr := store.Get("alice", ""); !errors.Is(gerr, ErrStoreDisabled) { + t.Fatalf("fixture: disabled store must refuse reads, got %v", gerr) + } + + n, err := store.PurgeLegacyIDPSubjectTokens() + if err != nil { + t.Fatalf("purge on disabled store: %v", err) + } + if n != 1 { + t.Errorf("purged %d rows, want 1", n) + } + if keys := bucketKeys(t, db); keys["alice"] { + t.Errorf("legacy row survived: %v", keys) + } +} + +// TestPurgeLegacyIDPSubjectTokens_NoBucketIsNoop: a fresh database (no store +// ever enabled) has no bucket and nothing to sweep. +func TestPurgeLegacyIDPSubjectTokens_NoBucketIsNoop(t *testing.T) { + db := openTestDB(t) + store, err := NewBBoltAESStore(db, "", zap.NewNop()) + if err != nil { + t.Fatalf("NewBBoltAESStore: %v", err) + } + n, err := store.PurgeLegacyIDPSubjectTokens() + if err != nil || n != 0 { + t.Errorf("n=%d err=%v, want 0 nil", n, err) + } +} diff --git a/internal/serveredition/setup.go b/internal/serveredition/setup.go index 623890e61..481df2759 100644 --- a/internal/serveredition/setup.go +++ b/internal/serveredition/setup.go @@ -29,7 +29,13 @@ func setupMultiUserOAuth(deps Dependencies) error { return nil } - cfg := deps.Config.ServerEdition + // deps.Config is the runtime's live/desired *config.Config β€” the pointer + // PATCH /api/v1/config marshals as its merge base and the next write-back + // persists. The derived values ApplyDefaults fills below (the MCPPROXY_CRED_KEY + // fallback, the Microsoft "common" tenant, the TTLs) must never land in + // that document, so they are applied to a clone and the clone is what every + // handler constructed here receives (Spec 107 FR-039). + cfg := deps.Config.ServerEdition.Clone() // Create user store. Constructing it is infallible; EnsureBuckets below is // not, which is why the owner gate is installed against the store BEFORE @@ -93,8 +99,9 @@ func setupMultiUserOAuth(deps Dependencies) error { } // Spec 107 FR-039: defaults (TTLs, Microsoft tenant, MCPPROXY_CRED_KEY - // fallback) are applied at boot only; Validate itself never mutates, so - // the write doors run the same rules without persisting derived values. + // fallback) are applied at boot only, to the clone above; Validate itself + // never mutates, so the write doors run the same rules without persisting + // derived values. cfg.ApplyDefaults() if err := cfg.Validate(); err != nil { return fmt.Errorf("server config validation: %w", err) @@ -130,6 +137,18 @@ func setupMultiUserOAuth(deps Dependencies) error { return fmt.Errorf("creating credential store: %w", err) } + // Spec 107 FR-033 residual: an earlier release with `store_idp_tokens: true` + // persisted each user's IdP access + offline refresh token in this bucket + // under the bare userID. The writer is gone and nothing reads, lists or + // deletes those rows through any door, so sweep them here β€” by key, no + // decryption, so it works with the store disabled too. Hygiene only: a + // failed sweep is logged and never keeps the server from coming up. + if purged, perr := credStore.PurgeLegacyIDPSubjectTokens(); perr != nil { + deps.Logger.Warnw("failed to purge legacy IdP subject-token rows from the credential store", "error", perr) + } else if purged > 0 { + deps.Logger.Infow("purged legacy IdP subject-token rows left by store_idp_tokens (removed in Spec 107)", "rows", purged) + } + // The LIVE view of the server-edition block, read through the same provider // the admin-servers check uses rather than a second mechanism. // diff --git a/internal/serveredition/setup_defaults_test.go b/internal/serveredition/setup_defaults_test.go new file mode 100644 index 000000000..20bfc0290 --- /dev/null +++ b/internal/serveredition/setup_defaults_test.go @@ -0,0 +1,162 @@ +//go:build server + +package serveredition + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.etcd.io/bbolt" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + teamsauth "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/users" +) + +// TestSetupMultiUserOAuth_DerivedDefaultsNeverReachTheLiveConfig pins the +// Spec 107 FR-039 split between Validate and ApplyDefaults at the point where +// it can actually be broken: setup.go. +// +// deps.Config is the runtime's live/desired *config.Config β€” the very pointer +// PATCH /api/v1/config marshals as its merge base and ApplyConfig writes back +// to disk. ApplyDefaults fills credential_encryption_key from MCPPROXY_CRED_KEY, +// oauth.tenant_id="common" and the 24h TTLs; every one of those fields is +// `omitempty`, so if ApplyDefaults ran on the shared pointer the env secret and +// the defaults would be emitted into the next write-back of the config file. +// Setup must therefore apply them to a clone and hand the clone to the +// handlers, leaving the operator's document exactly as loaded. +// +// Oracle discipline: the positive control proves the defaults DID apply to +// what the handlers use β€” the login redirect for a tenant-less Microsoft +// provider goes to the "common" tenant and the credential store came up +// enabled off the environment key β€” so the untouched live block is the split +// working, not ApplyDefaults never having run. +// +// BITES: call cfg.ApplyDefaults() on deps.Config.ServerEdition in setup.go. +func TestSetupMultiUserOAuth_DerivedDefaultsNeverReachTheLiveConfig(t *testing.T) { + t.Setenv("MCPPROXY_CRED_KEY", "") + + tmpDir := t.TempDir() + db, err := bbolt.Open(tmpDir+"/test.db", 0600, &bbolt.Options{Timeout: time.Second}) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + live := &config.Config{ + ServerEdition: &config.ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &config.ServerEditionOAuthConfig{ + Provider: "microsoft", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + // TenantID deliberately unset: ApplyDefaults fills "common". + }, + // TTLs deliberately zero: ApplyDefaults fills 24h. + }, + } + router := chi.NewRouter() + require.NoError(t, setupMultiUserOAuth(Dependencies{ + Router: router, + DB: db, + Logger: zap.NewNop().Sugar(), + DataDir: tmpDir, + Config: live, + })) + + // Positive control: the handlers run with the defaults applied. A + // tenant-less Microsoft provider redirects to the "common" tenant. + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/login", nil) + req.Host = "localhost:8080" + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusFound, rec.Code, "login must redirect (%s)", rec.Body.String()) + redirect, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + assert.Equal(t, "/common/oauth2/v2.0/authorize", redirect.Path, + "the OAuth handler must see the defaulted Microsoft tenant") + + // The live block the runtime will marshal on the next PATCH / write-back is + // exactly what the operator wrote: no derived value was written into it. + se := live.ServerEdition + assert.Equal(t, "", se.OAuth.TenantID, "oauth.tenant_id default must not be persisted into the live config") + assert.Equal(t, config.Duration(0), se.SessionTTL, "session_ttl default must not be persisted into the live config") + assert.Equal(t, config.Duration(0), se.BearerTokenTTL, "bearer_token_ttl default must not be persisted into the live config") + assert.Equal(t, "", se.CredentialEncryptionKey, "credential_encryption_key must not be persisted into the live config") +} + +// TestSetupMultiUserOAuth_EnvCredentialKeyNeverReachesTheLiveConfig is the +// secret half of the property above on its own: with MCPPROXY_CRED_KEY set, +// the credential store comes up enabled (positive control through the +// production credential route, which reports "unavailable" for a disabled +// store) while the live block's credential_encryption_key stays empty, so the +// environment secret is never written into the config file. +func TestSetupMultiUserOAuth_EnvCredentialKeyNeverReachesTheLiveConfig(t *testing.T) { + // 32 zero bytes, base64: a well-formed AES-256 key. + t.Setenv("MCPPROXY_CRED_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + + tmpDir := t.TempDir() + db, err := bbolt.Open(tmpDir+"/test.db", 0600, &bbolt.Options{Timeout: time.Second}) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + live := &config.Config{ + Servers: []*config.ServerConfig{{ + Name: "brokered", + URL: "https://brokered.example.com/mcp", + Protocol: "http", + Enabled: true, + Shared: true, + AuthBroker: &config.AuthBrokerConfig{ + Mode: config.AuthBrokerModeOAuthConnect, + TokenEndpoint: "https://idp.example.com/token", + AuthorizationEndpoint: "https://idp.example.com/authorize", + }, + }}, + ServerEdition: &config.ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &config.ServerEditionOAuthConfig{ + Provider: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }, + }, + } + router := chi.NewRouter() + logger := zap.NewNop().Sugar() + require.NoError(t, setupMultiUserOAuth(Dependencies{ + Router: router, + DB: db, + Logger: logger, + DataDir: tmpDir, + Config: live, + })) + + store := users.NewUserStore(db) + u := users.NewUser("tenant@example.com", "tenant@example.com", "google", "sub-tenant") + require.NoError(t, store.CreateUser(u)) + hmacKey, err := auth.GetOrCreateHMACKey(tmpDir) + require.NoError(t, err) + bearer, err := teamsauth.GenerateBearerToken(hmacKey, u.ID, u.Email, u.DisplayName, "user", u.Provider, time.Hour) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/credentials", nil) + req.Host = "localhost:8080" + req.Header.Set("Authorization", "Bearer "+bearer) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, "positive control: the credential surface answers (%s)", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"not_connected"`, + "positive control: the store must have come up enabled off MCPPROXY_CRED_KEY (a disabled store reports \"unavailable\")") + + assert.Equal(t, "", live.ServerEdition.CredentialEncryptionKey, + "MCPPROXY_CRED_KEY must not be copied into the live config block") +} diff --git a/internal/serveredition/setup_legacy_idp_purge_test.go b/internal/serveredition/setup_legacy_idp_purge_test.go new file mode 100644 index 000000000..93ee9b60d --- /dev/null +++ b/internal/serveredition/setup_legacy_idp_purge_test.go @@ -0,0 +1,95 @@ +//go:build server + +package serveredition + +import ( + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.etcd.io/bbolt" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// legacyCredentialBucket mirrors broker.credentialBucket (unexported there); +// the test seeds the bucket by name so it proves the production sweep found +// the real bucket, not one the test handed it. +const legacyCredentialBucket = "user_upstream_credentials" + +func seedCredentialRows(t *testing.T, db *bbolt.DB, keys ...string) { + t.Helper() + require.NoError(t, db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(legacyCredentialBucket)) + if err != nil { + return err + } + for _, k := range keys { + if err := b.Put([]byte(k), []byte("opaque")); err != nil { + return err + } + } + return nil + })) +} + +func credentialRowKeys(t *testing.T, db *bbolt.DB) map[string]bool { + t.Helper() + keys := map[string]bool{} + require.NoError(t, db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(legacyCredentialBucket)) + if b == nil { + return nil + } + return b.ForEach(func(k, _ []byte) error { + keys[string(k)] = true + return nil + }) + })) + return keys +} + +// TestSetupMultiUserOAuth_SweepsLegacyIDPSubjectTokensAtBoot: the production +// setup deletes the bare-userID rows the retired store_idp_tokens writer left +// behind (Spec 107 FR-033 residual) and leaves upstream credentials alone β€” +// with no encryption key configured, which is the state of a deployment that +// only ever enabled store_idp_tokens. +// +// BITES: drop the PurgeLegacyIDPSubjectTokens call from setup.go. +func TestSetupMultiUserOAuth_SweepsLegacyIDPSubjectTokensAtBoot(t *testing.T) { + t.Setenv("MCPPROXY_CRED_KEY", "") + + tmpDir := t.TempDir() + db, err := bbolt.Open(tmpDir+"/test.db", 0600, &bbolt.Options{Timeout: time.Second}) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + const legacyUser = "01HTEST0000000000000USERA" + seedCredentialRows(t, db, legacyUser, legacyUser+":github_0123456789abcdef") + require.Len(t, credentialRowKeys(t, db), 2, "fixture") + + require.NoError(t, setupMultiUserOAuth(Dependencies{ + Router: chi.NewRouter(), + DB: db, + Logger: zap.NewNop().Sugar(), + DataDir: tmpDir, + Config: &config.Config{ + ServerEdition: &config.ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &config.ServerEditionOAuthConfig{ + Provider: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }, + }, + }, + })) + + keys := credentialRowKeys(t, db) + assert.False(t, keys[legacyUser], "the legacy IdP subject-token row must be gone after boot: %v", keys) + assert.True(t, keys[legacyUser+":github_0123456789abcdef"], "upstream credentials must survive the sweep: %v", keys) +} diff --git a/internal/storage/agent_token_owner_quota_test.go b/internal/storage/agent_token_owner_quota_test.go new file mode 100644 index 000000000..a99255bdb --- /dev/null +++ b/internal/storage/agent_token_owner_quota_test.go @@ -0,0 +1,69 @@ +package storage + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" +) + +func mintOwnedToken(t *testing.T, m *Manager, userID, name string) error { + t.Helper() + raw, err := auth.GenerateToken() + require.NoError(t, err) + return m.CreateAgentToken(auth.AgentToken{ + UserID: userID, + Name: name, + Permissions: []string{auth.PermRead}, + }, raw, testHMACKey) +} + +// Issue #1177: one server-edition tenant must not be able to consume the +// deployment's entire shared token pool. +func TestCreateAgentToken_OwnerQuotaIsPerOwner(t *testing.T) { + m, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + for i := 0; i < auth.MaxTokensPerOwner; i++ { + require.NoError(t, mintOwnedToken(t, m, "tenant-a", fmt.Sprintf("tok-%02d", i))) + } + + require.ErrorIs(t, mintOwnedToken(t, m, "tenant-a", "one-too-many"), ErrAgentTokenOwnerLimitReached, + "the 26th token for one owner must be rejected") + require.NoError(t, mintOwnedToken(t, m, "tenant-b", "first"), + "one tenant's quota must not consume another tenant's slots") +} + +// Quota counts stored records, including revoked records, so soft revocation +// cannot be used to grow the bounded token bucket forever. Permanent deletion +// is the operation that frees storage and therefore frees a slot. +func TestCreateAgentToken_PermanentDeleteFreesOwnerSlot(t *testing.T) { + m, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + for i := 0; i < auth.MaxTokensPerOwner; i++ { + require.NoError(t, mintOwnedToken(t, m, "tenant-a", fmt.Sprintf("tok-%02d", i))) + } + require.ErrorIs(t, mintOwnedToken(t, m, "tenant-a", "blocked"), ErrAgentTokenOwnerLimitReached) + + require.NoError(t, m.RevokeAgentTokenForOwner("tenant-a", "tok-00")) + require.ErrorIs(t, mintOwnedToken(t, m, "tenant-a", "still-blocked"), ErrAgentTokenOwnerLimitReached, + "soft revocation must not evade the storage bound") + + require.NoError(t, m.DeleteAgentTokenForOwner("tenant-a", "tok-00")) + require.NoError(t, mintOwnedToken(t, m, "tenant-a", "after-delete"), + "permanent deletion must free the owner's slot") +} + +// Personal-edition tokens are ownerless and keep the established deployment +// cap; adding a server-edition quota must not lower that limit. +func TestCreateAgentToken_OwnerlessKeepsDeploymentCap(t *testing.T) { + m, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + for i := 0; i < auth.MaxTokensPerOwner+5; i++ { + require.NoError(t, mintOwnedToken(t, m, "", fmt.Sprintf("personal-%02d", i))) + } +} diff --git a/internal/storage/agent_tokens.go b/internal/storage/agent_tokens.go index 981afd023..00cd980ee 100644 --- a/internal/storage/agent_tokens.go +++ b/internal/storage/agent_tokens.go @@ -51,12 +51,16 @@ var ( // of a different tenant's token. ErrAgentTokenNameExists = errors.New("agent token with this name already exists") - // ErrAgentTokenLimitReached is returned when the OWNER's token cap - // (auth.MaxTokens) is reached. The cap is per owner (token.UserID), never - // deployment-wide: one tenant cannot exhaust another's slots, and the - // error tells the caller nothing about anyone else's tokens (issue #1177). + // ErrAgentTokenLimitReached is returned when the deployment-wide token cap + // is reached. ErrAgentTokenLimitReached = errors.New("maximum number of agent tokens reached") + // ErrAgentTokenOwnerLimitReached is returned when one owner reaches the + // server edition's per-owner quota. It is distinct from the deployment cap + // because the caller can remedy this condition by permanently deleting one + // of their own unused tokens. + ErrAgentTokenOwnerLimitReached = errors.New("maximum number of agent tokens for this owner reached") + // ErrAgentTokenOwnerInactive is returned by ValidateAgentToken when a // token's OWNER is no longer allowed to authenticate β€” disabled, or gone // from the user store entirely. The token record itself may be perfectly @@ -86,13 +90,11 @@ var ( // consulting the legacy owner-blind name index. Returns (nil, nil) when the // pair does not resolve. // -// A scan is correct and cheap here: the bucket holds at most auth.MaxTokens -// entries PER OWNER (the cap is per owner, issue #1177), so it is bounded by -// the number of token owners times the cap, and only low-frequency management -// operations resolve by name (the authentication hot path resolves by hash). -// It is also constant-time with respect to ownership β€” the whole bucket is -// walked regardless β€” so it adds no timing oracle for "does another tenant -// own this name?". +// A scan is correct and cheap here: the bucket is capped at auth.MaxTokens +// entries and only low-frequency management operations resolve by name (the +// authentication hot path resolves by hash). It is also constant-time with +// respect to ownership β€” the whole bucket is walked regardless β€” so it adds no +// timing oracle for "does another tenant own this name?". // // The caller must complete this scan before mutating the bucket: bbolt forbids // mutating a bucket while iterating it. @@ -146,6 +148,40 @@ func (m *Manager) findAgentTokenHashLocked(tx *bbolt.Tx, userID, name string) ([ return foundHash, foundToken, nil } +// countAgentTokensForOwnerLocked counts stored records belonging to userID. +// Revoked tokens count deliberately: revocation is a soft delete, so excluding +// them would let repeated mint-and-revoke cycles grow the bucket without bound. +// Permanent deletion is the operation that frees both storage and quota. +// +// The bucket is bounded by auth.MaxTokens, and this runs only on the +// low-frequency management path inside the same transaction as creation. +func (m *Manager) countAgentTokensForOwnerLocked(tx *bbolt.Tx, userID string) (int, error) { + tokenBucket := tx.Bucket([]byte(AgentTokensBucket)) + if tokenBucket == nil || userID == "" { + return 0, nil + } + + count := 0 + err := tokenBucket.ForEach(func(k, v []byte) error { + var token auth.AgentToken + if err := json.Unmarshal(v, &token); err != nil { + if m.logger != nil { + m.logger.Warnw("skipping unparseable agent token record while counting owner quota", + "bucket", AgentTokensBucket, "key", string(k), "error", err) + } + return nil + } + if token.UserID == userID { + count++ + } + return nil + }) + if err != nil { + return 0, err + } + return count, nil +} + // CreateAgentToken stores a new agent token. It hashes the raw token using // the provided HMAC key and stores the AgentToken record keyed by hash in the // "agent_tokens" bucket. @@ -155,14 +191,8 @@ func (m *Manager) findAgentTokenHashLocked(tx *bbolt.Tx, userID, name string) ([ // personal-edition token) additionally get a bare-name entry in the legacy // "agent_token_names" index so the personal edition is unchanged on disk. // -// The auth.MaxTokens cap is likewise PER OWNER (issue #1177): tokens with the -// same UserID count together, and the ownerless operator tokens form one owner -// of their own. One tenant can therefore never exhaust another's slots, and -// the personal edition β€” where every token is ownerless β€” behaves exactly as -// it did under the old global count. -// // Returns ErrAgentTokenNameExists if the same owner already has that name, or -// ErrAgentTokenLimitReached if that owner is at the cap. +// ErrAgentTokenLimitReached if the deployment-wide cap is reached. func (m *Manager) CreateAgentToken(token auth.AgentToken, rawToken string, hmacKey []byte) error { if token.Name == "" { return fmt.Errorf("agent token name cannot be empty") @@ -201,13 +231,23 @@ func (m *Manager) CreateAgentToken(token auth.AgentToken, rawToken string, hmacK return ErrAgentTokenNameExists } - // Enforce the cap for THIS OWNER. This is a second full walk of the - // bucket rather than Stats().KeyN: the key count is the deployment - // total, which is exactly the cross-tenant oracle #1177 removes. - count, err := m.countAgentTokensForOwnerLocked(tx, token.UserID, auth.MaxTokens) - if err != nil { - return err + // Enforce the per-owner server-edition quota before the deployment cap, + // so a caller who has filled their own allocation gets the actionable + // owner-specific error. Ownerless personal-edition tokens retain the + // long-standing deployment-only limit. + if token.UserID != "" { + ownerCount, err := m.countAgentTokensForOwnerLocked(tx, token.UserID) + if err != nil { + return err + } + if ownerCount >= auth.MaxTokensPerOwner { + return ErrAgentTokenOwnerLimitReached + } } + + // Preserve the deployment-wide storage bound. This is intentionally a + // raw record count: revoked records remain stored until permanent delete. + count := tokenBucket.Stats().KeyN if count >= auth.MaxTokens { return ErrAgentTokenLimitReached } @@ -246,56 +286,6 @@ func (m *Manager) CreateAgentToken(token auth.AgentToken, rawToken string, hmacK }) } -// errStopAgentTokenWalk is the sentinel countAgentTokensForOwnerLocked returns -// from its ForEach callback to end the walk early; it never escapes. -var errStopAgentTokenWalk = errors.New("stop agent token walk") - -// countAgentTokensForOwnerLocked counts the records in agent_tokens whose -// UserID equals userID, inside the given transaction, stopping early once -// `limit` matches have been found (the caller only needs to know whether the -// owner is at the cap). Revoked-but-not-deleted tokens still count: they hold -// a slot until deleted, exactly as they did under the old global count. -// -// There is no owner index β€” records are keyed by token hash and UserID lives -// inside the JSON β€” and because the cap is per owner the bucket may hold far -// more than auth.MaxTokens rows. So this is a FULL bucket walk that decodes -// each record; it must never give up after `limit` ROWS, only after `limit` -// MATCHES, or an owner whose rows sort late in key order would be uncapped. -// -// An unparseable row is skipped and logged, for the reason given on -// findAgentTokenHashLocked: aborting would turn every owner's create into a -// 500 over one corrupt record. A skipped row counts for nobody. -func (m *Manager) countAgentTokensForOwnerLocked(tx *bbolt.Tx, userID string, limit int) (int, error) { - tokenBucket := tx.Bucket([]byte(AgentTokensBucket)) - if tokenBucket == nil { - return 0, nil - } - - count := 0 - err := tokenBucket.ForEach(func(k, v []byte) error { - var token auth.AgentToken - if err := json.Unmarshal(v, &token); err != nil { - if m.logger != nil { - m.logger.Warnw("skipping unparseable agent token record", - "bucket", AgentTokensBucket, "key", string(k), "error", err) - } - return nil - } - if token.UserID != userID { - return nil - } - count++ - if count >= limit { - return errStopAgentTokenWalk - } - return nil - }) - if err != nil && !errors.Is(err, errStopAgentTokenWalk) { - return 0, fmt.Errorf("failed to count agent tokens for owner: %w", err) - } - return count, nil -} - // claimAgentTokenNameSlot reports whether the legacy owner-blind name index may // be pointed at a newly created OWNERLESS token. // diff --git a/internal/storage/agent_tokens_owner_cap_test.go b/internal/storage/agent_tokens_owner_cap_test.go index d3579e2f0..ef1e3f8fb 100644 --- a/internal/storage/agent_tokens_owner_cap_test.go +++ b/internal/storage/agent_tokens_owner_cap_test.go @@ -6,128 +6,56 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.etcd.io/bbolt" "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" ) -// Spec 107 FR-037 (issue #1177): auth.MaxTokens is a PER-OWNER cap, not a -// deployment-wide one. Tokens with the same UserID count together; ownerless -// operator tokens (UserID == "", every personal-edition token) form one owner. +// Issue #1177 (merged as #1286; Spec 107 FR-037 records the outcome): the +// agent-token cap is two bounds, not one. Every OWNED token (non-empty UserID) +// is counted against auth.MaxTokensPerOwner for its owner, and every stored +// record β€” owned or ownerless, live or revoked β€” still counts against the +// deployment-wide auth.MaxTokens storage bound. Ownerless personal-edition +// tokens are exempt from the owner quota, so the personal edition is unchanged. +// +// agent_token_owner_quota_test.go pins the headline properties (26th owned +// token refused, another tenant still mints, permanent delete frees the slot, +// ownerless tokens keep the deployment cap). The tests here pin the SHAPE of +// the count β€” there is no owner index, so it is a decode-every-row walk β€” and +// the interaction between the two bounds. // // Oracle discipline for every test below: a positive control mints the token -// immediately BELOW the cap for the same owner, so the ErrAgentTokenLimitReached -// that follows is the cap and not a name collision or an unwired store. +// immediately BELOW the bound for the same owner, so the sentinel that follows +// is the bound and not a name collision or an unwired store. -// fillOwnerToCap mints exactly auth.MaxTokens tokens for one owner and +// fillOwnerQuota mints exactly auth.MaxTokensPerOwner tokens for one owner and // requires every one of them to land. -func fillOwnerToCap(t *testing.T, mgr *Manager, owner string) { +func fillOwnerQuota(t *testing.T, mgr *Manager, owner string) { t.Helper() - for i := 0; i < auth.MaxTokens; i++ { - token, raw := makeOwnedTestToken(t, fmt.Sprintf("%s-fill-%03d", ownerLabel(owner), i), owner) + for i := 0; i < auth.MaxTokensPerOwner; i++ { + token, raw := makeOwnedTestToken(t, fmt.Sprintf("%s-fill-%03d", owner, i), owner) require.NoError(t, mgr.CreateAgentToken(token, raw, testHMACKey), - "owner %q token %d must land below the cap", owner, i) + "owner %q token %d must land below the owner quota", owner, i) } } -func ownerLabel(owner string) string { - if owner == "" { - return "ownerless" - } - return owner -} - -// TestAgentTokenCap_PerOwner: owner A at the cap cannot mint a 101st token, -// and owner B β€” who holds nothing β€” can still mint their first. -// -// BITES: with the global `tokenBucket.Stats().KeyN >= auth.MaxTokens` count, -// A's 100 tokens fill the bucket and B's first create fails. -func TestAgentTokenCap_PerOwner(t *testing.T) { - mgr, cleanup := setupTestStorageForAgentTokens(t) - defer cleanup() - - const ownerA = "01HTEST0000000000000USERA" - const ownerB = "01HTEST0000000000000USERB" - - fillOwnerToCap(t, mgr, ownerA) - - over, raw := makeOwnedTestToken(t, "a-one-too-many", ownerA) - err := mgr.CreateAgentToken(over, raw, testHMACKey) - require.ErrorIs(t, err, ErrAgentTokenLimitReached, "A's 101st token must hit A's own cap") - - first, raw := makeOwnedTestToken(t, "b-first", ownerB) - require.NoError(t, mgr.CreateAgentToken(first, raw, testHMACKey), - "B holds no tokens; A's cap must not block B's first") - - // The bucket now holds MaxTokens+1 rows: the cap is per owner, so the - // bucket itself is no longer bounded by auth.MaxTokens. - count, err := mgr.GetAgentTokenCount() - require.NoError(t, err) - assert.Equal(t, auth.MaxTokens+1, count) -} - -// TestAgentTokenCap_OwnerlessTokensAreOneOwner: the operator's ownerless -// tokens count together as ONE owner (so the personal edition is unchanged), -// and they do not count against a user's quota nor a user's against theirs. -// -// BITES: with the global count, 100 ownerless tokens block the user's first. -func TestAgentTokenCap_OwnerlessTokensAreOneOwner(t *testing.T) { - mgr, cleanup := setupTestStorageForAgentTokens(t) - defer cleanup() - - const ownerA = "01HTEST0000000000000USERA" - - fillOwnerToCap(t, mgr, "") - - over, raw := makeOwnedTestToken(t, "ownerless-one-too-many", "") - require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenLimitReached, - "the ownerless owner is capped like any other") - - first, raw := makeOwnedTestToken(t, "a-first", ownerA) - require.NoError(t, mgr.CreateAgentToken(first, raw, testHMACKey), - "the operator's ownerless tokens must not consume a user's quota") - - // And the other direction: A at the cap does not stop an ownerless mint - // once a slot is free in the ownerless quota. - require.NoError(t, mgr.DeleteAgentToken("ownerless-fill-000")) - freed, raw := makeOwnedTestToken(t, "ownerless-after-delete", "") - require.NoError(t, mgr.CreateAgentToken(freed, raw, testHMACKey), - "deleting one of the owner's own tokens must free a slot for that owner") -} - -// TestAgentTokenCap_CountsWholeBucketNotFirstMaxTokensRows pins the walk -// shape. There is no owner index β€” records are keyed by HMAC hash and UserID -// lives inside the JSON β€” and once the cap is per owner the bucket can hold -// far more than auth.MaxTokens rows. So the count must decode EVERY row and -// count only the new token's owner; it may stop early once MaxTokens matches -// are found, never after MaxTokens rows. -// -// The fixture writes more than MaxTokens other-owner rows directly into the -// bucket under keys that sort BEFORE every hex hash key ('!' < '0'), so a -// walk that gives up after MaxTokens rows sees only strangers, counts zero -// for the target owner, and lets a 101st token through; a walk that counts -// rows instead of owners blocks the target owner's first. -func TestAgentTokenCap_CountsWholeBucketNotFirstMaxTokensRows(t *testing.T) { - mgr, cleanup := setupTestStorageForAgentTokens(t) - defer cleanup() - - const ownerA = "01HTEST0000000000000USERA" - const stranger = "01HTEST00000000000STRANGER" - const strangerRows = auth.MaxTokens + 50 - +// seedRawAgentTokenRows writes records straight into the agent_tokens bucket +// under keys that sort BEFORE every hex HMAC key ('!' is 0x21, below '0'), so +// they are the first rows any cursor yields. +func seedRawAgentTokenRows(t *testing.T, mgr *Manager, owner string, n int) { + t.Helper() require.NoError(t, mgr.db.db.Update(func(tx *bbolt.Tx) error { bucket, err := tx.CreateBucketIfNotExists([]byte(AgentTokensBucket)) if err != nil { return err } - for i := 0; i < strangerRows; i++ { + for i := 0; i < n; i++ { rec := auth.AgentToken{ - Name: fmt.Sprintf("stranger-%03d", i), - UserID: stranger, - TokenHash: fmt.Sprintf("!stranger-%03d", i), - TokenPrefix: "mcp_agt_strang", + Name: fmt.Sprintf("%s-%03d", owner, i), + UserID: owner, + TokenHash: fmt.Sprintf("!%s-%03d", owner, i), + TokenPrefix: "mcp_agt_seeded", Permissions: []string{auth.PermRead}, CreatedAt: time.Now().UTC(), } @@ -135,33 +63,49 @@ func TestAgentTokenCap_CountsWholeBucketNotFirstMaxTokensRows(t *testing.T) { if err != nil { return err } - // '!' (0x21) sorts before every hex digit, so these rows are the - // first MaxTokens+50 the cursor yields. if err := bucket.Put([]byte(rec.TokenHash), data); err != nil { return err } } return nil })) +} + +// TestAgentTokenOwnerQuota_CountsMatchesNotRows pins the walk shape. Records +// are keyed by HMAC hash and UserID lives inside the JSON, so the owner count +// must decode EVERY row and count only the new token's owner. The fixture puts +// more than auth.MaxTokensPerOwner rows of a stranger's at the FRONT of key +// order: a walk that counted rows instead of owners would refuse the target +// owner's first token, and one that gave up after MaxTokensPerOwner rows would +// see only strangers, count zero, and let the 26th through. +func TestAgentTokenOwnerQuota_CountsMatchesNotRows(t *testing.T) { + mgr, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + const ownerA = "01HTEST0000000000000USERA" + const stranger = "01HTEST00000000000STRANGER" + const strangerRows = auth.MaxTokensPerOwner + 5 + + seedRawAgentTokenRows(t, mgr, stranger, strangerRows) // Positive control on the fixture: the strangers really are in the bucket - // and really do outnumber the cap. + // and really do outnumber the owner quota. count, err := mgr.GetAgentTokenCount() require.NoError(t, err) require.Equal(t, strangerRows, count) // The target owner's full quota still mints ... - fillOwnerToCap(t, mgr, ownerA) + fillOwnerQuota(t, mgr, ownerA) - // ... and the 101st is refused, even though the first MaxTokens rows in - // key order belong to someone else. + // ... and the next one is refused as THE OWNER's quota, even though the + // first rows in key order belong to someone else. over, raw := makeOwnedTestToken(t, "a-one-too-many", ownerA) - require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenLimitReached) + require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenOwnerLimitReached) } -// TestAgentTokenCap_UnparseableRowIsSkippedNotCounted: a corrupt row must -// neither abort the create for every tenant nor be counted against anyone. -func TestAgentTokenCap_UnparseableRowIsSkippedNotCounted(t *testing.T) { +// TestAgentTokenOwnerQuota_UnparseableRowIsSkippedNotCounted: a corrupt row +// must neither abort the create for every tenant nor be counted against anyone. +func TestAgentTokenOwnerQuota_UnparseableRowIsSkippedNotCounted(t *testing.T) { mgr, cleanup := setupTestStorageForAgentTokens(t) defer cleanup() @@ -175,8 +119,62 @@ func TestAgentTokenCap_UnparseableRowIsSkippedNotCounted(t *testing.T) { return bucket.Put([]byte("!corrupt"), []byte("{not json")) })) - fillOwnerToCap(t, mgr, ownerA) + fillOwnerQuota(t, mgr, ownerA) + + over, raw := makeOwnedTestToken(t, "a-one-too-many", ownerA) + require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenOwnerLimitReached) +} + +// TestAgentTokenOwnerQuota_OwnerlessAndOwnedDoNotShareAQuota: the operator's +// ownerless tokens are exempt from the owner quota and never count against a +// user's, and a user at their quota does not stop an ownerless mint. +func TestAgentTokenOwnerQuota_OwnerlessAndOwnedDoNotShareAQuota(t *testing.T) { + mgr, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + const ownerA = "01HTEST0000000000000USERA" + + // More ownerless tokens than any single owner may hold. + for i := 0; i < auth.MaxTokensPerOwner+5; i++ { + token, raw := makeOwnedTestToken(t, fmt.Sprintf("ownerless-%03d", i), "") + require.NoError(t, mgr.CreateAgentToken(token, raw, testHMACKey), + "ownerless tokens are exempt from the owner quota") + } + fillOwnerQuota(t, mgr, ownerA) over, raw := makeOwnedTestToken(t, "a-one-too-many", ownerA) - require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenLimitReached) + require.ErrorIs(t, mgr.CreateAgentToken(over, raw, testHMACKey), ErrAgentTokenOwnerLimitReached, + "the ownerless rows must not have been counted for the user, and the user's own quota still binds") + + after, raw := makeOwnedTestToken(t, "ownerless-after", "") + require.NoError(t, mgr.CreateAgentToken(after, raw, testHMACKey), + "a user at their quota must not block an ownerless mint below the deployment cap") +} + +// TestAgentTokenCap_DeploymentBoundStillApplies: the owner quota is enforced +// in ADDITION to the deployment-wide storage bound, not instead of it. Enough +// owners at their quota fill the deployment, and the next owner's FIRST token +// is refused with the deployment sentinel β€” the owner quota was not reached. +func TestAgentTokenCap_DeploymentBoundStillApplies(t *testing.T) { + mgr, cleanup := setupTestStorageForAgentTokens(t) + defer cleanup() + + owners := auth.MaxTokens / auth.MaxTokensPerOwner + require.Equal(t, auth.MaxTokens, owners*auth.MaxTokensPerOwner, + "fixture assumes the deployment cap is a whole number of owner quotas") + for i := 0; i < owners; i++ { + fillOwnerQuota(t, mgr, fmt.Sprintf("01HTEST000000000000OWNER%02d", i)) + } + + count, err := mgr.GetAgentTokenCount() + require.NoError(t, err) + require.Equal(t, auth.MaxTokens, count, "positive control: the deployment is exactly full") + + first, raw := makeOwnedTestToken(t, "late-first", "01HTEST0000000000000LATE") + require.ErrorIs(t, mgr.CreateAgentToken(first, raw, testHMACKey), ErrAgentTokenLimitReached, + "a full deployment refuses with the deployment sentinel, not the owner one") + + ownerless, raw := makeOwnedTestToken(t, "ownerless-late", "") + require.ErrorIs(t, mgr.CreateAgentToken(ownerless, raw, testHMACKey), ErrAgentTokenLimitReached, + "the deployment bound applies to ownerless tokens too") } From 36e0517f3c24116aa5763faad76fe749f0c62d15 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 14:12:47 +0300 Subject: [PATCH 06/35] fix(spec-107): codex round 1 for PR-A --- .github/RELEASE_NOTICE.md | 2 +- cmd/release-gate/docs_claims_test.go | 8 +++ .../server-edition-multiuser-auth.md | 2 +- docs/features/agent-tokens.md | 6 +- internal/config/auth_broker_stub.go | 17 +++-- internal/config/latent_symbols_guard_test.go | 37 ++++++++++- internal/config/legacy_keys_load_test.go | 37 +++++++++++ internal/config/loader_serveredition.go | 13 ++++ internal/config/personal_carriers_test.go | 47 +++++++++++++- internal/config/server_edition_config_stub.go | 47 ++++++++++++-- .../config_hotreload_personal_carrier_test.go | 65 +++++++++++++++++++ .../verification.md | 37 +++++++++++ 12 files changed, 299 insertions(+), 19 deletions(-) create mode 100644 internal/runtime/config_hotreload_personal_carrier_test.go diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md index 9ed3bcca2..759cefe0b 100644 --- a/.github/RELEASE_NOTICE.md +++ b/.github/RELEASE_NOTICE.md @@ -51,6 +51,6 @@ The `oauth_connect` connect flow, its REST routes, the `mcpproxy credential` com The server edition now enforces a **25-token quota per signed-in user** on top of the existing 100-record deployment cap ([#1177](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1177)). Revoked tokens keep their slot until they are permanently deleted. -- Server edition: a user at 25 tokens gets a `409 Conflict` from `POST /user/tokens` that names *their* quota β€” permanently deleting one of their unused tokens frees a slot β€” and no longer consumes the slots every other user shares. The 100-record deployment cap remains, and its `409` still says the limit is shared and points at an administrator. A user who already holds more than 25 tokens keeps them; they cannot create another until they are back under the quota. +- Server edition: a user at 25 tokens gets a `409 Conflict` from `POST /user/tokens` that names *their* quota β€” permanently deleting one of their unused tokens frees a slot β€” so one user can no longer take the whole pool. The 100-record deployment cap remains and every stored token still counts toward it, so a deployment whose stored records add up to 100 refuses the next token for everyone until an administrator frees records; that `409` still says the limit is shared and points at an administrator. A user who already holds more than 25 tokens keeps them; they cannot create another until they are back under the quota. - Personal edition: every token is ownerless, so the quota does not apply and the 100-token limit is unchanged. - No configuration change is needed. Details: [agent tokens](https://docs.mcpproxy.app/features/agent-tokens/). diff --git a/cmd/release-gate/docs_claims_test.go b/cmd/release-gate/docs_claims_test.go index 8b68f30cb..fdec477ea 100644 --- a/cmd/release-gate/docs_claims_test.go +++ b/cmd/release-gate/docs_claims_test.go @@ -40,6 +40,14 @@ var injectionClaimSentences = []string{ "JWT bearer token for MCP", "nothing re-validates at use time", "per-user connections", + // The three FR-036 corrections T013's list left implicit (codex round 1 + // on PR-A): the activity-log row that named the never-emitted broker + // events (`activity-log.md:28` at origin/main), the upstream-servers row + // that called the block "per-user token brokering" (`:112`), and the + // architecture line that listed the JWT as an MCP credential (`:53`). + "acquire/refresh/inject", + "per-user token brokering", + "JWT bearer (MCP/API)", } // removedTableRowCells are the first-cell literals of the Markdown table rows diff --git a/docs/development/server-edition-multiuser-auth.md b/docs/development/server-edition-multiuser-auth.md index 76c44df8f..87bdd51a2 100644 --- a/docs/development/server-edition-multiuser-auth.md +++ b/docs/development/server-edition-multiuser-auth.md @@ -265,4 +265,4 @@ go build -tags server ./cmd/mcpproxy # Build server editi go build ./cmd/mcpproxy # Verify personal edition unaffected ``` -> Note: server-edition `//go:build server` routes are invisible to `swag` / `verify-oas-coverage.sh` / CI lint (which don't pass `--build-tags server`). Lint locally with the tag and document endpoints here. +> Note: server-edition `//go:build server` routes are invisible to `swag` / `verify-oas-coverage.sh` (which don't pass `--build-tags server`), so document endpoints here. CI lints twice β€” bare and with `--build-tags server` β€” and race-tests `internal/server`, `internal/httpapi` and `internal/storage` under the tag (Spec 107 FR-047); run both lint passes locally before pushing (see the Lint block in `CLAUDE.md`). diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 325bf7a61..7d03b85ce 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -327,8 +327,10 @@ occupy a slot until they are permanently deleted, so once a limit is reached, creating another token answers `409 Conflict`: - **Your own quota (server edition, 25 per user).** The message tells you it is - your limit; permanently delete one of your unused tokens to free a slot. One - user filling their quota never blocks another user from creating theirs. + your limit; permanently delete one of your unused tokens to free a slot. The + quota keeps one user from taking the whole pool, but every stored token still + counts toward the deployment limit below, so a deployment whose records add + up to 100 refuses the next token for everyone. - **The deployment limit (100 stored records).** In the personal edition every token belongs to the one operator, so this is the only limit and deleting one of your tokens frees a slot. In the server edition the message says the limit diff --git a/internal/config/auth_broker_stub.go b/internal/config/auth_broker_stub.go index 6e458b52e..0a4ec6a62 100644 --- a/internal/config/auth_broker_stub.go +++ b/internal/config/auth_broker_stub.go @@ -7,8 +7,10 @@ import "encoding/json" // AuthBrokerConfig is the personal-edition carrier for a server's // `auth_broker` block. The connect flow is a server-edition feature (spec // 074); the personal edition keeps the field on ServerConfig so configs round -// trip (Spec 107 FR-040): the block is held as the raw JSON it was read from, -// never decoded, never validated and never warned about. +// trip (Spec 107 FR-040): the block is held as the canonical JSON of what was +// read (canonicalRawJSON β€” every key and value kept, key order and whitespace +// normalised so jsonEqual-based change detection stays quiet), never +// interpreted, never validated and never warned about. // // Omission is the parent pointer's job: ServerConfig.AuthBroker is // `*T,omitempty`, so a server without the key leaves the pointer nil and the @@ -18,13 +20,18 @@ type AuthBrokerConfig struct { raw json.RawMessage } -// UnmarshalJSON stores the document verbatim. +// UnmarshalJSON stores the document in canonical form (every key and value +// kept; key order and whitespace are not part of the contract). func (a *AuthBrokerConfig) UnmarshalJSON(data []byte) error { - a.raw = append(json.RawMessage(nil), data...) + raw, err := canonicalRawJSON(data) + if err != nil { + return err + } + a.raw = raw return nil } -// MarshalJSON emits the stored document verbatim; an empty carrier is `{}`. +// MarshalJSON emits the stored document; an empty carrier is `{}`. func (a AuthBrokerConfig) MarshalJSON() ([]byte, error) { if len(a.raw) == 0 { return []byte("{}"), nil diff --git a/internal/config/latent_symbols_guard_test.go b/internal/config/latent_symbols_guard_test.go index 44869ee2b..30ad81fed 100644 --- a/internal/config/latent_symbols_guard_test.go +++ b/internal/config/latent_symbols_guard_test.go @@ -70,6 +70,18 @@ var removedDecls = []removedDecl{ // FR-031 class A: broker exchange / resolve / inject chain. {Name: "TokenExchanger", Why: "broker.TokenExchanger deleted (FR-031)"}, {Name: "CredentialResolver", Why: "broker.CredentialResolver deleted (FR-031)"}, + // FR-031 names "CredentialResolver with its interfaces/errors": the + // resolver's collaborator interfaces, its not-connected error type, the + // policy hook and the three sentinel errors (credential_resolver.go at + // origin/main b39800a89). Scoped to package broker. + {Pkg: "broker", Name: "Exchanger", Why: "resolver collaborator interface deleted with the resolver (FR-031)"}, + {Pkg: "broker", Name: "Connector", Why: "resolver collaborator interface deleted with the resolver (FR-031)"}, + {Pkg: "broker", Name: "NotConnectedError", Why: "resolver error type deleted with the resolver (FR-031)"}, + {Pkg: "broker", Name: "PolicyHook", Why: "resolver policy hook deleted with the resolver (FR-031)"}, + {Pkg: "broker", Name: "PolicyHookFunc", Why: "resolver policy hook deleted with the resolver (FR-031)"}, + {Pkg: "broker", Name: "ErrUnauthenticated", Why: "resolver sentinel error deleted with the resolver (FR-031)"}, + {Pkg: "broker", Name: "ErrNoCredential", Why: "resolver sentinel error deleted with the resolver (FR-031)"}, + {Pkg: "broker", Name: "ErrBrokerNotConfigured", Why: "resolver sentinel error deleted with the resolver (FR-031)"}, {Name: "HeaderInjector", Why: "broker.HeaderInjector deleted (FR-031)"}, {Name: "ConnectionKey", Why: "broker.ConnectionKey deleted (FR-031)"}, {Name: "AuditActionInject", Why: "dead audit constant (FR-031)"}, @@ -83,6 +95,7 @@ var removedDecls = []removedDecl{ {Name: "ErrReauthRequired", Why: "IdP subject-token reader deleted (FR-033)"}, {Name: "persistIDPSubjectToken", Why: "IdP subject-token writer deleted (FR-033, T015)"}, {Recv: "OAuthHandler", Name: "SetCredentialStore", Why: "the login handler no longer holds a credential store to write IdP tokens into (FR-033, T015)"}, + {Recv: "OAuthHandler", Name: "credStore", Why: "the login handler's credential-store field left with the IdP subject-token writer (FR-033, T015)"}, {Name: "OfflineAuthParams", Why: "offline-access authorization parameters deleted; login never requests a refresh token (FR-033, T015)"}, {Name: "OfflineAccessScopes", Why: "offline_access scope set deleted; login never requests a refresh token (FR-033, T015)"}, {Name: "RefreshAccessToken", Why: "OAuthProvider.RefreshAccessToken deleted (FR-031)"}, @@ -96,6 +109,12 @@ var removedDecls = []removedDecl{ {Name: "BrokeredAuth", Why: "transport.BrokeredAuth type + HTTPTransportConfig.BrokeredAuth deleted (FR-031)"}, {Name: "EffectiveHeaders", Why: "transport.EffectiveHeaders deleted (FR-031)"}, {Name: "refuseBrokeredOAuth", Why: "transport brokered branch deleted (FR-031)"}, + // FR-031 deletes "every brokered branch" in transport/http.go:178-192 and + // upstream/core/connection_http.go:171-192; these are the helpers those + // branches were (origin/main b39800a89), not just the exported seam. + {Pkg: "transport", Recv: "HTTPTransportConfig", Name: "effectiveHeaders", Why: "transport brokered header merge deleted (FR-031, T016)"}, + {Pkg: "core", Recv: "Client", Name: "canUseHeadersStrategy", Why: "brokered strategy gate deleted (FR-031, T016)"}, + {Pkg: "core", Recv: "Client", Name: "brokeredHTTPConfig", Why: "brokered transport-config builder deleted (FR-031, T016)"}, // FR-032: dead config knobs. {Pkg: "config", Recv: "ServerEditionConfig", Name: "MaxUserServers", Why: "dead knob removed (FR-032)"}, {Pkg: "config", Recv: "ServerEditionConfig", Name: "WorkspaceIdleTimeout", Why: "dead knob removed (FR-032)"}, @@ -293,6 +312,10 @@ func TestLatentSymbolsGuard_RemovedDeclarationsAbsent(t *testing.T) { fset := token.NewFileSet() var violations []string + // The mode-literal scan is only meaningful if every validator it targets + // was actually found and inspected; a renamed validator must fail the + // guard, never pass it vacuously. + seenValidators := map[string]bool{} for _, path := range files { f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) if err != nil { @@ -316,9 +339,15 @@ func TestLatentSymbolsGuard_RemovedDeclarationsAbsent(t *testing.T) { continue } fn, ok := funcs[vf.Recv+"."+vf.Name] - if !ok || fn.Body == nil { + if !ok { + // A validator that is not in THIS file may live in a sibling; + // seenValidators proves each one was inspected somewhere. continue } + if fn.Body == nil { + continue + } + seenValidators[latentFuncLabel(vf)] = true ast.Inspect(fn.Body, func(n ast.Node) bool { switch x := n.(type) { case *ast.BasicLit: @@ -350,6 +379,12 @@ func TestLatentSymbolsGuard_RemovedDeclarationsAbsent(t *testing.T) { } } + for _, vf := range authBrokerValidatorFuncs { + if !seenValidators[latentFuncLabel(vf)] { + t.Errorf("auth_broker validator %s (package %s) was not found in the walked files; the accepted-mode scan cannot run without it β€” update authBrokerValidatorFuncs if it moved", latentFuncLabel(vf), vf.Pkg) + } + } + if len(violations) > 0 { sort.Strings(violations) t.Errorf("Spec 107 FR-035: %d removed declaration(s)/branch(es) still present (FR-031/FR-032/FR-033):\n %s", diff --git a/internal/config/legacy_keys_load_test.go b/internal/config/legacy_keys_load_test.go index f732fde56..0ffed135a 100644 --- a/internal/config/legacy_keys_load_test.go +++ b/internal/config/legacy_keys_load_test.go @@ -389,3 +389,40 @@ func TestLegacyKeys_TypedDecodeDropsThemWithoutTrace(t *testing.T) { "the typed struct has no field for a removed key, so nothing downstream of the decode can refuse it") } } + +// The normaliser re-encodes the raw document only when it dropped something, +// and that re-encode must not make the loader MORE lenient than the strict +// json.Unmarshal that follows on a clean file: a document with trailing +// content after the first JSON value is a parse error on both paths (codex +// round 1 on PR-A β€” a single Decoder.Decode accepted the first object and +// silently discarded the rest). +func TestLegacyKeys_NormaliserRejectsTrailingContentLikeTheStrictPath(t *testing.T) { + dir := t.TempDir() + doc := readLegacyFixture(t) + doc["data_dir"] = dir + data, err := json.MarshalIndent(doc, "", " ") + require.NoError(t, err) + + for name, trailer := range map[string]string{ + "second object": "\n{\"listen\": \"127.0.0.1:1\"}\n", + "garbage": "\nnot json\n", + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(dir, strings.ReplaceAll(name, " ", "-")+".json") + require.NoError(t, os.WriteFile(path, append(append([]byte(nil), data...), []byte(trailer)...), 0600)) + _, err := config.LoadFromFile(path) + require.Error(t, err, "a legacy document with trailing content must not load") + assert.Contains(t, err.Error(), "failed to parse config file") + }) + } + + // The same trailer on the CLEAN sibling (nothing to drop, original bytes + // reach the strict decoder) is refused too β€” the two paths agree. + cleanData, err := json.MarshalIndent(cleanSibling(t, doc), "", " ") + require.NoError(t, err) + cleanPath := filepath.Join(dir, "clean-trailer.json") + require.NoError(t, os.WriteFile(cleanPath, append(cleanData, []byte("\n{}\n")...), 0600)) + _, err = config.LoadFromFile(cleanPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse config file") +} diff --git a/internal/config/loader_serveredition.go b/internal/config/loader_serveredition.go index 9a8602255..3c17559cc 100644 --- a/internal/config/loader_serveredition.go +++ b/internal/config/loader_serveredition.go @@ -5,7 +5,9 @@ package config import ( "bytes" "encoding/json" + "errors" "fmt" + "io" ) // normalizeLoadedDocument is the server-build boot normaliser (Spec 107 @@ -25,6 +27,17 @@ func normalizeLoadedDocument(data []byte) ([]byte, []LoadDiagnostic, error) { if err := dec.Decode(&raw); err != nil { return nil, nil, fmt.Errorf("failed to parse config file: %w", err) } + // Decoder.Decode stops after the first value. The strict json.Unmarshal + // that follows on the untouched path refuses trailing content, and the + // re-encoded path must be exactly as strict, or a file with a second + // object / trailing garbage after a removed key would boot on the first + // object alone. + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("unexpected trailing content after the top-level object") + } + return nil, nil, fmt.Errorf("failed to parse config file: %w", err) + } diags := dropRemovedKeys(raw) if len(diags) == 0 { diff --git a/internal/config/personal_carriers_test.go b/internal/config/personal_carriers_test.go index 5cda7d46c..cc7610ce7 100644 --- a/internal/config/personal_carriers_test.go +++ b/internal/config/personal_carriers_test.go @@ -16,8 +16,51 @@ import ( // carrier is `{}`, and Clone() never aliases the source's backing array (the // value copy CopyServerConfig used to make would have). +// carrierProbe is deliberately NOT in lexical key order: the carrier must +// canonicalise what it stores (sorted keys, compact, numbers verbatim) so two +// documents that differ only in key order or whitespace marshal identically +// β€” DetectConfigChanges compares mcpServers with jsonEqual (bytes of +// json.Marshal), and the PATCH path re-emits the block from a sorted map, so a +// verbatim carrier would report a spurious "mcpServers" change on the first +// unrelated PATCH after boot and reconnect every upstream (codex round 1). const carrierProbe = `{"mode":"oauth_connect","n":9007199254740993,"d":0.1000000000000000055511151231257827}` +// carrierProbeCanonical is carrierProbe as the carrier emits it. +const carrierProbeCanonical = `{"d":0.1000000000000000055511151231257827,"mode":"oauth_connect","n":9007199254740993}` + +func TestPersonalCarriers_CanonicalFormIsKeyOrderAndWhitespaceIndependent(t *testing.T) { + spaced := "{\n \"n\": 9007199254740993,\n \"mode\": \"oauth_connect\",\n \"d\": 0.1000000000000000055511151231257827\n}" + + var a, b ServerConfig + require.NoError(t, json.Unmarshal([]byte(`{"name":"s","auth_broker":`+carrierProbe+`}`), &a)) + require.NoError(t, json.Unmarshal([]byte(`{"name":"s","auth_broker":`+spaced+`}`), &b)) + ab, err := json.Marshal(a.AuthBroker) + require.NoError(t, err) + bb, err := json.Marshal(b.AuthBroker) + require.NoError(t, err) + assert.Equal(t, carrierProbeCanonical, string(ab), "sorted keys, compact, numbers verbatim") + assert.Equal(t, string(ab), string(bb), "key order and whitespace must not leak into the carrier's bytes") + + var c, d Config + require.NoError(t, json.Unmarshal([]byte(`{"server_edition":`+carrierProbe+`}`), &c)) + require.NoError(t, json.Unmarshal([]byte(`{"server_edition":`+spaced+`}`), &d)) + cb, err := json.Marshal(c.ServerEdition) + require.NoError(t, err) + db, err := json.Marshal(d.ServerEdition) + require.NoError(t, err) + assert.Equal(t, carrierProbeCanonical, string(cb)) + assert.Equal(t, string(cb), string(db)) + + // Nested objects canonicalise too (the PATCH path sorts recursively). + var e, f Config + require.NoError(t, json.Unmarshal([]byte(`{"server_edition":{"oauth":{"provider":"google","client_id":"x"},"enabled":true}}`), &e)) + require.NoError(t, json.Unmarshal([]byte(`{"server_edition":{"enabled":true,"oauth":{"client_id":"x","provider":"google"}}}`), &f)) + eb, _ := json.Marshal(e.ServerEdition) + fb, _ := json.Marshal(f.ServerEdition) + assert.Equal(t, `{"enabled":true,"oauth":{"client_id":"x","provider":"google"}}`, string(eb)) + assert.Equal(t, string(eb), string(fb)) +} + func TestPersonalCarriers_CopyServerConfigDoesNotAliasAuthBroker(t *testing.T) { var src ServerConfig require.NoError(t, json.Unmarshal([]byte(`{"name":"s","url":"https://x/mcp","protocol":"http","auth_broker":`+carrierProbe+`}`), &src)) @@ -29,7 +72,7 @@ func TestPersonalCarriers_CopyServerConfigDoesNotAliasAuthBroker(t *testing.T) { before, err := json.Marshal(dst.AuthBroker) require.NoError(t, err) - assert.Equal(t, carrierProbe, string(before)) + assert.Equal(t, carrierProbeCanonical, string(before)) // Mutate the SOURCE carrier's bytes in place; an aliased backing array // would leak the change into the copy. @@ -50,7 +93,7 @@ func TestPersonalCarriers_ServerEditionCloneDoesNotAlias(t *testing.T) { before, err := json.Marshal(dst) require.NoError(t, err) - assert.Equal(t, carrierProbe, string(before)) + assert.Equal(t, carrierProbeCanonical, string(before)) src.ServerEdition.raw[2] = 'X' after, err := json.Marshal(dst) diff --git a/internal/config/server_edition_config_stub.go b/internal/config/server_edition_config_stub.go index afc8abf38..77e2a6463 100644 --- a/internal/config/server_edition_config_stub.go +++ b/internal/config/server_edition_config_stub.go @@ -2,13 +2,41 @@ package config -import "encoding/json" +import ( + "bytes" + "encoding/json" + "fmt" +) + +// canonicalRawJSON re-encodes one JSON value in canonical form: object keys +// sorted, whitespace removed, numbers kept as their exact decimal text +// (json.Number). The personal-build carriers store THIS rather than the source +// bytes, so two documents that differ only in key order or layout marshal +// identically. That matters because DetectConfigChanges compares mcpServers +// with jsonEqual (the bytes of json.Marshal) while PATCH /api/v1/config +// re-emits every block from a generic map in lexical order: a verbatim carrier +// made the first unrelated PATCH after boot report "mcpServers" changed and +// reconnect every upstream (codex round 1 on PR-A). Semantic content β€” every +// key and every value β€” is unchanged, which is the FR-040 contract. +func canonicalRawJSON(data []byte) (json.RawMessage, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("decode opaque block: %w", err) + } + out, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("re-encode opaque block: %w", err) + } + return out, nil +} // ServerEditionConfig is the personal-edition carrier for the `server_edition` // block. Server edition features are not available here, but the block must -// survive load β†’ save β†’ PATCH β†’ save byte-for-byte in meaning (Spec 107 -// FR-040): it is held as the raw JSON it was read from, never decoded, never -// validated, never normalised and never warned about. +// survive load β†’ save β†’ PATCH β†’ save intact in meaning (Spec 107 FR-040): it +// is held as the canonical JSON of what was read (canonicalRawJSON), never +// interpreted, never validated, never normalised and never warned about. // // Omission is the parent pointer's job: Config.ServerEdition is `*T,omitempty`, // so a document without the key leaves the pointer nil and the key stays @@ -18,13 +46,18 @@ type ServerEditionConfig struct { raw json.RawMessage } -// UnmarshalJSON stores the document verbatim. +// UnmarshalJSON stores the document in canonical form (every key and value +// kept; key order and whitespace are not part of the contract). func (c *ServerEditionConfig) UnmarshalJSON(data []byte) error { - c.raw = append(json.RawMessage(nil), data...) + raw, err := canonicalRawJSON(data) + if err != nil { + return err + } + c.raw = raw return nil } -// MarshalJSON emits the stored document verbatim; an empty carrier is `{}`. +// MarshalJSON emits the stored document; an empty carrier is `{}`. func (c ServerEditionConfig) MarshalJSON() ([]byte, error) { if len(c.raw) == 0 { return []byte("{}"), nil diff --git a/internal/runtime/config_hotreload_personal_carrier_test.go b/internal/runtime/config_hotreload_personal_carrier_test.go new file mode 100644 index 000000000..ba3bab41c --- /dev/null +++ b/internal/runtime/config_hotreload_personal_carrier_test.go @@ -0,0 +1,65 @@ +//go:build !server + +package runtime + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// Spec 107 FR-040 (codex round 1 on PR-A): in the personal build the +// `auth_broker` block is an opaque carrier. The boot config holds it as the +// FILE spelled it (server-edition struct order: mode, token_endpoint, +// authorization_endpoint, …) while a PATCH /api/v1/config re-emits it from a +// generic map (lexical key order). DetectConfigChanges compares mcpServers +// with jsonEqual β€” the bytes of json.Marshal β€” so a carrier that kept the +// source bytes verbatim reported "mcpServers" changed on the first unrelated +// PATCH after boot and Runtime scheduled a full LoadConfiguredServers +// (reconnecting every upstream). The carrier canonicalises instead; this pins +// the runtime-level consequence. +func TestDetectConfigChanges_PersonalOpaqueAuthBrokerIsKeyOrderBlind(t *testing.T) { + const fileOrder = `{ + "listen": "127.0.0.1:18108", + "tools_limit": 15, + "mcpServers": [{ + "name": "gh", + "url": "https://gh.example.com/mcp", + "protocol": "http", + "enabled": true, + "auth_broker": { + "mode": "oauth_connect", + "token_endpoint": "https://idp.example.com/token", + "authorization_endpoint": "https://idp.example.com/authorize", + "scopes": ["repo", "read:org"], + "resource": "https://gh.example.com" + } + }] +}` + + var oldCfg config.Config + require.NoError(t, json.Unmarshal([]byte(fileOrder), &oldCfg)) + require.Len(t, oldCfg.Servers, 1) + require.NotNil(t, oldCfg.Servers[0].AuthBroker) + + // The PATCH path: marshal the live config, decode to a generic map with + // UseNumber, merge an unrelated key, marshal (sorted keys), decode back. + baseBytes, err := json.Marshal(&oldCfg) + require.NoError(t, err) + var baseMap map[string]interface{} + require.NoError(t, json.Unmarshal(baseBytes, &baseMap)) + baseMap["tools_limit"] = 16 + mergedBytes, err := json.Marshal(baseMap) + require.NoError(t, err) + var newCfg config.Config + require.NoError(t, json.Unmarshal(mergedBytes, &newCfg)) + + result := DetectConfigChanges(&oldCfg, &newCfg) + assert.Contains(t, result.ChangedFields, "tools_limit") + assert.NotContains(t, result.ChangedFields, "mcpServers", + "an unrelated PATCH must not report the opaque auth_broker carrier as a server change (it would reconnect every upstream)") +} diff --git a/specs/107-server-edition-sso-hardening/verification.md b/specs/107-server-edition-sso-hardening/verification.md index e9d9b2a3c..fa6d1daaa 100644 --- a/specs/107-server-edition-sso-hardening/verification.md +++ b/specs/107-server-edition-sso-hardening/verification.md @@ -8,8 +8,45 @@ Evidence per PR. Each section records the real-instance run, the automated gate ### Automated checks +Run 2026-09-16 on branch `107-a-freeze-cut` (HEAD `c2559867a`, merge-base `b39800a89`) from the worktree root, in plan.md Β§Gates order. Go `go1.26.0 darwin/arm64`. Logs in the session scratchpad `gates/`. + +| # | Command | Outcome | +|---|---------|---------| +| 1 | `go build -o /dev/null ./cmd/mcpproxy` | pass (exit 0) | +| 2 | `go build -tags server -o /dev/null ./cmd/mcpproxy` | pass (exit 0) | +| 3 | `go vet ./...` and `go vet -tags server ./...` | pass (exit 0 both) | +| 4 | `go test -race -tags server -timeout 20m ./internal/serveredition/... ./internal/config/... ./internal/oauth/... ./internal/storage/...` | pass β€” 9 packages `ok` (serveredition, api, auth, broker, multiuser, users, config, oauth, storage) | +| 5 | `go test -race -tags server -timeout 20m -skip 'E2E\|Binary\|MCPProtocol\|TestInfoEndpoint\|TestGracefulShutdownNoPanic\|TestSocketInfoEndpoint' ./internal/server/... ./internal/httpapi/...` (the T005-widened `server-edition` CI job) | pass β€” `internal/server` 248.7s, `internal/server/tokens`, `internal/httpapi` all `ok` | +| 6 | `go test -race -timeout 20m -skip '' ./internal/...` (personal) | pass β€” 65 packages `ok`, 0 FAIL (incl. `internal/upstream/core`, `internal/transport`, `internal/server` 286.8s) | +| 7 | `go test -race -timeout 10m ./cmd/mcpproxy` and `go test -race -tags server -timeout 10m ./cmd/mcpproxy` | pass (exit 0 both) | +| 8 | `go test ./internal/server/ -run 'TestToolsListSnapshot_MatchesMergeBaseGoldens\|TestToolsListSnapshot_DeltaIsEnumerated\|TestMenuSurface_ExactDeltaFromPreFeature\|TestDirectFullMode_ByteStableAgainstPreFeatureE2E'` (frozen goldens, unregenerated) | pass β€” all 4 `--- PASS` | +| 9 | `go test ./cmd/release-gate/` | pass (`ok`, 3.3s; includes T013 `TestDocsMakeNoInjectionClaims`, `TestKeptSidebarEntriesResolve`) | +| 10 | `go test ./cmd/...` | pass β€” all packages `ok` | +| 11 | `golangci-lint run --config .github/.golangci.yml --timeout=10m ./...` | pass β€” `0 issues` | +| 12 | `golangci-lint run --config .github/.golangci.yml --build-tags server --timeout=10m ./...` (T005 second pass) | pass β€” `0 issues` (T006 inventory: the server tag exposes **zero** pre-existing lint findings; nothing to split into a `chore(lint):` PR) | +| 13 | `scripts/test-api-e2e.sh` β€” isolated: `pgrep -f test-api-e2e.sh` empty; scratch copy with only line 80 (`pkill -f "mcpproxy.*serve"`) deleted (`diff` = `80d79`), `LISTEN_PORT=18471` (verified free with `lsof`), personal `./mcpproxy` built from HEAD | pass β€” `Tests run: 65, passed: 65, failed: 0, skipped: 0`; `git checkout -- test/e2e-config.json` restored the tracked config; the live tray core (`/Applications/mcpproxy.app`, :8080) survived | +| 14 | `make swagger-verify` | pass β€” `OpenAPI artifacts are up to date` (no OAS change in PR-A) | +| 15 | `go run ./cmd/generate-types && go test ./cmd/generate-types/ -run TestContractsInSync` | pass β€” `contracts.ts` regenerated byte-identical (tree clean), `TestContractsInSync` PASS | +| 16 | `python3 scripts/gen-roadmap.py --check` | pass β€” `ROADMAP.md is up to date.` | +| 17 | `cd frontend && npx vitest run` | pass β€” 122 files, 1265 tests (includes T022a `settings-server-edition-wording.spec.ts`) | +| 18 | `gofmt -l $(git diff --name-only origin/main...HEAD -- '*.go')` (62 existing touched files) | pass β€” empty list | +| 19 | `python3 scripts/check-settings-parity.py` (fields.ts changed in T022) | pass β€” `60 setting(s) consistent across web + native` | +| 20 | `(cd native/macos/MCPProxy && swift test)` | **1160/1161** β€” `Executed 1161 tests, with 1 failure`. The failure is `AppLifecycleTests.testTheSharedJournalNeverWritesToTheRealInstanceRootUnderTests`, the known environmental red herring (memory `project_swift_test_silent_miss`): it fails whenever the live tray has written `~/.mcpproxy/tray-lifecycle.jsonl` (present, 91 KB, mtime 13:05 today). Pre-existing, not PR-A: PR-A touches **no** file under `native/` (`git diff --name-only origin/main...HEAD -- native/` = 0) and the `Native macOS Tests` job on main head `b1777e865` is `success` (`gh run list --commit b1777e865…`). | +| 21 | T024 confirmation β€” every PR-A test-first task run by name under its own tag (`-count=1 -v`): T008 `TestLatentSymbolsGuard_*` (both tags), T009 `TestLegacyKeys_*` Γ—7 + `TestPatchConfig_*`/`TestApplyConfig_*` Γ—5 (`-tags server`), T010 `TestPersonalBuild_OpaqueBlocksRoundTrip`, `TestPersonalCarriers_*` Γ—3, `TestConfigPatch_OpaqueBlocksSurviveUnrelatedPatch` (personal), T011 `TestConfigValidate_*`/`TestServerEditionConfig_*` Γ—7 (`-tags server`), T012 `TestCredentialList_*`/`TestCredentialStatus_*` Γ—3 (`-tags server`), T013 (see #9), T026 `TestAgentTokenCap_*` Γ—4, `TestCreateToken_CapReached` (both tags), `TestCreateUserToken_Cap*` Γ—2 (`-tags server`) | pass β€” 57 `--- PASS`, no `FAIL`, no `SKIP` | + +Tooling note: `/opt/homebrew/bin/golangci-lint` (2.5.0, built with go1.25.1) refuses this module (`go 1.26.0`) with *"the Go language version (go1.25) used to build golangci-lint is lower than the targeted Go version (1.26.0)"*; rows 11–12 ran the CI-pinned version instead: `GOFLAGS=-mod=mod go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9.0 …` (already in the module cache; `go.mod` untouched). + +Not run: none of the plan.md Β§Gates rows was skipped. PR-C/PR-D-only gates (Playwright tenant spec, SC-009 benchmark) do not apply to PR-A. + +Merge-readiness caveat (found while fetching `origin/main` for the gofmt row): `origin/main` moved to `b1777e865` *"fix: enforce per-owner agent token quotas (#1286)"*, which **closes #1177 with a different design** (25-token per-owner quota **plus** the 100-record deployment cap, new `ErrAgentTokenOwnerLimitReached` / `auth.MaxTokensPerOwner`, `internal/storage/agent_token_owner_quota_test.go`). `git merge-tree --write-tree origin/main HEAD` reports content conflicts in `internal/storage/agent_tokens.go`, `internal/serveredition/api/user_handlers.go`, `internal/serveredition/api/user_token_mutation_test.go`, `docs/development/server-edition-multiuser-auth.md`, and an add/add conflict in `.github/RELEASE_NOTICE.md` (main's copy is the Spec 105 PR-A notice from #1279). Phase A.3 (T026–T028) must be reconciled against #1286 before PR-A is opened; the gate results above are for this branch as-is. + ### Cross-review +- **Round 1** (2026-09-16, codex `gpt-5.6-sol`, `model_reasoning_effort=high`, `--sandbox read-only`, three briefs per T030 β€” (1) the cut, (2) config normaliser + carriers + Validate/ApplyDefaults split + per-owner cap, (3) docs + CI + release notice + tests; each brief carried the PR-A goal, FR-031..FR-037/FR-039/FR-040/FR-042/FR-047 verbatim, the contract strings, the file list and the green-suite list; verdict files `scratchpad/pra-review-r1-c{1,2,3}.md`). Verdicts: c1 FINDINGS (2Γ—P2), c2 FINDINGS (4Γ—P2), c3 FINDINGS (3Γ—P2, 1Γ—P3) β€” 10 raw findings, 1 duplicate across briefs (guard coverage, c1/c3), 9 distinct; **6 genuine, 3 rejected**; no P1. + - **Fixed** β€” (a) `internal/config/latent_symbols_guard_test.go` (c1+c3): the FR-035 guard named only T008's headline symbols; FR-031 also deletes "CredentialResolver with its interfaces/errors" and "every brokered branch" β€” added `broker.{Exchanger,Connector,NotConnectedError,PolicyHook,PolicyHookFunc,ErrUnauthenticated,ErrNoCredential,ErrBrokerNotConfigured}`, `(*OAuthHandler).credStore`, `(*HTTPTransportConfig).effectiveHeaders`, `(*Client).{canUseHeadersStrategy,brokeredHTTPConfig}`, and the guard now fails when a named validator function is not found (the mode-literal scan passed vacuously if `Validate`/`validateServerAuthBroker` moved). Proven to bite by dropping origin/main's `credential_resolver.go` + `transport/http.go` into the tree (8 violations), then removed. (b) `internal/config/loader_serveredition.go` (c2): the normaliser's single `Decoder.Decode` accepted a document with a second object / trailing garbage after the first value whenever it re-encoded (a removed key present), while the untouched path's strict `json.Unmarshal` refuses it β€” now `dec.Token()` must return `io.EOF`; red test `TestLegacyKeys_NormaliserRejectsTrailingContentLikeTheStrictPath` (three sub-cases, the clean sibling proves parity). (c) `internal/config/{server_edition_config,auth_broker}_stub.go` (c2, the one behavioural defect): the personal carriers stored the FILE bytes verbatim, PATCH re-emits the block from a lexically-sorted map, and `DetectConfigChanges` compares `mcpServers` with `jsonEqual` (bytes of `json.Marshal`) β€” so the first unrelated PATCH after boot on a file whose `auth_broker` keys were not lexical (every file the server edition ever wrote: struct order `mode, token_endpoint, …`) reported `mcpServers` changed and `runtime.go:1850` scheduled a full `LoadConfiguredServers`, reconnecting every upstream (the F16 false-positive class). Carriers now store the canonical form (`canonicalRawJSON`: sorted keys, compact, numbers as `json.Number` text β€” every key and value preserved, which is the FR-040 contract; the structural comparator of T010 is unaffected). Red tests: `TestPersonalCarriers_CanonicalFormIsKeyOrderAndWhitespaceIndependent` and the runtime-level `internal/runtime/config_hotreload_personal_carrier_test.go` (`!server`), which reproduced `ChangedFields: ["mcpServers","tools_limit"]` on HEAD. (d) `.github/RELEASE_NOTICE.md:54` + `docs/features/agent-tokens.md:329` (c3): "one user filling their quota never blocks another" overstated #1286's design β€” every stored token still counts toward the 100-record deployment cap; reworded. (e) `cmd/release-gate/docs_claims_test.go` (c3): added the three FR-036 phrases T013 left implicit (`acquire/refresh/inject`, `per-user token brokering`, `JWT bearer (MCP/API)`); proven to bite by appending them to `activity-log.md` (3 hits), then restored. (f) `docs/development/server-edition-multiuser-auth.md:268` (c3, P3): stale "CI lint doesn't pass `--build-tags server`" note corrected to the FR-047 workflow. + - **Rejected** β€” (1) c1 `setup.go:211` "`AdminHandlers` gets a frozen `cfg.AdminEmails` slice, contradicting live `admin_emails`": identical on origin/main (`cfg` was the boot pointer and a reload publishes a NEW `Config`, so the slice was already frozen at boot); the live read is the middleware's `ServerEditionConfigProvider` (#1169) and the `server_edition` live/`DetectConfigChanges` work is PR-B per `contracts/config-keys.md` β€” carried forward as a PR-B note (dashboard role label). (2) c2 `*_stub.go:13` "`"server_edition": null` becomes an absent key": T020 documents exactly this ("a JSON `null` also decodes to a nil pointer"; omission is the parent pointer's job) and `null`/absent are semantically identical for every consumer in both editions; pinned by `TestPersonalCarriers_EmptyAndNull`. (3) c2 `removed_keys.go:85` "a retired-mode block that also carries `header` emits three diagnostics, not one": every message is true (the leaves were ignored too), SC-005's one-warning-per-key holds, and `legacy_keys_load_test.go:357` deliberately pins the write door reporting both so the operator fixes the block in one edit β€” P3 design choice, not wrong behaviour. + - Re-run after the fixes: both builds; `go vet` (both tags) on the touched packages; `go test -race` `./internal/config/ ./internal/httpapi/` under both tags; `go test -race -skip ./internal/runtime/...` (both tags) and `./internal/serveredition/...` (`-tags server`); `go test ./cmd/release-gate/`; golangci-lint v2.9.0 bare + `--build-tags server` on the touched packages; `gofmt -l` on the touched files. Round 2 (re-review of the fixes) follows. + ## PR-B β€” generic OIDC + front door + telemetry ### Real instance From e15812163bf94a00ace3bdacc90cc103d91e2448 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 14:35:44 +0300 Subject: [PATCH 07/35] fix(spec-107): codex round 2 for PR-A --- .github/RELEASE_NOTICE.md | 2 +- cmd/mcpproxy/cli_config.go | 14 ++ .../cli_config_diagnostics_personal_test.go | 30 +++ cmd/mcpproxy/cli_config_diagnostics_test.go | 71 +++++++ docs/features/auth-broker.md | 2 +- docs/features/idp-token-storage.md | 2 +- internal/config/latent_symbols_guard_test.go | 197 +++++++++++++++++- internal/config/legacy_keys_load_test.go | 30 +++ internal/config/load_diagnostics.go | 24 ++- internal/serveredition/broker/bbolt_aes.go | 17 +- internal/serveredition/setup.go | 28 +-- .../setup_legacy_idp_purge_test.go | 46 ++++ .../verification.md | 4 + 13 files changed, 447 insertions(+), 20 deletions(-) create mode 100644 cmd/mcpproxy/cli_config_diagnostics_personal_test.go create mode 100644 cmd/mcpproxy/cli_config_diagnostics_test.go diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md index 759cefe0b..83767f854 100644 --- a/.github/RELEASE_NOTICE.md +++ b/.github/RELEASE_NOTICE.md @@ -35,7 +35,7 @@ This release removes the server-edition knobs and `auth_broker` modes that were `server_edition.store_idp_tokens` no longer stores anything. The identity-provider access and refresh tokens it used to persist at login existed only to feed the never-implemented `token_exchange`/`entra_obo` modes, which left a long-lived IdP refresh token at rest with nothing reading it. The writer, the reader and the offline-access scope and authorization parameters that asked the IdP for a refresh token (`offline_access`, `access_type=offline`) are removed (FR-033), so a fresh login no longer requests a refresh token from the IdP. - The key is still accepted so an old file loads. `"store_idp_tokens": true` logs one warning at boot β€” `server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it` β€” and does nothing else. -- Remove it from your configuration. Nothing in this release reads the IdP tokens an earlier release stored, and the first start of `mcpproxy-server` after upgrading **deletes them** from `config.db` (the rows are removed by key, so this happens whether or not `MCPPROXY_CRED_KEY` is still set; the log line `purged legacy IdP subject-token rows` reports the count). Credentials connected through the `oauth_connect` flow are not touched. +- Remove it from your configuration. Nothing in this release reads the IdP tokens an earlier release stored, and the first start of `mcpproxy-server` with `server_edition.enabled: true` after upgrading **deletes them** from `config.db` (the rows are removed by key before anything else in the server-edition setup runs, so this happens whether `MCPPROXY_CRED_KEY` is still set, unset or even invalid; the log line `purged legacy IdP subject-token rows` reports the count). Credentials connected through the `oauth_connect` flow are not touched. - The former [IdP Token Storage](https://docs.mcpproxy.app/features/idp-token-storage/) page is now a tombstone. ## Auth broker: a stored credential is stored, not injected diff --git a/cmd/mcpproxy/cli_config.go b/cmd/mcpproxy/cli_config.go index b6feceeff..8ea62cc85 100644 --- a/cmd/mcpproxy/cli_config.go +++ b/cmd/mcpproxy/cli_config.go @@ -1,9 +1,18 @@ package main import ( + "io" + "os" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" ) +// cliDiagnosticsWriter receives the loader's removed-key / deprecated-key +// findings (Spec 107 FR-032) for every CLI command that loads the config +// through loadCLIConfig. It is stderr so structured stdout (`-o json`) stays +// clean; tests swap it for a buffer. +var cliDiagnosticsWriter io.Writer = os.Stderr + // loadCLIConfig loads a CLI command's config from explicitPath (the command's // --config flag) when set, falling back to the default search path, and applies // the global --data-dir flag on top (GH #854/#897/#908). Without the DataDir @@ -22,6 +31,11 @@ func loadCLIConfig(explicitPath string) (*config.Config, error) { if err != nil { return nil, err } + // The server-build loader may have dropped removed keys / retired + // auth_broker modes and recorded a diagnostic each; several callers + // SaveConfig the result, so the operator must see the drop here (the + // personal build records none β€” opaque carriers, FR-040). + config.WriteLoadDiagnostics(cfg, cliDiagnosticsWriter) if dataDir != "" { cfg.DataDir = dataDir } diff --git a/cmd/mcpproxy/cli_config_diagnostics_personal_test.go b/cmd/mcpproxy/cli_config_diagnostics_personal_test.go new file mode 100644 index 000000000..69630fdf6 --- /dev/null +++ b/cmd/mcpproxy/cli_config_diagnostics_personal_test.go @@ -0,0 +1,30 @@ +//go:build !server + +package main + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLoadCLIConfig_PersonalBuildEmitsNoDiagnostics: the personal build +// carries server_edition and auth_broker as opaque blocks (FR-040) and +// records no diagnostics, so the CLI door prints nothing for the legacy +// fixture. +func TestLoadCLIConfig_PersonalBuildEmitsNoDiagnostics(t *testing.T) { + var stderr bytes.Buffer + prev := cliDiagnosticsWriter + cliDiagnosticsWriter = &stderr + t.Cleanup(func() { cliDiagnosticsWriter = prev }) + + path, err := filepath.Abs(filepath.Join("..", "..", "internal", "config", "testdata", "legacy_server_edition.json")) + require.NoError(t, err) + cfg, err := loadCLIConfig(path) + require.NoError(t, err) + assert.Empty(t, cfg.LoadDiagnostics()) + assert.Empty(t, stderr.String()) +} diff --git a/cmd/mcpproxy/cli_config_diagnostics_test.go b/cmd/mcpproxy/cli_config_diagnostics_test.go new file mode 100644 index 000000000..a0e969cc9 --- /dev/null +++ b/cmd/mcpproxy/cli_config_diagnostics_test.go @@ -0,0 +1,71 @@ +//go:build server + +package main + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// legacyServerEditionFixture is the SC-005 fixture: removed server_edition +// keys, a retired auth_broker mode, a removed auth_broker leaf and +// store_idp_tokens: true β€” five load diagnostics under the server build. +func legacyServerEditionFixture(t *testing.T) string { + t.Helper() + path, err := filepath.Abs(filepath.Join("..", "..", "internal", "config", "testdata", "legacy_server_edition.json")) + require.NoError(t, err) + return path +} + +// TestLoadCLIConfig_EmitsLoadDiagnostics pins Spec 107 FR-032 on the CLI +// door (codex round 2 on PR-A): `mcpproxy upstream add` in standalone mode, +// `telemetry enable|disable` and the API-key reset all load through +// loadCLIConfig and then SaveConfig the result, so under the server build the +// normaliser's drops would be written back with no operator-visible warning. +// The diagnostics must reach stderr β€” one line per diagnostic, the contract +// message verbatim, and never stdout (`-o json` consumers parse stdout). +func TestLoadCLIConfig_EmitsLoadDiagnostics(t *testing.T) { + var stderr bytes.Buffer + prev := cliDiagnosticsWriter + cliDiagnosticsWriter = &stderr + t.Cleanup(func() { cliDiagnosticsWriter = prev }) + + cfg, err := loadCLIConfig(legacyServerEditionFixture(t)) + require.NoError(t, err) + diags := cfg.LoadDiagnostics() + require.Len(t, diags, 5, "fixture must still carry five diagnostics: %+v", diags) + + lines := strings.Split(strings.TrimRight(stderr.String(), "\n"), "\n") + require.Len(t, lines, len(diags), "exactly one stderr line per diagnostic; got:\n%s", stderr.String()) + for i, d := range diags { + assert.Contains(t, lines[i], d.Message, "line %d carries the contract message", i) + assert.Contains(t, lines[i], d.Key, "line %d names the key", i) + assert.True(t, strings.HasPrefix(lines[i], "warning: "), "line %d is marked as a warning: %q", i, lines[i]) + } +} + +// TestLoadCLIConfig_CleanFileEmitsNothing: a file with nothing to drop must +// not print anything (the common case stays byte-for-byte quiet). +func TestLoadCLIConfig_CleanFileEmitsNothing(t *testing.T) { + var stderr bytes.Buffer + prev := cliDiagnosticsWriter + cliDiagnosticsWriter = &stderr + t.Cleanup(func() { cliDiagnosticsWriter = prev }) + + path := filepath.Join(t.TempDir(), "clean.json") + cfg := config.DefaultConfig() + cfg.APIKey = "clean-key" + require.NoError(t, config.SaveConfig(cfg, path)) + + loaded, err := loadCLIConfig(path) + require.NoError(t, err) + assert.Empty(t, loaded.LoadDiagnostics()) + assert.Empty(t, stderr.String()) +} diff --git a/docs/features/auth-broker.md b/docs/features/auth-broker.md index f91e79588..9b6c4a4a3 100644 --- a/docs/features/auth-broker.md +++ b/docs/features/auth-broker.md @@ -70,7 +70,7 @@ Config validation fails with `auth_broker.authorization_endpoint is required for ## Credential storage and the encryption key -Per-user credentials are encrypted with **AES-256-GCM** before they are written to BBolt (`config.db`), keyed by `server_edition.credential_encryption_key` β€” a base64-encoded 32-byte key. The environment variable **`MCPPROXY_CRED_KEY`** supplies the key when the config value is empty and is the recommended way to provide it in container or systemd deployments; an explicit config value always wins over the variable. +Per-user credentials are encrypted with **AES-256-GCM** before they are written to BBolt (`config.db`), keyed by `server_edition.credential_encryption_key` β€” a base64-encoded 32-byte key. The environment variable **`MCPPROXY_CRED_KEY`** is the recommended way to provide it in container or systemd deployments, and **it takes precedence**: when both are set the variable is used and the config value is ignored (`broker.ResolveMasterKey`), so a credential encrypted under one key is unreadable if the other is later put in front of it. Set exactly one. ```bash # Generate a fresh 32-byte key and base64-encode it diff --git a/docs/features/idp-token-storage.md b/docs/features/idp-token-storage.md index 0161662cf..e2de60c55 100644 --- a/docs/features/idp-token-storage.md +++ b/docs/features/idp-token-storage.md @@ -6,4 +6,4 @@ description: "Server edition: the store_idp_tokens feature was removed; the key # IdP Subject Token Storage (removed) -The server edition no longer persists identity-provider access or refresh tokens after login. The feature existed only to feed an on-behalf-of token exchange that was never wired to any upstream call, so a long-lived IdP refresh token at rest had no reader and was a leak surface rather than a capability. `server_edition.store_idp_tokens` is still **accepted** by the config loader for compatibility, but it is a **no-op**: a value of `true` logs one deprecation warning at startup (`server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it`) and nothing is stored. Tokens an earlier release persisted are deleted from `config.db` on the first start after upgrading (logged as `purged legacy IdP subject-token rows`); nothing in the current release could read them anyway. Per-upstream credentials obtained through the `oauth_connect` flow are unaffected β€” they are still stored encrypted under `credential_encryption_key` / `MCPPROXY_CRED_KEY`, as described in [Auth Broker](./auth-broker.md) and [Credential Commands](../cli/credential-commands.md). +The server edition no longer persists identity-provider access or refresh tokens after login. The feature existed only to feed an on-behalf-of token exchange that was never wired to any upstream call, so a long-lived IdP refresh token at rest had no reader and was a leak surface rather than a capability. `server_edition.store_idp_tokens` is still **accepted** by the config loader for compatibility, but it is a **no-op**: a value of `true` logs one deprecation warning at startup (`server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it`) and nothing is stored. Tokens an earlier release persisted are deleted from `config.db` on the first start with `server_edition.enabled: true` after upgrading, before any other server-edition setup step and without needing an encryption key (logged as `purged legacy IdP subject-token rows`); nothing in the current release could read them anyway. Per-upstream credentials obtained through the `oauth_connect` flow are unaffected β€” they are still stored encrypted under `credential_encryption_key` / `MCPPROXY_CRED_KEY`, as described in [Auth Broker](./auth-broker.md) and [Credential Commands](../cli/credential-commands.md). diff --git a/internal/config/latent_symbols_guard_test.go b/internal/config/latent_symbols_guard_test.go index 30ad81fed..2fb5bb378 100644 --- a/internal/config/latent_symbols_guard_test.go +++ b/internal/config/latent_symbols_guard_test.go @@ -122,6 +122,167 @@ var removedDecls = []removedDecl{ {Pkg: "config", Recv: "AuthBrokerConfig", Name: "HeaderFormat", Why: "injection header format removed (FR-032)"}, } +// removedFile is one non-test Go file FR-031 / FR-033 delete outright, with +// EVERY top-level declaration it held (types, funcs, methods, consts, vars) +// at the merge base. FR-035 asks for "every Go symbol FR-031 and FR-033 +// delete ... checked by name over the non-test AST", so the table is the +// mechanical dump of each file's AST at origin/main b39800a89, not a curated +// highlight list: restoring any one of those files, or a fragment of one, +// under any file name trips the guard. Methods are scoped to their receiver +// and every entry to its package, so an unrelated `Error` method or a helper +// of the same name elsewhere in the walked set is not matched. +type removedFile struct { + File string + FR string + Decls []removedDecl +} + +var removedFiles = []removedFile{ + {File: "internal/serveredition/auth/idp_subject_token.go", FR: "FR-033", Decls: []removedDecl{ + {Pkg: "auth", Name: "idpSubjectTokenType"}, + {Pkg: "auth", Name: "idpRefreshSkew"}, + {Pkg: "auth", Name: "ErrReauthRequired"}, + {Pkg: "auth", Recv: "OAuthHandler", Name: "persistIDPSubjectToken"}, + {Pkg: "auth", Recv: "OAuthHandler", Name: "GetValidIDPSubjectToken"}, + {Pkg: "auth", Name: "expiryFromExpiresIn"}, + {Pkg: "auth", Name: "splitScopes"}, + {Pkg: "auth", Name: "chooseScopes"}, + {Pkg: "auth", Name: "firstNonEmpty"}, + }}, + {File: "internal/serveredition/broker/credential_resolver.go", FR: "FR-031", Decls: []removedDecl{ + {Pkg: "broker", Name: "defaultRefreshThreshold"}, + {Pkg: "broker", Name: "ErrUnauthenticated"}, + {Pkg: "broker", Name: "ErrNoCredential"}, + {Pkg: "broker", Name: "ErrBrokerNotConfigured"}, + {Pkg: "broker", Name: "Exchanger"}, + {Pkg: "broker", Name: "Connector"}, + {Pkg: "broker", Name: "ConnectorProvider"}, + {Pkg: "broker", Name: "NotConnectedError"}, + {Pkg: "broker", Recv: "NotConnectedError", Name: "Error"}, + {Pkg: "broker", Name: "PolicyDecision"}, + {Pkg: "broker", Name: "PolicyInput"}, + {Pkg: "broker", Name: "PolicyHook"}, + {Pkg: "broker", Name: "PolicyHookFunc"}, + {Pkg: "broker", Recv: "PolicyHookFunc", Name: "Evaluate"}, + {Pkg: "broker", Name: "allowAllPolicy"}, + {Pkg: "broker", Recv: "allowAllPolicy", Name: "Evaluate"}, + {Pkg: "broker", Name: "PolicyDeniedError"}, + {Pkg: "broker", Recv: "PolicyDeniedError", Name: "Error"}, + {Pkg: "broker", Name: "ResolverDeps"}, + {Pkg: "broker", Name: "CredentialResolver"}, + {Pkg: "broker", Name: "acquisition"}, + {Pkg: "broker", Name: "NewCredentialResolver"}, + {Pkg: "broker", Recv: "CredentialResolver", Name: "Resolve"}, + {Pkg: "broker", Recv: "CredentialResolver", Name: "emitAudit"}, + {Pkg: "broker", Name: "auditReason"}, + {Pkg: "broker", Recv: "CredentialResolver", Name: "acquire"}, + {Pkg: "broker", Recv: "CredentialResolver", Name: "notConnected"}, + {Pkg: "broker", Recv: "CredentialResolver", Name: "connectorFor"}, + }}, + {File: "internal/serveredition/broker/injector.go", FR: "FR-031", Decls: []removedDecl{ + {Pkg: "broker", Name: "ErrBrokerStdioUnsupported"}, + {Pkg: "broker", Name: "fallbackBrokerHeader"}, + {Pkg: "broker", Name: "fallbackBrokerHeaderFormat"}, + {Pkg: "broker", Name: "resolver"}, + {Pkg: "broker", Name: "HeaderInjector"}, + {Pkg: "broker", Name: "NewHeaderInjector"}, + {Pkg: "broker", Recv: "HeaderInjector", Name: "InjectFor"}, + {Pkg: "broker", Name: "ConnectionKey"}, + }}, + {File: "internal/serveredition/broker/token_exchanger.go", FR: "FR-031", Decls: []removedDecl{ + {Pkg: "broker", Name: "grantTypeTokenExchange"}, + {Pkg: "broker", Name: "grantTypeJWTBearer"}, + {Pkg: "broker", Name: "tokenTypeAccessToken"}, + {Pkg: "broker", Name: "entraRequestedTokenUse"}, + {Pkg: "broker", Name: "defaultExchangeTimeout"}, + {Pkg: "broker", Name: "TokenExchanger"}, + {Pkg: "broker", Name: "NewTokenExchanger"}, + {Pkg: "broker", Name: "tokenResponse"}, + // tokenErrorResponse is NOT listed: oauth_connector.go kept its own copy of the RFC 6749 error body. + {Pkg: "broker", Recv: "TokenExchanger", Name: "Exchange"}, + {Pkg: "broker", Name: "buildExchangeForm"}, + {Pkg: "broker", Recv: "TokenExchanger", Name: "post"}, + {Pkg: "broker", Recv: "TokenExchanger", Name: "sanitizedError"}, + {Pkg: "broker", Name: "credentialFromResponse"}, + }}, + {File: "internal/serveredition/multiuser/router.go", FR: "FR-031", Decls: []removedDecl{ + {Pkg: "multiuser", Name: "ServerOwnership"}, + {Pkg: "multiuser", Name: "OwnershipShared"}, + {Pkg: "multiuser", Name: "OwnershipPersonal"}, + {Pkg: "multiuser", Name: "ServerInfo"}, + {Pkg: "multiuser", Name: "Router"}, + {Pkg: "multiuser", Name: "NewRouter"}, + {Pkg: "multiuser", Recv: "Router", Name: "GetUserServers"}, + {Pkg: "multiuser", Recv: "Router", Name: "GetServerForUser"}, + {Pkg: "multiuser", Recv: "Router", Name: "BrokeredConnectionKey"}, + {Pkg: "multiuser", Name: "nonUserPoolPrefix"}, + {Pkg: "multiuser", Name: "brokerPoolIdentity"}, + {Pkg: "multiuser", Recv: "Router", Name: "IsServerAccessible"}, + {Pkg: "multiuser", Recv: "Router", Name: "UpdateSharedServers"}, + {Pkg: "multiuser", Recv: "Router", Name: "GetSharedServerNames"}, + {Pkg: "multiuser", Recv: "Router", Name: "isSharedServer"}, + }}, + {File: "internal/serveredition/multiuser/tool_filter.go", FR: "FR-031", Decls: []removedDecl{ + {Pkg: "multiuser", Name: "ToolInfo"}, + {Pkg: "multiuser", Name: "ToolFilter"}, + {Pkg: "multiuser", Name: "NewToolFilter"}, + {Pkg: "multiuser", Recv: "ToolFilter", Name: "FilterToolsByUser"}, + {Pkg: "multiuser", Recv: "ToolFilter", Name: "GetAccessibleServerNames"}, + {Pkg: "multiuser", Recv: "ToolFilter", Name: "IsToolAccessible"}, + }}, + {File: "internal/serveredition/workspace/manager.go", FR: "FR-031", Decls: []removedDecl{ + {Pkg: "workspace", Name: "Manager"}, + {Pkg: "workspace", Name: "NewManager"}, + {Pkg: "workspace", Recv: "Manager", Name: "GetOrCreateWorkspace"}, + {Pkg: "workspace", Recv: "Manager", Name: "GetWorkspace"}, + {Pkg: "workspace", Recv: "Manager", Name: "RemoveWorkspace"}, + {Pkg: "workspace", Recv: "Manager", Name: "ActiveWorkspaceCount"}, + {Pkg: "workspace", Recv: "Manager", Name: "StartCleanup"}, + {Pkg: "workspace", Recv: "Manager", Name: "Stop"}, + {Pkg: "workspace", Recv: "Manager", Name: "cleanupIdle"}, + }}, + {File: "internal/serveredition/workspace/workspace.go", FR: "FR-031", Decls: []removedDecl{ + {Pkg: "workspace", Name: "UserWorkspace"}, + {Pkg: "workspace", Name: "NewUserWorkspace"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "LoadServers"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "GetServers"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "GetServer"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "AddServer"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "RemoveServer"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "UpdateServer"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "ServerNames"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "Touch"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "LastAccess"}, + {Pkg: "workspace", Recv: "UserWorkspace", Name: "Shutdown"}, + }}, + {File: "internal/transport/broker_auth.go", FR: "FR-031", Decls: []removedDecl{ + {Pkg: "transport", Name: "BrokeredAuth"}, + {Pkg: "transport", Name: "tokenPlaceholder"}, + {Pkg: "transport", Recv: "BrokeredAuth", Name: "HeaderValue"}, + {Pkg: "transport", Name: "EffectiveHeaders"}, + }}, +} + +// removedPackages are whole Go packages FR-031 deletes. Any non-test file in +// the walked set that declares one of these package names is a violation on +// its own, whatever it contains. +var removedPackages = map[string]string{ + "workspace": "package internal/serveredition/workspace deleted (FR-031)", +} + +// allRemovedDecls is the curated table plus every declaration of every +// deleted file, flattened once for the walk. +func allRemovedDecls() []removedDecl { + out := append([]removedDecl(nil), removedDecls...) + for _, rf := range removedFiles { + for _, d := range rf.Decls { + d.Why = fmt.Sprintf("declared in %s at origin/main b39800a89, deleted by %s", rf.File, rf.FR) + out = append(out, d) + } + } + return out +} + // removedAuthBrokerModes are the never-implemented modes FR-032 removes from // the validator's accepted set. They are only forbidden INSIDE the validator // functions below; the server-build normaliser (T018) legitimately carries @@ -316,20 +477,27 @@ func TestLatentSymbolsGuard_RemovedDeclarationsAbsent(t *testing.T) { // was actually found and inspected; a renamed validator must fail the // guard, never pass it vacuously. seenValidators := map[string]bool{} + removed := allRemovedDecls() for _, path := range files { f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) if err != nil { t.Fatalf("parse %s: %v", path, err) } + if why, gone := removedPackages[f.Name.Name]; gone { + violations = append(violations, fmt.Sprintf("file %s declares package %s β€” %s", latentRelPath(root, path), f.Name.Name, why)) + } decls, funcs := latentCollectDecls(fset, f) for _, d := range decls { if _, exempt := exemptDeclNames[d.Name]; exempt { continue } - for _, r := range removedDecls { + // One violation per declaration: the curated table and the + // per-file dump overlap on the headline symbols by design. + for _, r := range removed { if r.matches(d) { violations = append(violations, fmt.Sprintf("%s β€” %s", latentRel(root, d), r.Why)) + break } } } @@ -409,7 +577,7 @@ func TestLatentSymbolsGuard_CompatibilityDeclarationsRetained(t *testing.T) { if d.Kind == "field" && d.Recv == "ServerEditionConfig" && d.Name == "StoreIDPTokens" { found = true } - for _, r := range removedDecls { + for _, r := range allRemovedDecls() { if r.Name == d.Name { if _, exempt := exemptDeclNames[d.Name]; exempt { t.Errorf("exempt declaration %s is also in the removed table; fix the table", d.Name) @@ -440,3 +608,28 @@ func latentFuncLabel(vf removedDecl) string { } return vf.Name } + +// TestLatentSymbolsGuard_DeletedFilesAbsent pins the file-level half of the +// cut: every source file the removedFiles table was dumped from is gone, and +// the deleted workspace package directory with it. The declaration walk above +// catches a fragment restored under a new name; this catches the whole file +// coming back verbatim, before anyone reads the longer report. +func TestLatentSymbolsGuard_DeletedFilesAbsent(t *testing.T) { + root := latentGuardRepoRoot(t) + seen := map[string]bool{} + for _, rf := range removedFiles { + if seen[rf.File] { + t.Errorf("removedFiles lists %s twice; fix the table", rf.File) + } + seen[rf.File] = true + if len(rf.Decls) == 0 { + t.Errorf("removedFiles entry %s has no declarations; the dump is incomplete", rf.File) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rf.File))); err == nil { + t.Errorf("%s exists again; FR-031/FR-033 delete it (%s)", rf.File, rf.FR) + } + } + if _, err := os.Stat(filepath.Join(root, "internal", "serveredition", "workspace")); err == nil { + t.Errorf("internal/serveredition/workspace exists again; FR-031 deletes the package") + } +} diff --git a/internal/config/legacy_keys_load_test.go b/internal/config/legacy_keys_load_test.go index 0ffed135a..b182e3da6 100644 --- a/internal/config/legacy_keys_load_test.go +++ b/internal/config/legacy_keys_load_test.go @@ -21,6 +21,7 @@ package config_test // boot door and the raw-document check itself. import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -202,6 +203,35 @@ func TestLegacyKeys_CleanConfigRecordsNoDiagnostics(t *testing.T) { assert.Empty(t, cfg.LoadDiagnostics()) } +// Logger-less emit helper for the CLI door (codex round 2 on PR-A): the +// subcommands that load through cmd/mcpproxy loadCLIConfig and then +// SaveConfig would otherwise erase the dropped keys silently, so +// config.WriteLoadDiagnostics prints one `warning:` line per diagnostic, +// carrying the contract message and the key, and nothing at all for a nil +// config, a nil writer or a clean load. +func TestLegacyKeys_WriteLoadDiagnosticsPrintsOneWarningPerDiagnostic(t *testing.T) { + cfg, _ := loadLegacyFixture(t) + diags := cfg.LoadDiagnostics() + require.NotEmpty(t, diags) + + var buf bytes.Buffer + config.WriteLoadDiagnostics(cfg, &buf) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.Len(t, lines, len(diags), "one line per diagnostic; got:\n%s", buf.String()) + for i, d := range diags { + assert.Equal(t, "warning: "+d.Message+" (key: "+d.Key+")", lines[i]) + } + + buf.Reset() + config.WriteLoadDiagnostics(nil, &buf) + config.WriteLoadDiagnostics(cfg, nil) + assert.Empty(t, buf.String(), "nil config / nil writer write nothing") + + clean := config.DefaultConfig() + config.WriteLoadDiagnostics(clean, &buf) + assert.Empty(t, buf.String(), "a config without diagnostics writes nothing") +} + // Emit helper: the loader has no logger (loadConfigFile returns only an error // and main.go builds the logger after the load), so the recorded diagnostics // are emitted once by config.LogLoadDiagnostics β€” one WARN line each. diff --git a/internal/config/load_diagnostics.go b/internal/config/load_diagnostics.go index 809825a54..73111941c 100644 --- a/internal/config/load_diagnostics.go +++ b/internal/config/load_diagnostics.go @@ -1,6 +1,11 @@ package config -import "go.uber.org/zap" +import ( + "fmt" + "io" + + "go.uber.org/zap" +) // LoadDiagnostic is one non-fatal finding recorded while a configuration file // was loaded: a key the server edition no longer supports and dropped, or a @@ -42,3 +47,20 @@ func LogLoadDiagnostics(cfg *Config, logger *zap.Logger) { logger.Warn(d.Message, zap.String("key", d.Key)) } } + +// WriteLoadDiagnostics prints one `warning: (key: )` line per +// recorded diagnostic to w. It is the logger-less twin of LogLoadDiagnostics +// for the CLI subcommands, which have no zap logger but do load the file and +// β€” for `upstream add|remove` in standalone mode, `telemetry enable|disable` +// and the API-key reset β€” write it back, so a removed key would otherwise be +// erased with no operator-visible warning (FR-032). Callers pass stderr so +// `-o json` consumers of stdout are never disturbed. A nil config, a nil +// writer or no diagnostics writes nothing. +func WriteLoadDiagnostics(cfg *Config, w io.Writer) { + if cfg == nil || w == nil { + return + } + for _, d := range cfg.loadDiagnostics { + fmt.Fprintf(w, "warning: %s (key: %s)\n", d.Message, d.Key) + } +} diff --git a/internal/serveredition/broker/bbolt_aes.go b/internal/serveredition/broker/bbolt_aes.go index 1ca7f1582..5c498917b 100644 --- a/internal/serveredition/broker/bbolt_aes.go +++ b/internal/serveredition/broker/bbolt_aes.go @@ -238,8 +238,21 @@ func (s *BBoltAESStore) List(userID string) ([]CredentialEntry, error) { // colon (user IDs are ULIDs; the server key is appended with ":") and are // never touched. Returns the number of rows removed; idempotent. func (s *BBoltAESStore) PurgeLegacyIDPSubjectTokens() (int, error) { + return PurgeLegacyIDPSubjectTokens(s.db) +} + +// PurgeLegacyIDPSubjectTokens is the store-less form of the sweep above. It +// takes the raw database so setup can run it BEFORE NewBBoltAESStore validates +// the encryption key: a key that is set but malformed fails store construction +// and with it the whole server-feature setup, which is logged and survived β€” +// the sweep must not be lost behind that failure (codex round 2 on PR-A). A +// nil db is a no-op. +func PurgeLegacyIDPSubjectTokens(db *bbolt.DB) (int, error) { + if db == nil { + return 0, nil + } var legacy [][]byte - if err := s.db.View(func(tx *bbolt.Tx) error { + if err := db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(credentialBucket)) if b == nil { return nil @@ -256,7 +269,7 @@ func (s *BBoltAESStore) PurgeLegacyIDPSubjectTokens() (int, error) { if len(legacy) == 0 { return 0, nil } - if err := s.db.Update(func(tx *bbolt.Tx) error { + if err := db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(credentialBucket)) if b == nil { return nil diff --git a/internal/serveredition/setup.go b/internal/serveredition/setup.go index 481df2759..55a0ac766 100644 --- a/internal/serveredition/setup.go +++ b/internal/serveredition/setup.go @@ -29,6 +29,22 @@ func setupMultiUserOAuth(deps Dependencies) error { return nil } + // Spec 107 FR-033 residual: an earlier release with `store_idp_tokens: true` + // persisted each user's IdP access + offline refresh token in the + // credential bucket under the bare userID. The writer is gone and nothing + // reads, lists or deletes those rows through any door, so sweep them here + // β€” by key, no decryption, so it needs no encryption key β€” and BEFORE any + // fallible step: config validation, bucket creation, the HMAC key and + // credential-store construction (a key that is set but malformed) can all + // fail this setup, which the caller only logs, and the sweep must not be + // lost behind such a failure. Hygiene only: a failed sweep is logged and + // never keeps the server from coming up. + if purged, perr := broker.PurgeLegacyIDPSubjectTokens(deps.DB); perr != nil { + deps.Logger.Warnw("failed to purge legacy IdP subject-token rows from the credential store", "error", perr) + } else if purged > 0 { + deps.Logger.Infow("purged legacy IdP subject-token rows left by store_idp_tokens (removed in Spec 107)", "rows", purged) + } + // deps.Config is the runtime's live/desired *config.Config β€” the pointer // PATCH /api/v1/config marshals as its merge base and the next write-back // persists. The derived values ApplyDefaults fills below (the MCPPROXY_CRED_KEY @@ -137,18 +153,6 @@ func setupMultiUserOAuth(deps Dependencies) error { return fmt.Errorf("creating credential store: %w", err) } - // Spec 107 FR-033 residual: an earlier release with `store_idp_tokens: true` - // persisted each user's IdP access + offline refresh token in this bucket - // under the bare userID. The writer is gone and nothing reads, lists or - // deletes those rows through any door, so sweep them here β€” by key, no - // decryption, so it works with the store disabled too. Hygiene only: a - // failed sweep is logged and never keeps the server from coming up. - if purged, perr := credStore.PurgeLegacyIDPSubjectTokens(); perr != nil { - deps.Logger.Warnw("failed to purge legacy IdP subject-token rows from the credential store", "error", perr) - } else if purged > 0 { - deps.Logger.Infow("purged legacy IdP subject-token rows left by store_idp_tokens (removed in Spec 107)", "rows", purged) - } - // The LIVE view of the server-edition block, read through the same provider // the admin-servers check uses rather than a second mechanism. // diff --git a/internal/serveredition/setup_legacy_idp_purge_test.go b/internal/serveredition/setup_legacy_idp_purge_test.go index 93ee9b60d..094578d27 100644 --- a/internal/serveredition/setup_legacy_idp_purge_test.go +++ b/internal/serveredition/setup_legacy_idp_purge_test.go @@ -93,3 +93,49 @@ func TestSetupMultiUserOAuth_SweepsLegacyIDPSubjectTokensAtBoot(t *testing.T) { assert.False(t, keys[legacyUser], "the legacy IdP subject-token row must be gone after boot: %v", keys) assert.True(t, keys[legacyUser+":github_0123456789abcdef"], "upstream credentials must survive the sweep: %v", keys) } + +// TestSetupMultiUserOAuth_SweepsLegacyRowsEvenWhenTheKeyIsInvalid: the +// release notice promises the sweep on the first start after upgrading +// "whether or not MCPPROXY_CRED_KEY is still set". A key that is SET but +// malformed makes NewBBoltAESStore fail and setupMultiUserOAuth return, and +// SetupAll only logs that error β€” the server comes up without SSO but WITH +// the legacy IdP tokens still at rest. The sweep matches rows by key and +// needs no cipher, so it must run before the store is constructed (codex +// round 2 on PR-A). +// +// BITES: move the purge back behind NewBBoltAESStore. +func TestSetupMultiUserOAuth_SweepsLegacyRowsEvenWhenTheKeyIsInvalid(t *testing.T) { + t.Setenv("MCPPROXY_CRED_KEY", "not-base64-and-not-32-bytes!!") + + tmpDir := t.TempDir() + db, err := bbolt.Open(tmpDir+"/test.db", 0600, &bbolt.Options{Timeout: time.Second}) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + const legacyUser = "01HTEST0000000000000USERB" + seedCredentialRows(t, db, legacyUser, legacyUser+":github_0123456789abcdef") + + err = setupMultiUserOAuth(Dependencies{ + Router: chi.NewRouter(), + DB: db, + Logger: zap.NewNop().Sugar(), + DataDir: tmpDir, + Config: &config.Config{ + ServerEdition: &config.ServerEditionConfig{ + Enabled: true, + AdminEmails: []string{"admin@example.com"}, + OAuth: &config.ServerEditionOAuthConfig{ + Provider: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }, + }, + }, + }) + require.Error(t, err, "an invalid key is still a loud misconfiguration") + assert.Contains(t, err.Error(), "credential store") + + keys := credentialRowKeys(t, db) + assert.False(t, keys[legacyUser], "the legacy row must be swept before the key is validated: %v", keys) + assert.True(t, keys[legacyUser+":github_0123456789abcdef"], "upstream credentials must survive: %v", keys) +} diff --git a/specs/107-server-edition-sso-hardening/verification.md b/specs/107-server-edition-sso-hardening/verification.md index fa6d1daaa..65a5a4f1a 100644 --- a/specs/107-server-edition-sso-hardening/verification.md +++ b/specs/107-server-edition-sso-hardening/verification.md @@ -46,6 +46,10 @@ Merge-readiness caveat (found while fetching `origin/main` for the gofmt row): ` - **Fixed** β€” (a) `internal/config/latent_symbols_guard_test.go` (c1+c3): the FR-035 guard named only T008's headline symbols; FR-031 also deletes "CredentialResolver with its interfaces/errors" and "every brokered branch" β€” added `broker.{Exchanger,Connector,NotConnectedError,PolicyHook,PolicyHookFunc,ErrUnauthenticated,ErrNoCredential,ErrBrokerNotConfigured}`, `(*OAuthHandler).credStore`, `(*HTTPTransportConfig).effectiveHeaders`, `(*Client).{canUseHeadersStrategy,brokeredHTTPConfig}`, and the guard now fails when a named validator function is not found (the mode-literal scan passed vacuously if `Validate`/`validateServerAuthBroker` moved). Proven to bite by dropping origin/main's `credential_resolver.go` + `transport/http.go` into the tree (8 violations), then removed. (b) `internal/config/loader_serveredition.go` (c2): the normaliser's single `Decoder.Decode` accepted a document with a second object / trailing garbage after the first value whenever it re-encoded (a removed key present), while the untouched path's strict `json.Unmarshal` refuses it β€” now `dec.Token()` must return `io.EOF`; red test `TestLegacyKeys_NormaliserRejectsTrailingContentLikeTheStrictPath` (three sub-cases, the clean sibling proves parity). (c) `internal/config/{server_edition_config,auth_broker}_stub.go` (c2, the one behavioural defect): the personal carriers stored the FILE bytes verbatim, PATCH re-emits the block from a lexically-sorted map, and `DetectConfigChanges` compares `mcpServers` with `jsonEqual` (bytes of `json.Marshal`) β€” so the first unrelated PATCH after boot on a file whose `auth_broker` keys were not lexical (every file the server edition ever wrote: struct order `mode, token_endpoint, …`) reported `mcpServers` changed and `runtime.go:1850` scheduled a full `LoadConfiguredServers`, reconnecting every upstream (the F16 false-positive class). Carriers now store the canonical form (`canonicalRawJSON`: sorted keys, compact, numbers as `json.Number` text β€” every key and value preserved, which is the FR-040 contract; the structural comparator of T010 is unaffected). Red tests: `TestPersonalCarriers_CanonicalFormIsKeyOrderAndWhitespaceIndependent` and the runtime-level `internal/runtime/config_hotreload_personal_carrier_test.go` (`!server`), which reproduced `ChangedFields: ["mcpServers","tools_limit"]` on HEAD. (d) `.github/RELEASE_NOTICE.md:54` + `docs/features/agent-tokens.md:329` (c3): "one user filling their quota never blocks another" overstated #1286's design β€” every stored token still counts toward the 100-record deployment cap; reworded. (e) `cmd/release-gate/docs_claims_test.go` (c3): added the three FR-036 phrases T013 left implicit (`acquire/refresh/inject`, `per-user token brokering`, `JWT bearer (MCP/API)`); proven to bite by appending them to `activity-log.md` (3 hits), then restored. (f) `docs/development/server-edition-multiuser-auth.md:268` (c3, P3): stale "CI lint doesn't pass `--build-tags server`" note corrected to the FR-047 workflow. - **Rejected** β€” (1) c1 `setup.go:211` "`AdminHandlers` gets a frozen `cfg.AdminEmails` slice, contradicting live `admin_emails`": identical on origin/main (`cfg` was the boot pointer and a reload publishes a NEW `Config`, so the slice was already frozen at boot); the live read is the middleware's `ServerEditionConfigProvider` (#1169) and the `server_edition` live/`DetectConfigChanges` work is PR-B per `contracts/config-keys.md` β€” carried forward as a PR-B note (dashboard role label). (2) c2 `*_stub.go:13` "`"server_edition": null` becomes an absent key": T020 documents exactly this ("a JSON `null` also decodes to a nil pointer"; omission is the parent pointer's job) and `null`/absent are semantically identical for every consumer in both editions; pinned by `TestPersonalCarriers_EmptyAndNull`. (3) c2 `removed_keys.go:85` "a retired-mode block that also carries `header` emits three diagnostics, not one": every message is true (the leaves were ignored too), SC-005's one-warning-per-key holds, and `legacy_keys_load_test.go:357` deliberately pins the write door reporting both so the operator fixes the block in one edit β€” P3 design choice, not wrong behaviour. - Re-run after the fixes: both builds; `go vet` (both tags) on the touched packages; `go test -race` `./internal/config/ ./internal/httpapi/` under both tags; `go test -race -skip ./internal/runtime/...` (both tags) and `./internal/serveredition/...` (`-tags server`); `go test ./cmd/release-gate/`; golangci-lint v2.9.0 bare + `--build-tags server` on the touched packages; `gofmt -l` on the touched files. Round 2 (re-review of the fixes) follows. +- **Round 2** (2026-09-16, codex `gpt-5.6-sol`, `model_reasoning_effort=high`, `--sandbox read-only`, the same three briefs re-issued on HEAD `36e0517f3` with a round-2 preamble naming the six round-1 fixes to re-verify and the three rejections with their reasons; verdict files `scratchpad/pra-review-r2-c{1,2,3}.md`). Verdicts: c1 FINDINGS (1Γ—P2), c2 FINDINGS (1Γ—P2), c3 FINDINGS (3Γ—P2) β€” 5 findings, 5 distinct, none a repeat of a round-1 rejection; **4 genuine, 1 rejected**; no P1. + - **Fixed** β€” (a) `internal/config/latent_symbols_guard_test.go` (c1): FR-035 says "every Go symbol FR-031 and FR-033 delete … checked by name over the non-test AST", but the guard still named a curated subset (`workspace.Manager` stood in for the whole package; `UserWorkspace`/`NewUserWorkspace`, `PolicyDeniedError`, `ErrBrokerStdioUnsupported`, the constructors and every method were unlisted). The table is now the mechanical AST dump of all nine deleted non-test files at origin/main b39800a89 (`removedFiles`, 104 declarations scoped by package and receiver; the one exclusion, `tokenErrorResponse`, legitimately lives on in `oauth_connector.go`), plus a package-level rule (`removedPackages`: any walked file declaring `package workspace`) and a new `TestLatentSymbolsGuard_DeletedFilesAbsent` (each dumped file and the workspace directory are gone). Proven to bite by restoring `workspace.go` alone under a new name and dropping `PolicyDeniedError` + `ErrBrokerStdioUnsupported` into a fresh broker file: 16 violations + the directory check; probes removed. (b) `cmd/mcpproxy/cli_config.go` + `internal/config/load_diagnostics.go` (c2, the behavioural one): every CLI door that writes the config back β€” `upstream add|remove` in standalone mode, `telemetry enable|disable`, the API-key reset β€” loads through `loadCLIConfig`, where the server build normalises (drops) the removed keys and then `SaveConfig` erased them with **no** warning (T018 wired `LogLoadDiagnostics` only into `serve` and the hot reload; FR-032 requires one warning per key on load). New logger-less twin `config.WriteLoadDiagnostics(cfg, w)` prints `warning: (key: )` per diagnostic; `loadCLIConfig` writes it to stderr (`cliDiagnosticsWriter`, so `-o json` stdout stays clean). Red tests: `cmd/mcpproxy/cli_config_diagnostics_test.go` (`-tags server`: five lines for the SC-005 fixture, nothing for a clean file), `cli_config_diagnostics_personal_test.go` (`!server`: nothing β€” opaque carriers), and `TestLegacyKeys_WriteLoadDiagnosticsPrintsOneWarningPerDiagnostic` in `internal/config`. The direct `LoadFromFile` doors (`auth`, `call`, `code`, `tools`, `registry`, `secrets`) are read-only and untouched. (c) `internal/serveredition/setup.go` + `broker/bbolt_aes.go` + `.github/RELEASE_NOTICE.md:38` + `docs/features/idp-token-storage.md` (c3): the notice promised the legacy IdP-token sweep "whether or not `MCPPROXY_CRED_KEY` is still set", but a key that is set and **malformed** failed `NewBBoltAESStore` before the sweep, `setupMultiUserOAuth` returned, `SetupAll`'s error is only logged, and the server came up with the rows still at rest. The sweep is now the package-level `broker.PurgeLegacyIDPSubjectTokens(db)` (the method delegates to it) and runs first in the enabled path, before every fallible step (validation, `EnsureBuckets`, HMAC key, store construction); red test `TestSetupMultiUserOAuth_SweepsLegacyRowsEvenWhenTheKeyIsInvalid`. Notice and tombstone now say "first start with `server_edition.enabled: true`" and "set, unset or even invalid". (d) `docs/features/auth-broker.md:73` (c3): the precedence sentence was reversed β€” `broker.ResolveMasterKey` prefers `MCPPROXY_CRED_KEY` over `credential_encryption_key`, not the other way round; reworded ("set exactly one"). + - **Rejected** β€” (1) c3 `docs/development/server-edition-multiuser-auth.md:49` "the route table must show `POST /user/tokens` and `/user/tokens/{name}/regenerate` as session-cookie-only": that rule is FR-011, delivered by **PR-C** per plan.md Β§Delivery, and on HEAD `createUserToken` still accepts a bearer JWT (`user_handlers.go:1071ff`), so a "Session" row now would document behaviour that does not exist yet β€” the honesty PR describes HEAD; T023's slice of that file (`:27-28,40,53,54-55,206-210,242-243`) does not include the table rows. Carried forward as a PR-C doc item. + - Re-run after the fixes: both builds; `go vet` (both tags) on `internal/config`, `cmd/mcpproxy`, `internal/serveredition/...`; `go test -race -tags server ./internal/serveredition/... ./internal/config/... ./cmd/release-gate/` (8 packages ok); `go test -race ./internal/config/... ./cmd/mcpproxy/` (personal) and `go test -race -tags server ./cmd/mcpproxy/` β€” ok; `go test -race -run 'PersonalCarrier|HotReload' ./internal/runtime/` ok; golangci-lint v2.9.0 bare + `--build-tags server` on the three package trees β€” `0 issues` both; `gofmt -l` on every touched/added file empty. Round 3 (re-review of the fixes) follows. ## PR-B β€” generic OIDC + front door + telemetry From 03ef19def99705a6dea78236d9e6bbd1d046b22a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 14:55:14 +0300 Subject: [PATCH 08/35] fix(spec-107): codex round 3 for PR-A --- .github/RELEASE_NOTICE.md | 2 +- docs/cli/credential-commands.md | 5 ++- docs/features/agent-tokens.md | 10 +++-- internal/config/loader.go | 12 ++++- internal/config/personal_roundtrip_test.go | 44 +++++++++++++++++++ .../verification.md | 4 ++ 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md index 83767f854..9a848b629 100644 --- a/.github/RELEASE_NOTICE.md +++ b/.github/RELEASE_NOTICE.md @@ -51,6 +51,6 @@ The `oauth_connect` connect flow, its REST routes, the `mcpproxy credential` com The server edition now enforces a **25-token quota per signed-in user** on top of the existing 100-record deployment cap ([#1177](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1177)). Revoked tokens keep their slot until they are permanently deleted. -- Server edition: a user at 25 tokens gets a `409 Conflict` from `POST /user/tokens` that names *their* quota β€” permanently deleting one of their unused tokens frees a slot β€” so one user can no longer take the whole pool. The 100-record deployment cap remains and every stored token still counts toward it, so a deployment whose stored records add up to 100 refuses the next token for everyone until an administrator frees records; that `409` still says the limit is shared and points at an administrator. A user who already holds more than 25 tokens keeps them; they cannot create another until they are back under the quota. +- Server edition: a user at 25 tokens gets a `409 Conflict` from `POST /user/tokens` that names *their* quota β€” permanently deleting one of their unused tokens frees a slot β€” so one user can no longer take the whole pool. The 100-record deployment cap remains and every stored token still counts toward it, so a deployment whose stored records add up to 100 refuses the next token for everyone until an administrator frees records; a caller who is still under their own quota then gets the `409` that says the limit is shared and points at an administrator (the quota is checked first, so a user already at 25 sees their own-quota message instead). A user who already holds more than 25 tokens keeps them; they cannot create another until they are back under the quota. - Personal edition: every token is ownerless, so the quota does not apply and the 100-token limit is unchanged. - No configuration change is needed. Details: [agent tokens](https://docs.mcpproxy.app/features/agent-tokens/). diff --git a/docs/cli/credential-commands.md b/docs/cli/credential-commands.md index 08c19d8b2..81ab3480e 100644 --- a/docs/cli/credential-commands.md +++ b/docs/cli/credential-commands.md @@ -102,7 +102,10 @@ mcpproxy credential rm github All read commands honor the global `-o table|json|yaml` flag and the `MCPPROXY_OUTPUT` environment variable (table is the default). With `json` or `yaml` the "stored, not injected" statement goes to **stderr** so stdout stays -machine-parseable; the payload itself is the REST response unchanged. +machine-parseable. The payload is the REST data with the envelope removed: +`credential list` emits the bare array from the REST `credentials` field and +`credential status ` emits that server's single object; every field +keeps its REST name and meaning, and no secret values are ever included. ## Related REST endpoints (spec 074 T8) diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 7d03b85ce..0a4e4f448 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -330,12 +330,14 @@ creating another token answers `409 Conflict`: your limit; permanently delete one of your unused tokens to free a slot. The quota keeps one user from taking the whole pool, but every stored token still counts toward the deployment limit below, so a deployment whose records add - up to 100 refuses the next token for everyone. + up to 100 refuses the next token for everyone. The quota is checked first: a + user already at 25 always sees this message, whatever the deployment total. - **The deployment limit (100 stored records).** In the personal edition every token belongs to the one operator, so this is the only limit and deleting one - of your tokens frees a slot. In the server edition the message says the limit - is shared and points at an administrator, because deleting your own tokens - may not free a slot that other users' records are filling. + of your tokens frees a slot. In the server edition a caller who is still under + their own quota gets this message; it says the limit is shared and points at + an administrator, because deleting your own tokens may not free a slot that + other users' records are filling. ### List All Tokens diff --git a/internal/config/loader.go b/internal/config/loader.go index a6a0d322f..446e3cef2 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "context" "crypto/rand" "encoding/hex" @@ -264,9 +265,16 @@ func loadConfigFile(path string, cfg *Config) error { cfg.loadDiagnostics = diagnostics // First check if api_key is present in the JSON to distinguish between - // "not set" vs "explicitly set to empty" + // "not set" vs "explicitly set to empty". Decoded with UseNumber: the + // legacy "teams" alias below is re-marshaled FROM this map into the + // server-edition block, and in the personal build that block is an opaque + // carrier whose numbers must keep their decimal text (Spec 107 FR-040) β€” + // a float64 detour would round 2^53+1 or a long decimal before the + // carrier ever saw it. Only key presence is read from the map otherwise. var rawConfig map[string]interface{} - if err := json.Unmarshal(data, &rawConfig); err != nil { + rawDec := json.NewDecoder(bytes.NewReader(data)) + rawDec.UseNumber() + if err := rawDec.Decode(&rawConfig); err != nil { return fmt.Errorf("failed to parse config file for api_key detection: %w", err) } diff --git a/internal/config/personal_roundtrip_test.go b/internal/config/personal_roundtrip_test.go index 30fef8527..9ff23dc12 100644 --- a/internal/config/personal_roundtrip_test.go +++ b/internal/config/personal_roundtrip_test.go @@ -270,3 +270,47 @@ func captureStderr(t *testing.T, into *bytes.Buffer, fn func()) { t.Logf("stderr during load/save:\n%s", strings.TrimSpace(s)) } } + +// TestPersonalBuild_LegacyTeamsAliasKeepsNumberText: the legacy `teams` key +// (MCP-1086 alias of `server_edition`) reaches the personal carrier through +// the loader's alias step, which re-marshals the block from the generic +// api_key-detection map. That map must be decoded with UseNumber, or a +// 2^53+1 integer / a long decimal inside an old `teams` block is rounded +// through float64 BEFORE the carrier ever sees it, and the next write-back +// persists the rounded value under `server_edition` β€” an FR-040 violation on +// the one input shape the alias exists for (codex round 3 on PR-A). +// +// BITES: decode rawConfig in loadConfigFile with plain json.Unmarshal. +func TestPersonalBuild_LegacyTeamsAliasKeepsNumberText(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "teams.json") + doc := fmt.Sprintf(`{ + "listen": "127.0.0.1:0", + "data_dir": %q, + "teams": { + "enabled": true, + "admin_emails": ["legacy@example.com"], + "oauth": {"provider": "github", "client_id": "Iv1.abc", "client_secret": "ghp_x"}, + %q: %s, + %q: %s + } +}`, filepath.ToSlash(dir), probeIntKey, probeInt, probeDecKey, probeDec) + require.NoError(t, os.WriteFile(src, []byte(doc), 0o600)) + + cfg, err := LoadFromFile(src) + require.NoError(t, err) + require.NotNil(t, cfg.ServerEdition, "legacy teams block must land on the carrier") + + saved := filepath.Join(dir, "saved.json") + require.NoError(t, SaveConfig(cfg, saved)) + out, err := os.ReadFile(saved) + require.NoError(t, err) + + want := decodeUseNumber(t, []byte(doc))["teams"] + got := decodeUseNumber(t, out) + _, stillLegacy := got["teams"] + require.False(t, stillLegacy, "write-back must emit the block under server_edition, not teams") + if diff := structuralDiff("server_edition", want, got["server_edition"]); diff != "" { + t.Errorf("legacy teams block changed on personal write-back: %s", diff) + } +} diff --git a/specs/107-server-edition-sso-hardening/verification.md b/specs/107-server-edition-sso-hardening/verification.md index 65a5a4f1a..edfad9829 100644 --- a/specs/107-server-edition-sso-hardening/verification.md +++ b/specs/107-server-edition-sso-hardening/verification.md @@ -50,6 +50,10 @@ Merge-readiness caveat (found while fetching `origin/main` for the gofmt row): ` - **Fixed** β€” (a) `internal/config/latent_symbols_guard_test.go` (c1): FR-035 says "every Go symbol FR-031 and FR-033 delete … checked by name over the non-test AST", but the guard still named a curated subset (`workspace.Manager` stood in for the whole package; `UserWorkspace`/`NewUserWorkspace`, `PolicyDeniedError`, `ErrBrokerStdioUnsupported`, the constructors and every method were unlisted). The table is now the mechanical AST dump of all nine deleted non-test files at origin/main b39800a89 (`removedFiles`, 104 declarations scoped by package and receiver; the one exclusion, `tokenErrorResponse`, legitimately lives on in `oauth_connector.go`), plus a package-level rule (`removedPackages`: any walked file declaring `package workspace`) and a new `TestLatentSymbolsGuard_DeletedFilesAbsent` (each dumped file and the workspace directory are gone). Proven to bite by restoring `workspace.go` alone under a new name and dropping `PolicyDeniedError` + `ErrBrokerStdioUnsupported` into a fresh broker file: 16 violations + the directory check; probes removed. (b) `cmd/mcpproxy/cli_config.go` + `internal/config/load_diagnostics.go` (c2, the behavioural one): every CLI door that writes the config back β€” `upstream add|remove` in standalone mode, `telemetry enable|disable`, the API-key reset β€” loads through `loadCLIConfig`, where the server build normalises (drops) the removed keys and then `SaveConfig` erased them with **no** warning (T018 wired `LogLoadDiagnostics` only into `serve` and the hot reload; FR-032 requires one warning per key on load). New logger-less twin `config.WriteLoadDiagnostics(cfg, w)` prints `warning: (key: )` per diagnostic; `loadCLIConfig` writes it to stderr (`cliDiagnosticsWriter`, so `-o json` stdout stays clean). Red tests: `cmd/mcpproxy/cli_config_diagnostics_test.go` (`-tags server`: five lines for the SC-005 fixture, nothing for a clean file), `cli_config_diagnostics_personal_test.go` (`!server`: nothing β€” opaque carriers), and `TestLegacyKeys_WriteLoadDiagnosticsPrintsOneWarningPerDiagnostic` in `internal/config`. The direct `LoadFromFile` doors (`auth`, `call`, `code`, `tools`, `registry`, `secrets`) are read-only and untouched. (c) `internal/serveredition/setup.go` + `broker/bbolt_aes.go` + `.github/RELEASE_NOTICE.md:38` + `docs/features/idp-token-storage.md` (c3): the notice promised the legacy IdP-token sweep "whether or not `MCPPROXY_CRED_KEY` is still set", but a key that is set and **malformed** failed `NewBBoltAESStore` before the sweep, `setupMultiUserOAuth` returned, `SetupAll`'s error is only logged, and the server came up with the rows still at rest. The sweep is now the package-level `broker.PurgeLegacyIDPSubjectTokens(db)` (the method delegates to it) and runs first in the enabled path, before every fallible step (validation, `EnsureBuckets`, HMAC key, store construction); red test `TestSetupMultiUserOAuth_SweepsLegacyRowsEvenWhenTheKeyIsInvalid`. Notice and tombstone now say "first start with `server_edition.enabled: true`" and "set, unset or even invalid". (d) `docs/features/auth-broker.md:73` (c3): the precedence sentence was reversed β€” `broker.ResolveMasterKey` prefers `MCPPROXY_CRED_KEY` over `credential_encryption_key`, not the other way round; reworded ("set exactly one"). - **Rejected** β€” (1) c3 `docs/development/server-edition-multiuser-auth.md:49` "the route table must show `POST /user/tokens` and `/user/tokens/{name}/regenerate` as session-cookie-only": that rule is FR-011, delivered by **PR-C** per plan.md Β§Delivery, and on HEAD `createUserToken` still accepts a bearer JWT (`user_handlers.go:1071ff`), so a "Session" row now would document behaviour that does not exist yet β€” the honesty PR describes HEAD; T023's slice of that file (`:27-28,40,53,54-55,206-210,242-243`) does not include the table rows. Carried forward as a PR-C doc item. - Re-run after the fixes: both builds; `go vet` (both tags) on `internal/config`, `cmd/mcpproxy`, `internal/serveredition/...`; `go test -race -tags server ./internal/serveredition/... ./internal/config/... ./cmd/release-gate/` (8 packages ok); `go test -race ./internal/config/... ./cmd/mcpproxy/` (personal) and `go test -race -tags server ./cmd/mcpproxy/` β€” ok; `go test -race -run 'PersonalCarrier|HotReload' ./internal/runtime/` ok; golangci-lint v2.9.0 bare + `--build-tags server` on the three package trees β€” `0 issues` both; `gofmt -l` on every touched/added file empty. Round 3 (re-review of the fixes) follows. +- **Round 3** (2026-09-16, codex `gpt-5.6-sol`, `model_reasoning_effort=high`, `--sandbox read-only`, the same three briefs re-issued on HEAD `e15812163` with a round-3 preamble naming the four round-2 fixes to re-verify and the four rejections to date with their reasons; verdict files `scratchpad/pra-review-r3-c{1,2,3}.md`). Verdicts: c1 **CLEAN**, c2 FINDINGS (1Γ—P2), c3 FINDINGS (1Γ—P2, 1Γ—P3) β€” 3 findings, 3 distinct, none a repeat of a rejection; **3 genuine, 0 rejected**; no P1. + - **Fixed** β€” (a) `internal/config/loader.go:268` (c2, the behavioural one): the api_key-detection map `rawConfig` was decoded with plain `json.Unmarshal`, and the legacy `teams` alias (MCP-1086) is re-marshaled FROM that map onto `ServerEdition` β€” so in the personal build an old `teams` block reached the FR-040 carrier through a `float64` detour and the next write-back persisted `0.1` for `0.1000000000000000055511151231257827` (and `2^53` for `2^53+1`) under `server_edition`. The map is now decoded with `Decoder.UseNumber` (only key presence and the alias re-marshal read it; `json.Number` marshals verbatim). Red test `TestPersonalBuild_LegacyTeamsAliasKeepsNumberText` (`!server`) reproduced the rounding on HEAD; trailing-content strictness re-probed under both tags (whitespace loads; a second object / garbage still fails with "failed to parse config file"). (b) `.github/RELEASE_NOTICE.md:54` + `docs/features/agent-tokens.md:332-343` (c3, P2): "a deployment at 100 records refuses the next token for everyone; that 409 says the limit is shared" was unqualified β€” `CreateAgentToken` checks the 25-per-owner quota FIRST, so a user already at 25 sees their own-quota message whatever the deployment total; both now say so. (c) `docs/cli/credential-commands.md:105` (c3, P3): "the payload itself is the REST response unchanged" was wrong β€” `credential list -o json` emits the bare array from the REST `credentials` field and `credential status` one object (`cliclient.ListCredentials` unwraps the envelope); reworded. + - **Rejected** β€” none. + - Re-run after the fixes: both builds; `go vet` (both tags) on `internal/config`; `go test -race ./internal/config/` under both tags; `go test -race -skip ./internal/httpapi/ ./cmd/mcpproxy/` under both tags; `go test -race -run 'PersonalCarrier|HotReload' ./internal/runtime/`; `go test ./cmd/release-gate/` (docs-claims guard still green on the reworded docs); golangci-lint v2.9.0 bare + `--build-tags server` on `./internal/config/...` β€” `0 issues` both; `gofmt -l` on the two touched Go files empty; `go.mod`/`go.sum` untouched. Round 4 (re-review of the fixes) follows. ## PR-B β€” generic OIDC + front door + telemetry From 7a4e8fe220c6a19db59b0fea40c82a09b0b7f514 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 15:14:28 +0300 Subject: [PATCH 09/35] fix(spec-107): codex round 4 for PR-A --- docs/features/auth-broker.md | 2 +- specs/107-server-edition-sso-hardening/verification.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/features/auth-broker.md b/docs/features/auth-broker.md index 9b6c4a4a3..e09b9c1e6 100644 --- a/docs/features/auth-broker.md +++ b/docs/features/auth-broker.md @@ -60,7 +60,7 @@ The block lives under a server entry in the config file: | `resource` | no | RFC 8707 audience the resulting token is scoped to. | | `scopes` | no | Scopes requested for the upstream credential. | | `client_id` | noΒΉ | Identifies the gateway to the token/authorization endpoint. | -| `client_secret` | no | Authenticates a confidential client. A public client may omit it β€” PKCE still protects the code exchange. Supports `${env:VAR}` so the secret stays out of the file. | +| `client_secret` | no | Authenticates a confidential client. A public client may omit it β€” PKCE still protects the code exchange. The value is sent to the token endpoint exactly as written: `${env:VAR}` / `${keyring:NAME}` references are **not** expanded for the connect flow (only the upstream MCP client's own copy of a server config is expanded), so put the literal secret here or use a public client. | ΒΉ `client_id` is required at runtime for the connect flow (the connector rejects an empty client ID); it is validated when the connect flow is assembled. diff --git a/specs/107-server-edition-sso-hardening/verification.md b/specs/107-server-edition-sso-hardening/verification.md index edfad9829..5fa57bdc8 100644 --- a/specs/107-server-edition-sso-hardening/verification.md +++ b/specs/107-server-edition-sso-hardening/verification.md @@ -54,6 +54,10 @@ Merge-readiness caveat (found while fetching `origin/main` for the gofmt row): ` - **Fixed** β€” (a) `internal/config/loader.go:268` (c2, the behavioural one): the api_key-detection map `rawConfig` was decoded with plain `json.Unmarshal`, and the legacy `teams` alias (MCP-1086) is re-marshaled FROM that map onto `ServerEdition` β€” so in the personal build an old `teams` block reached the FR-040 carrier through a `float64` detour and the next write-back persisted `0.1` for `0.1000000000000000055511151231257827` (and `2^53` for `2^53+1`) under `server_edition`. The map is now decoded with `Decoder.UseNumber` (only key presence and the alias re-marshal read it; `json.Number` marshals verbatim). Red test `TestPersonalBuild_LegacyTeamsAliasKeepsNumberText` (`!server`) reproduced the rounding on HEAD; trailing-content strictness re-probed under both tags (whitespace loads; a second object / garbage still fails with "failed to parse config file"). (b) `.github/RELEASE_NOTICE.md:54` + `docs/features/agent-tokens.md:332-343` (c3, P2): "a deployment at 100 records refuses the next token for everyone; that 409 says the limit is shared" was unqualified β€” `CreateAgentToken` checks the 25-per-owner quota FIRST, so a user already at 25 sees their own-quota message whatever the deployment total; both now say so. (c) `docs/cli/credential-commands.md:105` (c3, P3): "the payload itself is the REST response unchanged" was wrong β€” `credential list -o json` emits the bare array from the REST `credentials` field and `credential status` one object (`cliclient.ListCredentials` unwraps the envelope); reworded. - **Rejected** β€” none. - Re-run after the fixes: both builds; `go vet` (both tags) on `internal/config`; `go test -race ./internal/config/` under both tags; `go test -race -skip ./internal/httpapi/ ./cmd/mcpproxy/` under both tags; `go test -race -run 'PersonalCarrier|HotReload' ./internal/runtime/`; `go test ./cmd/release-gate/` (docs-claims guard still green on the reworded docs); golangci-lint v2.9.0 bare + `--build-tags server` on `./internal/config/...` β€” `0 issues` both; `gofmt -l` on the two touched Go files empty; `go.mod`/`go.sum` untouched. Round 4 (re-review of the fixes) follows. +- **Round 4** (2026-09-16, codex `gpt-5.6-sol`, `model_reasoning_effort=high`, `--sandbox read-only`, the same three briefs re-issued on HEAD `03ef19def` with a round-4 preamble naming the three round-3 fixes to re-verify and the four rejections to date with their reasons; verdict files `scratchpad/pra-review-r4-c{1,2,3}.md`). Verdicts: c1 **CLEAN**, c2 **CLEAN** ("all three round-3 fixes are correct on HEAD"), c3 FINDINGS (1Γ—P2) β€” 1 finding, not a repeat of a rejection; **1 genuine, 0 rejected**; no P1. + - **Fixed** β€” (a) `docs/features/auth-broker.md:63` (c3, P2): the `client_secret` row PR-A added claimed "Supports `${env:VAR}` so the secret stays out of the file", but nothing expands it for the connect flow β€” `connectorProvider.connector` (`api/connector_provider.go:80-90`) copies `server.AuthBroker.ClientSecret` from the boot/live `config.ServerConfig` and `(*OAuthConnector).postToken` (`broker/oauth_connector.go:276`) sends it verbatim; the only `ExpandStructSecrets*` caller in production is `upstream/core.NewClientWithOptions` (`client.go:175-178`) on the MCP client's own `CopyServerConfig`, so a reference would reach the token endpoint as the literal string and fail `invalid_client`. Honesty PR, so the sentence was corrected (references are **not** expanded for the connect flow; put the literal or use a public client) rather than adding expansion behaviour; wiring the resolver into the connector is a PR-B+ item. Verified not pinned elsewhere (`grep 'env:VAR'` across docs/, `cmd/release-gate/`, the notice and `auth_broker.go`: only this row). `go test ./cmd/release-gate/` still green. + - **Rejected** β€” none. + - Suites re-run on this HEAD before the round (all green): both builds; `go test -race ./internal/config/` under both tags; `go test -race -skip ./internal/httpapi/ ./cmd/mcpproxy/ ./internal/storage/ ./cmd/release-gate/` (personal) and `./internal/httpapi/ ./cmd/mcpproxy/ ./internal/storage/ ./internal/serveredition/...` (`-tags server`) β€” 13 packages ok. After the fix: `go test ./cmd/release-gate/` ok (docs-only change; no Go file touched). Round 5 (re-review of the fix) follows if the orchestrator schedules one; two of three chunks are already CLEAN. ## PR-B β€” generic OIDC + front door + telemetry From 14611c98529354d7e5e3a120ef43c21456f337b3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 16:11:07 +0300 Subject: [PATCH 10/35] docs(spec-107): PR-A verification and task ticks --- ROADMAP.md | 4 +- .../107-server-edition-sso-hardening/tasks.md | 54 ++++++------- .../verification.md | 78 ++++++++++++++++++- 3 files changed, 106 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f88e92c2..b85c7e6f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -894,7 +894,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 | 4/126 (3%) | [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 | 31/126 (25%) | [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/) | | @@ -1036,4 +1036,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/) | `drafted` | 0/109 (0%) | | [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` | 4/126 (3%) | +| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 31/126 (25%) | diff --git a/specs/107-server-edition-sso-hardening/tasks.md b/specs/107-server-edition-sso-hardening/tasks.md index 7f2a98ba2..ecd7be9d0 100644 --- a/specs/107-server-edition-sso-hardening/tasks.md +++ b/specs/107-server-edition-sso-hardening/tasks.md @@ -29,9 +29,9 @@ Gate set for every PR = plan.md Β§Gates. Never claim a skipped gate passed; writ ## Phase A.1 β€” CI and lint under the server tag (FR-047 part 1) -- [ ] T005 [X] Add a second lint step `--build-tags server` to `.github/workflows/unit-tests.yml:294-304` and widen the `server-edition` job (`:340`) with `./internal/server/... ./internal/httpapi/... ./internal/storage/...` under `-tags server` **with** the `-skip` regex from `:148`; document both in `CLAUDE.md` "Lint" block β€” `.github/workflows/unit-tests.yml`, `CLAUDE.md` -- [ ] T006 [P] [X] Run the widened suites locally and inventory every pre-existing lint finding the server tag exposes (record the exact list and count in verification.md). Fix in PR-A only the findings in files PR-A already touches; every other pre-existing finding lands in a separate prerequisite `chore(lint):` PR opened before PR-A (or, if ≀ 5 and trivial, as one isolated `chore:` commit at the head of PR-A) so PR-A stays reviewable and within F1–F4 β€” files as found -- [ ] T007 [P] [X] Create `.github/RELEASE_NOTICE.md` (consumed at `release.yml:1466`) with the PR-A bullets of FR-042: removed keys/modes, `store_idp_tokens` no-op, "stored, not injected", per-owner token cap β€” `.github/RELEASE_NOTICE.md` +- [x] T005 [X] Add a second lint step `--build-tags server` to `.github/workflows/unit-tests.yml:294-304` and widen the `server-edition` job (`:340`) with `./internal/server/... ./internal/httpapi/... ./internal/storage/...` under `-tags server` **with** the `-skip` regex from `:148`; document both in `CLAUDE.md` "Lint" block β€” `.github/workflows/unit-tests.yml`, `CLAUDE.md` +- [x] T006 [P] [X] Run the widened suites locally and inventory every pre-existing lint finding the server tag exposes (record the exact list and count in verification.md). Fix in PR-A only the findings in files PR-A already touches; every other pre-existing finding lands in a separate prerequisite `chore(lint):` PR opened before PR-A (or, if ≀ 5 and trivial, as one isolated `chore:` commit at the head of PR-A) so PR-A stays reviewable and within F1–F4 β€” files as found +- [x] T007 [P] [X] Create `.github/RELEASE_NOTICE.md` (consumed at `release.yml:1466`) with the PR-A bullets of FR-042: removed keys/modes, `store_idp_tokens` no-op, "stored, not injected", per-owner token cap β€” `.github/RELEASE_NOTICE.md` ## Phase A.2 β€” US5: the code and the docs stop promising credential injection (P2) @@ -39,39 +39,39 @@ Gate set for every PR = plan.md Β§Gates. Never claim a skipped gate passed; writ ### Tests first (must fail on HEAD) -- [ ] T008 [US5] **[behaviour-red]** (every listed declaration exists on HEAD) Write `internal/config/latent_symbols_guard_test.go` (both tags): walk the non-test AST of `internal/serveredition/...`, `internal/transport`, `internal/upstream/core`, `internal/config` and fail while any FR-031/FR-033 declaration exists (`Router`, `NewRouter`, `ToolFilter`, `NewToolFilter`, `workspace.Manager`, `TokenExchanger`, `CredentialResolver`, `HeaderInjector`, `ConnectionKey`, `AuditActionInject|Acquire|Refresh`, `AuditMethodTokenExchange|EntraOBO`, `auditMethodForMode`, `GetValidIDPSubjectToken`, `ErrReauthRequired`, `RefreshAccessToken`, `(*OAuthConnector).Refresh`, `ConnectorProvider`, `ConnectorFor`, `SetBrokeredAuth`, `brokeredAuth`, `BrokeredAuth`, `EffectiveHeaders`, `refuseBrokeredOAuth`), the fields `MaxUserServers`, `WorkspaceIdleTimeout`, `Header`, `HeaderFormat`, and the literals `"token_exchange"`/`"entra_obo"` inside `validateServerAuthBroker`'s accepted set β€” exempting by name the `StoreIDPTokens` decoder field, its warning string and the normaliser's key/mode strings (FR-035) β€” `internal/config/latent_symbols_guard_test.go` -- [ ] T009 [P] [US5] **[compile-red until T018 (`ValidateRemovedKeys`, `LoadDiagnostics`)]** Write `internal/config/legacy_keys_load_test.go` (`-tags server`): a fixture config carrying `server_edition.max_user_servers`, `workspace_idle_timeout`, `store_idp_tokens: true` and two servers with `auth_broker.mode: token_exchange` / `auth_broker.header: X-Auth` loads without error and records exactly one **load diagnostic** per key/mode on the returned `Config` (`cfg.LoadDiagnostics() []LoadDiagnostic`, T018 β€” the loader has no logger: `loadConfigFile` (`loader.go:242`) returns only an error and `main.go` builds the logger at `:499`, after the load at `:744`, so the warning cannot be emitted from the loader and, once the typed decode has dropped the keys, cannot be reconstructed later); a second assertion drives the same diagnostics through the emit helper with a zap observer and sees one WARN line per diagnostic; `DetectConfigChanges` reports none of them, and the write-back omits them; the same document sent through `PATCH /api/v1/config` and `/config/apply` is refused with the `contracts/config-keys.md` messages by the **raw-document check** (`config.ValidateRemovedKeys(raw map[string]any)`, T018) β€” `Config.Validate()` alone cannot see them because `json.Unmarshal` into the typed struct silently drops unknown keys (`httpapi/server.go:5405-5408`); the test asserts all three write doors (PATCH, apply, boot load) separately β€” `internal/config/legacy_keys_load_test.go`, `internal/config/testdata/legacy_server_edition.json`, `internal/httpapi/config_patch_removed_keys_test.go` (`-tags server`) -- [ ] T010 [P] [US5] **[behaviour-red for `personal_roundtrip_test.go` (the personal stubs are `struct{}` today, so the blocks are erased on write-back); compile-red until T020 for `config_patch_roundtrip_test.go`** (it needs the exported merge helper `httpapi.MergeConfigPatch`, which T020 adds as a one-line wrapper over the unexported `deepMergeJSON`, `httpapi/server.go:5512`) Write `internal/config/personal_roundtrip_test.go` (`!server`): load the same fixture, `SaveConfig`, PATCH-merge an unrelated key through `httpapi.MergeConfigPatch` (the `handlePatchConfig` merge path, T020), save again; compare `server_edition` and every `auth_broker` block under an independent `UseNumber` structural comparator with `9007199254740993` and `0.1000000000000000055511151231257827` planted; assert no warning was logged β€” `internal/config/personal_roundtrip_test.go`, `internal/httpapi/config_patch_roundtrip_test.go` -- [ ] T011 [P] [US5] **[compile-red until T019 (`ApplyDefaults`); the `Config.Validate` reach case is behaviour-red]** Write `internal/config/server_edition_validate_test.go` (`-tags server`): `Config.Validate()`/`ValidateDetailed()` reach `ServerEditionConfig.Validate` (an enabled block with `provider: bogus` fails at `Config.Validate`, today it passes); `Validate` is non-mutating (`TenantID`, TTLs, `CredentialEncryptionKey` unchanged after the call); `ApplyDefaults` fills them β€” `internal/config/server_edition_validate_test.go` -- [ ] T012 [P] [US5] **[behaviour-red]** Write `cmd/mcpproxy/credential_cmd_wording_test.go` (`-tags server`): `credential list` and `credential status` output begins with the "stored … NOT injected" line of `contracts/rest-endpoints.md` Β§12 β€” `cmd/mcpproxy/credential_cmd_wording_test.go` -- [ ] T013 [P] [US5] **[behaviour-red]** Write `docs/docs_injection_claims_test.go` (or extend `cmd/release-gate/`): fail while any FR-036 sentence survives (`"injects it at call time"`, `"Credential resolution"`, `"Header injection"`, `"Per-(user, server) connection keying"`, `"token_exchange"` row, `"entra_obo"` row, `"JWT bearer token for MCP"`, `"nothing re-validates at use time"`, `"per-user connections"`, the `workspace/` row) and while `website/sidebars.js:55,129,130,263` name a file that does not exist β€” `cmd/release-gate/docs_claims_test.go` +- [x] T008 [US5] **[behaviour-red]** (every listed declaration exists on HEAD) Write `internal/config/latent_symbols_guard_test.go` (both tags): walk the non-test AST of `internal/serveredition/...`, `internal/transport`, `internal/upstream/core`, `internal/config` and fail while any FR-031/FR-033 declaration exists (`Router`, `NewRouter`, `ToolFilter`, `NewToolFilter`, `workspace.Manager`, `TokenExchanger`, `CredentialResolver`, `HeaderInjector`, `ConnectionKey`, `AuditActionInject|Acquire|Refresh`, `AuditMethodTokenExchange|EntraOBO`, `auditMethodForMode`, `GetValidIDPSubjectToken`, `ErrReauthRequired`, `RefreshAccessToken`, `(*OAuthConnector).Refresh`, `ConnectorProvider`, `ConnectorFor`, `SetBrokeredAuth`, `brokeredAuth`, `BrokeredAuth`, `EffectiveHeaders`, `refuseBrokeredOAuth`), the fields `MaxUserServers`, `WorkspaceIdleTimeout`, `Header`, `HeaderFormat`, and the literals `"token_exchange"`/`"entra_obo"` inside `validateServerAuthBroker`'s accepted set β€” exempting by name the `StoreIDPTokens` decoder field, its warning string and the normaliser's key/mode strings (FR-035) β€” `internal/config/latent_symbols_guard_test.go` +- [x] T009 [P] [US5] **[compile-red until T018 (`ValidateRemovedKeys`, `LoadDiagnostics`)]** Write `internal/config/legacy_keys_load_test.go` (`-tags server`): a fixture config carrying `server_edition.max_user_servers`, `workspace_idle_timeout`, `store_idp_tokens: true` and two servers with `auth_broker.mode: token_exchange` / `auth_broker.header: X-Auth` loads without error and records exactly one **load diagnostic** per key/mode on the returned `Config` (`cfg.LoadDiagnostics() []LoadDiagnostic`, T018 β€” the loader has no logger: `loadConfigFile` (`loader.go:242`) returns only an error and `main.go` builds the logger at `:499`, after the load at `:744`, so the warning cannot be emitted from the loader and, once the typed decode has dropped the keys, cannot be reconstructed later); a second assertion drives the same diagnostics through the emit helper with a zap observer and sees one WARN line per diagnostic; `DetectConfigChanges` reports none of them, and the write-back omits them; the same document sent through `PATCH /api/v1/config` and `/config/apply` is refused with the `contracts/config-keys.md` messages by the **raw-document check** (`config.ValidateRemovedKeys(raw map[string]any)`, T018) β€” `Config.Validate()` alone cannot see them because `json.Unmarshal` into the typed struct silently drops unknown keys (`httpapi/server.go:5405-5408`); the test asserts all three write doors (PATCH, apply, boot load) separately β€” `internal/config/legacy_keys_load_test.go`, `internal/config/testdata/legacy_server_edition.json`, `internal/httpapi/config_patch_removed_keys_test.go` (`-tags server`) +- [x] T010 [P] [US5] **[behaviour-red for `personal_roundtrip_test.go` (the personal stubs are `struct{}` today, so the blocks are erased on write-back); compile-red until T020 for `config_patch_roundtrip_test.go`** (it needs the exported merge helper `httpapi.MergeConfigPatch`, which T020 adds as a one-line wrapper over the unexported `deepMergeJSON`, `httpapi/server.go:5512`) Write `internal/config/personal_roundtrip_test.go` (`!server`): load the same fixture, `SaveConfig`, PATCH-merge an unrelated key through `httpapi.MergeConfigPatch` (the `handlePatchConfig` merge path, T020), save again; compare `server_edition` and every `auth_broker` block under an independent `UseNumber` structural comparator with `9007199254740993` and `0.1000000000000000055511151231257827` planted; assert no warning was logged β€” `internal/config/personal_roundtrip_test.go`, `internal/httpapi/config_patch_roundtrip_test.go` +- [x] T011 [P] [US5] **[compile-red until T019 (`ApplyDefaults`); the `Config.Validate` reach case is behaviour-red]** Write `internal/config/server_edition_validate_test.go` (`-tags server`): `Config.Validate()`/`ValidateDetailed()` reach `ServerEditionConfig.Validate` (an enabled block with `provider: bogus` fails at `Config.Validate`, today it passes); `Validate` is non-mutating (`TenantID`, TTLs, `CredentialEncryptionKey` unchanged after the call); `ApplyDefaults` fills them β€” `internal/config/server_edition_validate_test.go` +- [x] T012 [P] [US5] **[behaviour-red]** Write `cmd/mcpproxy/credential_cmd_wording_test.go` (`-tags server`): `credential list` and `credential status` output begins with the "stored … NOT injected" line of `contracts/rest-endpoints.md` Β§12 β€” `cmd/mcpproxy/credential_cmd_wording_test.go` +- [x] T013 [P] [US5] **[behaviour-red]** Write `docs/docs_injection_claims_test.go` (or extend `cmd/release-gate/`): fail while any FR-036 sentence survives (`"injects it at call time"`, `"Credential resolution"`, `"Header injection"`, `"Per-(user, server) connection keying"`, `"token_exchange"` row, `"entra_obo"` row, `"JWT bearer token for MCP"`, `"nothing re-validates at use time"`, `"per-user connections"`, the `workspace/` row) and while `website/sidebars.js:55,129,130,263` name a file that does not exist β€” `cmd/release-gate/docs_claims_test.go` ### Implementation -- [ ] T014 [US5] Delete class A: `internal/serveredition/multiuser/{router.go,tool_filter.go,router_test.go,router_broker_test.go,router_broker_agent_test.go}`, package `internal/serveredition/workspace/`, `internal/serveredition/broker/{token_exchanger.go,credential_resolver.go,injector.go}` + their tests + `audit_resolver_test.go`; trim `broker/audit.go` to `AuditMethodConnect`/`AuditActionConnect`; remove `TestIntegration_CrossUserIsolation/_GetServerForUser/_ToolFilterAccessibleServerNames` from `multiuser/isolation_test.go` (keep `_ActivityIsolation*`); list the six retired security-regression tests by name in the PR body (FR-031) β€” files above -- [ ] T015 [US5] Delete `internal/serveredition/auth/idp_subject_token.go` (+ the five `TestGetValidIDPSubjectToken_*` and the two `TestHandleCallback_*IDPSubjectToken*`), `OAuthProvider.RefreshAccessToken` (`oauth_providers.go:212-256`), `OfflineAccessScopes`/`OfflineAuthParams` and their use at `oauth_handler.go:139-144`, the `persistIDPSubjectToken` call at `:252`, `SetCredentialStore` on the handler if now unused; `(*OAuthConnector).Refresh` (`oauth_connector.go:265-300` + tests `:291-354`); `(*CredentialHandlers).ConnectorProvider`, `connectorProvider.ConnectorFor` + assertion (`credential_handlers.go:63-68`, `connector_provider.go:102-105,137-138`) β€” files above -- [ ] T016 [US5] Remove the brokered seam from the personal binary: `internal/transport/broker_auth.go` (+ `broker_auth_test.go`, `broker_auth_integration_test.go`), `HTTPTransportConfig.BrokeredAuth` + `refuseBrokeredOAuth`/`effectiveHeaders` in `transport/http.go:135-139,178-192`, `Client.SetBrokeredAuth`/`brokeredAuth`/brokered branches in `upstream/core/connection_http.go:28-29,44-45,171-192` and `client.go:93-99` (+ `connection_http_broker_test.go`), `TestAuthStrategies_BrokeredWinsOverOAuthBlock` (`auth_strategy_oauth_block_test.go:71-88`), `TestOAuthClients_RefuseBrokeredAuth` (`oauth_static_headers_test.go:78-91`); keep `TestOAuthClients_CarryStaticHeaders` green β€” files above -- [ ] T017 [US5] Remove `MaxUserServers`/`WorkspaceIdleTimeout` (`server_edition_config.go:17-20,45-46,96-101`), `AuthBrokerConfig.Header/HeaderFormat` + defaults (`auth_broker.go:44-64`), the `token_exchange`/`entra_obo` constants and validator arms (`auth_broker.go:11-13,74-79,113-138`); update the `internal/oauth/serverfields_serveredition.go` mask table (+ its test) for the removed leaves β€” the storage canary `TestSaveServerSyncFieldCoverage` (`async_ops_test.go:353,415`) sees only the top-level `AuthBroker` field and skips it, so it needs **no** change and cannot cover nested leaves; **retire or rewrite every existing test that names a removed symbol or asserts the old mutating `Validate`** (compile-red otherwise): `internal/config/server_edition_config_test.go:24-25,146-149,216-217,251-252,271-272,326,342,390-391` (`MaxUserServers`/`WorkspaceIdleTimeout`), `internal/config/auth_broker_test.go:27-42,66-94,104,120,157,164` (`Header`/`HeaderFormat`/`AuthBrokerModeTokenExchange`/`EntraOBO`), `internal/config/teams_credential_test.go:36ff` (`MCPPROXY_CRED_KEY` fallback moves from `Validate` to `ApplyDefaults` β€” the assertion moves with it, T019); swap the inert `token_exchange` fixture strings to `oauth_connect` in `api/credential_handlers_test.go`, `api/user_handlers_redaction_test.go:78`, `setup_wiring_test.go:257`, `runtime/server_sharing_test.go:37`, `broker/credential_store_test.go:58`, `config/auth_broker_test.go` β€” files above -- [ ] T018 [US5] Server-build normaliser beside the `teams` alias (`internal/config/loader.go:271-285`), run on the **raw map before the typed decode**: drop the removed keys/modes and append one `LoadDiagnostic{Key, Message}` each (exact strings in `contracts/config-keys.md`) to an unexported slice on `Config` exposed by `LoadDiagnostics()`; `store_idp_tokens: true` adds its deprecation diagnostic the same way; the diagnostics are logged **once** by `config.LogLoadDiagnostics(cfg, logger)` β€” called from `cmd/mcpproxy/main.go` right after `logs.SetupLogger` (`:499`) and from the hot-reload path after each successful reload (the reload has a logger) β€” never from the loader itself (no logger exists there, finding of round 2); the personal build records no diagnostics (opaque carriers); make `DetectConfigChanges` blind to the dropped keys (nothing to add β€” assert in T009). **Write-time refusal is a raw-document check, not `Config.Validate`**: add `config.ValidateRemovedKeys(raw map[string]any) []ValidationError` (server build; personal stub returns nil) that walks `server_edition.{max_user_servers,workspace_idle_timeout}` and every `mcpServers[].auth_broker.{header,header_format,mode∈{token_exchange,entra_obo}}` in the generic map, and call it in `handlePatchConfig` on the merged map **before** `json.Unmarshal(mergedBytes, &merged)` (`httpapi/server.go:5398-5408`), in `/config/apply` on the decoded raw document, and nowhere on the boot path (boot normalises + records diagnostics instead) β€” `internal/config/loader.go`, `internal/config/loader_serveredition.go` (new, `//go:build server`), `internal/config/removed_keys{,_stub}.go` (new), `internal/config/load_diagnostics.go` (new, edition-neutral type + `LogLoadDiagnostics`), `internal/httpapi/server.go`, `cmd/mcpproxy/main.go`, `internal/runtime/config_hotreload.go` (reload emit) -- [ ] T019 [US5] Split `ServerEditionConfig.Validate` into non-mutating `Validate()` + `ApplyDefaults()` (TTLs, Microsoft `TenantID`, `MCPPROXY_CRED_KEY` fallback); call `Validate` from `Config.Validate()` and `ValidateDetailed()` under the server build (`internal/config/config_validate_serveredition.go` + `_stub.go`); call `ApplyDefaults()` then `Validate()` at `setup.go:96` β€” `internal/config/server_edition_config.go`, `internal/config/config.go`, `internal/config/config_validate_serveredition{,_stub}.go`, `internal/serveredition/setup.go` -- [ ] T020 [US5] Personal-build carriers: `type ServerEditionConfig struct{ raw json.RawMessage }` and `type AuthBrokerConfig struct{ raw json.RawMessage }` with verbatim `MarshalJSON`/`UnmarshalJSON`. **Omission is the parent pointer's job, not the carrier's**: `Config.ServerEdition` and `ServerConfig.AuthBroker` are `*T` with `omitempty` (`config.go:584,752`), so a document without the key leaves the pointer nil and the key is omitted on write; a JSON `null` also decodes to a nil pointer. A non-nil carrier with nil `raw` (only constructible from Go code) marshals as `{}` β€” `MarshalJSON` must return valid JSON and cannot remove the parent key. A build-tagged `Clone()` deep-copies the carrier (`append(json.RawMessage(nil), raw...)`); the existing value copy `broker := *src.AuthBroker` at `merge.go:678-684` (inside `CopyServerConfig`) would alias the backing array and becomes `dst.AuthBroker = src.AuthBroker.Clone()`; **there is no `ServerEdition` copy call at HEAD** (`merge.go` copies servers only; the config snapshot shares the top-level pointer and nothing mutates it in the personal build), so `Clone()` on `ServerEditionConfig` is provided for symmetry and pinned by a unit test but wired nowhere; test: mutating the source `AuthBroker` carrier after `CopyServerConfig` leaves the copy unchanged; `handlePatchConfig` decodes patch and base with `json.Decoder.UseNumber` (`internal/httpapi/server.go:5332,5376`); export `MergeConfigPatch(base, patch map[string]any) map[string]any` as a one-line wrapper over `deepMergeJSON` (`:5512`) for T010 β€” `internal/config/server_edition_config_stub.go`, `internal/config/auth_broker_stub.go`, `internal/config/merge.go`, `internal/httpapi/server.go` -- [ ] T021 [P] [US5] `mcpproxy credential list|status` banner (`cmd/mcpproxy/credential_cmd.go:32` `Short:` and the output header) β€” `cmd/mcpproxy/credential_cmd.go` -- [ ] T022a [P] [US5] **[behaviour-red]** (the row exists at `fields.ts:327`) Extend `frontend/tests/unit/settings-server-edition-wording.spec.ts` to assert that no `SERVER_EDITION_FIELDS` entry has key `server_edition.max_user_servers` β€” `frontend/tests/unit/settings-server-edition-wording.spec.ts` -- [ ] T022 [P] [US5] (after T022a) Web UI: drop `server_edition.max_user_servers` from `SERVER_EDITION_FIELDS` (`frontend/src/views/settings/fields.ts:327`) β€” `frontend/src/views/settings/fields.ts` -- [ ] T023 [P] [US5] Docs corrections (FR-036, this PR's slice): `docs/features/idp-token-storage.md` β†’ one-paragraph tombstone; `docs/features/auth-broker.md` and `docs/cli/credential-commands.md` open with "stored, not injected", lose `:82-106` and the `token_exchange`/`entra_obo` rows, and `auth-broker.md` keeps a `credential_encryption_key` / `MCPPROXY_CRED_KEY` paragraph (the tombstoned `idp-token-storage.md` was its only home); `docs/development/server-edition-multiuser-auth.md:27-28,40,53,54-55,206-210,242-243` corrected; `docs/features/activity-log.md:28,300`; `docs/configuration/upstream-servers.md:112`; `CLAUDE.md` editions row (no server `.deb`/tar.gz); `internal/config/auth_broker.go:24-27` and `config.go:746-752` comments β€” files above -- [ ] T024 [US5] Run both builds, `go vet ./...`, both lint passes, `go test -race ./internal/upstream/... ./internal/transport/...`, the server race suites, and confirm T008–T013 now pass; regenerate nothing (no OAS/contract change in this PR) but run `make swagger-verify` and `TestContractsInSync` to prove it β€” verification.md Β§PR-A -- [ ] T025 [US5] Real-instance verification: quickstart Β§9 (personal-build round trip of a config with the removed keys and a valid `oauth_connect` block), then a server-edition boot of the same file asserting the one-warning-per-key log lines and `Enabled` still true; `mcpproxy credential list` shows the banner; record in verification.md β€” `verification.md` +- [x] T014 [US5] Delete class A: `internal/serveredition/multiuser/{router.go,tool_filter.go,router_test.go,router_broker_test.go,router_broker_agent_test.go}`, package `internal/serveredition/workspace/`, `internal/serveredition/broker/{token_exchanger.go,credential_resolver.go,injector.go}` + their tests + `audit_resolver_test.go`; trim `broker/audit.go` to `AuditMethodConnect`/`AuditActionConnect`; remove `TestIntegration_CrossUserIsolation/_GetServerForUser/_ToolFilterAccessibleServerNames` from `multiuser/isolation_test.go` (keep `_ActivityIsolation*`); list the six retired security-regression tests by name in the PR body (FR-031) β€” files above +- [x] T015 [US5] Delete `internal/serveredition/auth/idp_subject_token.go` (+ the five `TestGetValidIDPSubjectToken_*` and the two `TestHandleCallback_*IDPSubjectToken*`), `OAuthProvider.RefreshAccessToken` (`oauth_providers.go:212-256`), `OfflineAccessScopes`/`OfflineAuthParams` and their use at `oauth_handler.go:139-144`, the `persistIDPSubjectToken` call at `:252`, `SetCredentialStore` on the handler if now unused; `(*OAuthConnector).Refresh` (`oauth_connector.go:265-300` + tests `:291-354`); `(*CredentialHandlers).ConnectorProvider`, `connectorProvider.ConnectorFor` + assertion (`credential_handlers.go:63-68`, `connector_provider.go:102-105,137-138`) β€” files above +- [x] T016 [US5] Remove the brokered seam from the personal binary: `internal/transport/broker_auth.go` (+ `broker_auth_test.go`, `broker_auth_integration_test.go`), `HTTPTransportConfig.BrokeredAuth` + `refuseBrokeredOAuth`/`effectiveHeaders` in `transport/http.go:135-139,178-192`, `Client.SetBrokeredAuth`/`brokeredAuth`/brokered branches in `upstream/core/connection_http.go:28-29,44-45,171-192` and `client.go:93-99` (+ `connection_http_broker_test.go`), `TestAuthStrategies_BrokeredWinsOverOAuthBlock` (`auth_strategy_oauth_block_test.go:71-88`), `TestOAuthClients_RefuseBrokeredAuth` (`oauth_static_headers_test.go:78-91`); keep `TestOAuthClients_CarryStaticHeaders` green β€” files above +- [x] T017 [US5] Remove `MaxUserServers`/`WorkspaceIdleTimeout` (`server_edition_config.go:17-20,45-46,96-101`), `AuthBrokerConfig.Header/HeaderFormat` + defaults (`auth_broker.go:44-64`), the `token_exchange`/`entra_obo` constants and validator arms (`auth_broker.go:11-13,74-79,113-138`); update the `internal/oauth/serverfields_serveredition.go` mask table (+ its test) for the removed leaves β€” the storage canary `TestSaveServerSyncFieldCoverage` (`async_ops_test.go:353,415`) sees only the top-level `AuthBroker` field and skips it, so it needs **no** change and cannot cover nested leaves; **retire or rewrite every existing test that names a removed symbol or asserts the old mutating `Validate`** (compile-red otherwise): `internal/config/server_edition_config_test.go:24-25,146-149,216-217,251-252,271-272,326,342,390-391` (`MaxUserServers`/`WorkspaceIdleTimeout`), `internal/config/auth_broker_test.go:27-42,66-94,104,120,157,164` (`Header`/`HeaderFormat`/`AuthBrokerModeTokenExchange`/`EntraOBO`), `internal/config/teams_credential_test.go:36ff` (`MCPPROXY_CRED_KEY` fallback moves from `Validate` to `ApplyDefaults` β€” the assertion moves with it, T019); swap the inert `token_exchange` fixture strings to `oauth_connect` in `api/credential_handlers_test.go`, `api/user_handlers_redaction_test.go:78`, `setup_wiring_test.go:257`, `runtime/server_sharing_test.go:37`, `broker/credential_store_test.go:58`, `config/auth_broker_test.go` β€” files above +- [x] T018 [US5] Server-build normaliser beside the `teams` alias (`internal/config/loader.go:271-285`), run on the **raw map before the typed decode**: drop the removed keys/modes and append one `LoadDiagnostic{Key, Message}` each (exact strings in `contracts/config-keys.md`) to an unexported slice on `Config` exposed by `LoadDiagnostics()`; `store_idp_tokens: true` adds its deprecation diagnostic the same way; the diagnostics are logged **once** by `config.LogLoadDiagnostics(cfg, logger)` β€” called from `cmd/mcpproxy/main.go` right after `logs.SetupLogger` (`:499`) and from the hot-reload path after each successful reload (the reload has a logger) β€” never from the loader itself (no logger exists there, finding of round 2); the personal build records no diagnostics (opaque carriers); make `DetectConfigChanges` blind to the dropped keys (nothing to add β€” assert in T009). **Write-time refusal is a raw-document check, not `Config.Validate`**: add `config.ValidateRemovedKeys(raw map[string]any) []ValidationError` (server build; personal stub returns nil) that walks `server_edition.{max_user_servers,workspace_idle_timeout}` and every `mcpServers[].auth_broker.{header,header_format,mode∈{token_exchange,entra_obo}}` in the generic map, and call it in `handlePatchConfig` on the merged map **before** `json.Unmarshal(mergedBytes, &merged)` (`httpapi/server.go:5398-5408`), in `/config/apply` on the decoded raw document, and nowhere on the boot path (boot normalises + records diagnostics instead) β€” `internal/config/loader.go`, `internal/config/loader_serveredition.go` (new, `//go:build server`), `internal/config/removed_keys{,_stub}.go` (new), `internal/config/load_diagnostics.go` (new, edition-neutral type + `LogLoadDiagnostics`), `internal/httpapi/server.go`, `cmd/mcpproxy/main.go`, `internal/runtime/config_hotreload.go` (reload emit) +- [x] T019 [US5] Split `ServerEditionConfig.Validate` into non-mutating `Validate()` + `ApplyDefaults()` (TTLs, Microsoft `TenantID`, `MCPPROXY_CRED_KEY` fallback); call `Validate` from `Config.Validate()` and `ValidateDetailed()` under the server build (`internal/config/config_validate_serveredition.go` + `_stub.go`); call `ApplyDefaults()` then `Validate()` at `setup.go:96` β€” `internal/config/server_edition_config.go`, `internal/config/config.go`, `internal/config/config_validate_serveredition{,_stub}.go`, `internal/serveredition/setup.go` +- [x] T020 [US5] Personal-build carriers: `type ServerEditionConfig struct{ raw json.RawMessage }` and `type AuthBrokerConfig struct{ raw json.RawMessage }` with verbatim `MarshalJSON`/`UnmarshalJSON`. **Omission is the parent pointer's job, not the carrier's**: `Config.ServerEdition` and `ServerConfig.AuthBroker` are `*T` with `omitempty` (`config.go:584,752`), so a document without the key leaves the pointer nil and the key is omitted on write; a JSON `null` also decodes to a nil pointer. A non-nil carrier with nil `raw` (only constructible from Go code) marshals as `{}` β€” `MarshalJSON` must return valid JSON and cannot remove the parent key. A build-tagged `Clone()` deep-copies the carrier (`append(json.RawMessage(nil), raw...)`); the existing value copy `broker := *src.AuthBroker` at `merge.go:678-684` (inside `CopyServerConfig`) would alias the backing array and becomes `dst.AuthBroker = src.AuthBroker.Clone()`; **there is no `ServerEdition` copy call at HEAD** (`merge.go` copies servers only; the config snapshot shares the top-level pointer and nothing mutates it in the personal build), so `Clone()` on `ServerEditionConfig` is provided for symmetry and pinned by a unit test but wired nowhere; test: mutating the source `AuthBroker` carrier after `CopyServerConfig` leaves the copy unchanged; `handlePatchConfig` decodes patch and base with `json.Decoder.UseNumber` (`internal/httpapi/server.go:5332,5376`); export `MergeConfigPatch(base, patch map[string]any) map[string]any` as a one-line wrapper over `deepMergeJSON` (`:5512`) for T010 β€” `internal/config/server_edition_config_stub.go`, `internal/config/auth_broker_stub.go`, `internal/config/merge.go`, `internal/httpapi/server.go` +- [x] T021 [P] [US5] `mcpproxy credential list|status` banner (`cmd/mcpproxy/credential_cmd.go:32` `Short:` and the output header) β€” `cmd/mcpproxy/credential_cmd.go` +- [x] T022a [P] [US5] **[behaviour-red]** (the row exists at `fields.ts:327`) Extend `frontend/tests/unit/settings-server-edition-wording.spec.ts` to assert that no `SERVER_EDITION_FIELDS` entry has key `server_edition.max_user_servers` β€” `frontend/tests/unit/settings-server-edition-wording.spec.ts` +- [x] T022 [P] [US5] (after T022a) Web UI: drop `server_edition.max_user_servers` from `SERVER_EDITION_FIELDS` (`frontend/src/views/settings/fields.ts:327`) β€” `frontend/src/views/settings/fields.ts` +- [x] T023 [P] [US5] Docs corrections (FR-036, this PR's slice): `docs/features/idp-token-storage.md` β†’ one-paragraph tombstone; `docs/features/auth-broker.md` and `docs/cli/credential-commands.md` open with "stored, not injected", lose `:82-106` and the `token_exchange`/`entra_obo` rows, and `auth-broker.md` keeps a `credential_encryption_key` / `MCPPROXY_CRED_KEY` paragraph (the tombstoned `idp-token-storage.md` was its only home); `docs/development/server-edition-multiuser-auth.md:27-28,40,53,54-55,206-210,242-243` corrected; `docs/features/activity-log.md:28,300`; `docs/configuration/upstream-servers.md:112`; `CLAUDE.md` editions row (no server `.deb`/tar.gz); `internal/config/auth_broker.go:24-27` and `config.go:746-752` comments β€” files above +- [x] T024 [US5] Run both builds, `go vet ./...`, both lint passes, `go test -race ./internal/upstream/... ./internal/transport/...`, the server race suites, and confirm T008–T013 now pass; regenerate nothing (no OAS/contract change in this PR) but run `make swagger-verify` and `TestContractsInSync` to prove it β€” verification.md Β§PR-A +- [x] T025 [US5] Real-instance verification: quickstart Β§9 (personal-build round trip of a config with the removed keys and a valid `oauth_connect` block), then a server-edition boot of the same file asserting the one-warning-per-key log lines and `Enabled` still true; `mcpproxy credential list` shows the banner; record in verification.md β€” `verification.md` ## Phase A.3 β€” US6: per-owner token cap (P3, #1177) -- [ ] T026 [US6] **[behaviour-red]** (existing APIs; the cap is global today) Write failing `internal/storage/agent_tokens_owner_cap_test.go`: owner A at 100 β†’ A's 101st `ErrAgentTokenLimitReached`, B's first succeeds, ownerless operator tokens count as one owner; and `internal/serveredition/api/user_token_cap_test.go` (`-tags server`): the 409 body names only the caller's count; `internal/httpapi/tokens_cap_test.go`: personal 409 body unchanged in meaning β€” files above -- [ ] T027 [US6] Enforce per owner in `CreateAgentToken` (`internal/storage/agent_tokens.go:194-198`): **there is no owner index** (records are keyed by hash, `UserID` lives inside the JSON) and once the global cap goes the bucket may hold far more than `MaxTokens` rows, so the count is a **full bucket walk** that decodes each record and counts `UserID == new.UserID`, stopping early only once `MaxTokens` matches are found (never after `MaxTokens` rows); correct the now-false comment at `:87-92` ("the bucket is capped at auth.MaxTokens entries"); the T026 fixture seeds > 100 other-owner rows *before* the target owner's rows in key order and asserts the target owner's 100th token still succeeds and the 101st fails; reword the 409 bodies at `internal/httpapi/tokens.go:224` and `user_handlers.go:1165-1174`; update `docs/development/server-edition-multiuser-auth.md:115-121` and `docs/features/agent-tokens.md`; commit message `Related #1177` β€” files above -- [ ] T028 [US6] Real-instance verification: quickstart Β§8 on a server-edition instance with two seeded users (harness or `curl` loop); record in verification.md β€” `verification.md` +- [x] T026 [US6] **[behaviour-red]** (existing APIs; the cap is global today) Write failing `internal/storage/agent_tokens_owner_cap_test.go`: owner A at 100 β†’ A's 101st `ErrAgentTokenLimitReached`, B's first succeeds, ownerless operator tokens count as one owner; and `internal/serveredition/api/user_token_cap_test.go` (`-tags server`): the 409 body names only the caller's count; `internal/httpapi/tokens_cap_test.go`: personal 409 body unchanged in meaning β€” files above +- [x] T027 [US6] Enforce per owner in `CreateAgentToken` (`internal/storage/agent_tokens.go:194-198`): **there is no owner index** (records are keyed by hash, `UserID` lives inside the JSON) and once the global cap goes the bucket may hold far more than `MaxTokens` rows, so the count is a **full bucket walk** that decodes each record and counts `UserID == new.UserID`, stopping early only once `MaxTokens` matches are found (never after `MaxTokens` rows); correct the now-false comment at `:87-92` ("the bucket is capped at auth.MaxTokens entries"); the T026 fixture seeds > 100 other-owner rows *before* the target owner's rows in key order and asserts the target owner's 100th token still succeeds and the 101st fails; reword the 409 bodies at `internal/httpapi/tokens.go:224` and `user_handlers.go:1165-1174`; update `docs/development/server-edition-multiuser-auth.md:115-121` and `docs/features/agent-tokens.md`; commit message `Related #1177` β€” files above +- [x] T028 [US6] Real-instance verification: quickstart Β§8 on a server-edition instance with two seeded users (harness or `curl` loop); record in verification.md β€” `verification.md` ## Phase A.4 β€” Delivery -- [ ] T029 [X] Run the full gate set (plan.md Β§Gates) including the isolated `test-api-e2e.sh`; record every command and outcome β€” `verification.md` -- [ ] T030 [X] Cross-model review with codex `gpt-5.6-sol` (briefs split: cut diff / config normaliser + carriers / docs), ≀ 10 rounds, verify each finding before fixing; record rounds and verdicts β€” `verification.md` +- [x] T029 [X] Run the full gate set (plan.md Β§Gates) including the isolated `test-api-e2e.sh`; record every command and outcome β€” `verification.md` +- [x] T030 [X] Cross-model review with codex `gpt-5.6-sol` (briefs split: cut diff / config normaliser + carriers / docs), ≀ 10 rounds, verify each finding before fixing; record rounds and verdicts β€” `verification.md` - [ ] T031 [X] Open PR-A (`--body-file`; `Related #1177`; the six retired tests listed by name; no AI attribution); tick the roadmap task row `pr:`; run `python3 scripts/gen-roadmap.py --check`; repair CI until green; do not merge without instruction β€” `roadmap.yaml`, `ROADMAP.md` --- diff --git a/specs/107-server-edition-sso-hardening/verification.md b/specs/107-server-edition-sso-hardening/verification.md index 5fa57bdc8..1c167b585 100644 --- a/specs/107-server-edition-sso-hardening/verification.md +++ b/specs/107-server-edition-sso-hardening/verification.md @@ -6,6 +6,54 @@ Evidence per PR. Each section records the real-instance run, the automated gate ### Real instance +Run 2026-09-16 on HEAD `fb389e632` (branch merged with `origin/main` `b1777e865`, see the merge note under Automated checks). Binaries built from THIS tree into the session scratchpad `rig/` (`go build -tags server -o rig/mcpproxy-server ./cmd/mcpproxy` β†’ `MCPProxy v0.1.0 (server)`; `go build -o rig/mcpproxy ./cmd/mcpproxy` β†’ `(personal)`). Each instance ran on its own high port with **both** `--config //mcp_config.json` and `--data-dir /` (never `~/.mcpproxy`), started as its own background call, readiness polled on `/readyz`, and stopped by PID (`kill $(pgrep -f 'serve.*')`, waited out until `pgrep` was empty; the live tray core on :8080 was never touched). Ports 18317/18318 were verified free with `lsof` first. + +**Fixture** (`rig/legacy.json`, identical for both editions apart from `listen`): `server_edition` with `enabled: true`, `admin_emails`, a valid `oauth` block (`provider: google`), plus the removed `max_user_servers: 7`, `workspace_idle_timeout: "45m"`, the deprecated `store_idp_tokens: true`, and two planted numerals `big: 9007199254740993` / `frac: 0.1000000000000000055511151231257827`; three disabled `http` servers β€” `legacy-exchange` (`auth_broker.mode: token_exchange` + `header: X-Auth`), `legacy-header` (`mode: oauth_connect` + `header`/`header_format`), `connect-ok` (a valid `oauth_connect` block with `scopes`, `resource` and the same two planted numerals). + +**T025 β€” quickstart Β§9, personal build round trip (US5, FR-040)** β€” `rig/mcpproxy serve --config rig/personal/mcp_config.json --data-dir rig/personal --listen 127.0.0.1:18317 --log-level=debug`. + +- `/readyz` 200; `GET /api/v1/status` β†’ `{"edition":"personal"}`. The boot write-back (API-key bootstrap; `mcp_config.json.bak` created) and a `PATCH /api/v1/config {"tool_response_limit": 12345}` through the `handlePatchConfig` merge path (`{"success":true}`, the key landed in the file) both rewrote the file. +- Comparator (quickstart Β§9's `python3` block extended to every `auth_broker` block, `parse_int=str, parse_float=str`): `server_edition` and all three `auth_broker` blocks are **identical** before/after, removed keys included (`max_user_servers: "7"`, `workspace_idle_timeout: "45m"`, `store_idp_tokens: true`, `header`, `header_format`, `mode: token_exchange`), and the planted numerals survived verbatim (`9007199254740993`, `0.1000000000000000055511151231257827`) in both blocks. +- `grep -c 'no longer supported|never implemented|store_idp_tokens' rig/personal/main.log` = **0**: the personal build records no diagnostic and logs no warning (the only "deprecated" lines are the pre-existing `features` auto-clean, unrelated). + +**T025 β€” server-edition boot of the same file (FR-032/FR-033/FR-035)** β€” `rig/mcpproxy-server serve --config rig/server/mcp_config.json --data-dir rig/server --listen 127.0.0.1:18318 --log-level=debug`. + +- `/readyz` 200; `GET /api/v1/status` β†’ `{"edition":"server","running":true}`; `Server multi-user OAuth initialized {"provider": "google", "admin_emails": ["dana@example.com"]}` β€” **`Enabled` still true** after normalisation (and `GET /api/v1/auth/me` with a seeded session cookie answered `{"email":"alice@example.com","role":"user",…}`). +- Exactly **one WARN per key/mode at boot**, all from `mcpproxy/main.go:510` (`config.LogLoadDiagnostics` right after `logs.SetupLogger`), message text byte-equal to `contracts/config-keys.md`: + ``` + WARN mcpproxy/main.go:510 server_edition.max_user_servers is no longer supported and was ignored {"key": "server_edition.max_user_servers"} + WARN mcpproxy/main.go:510 server_edition.workspace_idle_timeout is no longer supported and was ignored {"key": "server_edition.workspace_idle_timeout"} + WARN mcpproxy/main.go:510 auth_broker.mode "token_exchange" was never implemented; the auth_broker block for server "legacy-exchange" was ignored {"key": "mcpServers[0].auth_broker.mode"} + WARN mcpproxy/main.go:510 auth_broker.header is no longer supported and was ignored {"key": "mcpServers[0].auth_broker.header"} + WARN mcpproxy/main.go:510 auth_broker.header is no longer supported and was ignored {"key": "mcpServers[1].auth_broker.header"} + WARN mcpproxy/main.go:510 auth_broker.header_format is no longer supported and was ignored {"key": "mcpServers[1].auth_broker.header_format"} + WARN mcpproxy/main.go:510 server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it {"key": "server_edition.store_idp_tokens"} + ``` + `grep -o '"key": "…"' | sort | uniq -c` = 1 per key, except `server_edition.store_idp_tokens` = 2: the API-key bootstrap write-back triggered a file hot reload one second later (`config_watcher.go:102 … hot-reloading` β†’ `lifecycle.go:1600`), and the reload re-loaded the written-back file, which no longer carries the dropped keys but still carries the retained `store_idp_tokens`. So each **load** warns once per key present, as T018 specifies (boot + every successful reload); the dropped keys cannot warn twice because the first write-back removes them. +- Written-back file: `server_edition` = `{"enabled":true,"admin_emails":[…],"oauth":{…},"session_ttl":"24h0m0s","store_idp_tokens":true}` (removed keys gone, deprecated key retained); `legacy-exchange.auth_broker` = `null` (whole block dropped), `legacy-header.auth_broker` = `{mode, token_endpoint, authorization_endpoint, client_id}` (only `header`/`header_format` dropped), `connect-ok.auth_broker` intact with `scopes` and `resource`. +- Write door: `PATCH /api/v1/config {"server_edition":{"max_user_servers":3}}` with the operator key β†’ **400** `{"validation_errors":[{"field":"server_edition.max_user_servers","message":"server_edition.max_user_servers is no longer supported and was ignored"}]}` (same text, refused not dropped β€” `ValidateRemovedKeys` on the raw document). +- `credential_encryption_key` unset β†’ one WARN `upstream credential broker disabled: no encryption key configured (set MCPPROXY_CRED_KEY or server_edition.credential_encryption_key)`; no `purged legacy IdP subject-token rows` line (fresh DB, nothing to purge). + +**T025 β€” `mcpproxy credential list|status` banner (FR-034)** β€” user JWT minted with Alice's session cookie via `POST /api/v1/auth/token` (348 bytes), then `rig/mcpproxy-server credential list --url http://127.0.0.1:18318 --token "$JWT"`: +``` +Stored credentials are kept for a future broker and are NOT injected into upstream calls in this release. +SERVER MODE STATUS TOKEN EXPIRES +------------------------------------------------------------------------------------------ +connect-ok oauth_connect unavailable - - +legacy-header oauth_connect unavailable - - +``` +`credential status connect-ok` opens with the same line, then `Server: connect-ok / Mode: oauth_connect / Status: unavailable`; `credential --help` carries it as the second paragraph. `legacy-exchange` is absent from the list because its `auth_broker` block was dropped at load. + +**T028 β€” quickstart Β§8, per-owner token cap on the same server instance (US6, #1177)**. The quickstart's fake OIDC IdP is PR-B, so the two tenants were seeded straight into the rig's `config.db` before boot with a throwaway `//go:build server` program (`scratchpad/seed/main.go`, compiled through `go build -overlay` so nothing entered the tree) that calls `users.NewUserStore(db).EnsureBuckets/CreateUser/CreateSession` for `alice@example.com` and `bob@example.com` (`provider: google`, 24h sessions). The cap was driven with `curl -b "mcpproxy_session=" -X POST /api/v1/user/tokens -d '{"name":…,"allowed_servers":["*"],"permissions":["read"],"expires_in":"720h"}'` in a loop. **Note the numbers: the cap this branch ships is the design `origin/main` merged in #1286 (`auth.MaxTokensPerOwner = 25` per owner *inside* the unchanged `auth.MaxTokens = 100` deployment record cap), not the "100 per owner replacing the global count" of spec FR-037 / tasks T026–T028 / quickstart Β§8 β€” the code wins (rule 9), and the spec text is a follow-up correction.** + +- Alice: mints 1–25 β†’ `201` (`allowed_servers` materialised to `["legacy-exchange","legacy-header","connect-ok"]`, never `"*"`); mint **#26 β†’ `409`** `{"error":"Conflict","message":"You have reached your limit of 25 agent tokens. Permanently delete one you no longer use to free a slot.","status_code":409}` β€” names *her* quota, no fleet total. `GET /user/tokens` as Alice β†’ 25. +- Bob's first mint β†’ **`201`**; his list β†’ 1 (Alice's exhaustion does not touch him). +- Ownerless operator mint `POST /api/v1/tokens` with `X-API-Key` β†’ **`201`** (`allowed_servers: ["*"]`; ownerless tokens are exempt from the owner quota and count only toward the 100-record deployment cap). +- Alice again after Bob and the operator minted β†’ still `409` with the same body. +- Revoke `DELETE /user/tokens/a2` β†’ 200, Alice's next mint β†’ still `409` (a revoked record keeps its slot, as the release notice says); `DELETE /user/tokens/a2/permanent` β†’ 200, Alice's next mint β†’ **`201`**. + +Teardown: both instances stopped by PID (`MCP proxy server shutdown complete` in each `main.log`), `pgrep -f 'serve.*1831[78]'` empty; the rig directory stays in the session scratchpad. + ### Automated checks Run 2026-09-16 on branch `107-a-freeze-cut` (HEAD `c2559867a`, merge-base `b39800a89`) from the worktree root, in plan.md Β§Gates order. Go `go1.26.0 darwin/arm64`. Logs in the session scratchpad `gates/`. @@ -38,7 +86,34 @@ Tooling note: `/opt/homebrew/bin/golangci-lint` (2.5.0, built with go1.25.1) ref Not run: none of the plan.md Β§Gates rows was skipped. PR-C/PR-D-only gates (Playwright tenant spec, SC-009 benchmark) do not apply to PR-A. -Merge-readiness caveat (found while fetching `origin/main` for the gofmt row): `origin/main` moved to `b1777e865` *"fix: enforce per-owner agent token quotas (#1286)"*, which **closes #1177 with a different design** (25-token per-owner quota **plus** the 100-record deployment cap, new `ErrAgentTokenOwnerLimitReached` / `auth.MaxTokensPerOwner`, `internal/storage/agent_token_owner_quota_test.go`). `git merge-tree --write-tree origin/main HEAD` reports content conflicts in `internal/storage/agent_tokens.go`, `internal/serveredition/api/user_handlers.go`, `internal/serveredition/api/user_token_mutation_test.go`, `docs/development/server-edition-multiuser-auth.md`, and an add/add conflict in `.github/RELEASE_NOTICE.md` (main's copy is the Spec 105 PR-A notice from #1279). Phase A.3 (T026–T028) must be reconciled against #1286 before PR-A is opened; the gate results above are for this branch as-is. +**Run 2 (T029, after the review rounds and the merge with `origin/main`)** β€” 2026-09-16 on HEAD `fb389e632` = `7a4e8fe22` (round-4 fix, the last code change) + merge commit `Merge origin/main (b1777e865) into 107-a-freeze-cut`. The merge resolved the one conflict, the add/add on `.github/RELEASE_NOTICE.md`, by keeping the branch copy, whose first section is a byte-identical prefix of main's Spec 105 notice from #1279 (`head -n 11 | diff` = empty) and which appends the four PR-A entries after it; everything else auto-merged (main's #1279 + #1286 = 88 files). The merge-readiness caveat below is therefore closed: `git merge-tree origin/main HEAD` is clean, and the Phase A.3 conflicts it listed had already been reconciled in `3bf4394c8` (the branch carries #1286's `MaxTokensPerOwner`/`ErrAgentTokenOwnerLimitReached`/`agent_token_owner_quota_test.go`). Same machine and toolchain as run 1; logs in the session scratchpad `gates-r6/`. Every plan.md Β§Gates row was re-run, in order: + +| # | Command | Outcome | +|---|---------|---------| +| 1 | `go build -o /dev/null ./cmd/mcpproxy` | pass | +| 2 | `go build -tags server -o /dev/null ./cmd/mcpproxy` (and `-o rig/mcpproxy-server` for the real-instance run) | pass | +| 3 | `go vet ./...` and `go vet -tags server ./...` | pass (exit 0 both) | +| 4 | `go test -race -tags server -timeout 20m ./internal/serveredition/... ./internal/config/... ./internal/oauth/... ./internal/storage/...` | pass β€” 9 packages `ok`, 0 FAIL | +| 5 | `go test -race -tags server -timeout 20m -skip '' ./internal/server/... ./internal/httpapi/...` | pass β€” `internal/server` 371.5s, `internal/server/tokens`, `internal/httpapi` `ok` | +| 6 | `go test -race -timeout 20m -skip '' ./internal/...` (personal) | pass β€” 65 packages `ok`, 0 FAIL | +| 7 | `go test -race -timeout 10m ./cmd/mcpproxy` and `… -tags server …` | pass (`ok` both) | +| 8 | `go test -count=1 -v ./internal/server/ -run 'TestToolsListSnapshot_MatchesMergeBaseGoldens\|TestToolsListSnapshot_DeltaIsEnumerated\|TestMenuSurface_ExactDeltaFromPreFeature\|TestDirectFullMode_ByteStableAgainstPreFeatureE2E'` (frozen goldens, unregenerated, on the tree that now includes #1279) | pass β€” all 4 `--- PASS` | +| 9 | `go test ./cmd/release-gate/` | pass (`ok`, 0.9s) | +| 10 | `go test ./cmd/...` | pass β€” 8 packages `ok` | +| 11 | `golangci-lint run --config .github/.golangci.yml --timeout=10m ./...` (v2.9.0 via `go run`, see the tooling note) | pass β€” `0 issues` | +| 12 | `golangci-lint run --config .github/.golangci.yml --build-tags server --timeout=10m ./...` | pass β€” `0 issues` | +| 13 | `scripts/test-api-e2e.sh` β€” isolated: `pgrep -f test-api-e2e.sh` empty, no foreign scratch core in `pgrep -fl 'mcpproxy.*serve'`; scratch copy with only line 80 (`pkill -f "mcpproxy.*serve"`) deleted (`diff` = `80d79`); `LISTEN_PORT=18523` and launcher port 39933 verified free with `lsof`; personal `./mcpproxy` rebuilt from HEAD first; run only after rows 4–7 had finished so the suite was not racing the `-race` sweeps | pass β€” `Tests run: 65, passed: 65, failed: 0`; `git checkout -- test/e2e-config.json` restored the tracked config; the live tray core (pid 60473, :8080) survived | +| 14 | `make swagger-verify` | pass β€” `OpenAPI artifacts are up to date` | +| 15 | `go run ./cmd/generate-types && go test ./cmd/generate-types/ -run TestContractsInSync` | pass β€” `contracts.ts` regenerated byte-identical (tree clean), PASS | +| 16 | `python3 scripts/gen-roadmap.py --check` | pass β€” `ROADMAP.md is up to date.` | +| 17 | `cd frontend && npx vitest run` | pass β€” 122 files, 1265 tests | +| 18 | `gofmt -l $(git diff --name-only origin/main...HEAD -- '*.go')` (65 touched Go files still on disk) | pass β€” empty list | +| 19 | `python3 scripts/check-settings-parity.py` | pass β€” consistent (`server_edition.enabled`, `server_edition.oauth.provider` excluded by design) | +| 20 | `(cd native/macos/MCPProxy && swift test)` | **1160/1161** β€” `Executed 1161 tests, with 1 failure`: the same `AppLifecycleTests.testTheSharedJournalNeverWritesToTheRealInstanceRootUnderTests` as run 1 (live tray's `~/.mcpproxy/tray-lifecycle.jsonl`, 91 KB, mtime 16:05 today β€” memory `project_swift_test_silent_miss`). Pre-existing and environmental: PR-A touches no file under `native/` (`git diff --name-only origin/main...HEAD -- native/` = 0). | + +Not run: none. No gate regressed between run 1 and run 2; the only tree change in between was the four review-round fixes (recorded under Cross-review) and the merge. + +Merge-readiness caveat (found while fetching `origin/main` for the gofmt row): `origin/main` moved to `b1777e865` *"fix: enforce per-owner agent token quotas (#1286)"*, which **closes #1177 with a different design** (25-token per-owner quota **plus** the 100-record deployment cap, new `ErrAgentTokenOwnerLimitReached` / `auth.MaxTokensPerOwner`, `internal/storage/agent_token_owner_quota_test.go`). `git merge-tree --write-tree origin/main HEAD` reports content conflicts in `internal/storage/agent_tokens.go`, `internal/serveredition/api/user_handlers.go`, `internal/serveredition/api/user_token_mutation_test.go`, `docs/development/server-edition-multiuser-auth.md`, and an add/add conflict in `.github/RELEASE_NOTICE.md` (main's copy is the Spec 105 PR-A notice from #1279). Phase A.3 (T026–T028) must be reconciled against #1286 before PR-A is opened; the gate results above are for this branch as-is. **Closed 2026-09-16**: the A.3 reconciliation landed in `3bf4394c8`, and the remaining `RELEASE_NOTICE.md` add/add conflict was resolved by the merge commit recorded in Run 2 above; the branch now merges clean. ### Cross-review @@ -58,6 +133,7 @@ Merge-readiness caveat (found while fetching `origin/main` for the gofmt row): ` - **Fixed** β€” (a) `docs/features/auth-broker.md:63` (c3, P2): the `client_secret` row PR-A added claimed "Supports `${env:VAR}` so the secret stays out of the file", but nothing expands it for the connect flow β€” `connectorProvider.connector` (`api/connector_provider.go:80-90`) copies `server.AuthBroker.ClientSecret` from the boot/live `config.ServerConfig` and `(*OAuthConnector).postToken` (`broker/oauth_connector.go:276`) sends it verbatim; the only `ExpandStructSecrets*` caller in production is `upstream/core.NewClientWithOptions` (`client.go:175-178`) on the MCP client's own `CopyServerConfig`, so a reference would reach the token endpoint as the literal string and fail `invalid_client`. Honesty PR, so the sentence was corrected (references are **not** expanded for the connect flow; put the literal or use a public client) rather than adding expansion behaviour; wiring the resolver into the connector is a PR-B+ item. Verified not pinned elsewhere (`grep 'env:VAR'` across docs/, `cmd/release-gate/`, the notice and `auth_broker.go`: only this row). `go test ./cmd/release-gate/` still green. - **Rejected** β€” none. - Suites re-run on this HEAD before the round (all green): both builds; `go test -race ./internal/config/` under both tags; `go test -race -skip ./internal/httpapi/ ./cmd/mcpproxy/ ./internal/storage/ ./cmd/release-gate/` (personal) and `./internal/httpapi/ ./cmd/mcpproxy/ ./internal/storage/ ./internal/serveredition/...` (`-tags server`) β€” 13 packages ok. After the fix: `go test ./cmd/release-gate/` ok (docs-only change; no Go file touched). Round 5 (re-review of the fix) follows if the orchestrator schedules one; two of three chunks are already CLEAN. +- **Round 5** (2026-09-16, reviewer switched per maintainer directive to the `opencode` CLI, model `github-copilot/gpt-5.6-sol`, project-local `opencode.json` with explicit permissions (edit/question/webfetch/external_directory=deny), stdin closed, `gtimeout 1500`, one process at a time under the `/private/tmp/claude-501/opencode.lock` mutex; the same three briefs re-issued on HEAD `7a4e8fe22` with a round-5 preamble naming the single round-4 fix to re-verify and the four rejections to date with their reasons; the reviewer was allowed read-only shell commands and targeted single-package `go test -run`, never a tree mutation; logs `scratchpad/pra-oc-r5-c{1,2,3}.log`, ANSI-stripped `.txt`). Verdicts: c1 **CLEAN**, c2 **CLEAN**, c3 **CLEAN** β€” 0 findings; every log carries a `VERDICT:` line and `grep -c 'permission requested'` is 0 on all three, so none counts as EMPTY. Each chunk independently confirmed the round-4 wording (`auth_broker.client_secret` references are not expanded for the connect flow) against `api/connector_provider.go` and `broker/oauth_connector.go` and found no surviving `${env:VAR}` claim in the diff. The reviewer's own read-only probes on this HEAD, all green: `go test ./cmd/release-gate`; `TestLatentSymbolsGuard*`, `TestPersonalBuild_OpaqueBlocksRoundTrip`, `TestPersonalCarriers*` (personal) and `TestLegacyKeys_*`, `TestPurgeLegacyIDPSubjectTokens*` (`-tags server`) in `internal/config` / `broker`; `TestConfigPatch_*` in `internal/httpapi`; `TestCredential(List|Status)_*` + `TestLoadCLIConfig_*` (`-tags server`) in `cmd/mcpproxy`; `TestSetupMultiUserOAuth_*` in `internal/serveredition`; `TestAgentTokenOwnerQuota*`/`TestAgentTokenCap_*` in `internal/storage` and `TestCreateUserToken_*` (`-tags server`) in `serveredition/api`. Tree clean after the round (`git status` empty, HEAD unchanged). No fix pushed, so no round is consumed beyond this one: 5 of 10 used. **PR-A cross-review closed CLEAN.** ## PR-B β€” generic OIDC + front door + telemetry From c741f82f23e4a832141531ddfc69fc2aac8ac45c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 16:12:21 +0300 Subject: [PATCH 11/35] docs(roadmap): link PR-A --- ROADMAP.md | 6 +++--- roadmap.yaml | 1 + specs/107-server-edition-sso-hardening/tasks.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b85c7e6f0..f12b3013b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -431,7 +431,7 @@ graph LR | Task | Status | Refs | | --- | --- | --- | -| PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) | πŸ”΅ In progress | β€” | +| PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) | πŸ”΅ In progress | #1287 | | PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) | βšͺ Todo | β€” | | PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4) | βšͺ Todo | β€” | | PR-D attributable JSONL audit line + auth_event + config/doctor/metrics (US3) | βšͺ Todo | β€” | @@ -894,7 +894,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 | 31/126 (25%) | [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 | 32/126 (25%) | [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/) | | @@ -1036,4 +1036,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/) | `drafted` | 0/109 (0%) | | [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` | 31/126 (25%) | +| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 32/126 (25%) | diff --git a/roadmap.yaml b/roadmap.yaml index f4be67c1f..8859ed3da 100644 --- a/roadmap.yaml +++ b/roadmap.yaml @@ -812,6 +812,7 @@ epics: title: PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) status: in_progress depends_on: [] + pr: "#1287" - id: sso-pr-b-oidc-front-door title: PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) status: todo diff --git a/specs/107-server-edition-sso-hardening/tasks.md b/specs/107-server-edition-sso-hardening/tasks.md index ecd7be9d0..bafddddc0 100644 --- a/specs/107-server-edition-sso-hardening/tasks.md +++ b/specs/107-server-edition-sso-hardening/tasks.md @@ -72,7 +72,7 @@ Gate set for every PR = plan.md Β§Gates. Never claim a skipped gate passed; writ - [x] T029 [X] Run the full gate set (plan.md Β§Gates) including the isolated `test-api-e2e.sh`; record every command and outcome β€” `verification.md` - [x] T030 [X] Cross-model review with codex `gpt-5.6-sol` (briefs split: cut diff / config normaliser + carriers / docs), ≀ 10 rounds, verify each finding before fixing; record rounds and verdicts β€” `verification.md` -- [ ] T031 [X] Open PR-A (`--body-file`; `Related #1177`; the six retired tests listed by name; no AI attribution); tick the roadmap task row `pr:`; run `python3 scripts/gen-roadmap.py --check`; repair CI until green; do not merge without instruction β€” `roadmap.yaml`, `ROADMAP.md` +- [x] T031 [X] Open PR-A (`--body-file`; `Related #1177`; the six retired tests listed by name; no AI attribution); tick the roadmap task row `pr:`; run `python3 scripts/gen-roadmap.py --check`; repair CI until green; do not merge without instruction β€” `roadmap.yaml`, `ROADMAP.md` --- From 6cf54279bd70a9718c355cd448d828730a33e1f6 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 17:18:01 +0300 Subject: [PATCH 12/35] test(oauthserver): fake OIDC identity provider and server-edition dev rig (Spec 107 PR-B) --- .../server-edition-multiuser-auth.md | 40 +- scripts/dev-server-edition.sh | 411 +++++++++++ tests/oauthserver/authorize.go | 6 + tests/oauthserver/cmd/server/main.go | 342 +++++++-- tests/oauthserver/cmd/server/main_test.go | 156 +++++ tests/oauthserver/discovery.go | 34 +- tests/oauthserver/jwks.go | 63 +- tests/oauthserver/oidc_test.go | 651 ++++++++++++++++++ tests/oauthserver/options.go | 83 +++ tests/oauthserver/server.go | 51 +- tests/oauthserver/templates/login.html | 1 + tests/oauthserver/token.go | 215 ++++++ tests/oauthserver/types.go | 26 +- tests/oauthserver/userinfo.go | 90 +++ 14 files changed, 2096 insertions(+), 73 deletions(-) create mode 100755 scripts/dev-server-edition.sh create mode 100644 tests/oauthserver/cmd/server/main_test.go create mode 100644 tests/oauthserver/oidc_test.go create mode 100644 tests/oauthserver/userinfo.go diff --git a/docs/development/server-edition-multiuser-auth.md b/docs/development/server-edition-multiuser-auth.md index 87bdd51a2..73eed4215 100644 --- a/docs/development/server-edition-multiuser-auth.md +++ b/docs/development/server-edition-multiuser-auth.md @@ -261,8 +261,46 @@ bucket whose `allowed_servers` contains `"*"` and whose `user_id` is non-empty ```bash go test -tags server ./internal/serveredition/... -v -race # All server unit + integration tests -go build -tags server ./cmd/mcpproxy # Build server edition +go build -tags server -o mcpproxy-server ./cmd/mcpproxy # Build server edition (always -o: a bare build overwrites ./mcpproxy) go build ./cmd/mcpproxy # Verify personal edition unaffected ``` +### Local rig: `scripts/dev-server-edition.sh` + +The Spec 107 verification rig runs the whole SSO path against a fake OpenID +Provider, loopback-only, in a scratch directory β€” it never touches +`~/.mcpproxy`, the tray's core or a real IdP. It is the executable form of +`specs/107-server-edition-sso-hardening/quickstart.md`; the script is the +source of truth and that page is its narrative. + +```bash +scripts/dev-server-edition.sh # phase b: build, fake IdP, boot, headless login, /auth/me +scripts/dev-server-edition.sh --phase c # + tenant principal on core REST (PR-C) +scripts/dev-server-edition.sh --phase d --keep # + token mint, /mcp gate, audit tail (PR-D); keep the scratch dir +scripts/dev-server-edition.sh --idp-args "-token-error bad-signature" # one US2 tamper case: expects a 403, no session +``` + +What it does, in order: builds `mcpproxy-server` (`-tags server -o`, never +bare) and the fake IdP (`tests/oauthserver/cmd/server -oidc`) into the scratch +dir; runs `npm ci --prefix tests/echo-rugpull-server` once for the stdio +fixture (`node tests/echo-rugpull-server/index.js`, a deterministic `echo` +tool while `DESC_FILE` is unset); writes `mcp_config.json` with +`server_edition.oauth.provider: "oidc"` pointing at the IdP through +`${env:OIDC_CLIENT_ID}` / `${env:OIDC_CLIENT_SECRET}`; boots the server +edition with **both** `--config` and `--data-dir` on a free `18xxx` port and +waits for `/readyz` + `/api/v1/status` (`edition: server`); performs the +headless login as `alice@example.com` (302 to the IdP with `S256` + `nonce`, +POST the login form without following redirects, then GET the callback) and +prints `/api/v1/auth/me`; repeats the login with +`redirect_uri=https://evil.example/` and asserts the 302 lands on `/ui/`. + +Rules it encodes: every wait loop is bounded and every `curl` carries +`--max-time`, so a missing piece (an older branch without the `oidc` provider +answers exit 4 at config load) is reported with the reason, never hung on; +teardown kills only the PIDs it started (never `pkill` by name) and removes +the scratch dir only when it created it via `mktemp` (`--scratch DIR` and +`MCPPROXY_RIG_SCRATCH` are always kept, as is any failed run). Gates for the +script itself: `bash -n scripts/dev-server-edition.sh` and `shellcheck +scripts/dev-server-edition.sh`. + > Note: server-edition `//go:build server` routes are invisible to `swag` / `verify-oas-coverage.sh` (which don't pass `--build-tags server`), so document endpoints here. CI lints twice β€” bare and with `--build-tags server` β€” and race-tests `internal/server`, `internal/httpapi` and `internal/storage` under the tag (Spec 107 FR-047); run both lint passes locally before pushing (see the Lint block in `CLAUDE.md`). diff --git a/scripts/dev-server-edition.sh b/scripts/dev-server-edition.sh new file mode 100755 index 000000000..6e0da438d --- /dev/null +++ b/scripts/dev-server-edition.sh @@ -0,0 +1,411 @@ +#!/usr/bin/env bash +# dev-server-edition.sh β€” Spec 107 local verification rig (quickstart.md). +# +# Isolated, synthetic, loopback-only. Builds the server edition into a scratch +# directory, starts the fake OpenID Provider from tests/oauthserver, writes a +# scratch config + data dir, boots the server edition on a high port, performs +# the headless login as alice@example.com and prints /api/v1/auth/me. Nothing +# here touches ~/.mcpproxy, the tray's core, or a production IdP. +# +# Usage: +# scripts/dev-server-edition.sh [--phase b|c|d] [--scratch DIR] [--port N] +# [--idp-port N] [--idp-args ""] +# [--keep] [--skip-build] +# +# --phase b quickstart Β§0–§4: build, fake IdP, config, boot, headless login, +# open-redirect check (default) +# --phase c + Β§5: tenant principal on core REST (PR-C) +# --phase d + Β§6: mint an agent token as Alice, call /mcp, audit tail (PR-D) +# --scratch root for the binary, config, data dir and logs +# (default: $MCPPROXY_RIG_SCRATCH or mktemp under $TMPDIR) +# --idp-args extra flags for the fake IdP, e.g. "-token-error bad-signature"; +# any tamper flag switches the login step to expect a refusal +# --keep keep the scratch directory on success (always kept on failure) +# --skip-build reuse /mcpproxy-server and /oauthserver +# +# Rules this script encodes (all verified, see quickstart.md "Rules"): +# * the server binary is built with `-o` β€” a bare `go build -tags server +# ./cmd/mcpproxy` clobbers ./mcpproxy; +# * the instance runs with BOTH --config and --data-dir, else the generated +# API key lands in a file the next boot never reads; +# * teardown kills only the PIDs this script started β€” never pkill by name; +# * every curl carries --max-time and every wait loop is bounded, so a +# missing feature (e.g. the OIDC provider on an older branch) is reported, +# never hung on. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/.." && pwd)" +cd "$REPO" + +PHASE="b" +SCRATCH="${MCPPROXY_RIG_SCRATCH:-}" +PORT="" +IDP_PORT="" +IDP_EXTRA="" +KEEP="0" +SKIP_BUILD="0" +SCRATCH_OWNED="0" + +usage() { sed -n '2,34p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --phase) PHASE="${2:-}"; shift 2 ;; + --phase=*) PHASE="${1#*=}"; shift ;; + --scratch) SCRATCH="${2:-}"; shift 2 ;; + --scratch=*) SCRATCH="${1#*=}"; shift ;; + --port) PORT="${2:-}"; shift 2 ;; + --port=*) PORT="${1#*=}"; shift ;; + --idp-port) IDP_PORT="${2:-}"; shift 2 ;; + --idp-port=*) IDP_PORT="${1#*=}"; shift ;; + --idp-args) IDP_EXTRA="${2:-}"; shift 2 ;; + --idp-args=*) IDP_EXTRA="${1#*=}"; shift ;; + --keep) KEEP="1"; shift ;; + --skip-build) SKIP_BUILD="1"; shift ;; + -h | --help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$PHASE" in +b | c | d) ;; +*) echo "--phase must be b, c or d (got: '$PHASE')" >&2; exit 2 ;; +esac + +# --------------------------------------------------------------------------- +# Output helpers. Assertions are fail-fast: later steps depend on earlier ones. +# --------------------------------------------------------------------------- +ts() { date '+%H:%M:%S'; } +log() { echo "$(ts) [rig] $*"; } +ok() { echo "$(ts) [rig] ok $*"; } +FAILED="0" +die() { + FAILED="1" + echo "$(ts) [rig] FAIL $*" >&2 + exit 1 +} +tail_log() { # tail_log