You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: AGENTS.md
+6-2Lines changed: 6 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -168,7 +168,7 @@ ContextForge implements a **two-layer security model**:
168
168
169
169
**Key behaviors:**
170
170
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.
172
172
- **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.
173
173
-**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.
174
174
-**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
191
191
- Keep the two-layer model on every path:
192
192
- Layer 1: token scoping controls what a caller can see.
193
193
- 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.
195
195
- 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.
196
196
- Do not accept inbound client auth tokens via URL query parameters.
197
197
- 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
203
203
- 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.
204
204
-`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.
205
205
- 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.
Copy file name to clipboardExpand all lines: docs/docs/architecture/multitenancy.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -97,6 +97,10 @@ flowchart TD
97
97
style N fill:#f3e5f5
98
98
```
99
99
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.
Copy file name to clipboardExpand all lines: docs/docs/architecture/oauth-design.md
+25Lines changed: 25 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -328,6 +328,31 @@ This path is gated by `SSO_API_TOKEN_AUTH_ENABLED` (global) and `SSOProvider.tru
328
328
!!! note "Revocation and role-sync caveats"
329
329
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.
330
330
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
+
331
356
## Future Enhancements
332
357
333
358
- Wire UI toggles for token storage and auto-refresh to backend logic.
Copy file name to clipboardExpand all lines: docs/docs/manage/configuration.md
+11Lines changed: 11 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -223,6 +223,17 @@ All defaults preserve the current behavior: trust mode defaults to `db` and the
223
223
224
224
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.
225
225
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
+
226
237
### UI Features
227
238
228
239
For detailed guidance on embedding and section customization, see [Admin UI Customization](admin-ui-customization.md).
Copy file name to clipboardExpand all lines: docs/docs/manage/rbac.md
+12Lines changed: 12 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -682,6 +682,18 @@ When `AUTH_REQUIRED=false`:
682
682
683
683
---
684
684
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.
0 commit comments