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:
Permissioninapp/core/authz.py, valuesmodule:action(e.g.tickets:escalate). Metadata (module, action, description) is exposed atGET /roles/catalogue. The catalogue is code because the routes are; which permissions a role holds is data. - Roles: rows of
roleswith apermissionslist (app/models/role.py). There is no engine and no cache:get_current_userloads 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_rolesbuildsroles,role_ids,permissions,kinds,platformfrom those rows. A tenant's role never yields a cross-tenant permission. API keys carryscopesinstead (auth_type == "api_key"), never roles. user_has_permission(user, permission): the permission is inpermissions(or, for a key, inscopes).
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):
EXEMPT_RULES-public(health,/auth/*, public invite validate/accept) orauthenticated(self-service:/me,/me/*,/users/me*,/notifications*,POST /tenants/switch,GET /announcements; API keys are refused on these).RULES- explicit overrides, e.g.POST /tickets/{}/escalate -> tickets:escalate.MODULE_PATHS- path prefix -> module; safe methods need<module>:read, others<module>:write(tenants,api_keys,roles,platformusemanage).
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
- Put it in an existing router (already mounted with
_auth) or mount a new one with_auth. - Make sure its path resolves: reuse a module in
MODULE_PATHS, or add the module (+ thePermissionmembers, their descriptions inapp/core/permissions.py, a place inapp/core/role_templates.pyand a data migration for existing roles), or add a rule toRULES. - Self-service or public endpoints go to
EXEMPT_RULES- deliberately, with a comment. - 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.