JWT Auth & RBAC Hierarchy
traceo intermediate 5 min read
ELI5
Every request brings a stamped passport (JWT). At the door, BetterAuth checks the stamps and writes the visitor’s name and role on a sticky note (UserContext) that travels with the request. Each route looks at the role on the sticky note and, like a venue with viewer < editor < admin < owner tiers, lets the visitor in only if their tier reaches the floor they asked for.
Technical Deep Dive
Token Path
sequenceDiagram participant C as Client participant M as AuthMiddleware (engine) / decorator (mcp) participant B as BetterAuth participant CTX as UserContext (contextvars) participant H as Handler C->>M: Authorization: Bearer <jwt> M->>B: validate(jwt) B-->>M: UserInfo {user_id, email, role, workspace_id} M->>CTX: set(UserContext) M->>H: invoke H->>CTX: read role / workspace_id H-->>C: 200 / 403Where Each Path Enforces
| Surface | Mechanism | File |
|---|---|---|
| Engine HTTP | Global AuthMiddleware | engine/src/engine/auth/middleware.py |
| MCP HTTP routes | Per-route @require_permission | traceo_mcp_server/auth/middleware.py |
| MCP tools | @require_permission decorator on @mcp.tool() | traceo_mcp_server/server.py |
The engine’s middleware excludes /health, /ready, /metrics, /docs from validation. The MCP server’s /health is similarly anonymous because no decorator is applied to it.
Role Hierarchy
viewer < editor < admin < ownerrequire_permission(Permission.X) checks that the caller’s role is >= the role required for permission X. Examples observable in server.py:
| Permission | Tools requiring it |
|---|---|
REQUIREMENTS_CREATE | create_requirement, ariel_baseline_create |
REQUIREMENTS_UPDATE | update_requirement, ariel_baseline_freeze, ariel_sync |
REQUIREMENTS_DELETE | delete_requirement |
| (none) | list_requirements, search_requirements, analyze_impact (read-only) |
UserContext via contextvars
UserContext is stored in a contextvars.ContextVar so that async handlers, background tasks spawned with asyncio.create_task, and SQL helpers (auth.workspace_id()) all see the same identity without threading it through every function signature.
Key Terms
- BetterAuth → external JWT issuer/validator used by both services.
- UserContext → contextvars-scoped record of
user_id,email,role,workspace_id. @require_permission→ MCP-side decorator that gates a tool by role tier.
Q&A
Q: A tool has no @require_permission — is it public?
A: No. It still needs a valid JWT (the request must arrive with a populated UserContext); it just has no role floor. list_requirements is a typical case.
Q: Why does the engine use middleware while the MCP uses decorators? A: The MCP server exposes both HTTP routes and MCP tools, and some tools are anonymous-readable. A global middleware would couple both surfaces; per-decorator gating leaves room for fine-grained tool policy.
Q: What sets auth.workspace_id() for RLS?
A: A session GUC written at DB connection checkout, derived from UserContext.workspace_id. If the JWT is invalid, no GUC is set, and RLS filters everything to zero.
Examples
create_requirement is decorated with @require_permission(Permission.REQUIREMENTS_CREATE). A viewer-role JWT reaches the decorator, fails the role floor (viewer < editor), and the tool returns a structured error before any service call. Switching the JWT to an editor of the same workspace passes, and the row lands behind the workspace RLS predicate.
neighbors on the map
- Multi-Strategy Authentication debugging 401 errors across different clients
- Airlock JWT Handoff & Session Cookies debugging login loops or session expiry
- Chronicle JWT Claims & Dev Tokens issuing tokens from an external auth provider
- LORE RBAC & Airlock Auth Flow implementing authentication in a new LORE page