CRUMB a card from devarno-cloud

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 / 403

Where Each Path Enforces

SurfaceMechanismFile
Engine HTTPGlobal AuthMiddlewareengine/src/engine/auth/middleware.py
MCP HTTP routesPer-route @require_permissiontraceo_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 < owner

require_permission(Permission.X) checks that the caller’s role is >= the role required for permission X. Examples observable in server.py:

PermissionTools requiring it
REQUIREMENTS_CREATEcreate_requirement, ariel_baseline_create
REQUIREMENTS_UPDATEupdate_requirement, ariel_baseline_freeze, ariel_sync
REQUIREMENTS_DELETEdelete_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