Skip to main content

Authorization - permissions, roles and route gating

Permissions say what a caller may do; memberships (Membership: platform, tenant and account scope) say where, and point at the role that grants the permissions. Both layers apply and stay independent: a route is gated by permission centrally, and keeps its own data scoping (own account, own tickets, ...) in route code. Roles themselves are described in Roles and scopes.

Model​

  • Catalogue: Permission in app/core/authz.py, values module:action (e.g. tickets:escalate). Metadata (module, action, description) is exposed at GET /roles/catalogue. The catalogue is code because the routes are; which permissions a role holds is data.
  • Roles: rows of roles with a permissions list (app/models/role.py). There is no engine and no cache: get_current_user loads the caller's roles with their memberships on every request (membership_service.effective_roles), so an edit applies on the next request in every worker.
  • Claims: claims_from_roles builds roles, role_ids, permissions, kinds, platform from those rows. A tenant's role never yields a cross-tenant permission. API keys carry scopes instead (auth_type == "api_key"), never roles.
  • user_has_permission(user, permission): the permission is in permissions (or, for a key, in scopes).

Central gating​

Every authenticated router is mounted with _auth in app/api/v1/router.py: [Depends(get_current_user), Depends(enforce_route_permission)]. The gate (app/api/route_gate.py) asks app.core.permissions.resolve(method, route_template):

  1. EXEMPT_RULES - public (health, /auth/*, public invite validate/accept) or authenticated (self-service: /me, /me/*, /users/me*, /notifications*, POST /tenants/switch, GET /announcements; API keys are refused on these).
  2. RULES - explicit overrides, e.g. POST /tickets/{}/escalate -> tickets:escalate.
  3. MODULE_PATHS - path prefix -> module; safe methods need <module>:read, others <module>:write (tenants, api_keys, roles, platform use manage).

No match -> 403 Route has no permission mapping (fail closed). A 403 for a missing permission is Insufficient permission. Permissions in MFA_PERMISSIONS (platform:*, tenants:manage, connectors:write|configure) additionally need an MFA-enabled user while SUPERADMIN_REQUIRES_MFA is on. Route code never checks a role name: it only scopes data, by the caller's kinds and memberships (is_account_scoped, is_end_customer, is_staff).

Managing roles​

See Roles and scopes: /roles for a tenant's roles and /platform/roles for the platform's, with the permission-subset rules and the audit trail.

Adding a route​

  1. Put it in an existing router (already mounted with _auth) or mount a new one with _auth.
  2. Make sure its path resolves: reuse a module in MODULE_PATHS, or add the module (+ the Permission members, their descriptions in app/core/permissions.py, a place in app/core/role_templates.py and a data migration for existing roles), or add a rule to RULES.
  3. Self-service or public endpoints go to EXEMPT_RULES - deliberately, with a comment.
  4. Run tests/test_route_permission_guard.py: it fails listing any unmapped route, and checks that the gate really blocks a role-less caller on every permission route.

Rolling out a new default​

Roles are data, so changing a template in app/core/role_templates.py does not change existing roles. To give existing roles a new permission, write a data migration; to return a single role to its template, use POST /roles/{id}/reset (the dashboard's "Reset to default"). A fresh database and mise run db:reseed always start from the current templates.