Skip to content
Merged
19 changes: 14 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ ContextForge implements a **two-layer security model**:

### Token Scoping Quick Reference

The `teams` claim in JWT tokens determines resource visibility:
**API / legacy tokens** β€” JWT `teams` claim is the sole authority (`normalize_token_teams()`):

| JWT `teams` State | `is_admin: true` | `is_admin: false` |
|-------------------|------------------|-------------------|
Expand All @@ -87,11 +87,20 @@ The `teams` claim in JWT tokens determines resource visibility:
| `teams: []` | PUBLIC-ONLY `[]` | PUBLIC-ONLY `[]` |
| `teams: ["t1"]` | Team + Public | Team + Public |

**Session tokens** (`token_use: "session"`) β€” DB is the authority; JWT `teams` only narrows (`resolve_session_teams()`):

| JWT `teams` State | DB admin? | Result | Access Level |
|-------------------|-----------|--------|--------------|
| any | yes | `None` | ADMIN BYPASS (DB authority) |
| Missing/null/`[]` | no | DB teams | Full DB membership |
| `["t1"]` | no | intersection | Narrowed to overlap |
| `["revoked"]` | no | `[]` | Public-only (fail-closed) |

**Key behaviors:**

- 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
- **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.
- **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.
- **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.

### Security Invariants (Required)

Expand All @@ -100,7 +109,7 @@ The `teams` claim in JWT tokens determines resource visibility:
- Keep the two-layer model on every path:
- Layer 1: token scoping controls what a caller can see.
- Layer 2: RBAC controls what a caller can do.
- Do not re-implement token team interpretation logic; always use `normalize_token_teams()` in `mcpgateway/auth.py`.
- 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`).
- Do not accept inbound client auth tokens via URL query parameters.
- Legacy `INSECURE_ALLOW_QUERYPARAM_AUTH` is interop-only for outbound peer auth and must remain opt-in and host-restricted.
- High-risk transports must be feature-flagged and disabled by default.
Expand Down
48 changes: 37 additions & 11 deletions docs/docs/architecture/multitenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,23 @@ Enforcement summary:

### How Token Scoping Works

Token scoping is the mechanism that determines which resources a user can **see** based on the `teams` claim in their JWT token. The `normalize_token_teams()` function in `mcpgateway/auth.py` is the **single source of truth** for interpreting JWT team claims.
Token scoping is the mechanism that determines which resources a user can **see** based on the `teams` claim in their JWT token.

**Key functions** in `mcpgateway/auth.py`:

- `normalize_token_teams()` β€” The **single source of truth** for interpreting JWT team claims into a canonical form. Used directly by API tokens and legacy tokens.
- `resolve_session_teams()` β€” The **single policy point** for session-token team resolution. Teams are always resolved from the database first so that revoked memberships take effect immediately. If the JWT carries a non-empty `teams` claim, the result is narrowed to the intersection of DB teams and JWT teams β€” letting callers scope a session to a subset of their memberships without risking stale grants. If the intersection is empty (e.g. all JWT-claimed teams have been revoked), an empty list is returned, which demotes the session to public-only scope β€” the user can still access public resources and their own private resources, but loses access to all team-scoped resources. An explicit `teams: []` (empty list) in the JWT is treated as "no restriction requested" and returns the full DB membership β€” this intentionally differs from `normalize_token_teams` where `[] β†’ public-only`. If `email` is `None` or empty, returns `[]` (public-only) β€” an identity-less session token never receives admin bypass.
- `_narrow_by_jwt_teams()` β€” Shared intersection logic used by `resolve_session_teams`.
- API tokens and legacy tokens always use `normalize_token_teams()` directly.

### Secure-First Defaults

The token scoping system follows a **secure-first design principle**: when in doubt, access is restricted.

#### API Tokens and Legacy Tokens

These use `normalize_token_teams()` directly β€” the JWT `teams` claim is the sole authority:

| JWT `teams` State | `is_admin` | Result | Access Level |
|-------------------|------------|--------|--------------|
| Key MISSING | any | `[]` | PUBLIC-ONLY (secure default) |
Expand All @@ -328,8 +339,24 @@ The token scoping system follows a **secure-first design principle**: when in do
| Key `[]` (empty) | any | `[]` | PUBLIC-ONLY |
| Key `["t1", "t2"]` | any | `["t1", "t2"]` | TEAM-SCOPED |

#### Session Tokens

Session tokens use `resolve_session_teams()` β€” the **database** is the authority, and the JWT `teams` claim only narrows (never broadens) the result:

| JWT `teams` State | DB Teams | Result | Access Level |
|-------------------|----------|--------|--------------|
| Key MISSING | `["t1", "t2"]` | `["t1", "t2"]` | Full DB membership |
| Key `null` | `["t1", "t2"]` | `["t1", "t2"]` | Full DB membership |
| Key `[]` (empty) | `["t1", "t2"]` | `["t1", "t2"]` | Full DB membership (no restriction requested) |
| Key `["t1"]` | `["t1", "t2"]` | `["t1"]` | Narrowed to intersection |
| Key `["revoked"]` | `["t1", "t2"]` | `[]` | Empty intersection β€” public-only (fail-closed) |
| any | `None` (admin) | `None` | ADMIN BYPASS (DB authority) |

!!! warning "Critical Security Behavior"
A **missing** `teams` key always results in public-only access, even for admin users. Admin bypass requires **explicit** `teams: null` combined with `is_admin: true`.
A **missing** `teams` key always results in public-only access for API/legacy tokens, even for admin users. Admin bypass requires **explicit** `teams: null` combined with `is_admin: true`.

!!! note "Session Token Membership Staleness"
Session tokens skip the `_check_team_membership` re-validation in the token scoping middleware because `resolve_session_teams()` already resolved membership from the database. Membership staleness is bounded by the `auth_cache` TTL. The cache stores the full DB membership (not the per-session narrowed intersection) so that multiple sessions for the same user narrow independently.

### Multi-Team Token Behavior

Expand All @@ -343,7 +370,7 @@ This means the first team in the token's teams array has special significance fo

### Return Value Semantics

The `normalize_token_teams()` function returns:
The `normalize_token_teams()` and `resolve_session_teams()` functions return:

| Return Value | Meaning | Query Behavior |
|--------------|---------|----------------|
Expand Down Expand Up @@ -1215,14 +1242,13 @@ flowchart TD

These behaviors are enforced consistently across all access paths:

1. `normalize_token_teams()` is the ONLY function that interprets JWT team claims
2. Missing `teams` key always returns `[]` (public-only, secure default)
3. Admin bypass requires BOTH `teams: null` AND `is_admin: true`, and both `token_teams=None` AND `user_email=None` in the service layer
4. Empty teams list (`[]`) results in public-only access, even for admins
5. All list endpoints pass `token_teams` to the service layer
6. Service layer applies visibility filtering based on `token_teams` via `BaseService._apply_access_control()`
7. Public-only tokens can ONLY access `visibility='public'` resources β€” owner and team access are both suppressed
8. Owner-based access (`owner_email`) grants visibility only for `visibility='private'` resources β€” it does not bypass team scoping for team-visibility resources
1. `normalize_token_teams()` is the canonical interpreter of JWT team claims; `resolve_session_teams()` is the single policy point for session tokens (always DB-resolved)
2. For API/legacy tokens: missing `teams` key always returns `[]` (public-only, secure default); empty `teams: []` also returns `[]`. For session tokens: missing, null, or empty `teams` returns the full DB membership (no narrowing requested)
3. Admin bypass for API/legacy tokens requires BOTH `teams: null` AND `is_admin: true`; for session tokens, admin bypass is DB-derived (`is_admin` flag). In both cases the service layer requires `token_teams=None` AND `user_email=None` for unrestricted queries
4. All list endpoints pass `token_teams` to the service layer
5. Service layer applies visibility filtering based on `token_teams` via `BaseService._apply_access_control()`
6. Public-only tokens can ONLY access `visibility='public'` resources β€” owner and team access are both suppressed
7. Owner-based access (`owner_email`) grants visibility only for `visibility='private'` resources β€” it does not bypass team scoping for team-visibility resources

### Related Documentation

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/development/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Tracked outputs:

- `docs/docs/design/images/classes.svg` (class diagram)
- `docs/docs/design/images/packages.svg` (package diagram)
- `docs/docs/design/images/code2flow.svg` (call flow diagram)


Commit updated SVGs when they change so the architecture pages stay current.

Expand Down
45 changes: 31 additions & 14 deletions docs/docs/manage/rbac.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,17 @@ Protected entities:

## Token Scoping Model

Token scoping controls what resources a token can access based on the `teams` claim in the JWT payload. The `normalize_token_teams()` function is the **single source of truth** for interpreting JWT team claims across all enforcement points.
Token scoping controls what resources a token can access based on the `teams` claim in the JWT payload. The `normalize_token_teams()` function is the **single source of truth** for interpreting JWT team claims into a canonical form. For session tokens, `resolve_session_teams()` is the **single policy point** β€” it resolves teams from the database first (so revoked memberships take effect immediately), then narrows the result to the intersection with any JWT-embedded `teams` claim (so callers can scope a session to a subset of their memberships). If the intersection is empty (e.g. all JWT-claimed teams have been revoked), an empty list is returned, demoting the session to public-only scope β€” the user can still access public resources and their own private resources, but loses access to all team-scoped resources. An explicit `teams: []` in a session JWT is treated as "no restriction requested" and returns the full DB membership.

### Token Scoping Contract

The `teams` claim in JWT tokens determines resource visibility. The system follows a **secure-first design**: when in doubt, access is denied.

**API tokens and legacy tokens** β€” JWT `teams` claim is the sole authority:

```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Token Teams Claim Handling β”‚
β”‚ API / Legacy Token Teams Claim Handling β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ JWT Claim State β”‚ is_admin: true β”‚ is_admin: false β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
Expand All @@ -158,14 +160,28 @@ The `teams` claim in JWT tokens determines resource visibility. The system follo
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

!!! warning "Admin Bypass Requirements"
Admin bypass (unrestricted access) requires **BOTH** conditions:
**Session tokens** β€” DB is the authority; JWT `teams` only narrows (never broadens):

1. `teams: null` (explicit null, not missing key)
2. `is_admin: true`
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Session Token Teams Resolution β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ JWT Claim State β”‚ DB Teams β”‚ Result β”‚ Access Level β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Missing / null / [] β”‚ ["t1", "t2"] β”‚ ["t1", "t2"] β”‚ Full DB scope β”‚
β”‚ ["t1"] β”‚ ["t1", "t2"] β”‚ ["t1"] β”‚ Narrowed β”‚
β”‚ ["revoked"] β”‚ ["t1", "t2"] β”‚ [] β”‚ Public-only β”‚
β”‚ any β”‚ None (admin) β”‚ None β”‚ Admin bypass β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

!!! note "Session Token Staleness"
Session tokens skip `_check_team_membership` re-validation in the token scoping middleware because `resolve_session_teams()` already resolved membership from the database. Membership staleness is bounded by the `auth_cache` TTL. The cache stores the full DB membership (not the per-session narrowed intersection) so that multiple sessions for the same user narrow independently.

!!! warning "Admin Bypass Requirements"
**API / legacy tokens:** Admin bypass requires **BOTH** `teams: null` (explicit null, not missing key) and `is_admin: true`. A missing `teams` key or `teams: []` results in public-only access, even for admins.

A missing `teams` key always results in public-only access, even for admins.
An empty `teams: []` also results in public-only access, even for admins.
**Session tokens:** Admin bypass is determined by the **database** `is_admin` flag, not the JWT `teams` claim. If the DB user is admin, `resolve_session_teams()` returns `None` (admin bypass) regardless of the JWT `teams` state. The JWT `teams` claim cannot narrow an admin session β€” it only narrows non-admin sessions.

### `is_admin`, `teams`, and `token_use` (Mental Model)

Expand Down Expand Up @@ -194,7 +210,7 @@ Key points:
- Verify JWT/API token.
- Resolve `token_use`.
2. **Normalize/resolve teams**
- `token_use=session` β†’ resolve from DB (`_resolve_teams_from_db()`).
- `token_use=session` β†’ resolve from DB and optionally narrow by JWT claim (`resolve_session_teams()`).
- `token_use!=session` β†’ normalize JWT `teams` (`normalize_token_teams()`).
3. **Layer 1: Token scoping**
- Filter accessible resources by `token_teams`.
Expand Down Expand Up @@ -222,13 +238,14 @@ Key points:

1. **Secure-First Defaults**

- Missing `teams` key always returns `[]` (public-only access)
- This prevents accidental exposure when tokens are misconfigured
- API/legacy tokens: missing `teams` key always returns `[]` (public-only access), preventing accidental exposure when tokens are misconfigured
- Session tokens: missing/null/empty `teams` returns full DB membership (no narrowing requested); the DB is the authority

2. **Explicit Admin Bypass**

- Admin bypass requires explicit `teams: null` AND `is_admin: true`
- Empty teams `[]` disables bypass even for admins
- API/legacy tokens: admin bypass requires explicit `teams: null` AND `is_admin: true`
- Session tokens: admin bypass is DB-derived (DB `is_admin` flag); JWT `teams` only narrows non-admin sessions
- Empty teams `[]` disables bypass even for admins (API/legacy tokens)

3. **Scoped Automation Tokens**

Expand Down Expand Up @@ -620,7 +637,7 @@ When `AUTH_REQUIRED=false`:

| Use Case | Recommended Token Scope |
|----------|------------------------|
| Admin UI access | Session token (`teams: null` + `is_admin: true`) |
| Admin UI access | Session token (admin bypass is DB-derived; no `teams` claim needed) |
| CI/CD pipeline | `teams: []` (public-only) |
| Service integration | Specific team(s) |
| Developer access | Personal team + project teams |
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/manage/securing.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ Tokens can be scoped to specific teams using the `teams` JWT claim:
**Security Default**: Non-admin tokens without explicit team scope default to public-only access (principle of least privilege).

!!! note "Session Tokens vs API Tokens"
For `token_use: "session"` (Admin UI login), teams are resolved server-side from DB/cache on each request.
For `token_use: "session"` (Admin UI login), teams are resolved server-side from DB/cache on each request via `resolve_session_teams()`. If the JWT carries a non-empty `teams` claim, the result is narrowed to the intersection of DB teams and JWT teams, allowing callers to scope a session to a subset of their memberships.
For `token_use: "api"` or legacy tokens, teams are interpreted from the JWT `teams` claim using `normalize_token_teams()`.

#### Server-Scoped Tokens
Expand Down
Loading
Loading