Skip to content

Commit 1994d61

Browse files
committed
docs(auth): document JWT-trust mode and complete validation gate
Extra scope beyond the docs sweep: - Error-path wiring for the B.2 matrix disabled rows that B.3/B.12 did not cover: password login/register/reset (401), SSO browser login (401, at the browser callback; the cited service function stays live for the default-funnel provisioning path), session-token refresh (401), user management in the admin UI and admin API (403), invitations and team membership writes for trust-only principals (403 via the new LocalUserRecordRequiredError). tests/unit/mcpgateway/ test_trust_mode_disabled_surfaces.py covers every disabled matrix row and asserts status plus message. - Test-isolation fixes exposed by the two-mode gate: a conftest autouse fixture clears the correlation-id contextvar per test (a sync test leaked it into the worker root context and broke an A.9 audit lookup), a conftest autouse fixture pins jwt_trust_mode=db per test so the suite is hermetic when JWT_TRUST_MODE=jwt-trust is exported, test_jwt_trust_config test_defaults now removes the ambient JWT_TRUST_MODE it claimed to isolate, and the auth_cache key doctest asserts the mode segment against settings instead of a hard-coded db. - Pylint false-positive fix (pre-existing, gate-blocking): inline not-callable disables on the two SQLAlchemy func.now() server defaults in the external_group_mappings model. - .secrets.baseline regenerated by make detect-secrets-scan (line drift from the doc edits). Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent 3f7e101 commit 1994d61

19 files changed

Lines changed: 594 additions & 16 deletions

.secrets.baseline

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

AGENTS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ ContextForge implements a **two-layer security model**:
168168

169169
**Key behaviors:**
170170

171-
- **API/legacy tokens**: Missing `teams` key = public-only access (secure default). Admin bypass requires BOTH `teams: null` AND `is_admin: true`. `normalize_token_teams()` in `mcpgateway/auth.py` is the single source of truth.
171+
- **API/legacy tokens**: Missing `teams` key = public-only access (secure default). Admin bypass requires BOTH `teams: null` AND `is_admin: true`. `normalize_token_teams()` in `mcpgateway/auth_context.py` is the single source of truth.
172172
- **Token creation defaults to the creator's personal team**: `POST /tokens` (and admin-delegated creation) with no `team_id` no longer mints a `teams: null` (public-only) token for non-admin callers. `TokenCatalogService.get_default_team_id()` resolves the caller's (or, for admin delegation, the target's) personal team and `routers/tokens.py::create_token` uses it when the caller belongs to that team; it falls back to single-team inheritance, then to `team_id=None` plus a `TokenCreateResponse.warnings` entry only when neither applies (no personal team and multiple/zero teams). Un-narrowed admins are exempt — `team_id=None` for them is a deliberate global-scope token. The permission-containment check (`_get_caller_permissions`) still uses the *requested* `team_id`, not the defaulted one, so this does not raise the ceiling on what `scope.permissions` a caller may request. Separately, `derive_token_team_id()` in `mcpgateway/auth.py` — the function that turns a single-team token's claim into `request.state.team_id` for RBAC/rate-limit/routing context — excludes personal teams, since a personal team auto-grants `team_admin`; a personal-team-scoped token instead falls through to `check_any_team`, matching how `PermissionService._get_user_roles` already treats personal teams.
173173
- **Session tokens**: Admin bypass is determined by the DB `is_admin` flag, not the JWT `teams` claim. Non-admin sessions can be narrowed via JWT `teams`. `resolve_session_teams()` in `mcpgateway/auth.py` is the single policy point.
174174
- **Layer 1 only**: Token scoping controls visibility (what you can see). RBAC (Layer 2) is evaluated independently — session-token narrowing does not restrict which team roles are checked for permissions.
@@ -191,7 +191,7 @@ The derived triple is memoized on `request.state` per principal, so calling the
191191
- Keep the two-layer model on every path:
192192
- Layer 1: token scoping controls what a caller can see.
193193
- Layer 2: RBAC controls what a caller can do.
194-
- Do not re-implement token team interpretation logic; use `normalize_token_teams()` for API/legacy tokens and `resolve_session_teams()` for session tokens (both in `mcpgateway/auth.py`).
194+
- Do not re-implement token team interpretation logic; use `normalize_token_teams()` in `mcpgateway/auth_context.py` for API/legacy tokens and `resolve_session_teams()` in `mcpgateway/auth.py` for session tokens.
195195
- Do not re-implement Layer 1 token scope semantics; use `token_scope_grants()` in `mcpgateway/middleware/rbac.py`, the single policy point shared by the RBAC decorators and `TokenScopingMiddleware`. Empty token scopes mean "inherit from RBAC at runtime" (what `TokenCatalogService._generate_token()` emits for tokens created without an explicit scope) and must never be treated as deny-all; `*` grants everything and `<category>.*` grants that category.
196196
- Do not accept inbound client auth tokens via URL query parameters.
197197
- Legacy `INSECURE_ALLOW_QUERYPARAM_AUTH` is interop-only for outbound peer auth and must remain opt-in and host-restricted.
@@ -203,6 +203,10 @@ The derived triple is memoized on `request.state` per principal, so calling the
203203
- A `token-exchange` OAuth grant (RFC 8693 / On-Behalf-Of) exists for gateways; with it, the user's inbound JWT is exchanged with a trusted Authorization Server and **never forwarded upstream** — only the exchanged token is sent to the downstream MCP server.
204204
- `token_url` on a `token-exchange` gateway is an SSRF / egress boundary: the user's ContextForge JWT is POSTed to it as the `subject_token`, it is validated at config time, and creating or modifying token-exchange gateways is a privileged action.
205205
- Audit token-exchange operations via the structured logging sink with a `correlation_id`; never log raw subject tokens or exchanged tokens.
206+
- **Trust-mode dispatch rule**: a token is trust-eligible when (a) gateway-signed: `token_use=="trusted"` AND trust mode ON AND required mapped claims present AND configured revocation claim present; or (b) external IdP: trust mode ON AND issuer is a configured trust root (`trusted_for_api_auth` + `api_audience`) AND required mapped claims present AND configured revocation claim present. All other tokens follow the default funnel. A `token_use="trusted"` token with trust mode OFF is rejected 401 — the marker never enters the default funnel.
207+
- **Trust-mode revocation guarantee**: a trust-eligible token without the configured revocation claim (`JWT_TRUST_REVOCATION_CLAIM`, default `jti`; `uti` for Entra roots) is rejected 401. Revocation is keyed by the configured claim only; there is no sid-keyed revocation for trust-mode principals.
208+
- **Trust-mode admin-claim posture**: `is_admin` in trust mode derives from the mapped admin claim, never from a database row. The posture is fail-closed: a missing admin claim means non-admin.
209+
- **Trust-mode `is_active` loss**: trust-mode principals have no `is_active` database field. Deactivation happens at the identity provider (the token is no longer issued) or through the revocation blocklist.
206210

207211
#### UAID Cross-Gateway Security
208212

docs/docs/architecture/multitenancy.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ flowchart TD
9797
style N fill:#f3e5f5
9898
```
9999

100+
### Trust Mode & Cross-Tenant Group Mapping
101+
102+
When `JWT_TRUST_MODE=jwt-trust`, no local user record exists for trust-mode principals, so team membership cannot come from `email_team_members` rows. The `external_group_mappings` table closes this gap: each row maps one external group — keyed by `(issuer, tenant, external_group_id)` — to one ContextForge team (`cf_team_id`) and, optionally, one role (`cf_role`). At authentication time the resolver reads the token's group claims, matches them against the mappings for the token's issuer and tenant, and derives the principal's team list (and mapped roles) from the result. Because the mapping key carries the issuer and tenant, two identity providers — or two tenants of one provider — can map groups with the same name to different ContextForge teams without collision. Team membership therefore follows the identity provider's group assignments: a user moved between groups at the identity provider lands in the matching ContextForge teams on the next token, with no local write.
103+
100104
---
101105

102106
## Team Architecture & Management

docs/docs/architecture/oauth-design.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,31 @@ This path is gated by `SSO_API_TOKEN_AUTH_ENABLED` (global) and `SSOProvider.tru
328328
!!! note "Revocation and role-sync caveats"
329329
ContextForge cannot revoke an externally-issued token before its own expiry — only local user-deactivation/team-membership changes take effect immediately. If role-sync is enabled for the provider, teams/admin status are re-derived from token claims into the local DB on each provisioning pass. See the [SSO documentation](../manage/sso.md#machine-to-machine-api-auth-with-external-idp-tokens) for details.
330330

331+
## JWT Trust Mode
332+
333+
JWT trust mode (`JWT_TRUST_MODE=jwt-trust`) builds on the inbound external-token path above: a signed JWT alone proves identity, roles, and teams, and no local user record is read on the request path.
334+
335+
### Dispatch rule (disjunctive eligibility)
336+
337+
A token is trust-eligible when either branch holds (see `docs/docs/architecture/auth-token-dispatch.md` for the full rule and the deny matrix):
338+
339+
- **(a) Gateway-signed:** the token carries `token_use="trusted"` AND trust mode is ON AND the required mapped claims are present AND the configured revocation claim is present. `POST /admin/tokens/trust` mints these tokens for local users from server-side authority.
340+
- **(b) External IdP:** trust mode is ON AND the issuer is a configured trust root (`trusted_for_api_auth` plus a non-empty `api_audience` on the `SSOProvider`) AND the required mapped claims are present AND the configured revocation claim is present.
341+
342+
Every other token follows the default funnel, even when trust mode is ON: session tokens, API tokens, and external IdP tokens from non-trust-root issuers keep the default (database-backed) behavior, including JIT provisioning. A token that carries `token_use="trusted"` while trust mode is OFF is rejected with `401`; the marker never enters the default funnel.
343+
344+
### Revocation guarantee
345+
346+
A trust-eligible token that lacks the configured revocation claim (`JWT_TRUST_REVOCATION_CLAIM`, default `jti`; Entra trust roots may use `uti`) is rejected with `401`. The claim is mandatory because it is the only revocation handle trust mode has: revoking the claim value in the blocklist denies the token on the next request. Revocation is keyed by the configured claim only; there is no sid-keyed revocation for trust-mode principals.
347+
348+
### Overage policy
349+
350+
Entra tokens that exceed the group-claim limit carry an overage marker instead of a `groups` array. `JWT_TRUST_OVERAGE_POLICY` selects the behavior:
351+
352+
- `fail_closed` (default): reject the token with `401`.
353+
- `graph_lookup`: resolve the full group list through the app-only Microsoft Graph client and cache the result.
354+
- `proceed_without_groups`: authenticate the principal without group-derived teams; claims-derived roles and explicit teams still apply.
355+
331356
## Future Enhancements
332357

333358
- Wire UI toggles for token storage and auto-refresh to backend logic.

docs/docs/manage/configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,17 @@ All defaults preserve the current behavior: trust mode defaults to `db` and the
223223

224224
Posture change in `jwt-trust` mode: the gateway does not read the local user record on the request path, so there is no per-user `is_active` kill-switch for trust-mode principals. To withdraw access, revoke the token through the configured revocation claim (`JWT_TRUST_REVOCATION_CLAIM`, default `jti`) or remove the external group mapping. A token that carries `token_use="trusted"` is rejected with `401` when trust mode is `db`: the marker never enters the default funnel. The auth-cache Redis key carries the mode as a namespace segment, so a mode flip cold-starts every auth cache automatically.
225225

226+
Surfaces disabled in `jwt-trust` mode (the full decision table lives in `docs/docs/architecture/auth-feature-mode-matrix.md`):
227+
228+
- Password login, registration, and password reset return `401` with "Password authentication disabled in trust mode".
229+
- SSO browser login returns `401` with "SSO browser login disabled in trust mode".
230+
- Session-token refresh returns `401` with "Session refresh disabled in trust mode".
231+
- User management (admin UI and admin API) returns `403` with "User management disabled in trust mode".
232+
- Invitations and team membership writes for a principal with no local user record return `403` with "Invitations require local user records" or "Team membership writes require local user records".
233+
- API-token minting for a trust-only principal fails with "Token minting is disabled for trust-only principals. Create a local user account first.". Principals with a local user record mint tokens normally.
234+
235+
Mint endpoint: `POST /admin/tokens/trust` mints a gateway-signed trust token (`token_use="trusted"`) for a local user. The endpoint is admin-only. Every claim derives from server-side authority: the database admin flag, team memberships, and role assignments. The `sub` claim is the target's canonical user_id; a body that names another subject is rejected with `403`. Trust tokens are ephemeral: the mint writes no token-catalog row, and revocation is through the jti-based blocklist only.
236+
226237
### UI Features
227238

228239
For detailed guidance on embedding and section customization, see [Admin UI Customization](admin-ui-customization.md).

docs/docs/manage/rbac.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,18 @@ When `AUTH_REQUIRED=false`:
682682

683683
---
684684

685+
## Trust Mode
686+
687+
When `JWT_TRUST_MODE=jwt-trust`, a signed JWT alone proves identity, roles, and teams. The gateway does not read the local user record on the request path. RBAC inputs change source, not shape:
688+
689+
- **Teams and roles come from mapped claims, not database rows.** The claims named by `JWT_CLAIM_TEAMS` and `JWT_CLAIM_ROLES` carry the values. External group identifiers map to ContextForge teams (and optionally one role) through the `external_group_mappings` table, keyed by `(issuer, tenant, external_group_id)`.
690+
- **The admin flag comes from the mapped admin claim.** `JWT_CLAIM_ADMIN` (default `is_admin`) decides platform-admin status. The posture is fail-closed: a token without the admin claim is not an admin, even when a database row for the same user says otherwise.
691+
- **Permission checks are unchanged.** Roles resolve to permissions through the same role table. Team-scoped checks consume the claims-derived team list the same way they consume a database-derived list.
692+
- **No `is_active` kill-switch exists for trust-mode principals.** Withdraw access at the identity provider (the token is no longer issued) or revoke the token through the configured revocation claim (`JWT_TRUST_REVOCATION_CLAIM`, default `jti`).
693+
- **Local-record writes are disabled for trust-only principals.** Invitations, team membership writes, and token-catalog minting need a local user record and return a clear error without one. See `docs/docs/architecture/auth-feature-mode-matrix.md` for the full surface matrix.
694+
695+
---
696+
685697
## Best Practices
686698

687699
### Token Lifecycle

0 commit comments

Comments
 (0)