Roles and scopes
Principles
- A role says what you may do; a scope says where. Roles are never global: every grant is an
explicit
Membershiprow, so one person can hold one role in Celtic and another in HIBS. - Nothing is implicit. No role silently reaches every tenant, nothing is inherited from another role, and there is no hidden flag. Not holding a row means no access, whatever the person's job title.
- Roles are data, not code. A role is a row in
roles; what it can do is the list of permissions on that row. Nothing in the code is keyed on a role's name or key. Tenant admins edit their tenant's roles, superadmins edit any tenant's roles and the platform's. - You can only hand out what you hold. Who may grant a role, edit it or act on a user is decided by comparing permissions (see "Who may manage whom"), not by a rank.
The roles table
| Column | Meaning |
|---|---|
tenant_id | The owning tenant; NULL for a platform role (the console) |
key | Stable slug ([a-z][a-z0-9_]{1,39}), unique inside the tenant (or among platform roles). Fixed after creation |
name, description | What people see; editable |
permissions | The catalogue keys (module:action) the role grants. No inheritance: the list is the role |
kind | staff, client or customer. The one thing a permission list cannot say: whose data a holder sees (below). Fixed after creation |
is_system | Pre-configured: editable and resettable, never deleted |
template_key | The seed template it was created from; "reset" restores it |
kind drives the few behaviours that are not a permission:
staffworks the whole tenant (or the platform).clientis a client company's user, limited to the accounts they are restricted to by account-scope memberships (what searches, accounts, lines, customers and tickets show).customeris an end customer's self-service login: only their own tickets, and it is the author type of their comments. It is created by the self-service flow, never by an invitation.
A user holding several roles gets the union of their permissions; one staff role lifts a
client restriction.
Pre-configured roles
The seed templates live in app/core/role_templates.py and are copied into rows:
| Role | Where | Kind | Default |
|---|---|---|---|
superadmin | platform, created at startup | staff | Every permission |
tenant_admin | every tenant, created with it | staff | Everything except the cross-tenant permissions; keeps roles:* and users:* |
operator | every tenant | staff | Create and edit the tenant's data; can read users and send invitations but not manage users, roles, API keys or integrations |
member | every tenant | client | Read the tenant's data and work tickets |
customer | every tenant | customer | Raise and follow own tickets |
Extra templates are offered as starting points when creating a role (people_manager,
operator_read_only, member_read_only, see GET /roles/templates) but are not created
automatically. Where the defaults are applied:
- A new tenant (
tenant.create_tenant,scripts/seed_data.py,scripts/bootstrap_admin.py --tenant) gets its own copy of the four tenant roles throughprovision_tenant_roles. - Startup (
app/main.pylifespan) creates the platformsuperadminrole and restores the permissions it may never lose. mise run db:reseedrebuilds everything from the templates, dev users included.
Each tenant owns its copy: editing operator in one tenant changes nothing in another.
Cross-tenant permissions
tenants:manage, platform:* and connectors:write|configure are
PLATFORM_ONLY_PERMISSIONS: only a platform role may hold them. A tenant role is refused them
when saved, and claims_from_roles ignores them even if a stored list somehow contains them.
Grants (memberships)
memberships holds every grant:
scope_type | scope_id | role_id | Meaning |
|---|---|---|---|
platform | the fixed platform id | a platform role | Console access and every tenant. tenant_id is NULL |
tenant | tenants.id | one of that tenant's roles | The role in that tenant |
account | Account.account_id | NULL | A restriction, not a grant |
- A platform role reaches every tenant because the platform scope covers them - not because a tenant role is implied. A platform user holds no tenant row and does not appear in a tenant's user list.
- Effective roles are per request.
get_current_userresolves the caller's roles from the database every request: their platform role(s) plus their role in the tenant named byX-Tenant-ID(with no tenant in the request: everything they hold). Access tokens carry identity only, so a role edit applies on the very next request. - The claims are rebuilt from those rows (
claims_from_roles):roles(keys),role_ids,permissions,kindsandplatform.GET /mereturns them (plusgrants, which the tenant switcher uses). Clients gate onpermissions, never on a role name. - MFA. The cross-tenant permissions require MFA (
SUPERADMIN_REQUIRES_MFA, default on).
Who may manage whom
Everything is a comparison of permission sets (covers(held, needed)):
- Granting a role (invite, create user, set role, tenant memberships, CSV import, account
members, restore): you must hold at least one permission and every permission the role holds, and
the role's
kindmust be allowed for you - someone with nostaffrole can only hand outclientroles, becausekindis what lifts the account restriction and a subset of permissions would not keep a colleague inside their accounts. Only a tenant's own roles can be granted in it, never acustomerrole (self-service creates those) and never a platform role (console only). The default roles used by some flows (thememberrole for an account member or a restore) are checked the same way, so editing them cannot be used to hand out more than the caller holds. - A tenant named in a URL (
/tenants/{id}/memberships): the caller must hold a role in that tenant (or a platform role), whateverX-Tenant-IDthey sent. - Acting on a user (suspend, delete, reset MFA, change role, set password): you cannot act on yourself (except renaming) and cannot act on someone who holds a permission you lack. A user who also belongs to another tenant can't have their password set from this one.
- Editing a role: you can only put permissions you hold into it, and only edit or delete a role whose permissions you hold in full.
- Locked grants.
granted_by_role/invited_by_rolerecord the grantor's role id; you can't undo a grant whose grantor held permissions you lack.
Managing roles
| Endpoint | Purpose |
|---|---|
GET /roles, GET/PATCH/DELETE /roles/{id}, POST /roles | The tenant's roles (tenant from X-Tenant-ID), roles:read / roles:manage |
POST /roles/{id}/reset | Restore a role created from a template to that template |
GET /roles/catalogue, GET /roles/templates | The permission catalogue (with platform_only) and the starting points |
/platform/roles | The platform's roles, platform:read / platform:manage with MFA. Read, edit and reset only: there is no way to grant a second platform role, so none can be created or deleted |
GET /users/assignable-roles | The roles the caller may give out (kind, grantable permissions): what role pickers use; needs only users:read |
Rules the API enforces, each with a readable detail. Authorization rules answer 403
(putting permissions you do not hold into a role, editing a role that holds permissions you do not
hold). Input rules answer 400 (permissions must exist; a tenant role cannot hold cross-tenant
permissions; key and kind cannot change; new roles are staff or client; platform roles
cannot be created; the system superadmin keeps roles:* and platform:*, the system
tenant_admin keeps roles:* and users:*). State rules answer 409 (a key may not repeat in
the tenant or shadow a platform role; system roles and roles in use - a membership or a live
pending invite - cannot be deleted; an expired invite does not count). Edits and deletes lock the
role row, so concurrent changes are serialised.
Every change is audited with the permissions added and removed (domain=role, tenant-scoped;
platform roles tenant-less) and shows in GET /audit?domain=role&entity_id=<role id>.
Changing the model later
- A new permission needs a
Permissionmember, a description (app/core/permissions.py), a route that requires it, a place in the templates, and a data migration that adds it to the existing roles that should hold it: roles are data, so existing rows do not pick up a new default by themselves.tests/test_route_permission_guard.pyfails if a permission is unused or a route is unmapped;tests/test_role_templates.pyguards the templates. - Changing a template only affects tenants created afterwards and "reset".
Operating notes
- Startup is safe with several processes: platform and tenant roles are created with
INSERT ... ON CONFLICT DO NOTHING.