Skip to main content

Roles and scopes

Principles​

  1. A role says what you may do; a scope says where. Roles are never global: every grant is an explicit Membership row, so one person can hold one role in Celtic and another in HIBS.
  2. 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.
  3. 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.
  4. 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​

ColumnMeaning
tenant_idThe owning tenant; NULL for a platform role (the console)
keyStable slug ([a-z][a-z0-9_]{1,39}), unique inside the tenant (or among platform roles). Fixed after creation
name, descriptionWhat people see; editable
permissionsThe catalogue keys (module:action) the role grants. No inheritance: the list is the role
kindstaff, client or customer. The one thing a permission list cannot say: whose data a holder sees (below). Fixed after creation
is_systemPre-configured: editable and resettable, never deleted
template_keyThe seed template it was created from; "reset" restores it

kind drives the few behaviours that are not a permission:

  • staff works the whole tenant (or the platform).
  • client is 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).
  • customer is 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:

RoleWhereKindDefault
superadminplatform, created at startupstaffEvery permission
tenant_adminevery tenant, created with itstaffEverything except the cross-tenant permissions; keeps roles:* and users:*
operatorevery tenantstaffCreate and edit the tenant's data; can read users and send invitations but not manage users, roles, API keys or integrations
memberevery tenantclientRead the tenant's data and work tickets
customerevery tenantcustomerRaise 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 through provision_tenant_roles.
  • Startup (app/main.py lifespan) creates the platform superadmin role and restores the permissions it may never lose.
  • mise run db:reseed rebuilds 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_typescope_idrole_idMeaning
platformthe fixed platform ida platform roleConsole access and every tenant. tenant_id is NULL
tenanttenants.idone of that tenant's rolesThe role in that tenant
accountAccount.account_idNULLA 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_user resolves the caller's roles from the database every request: their platform role(s) plus their role in the tenant named by X-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, kinds and platform. GET /me returns them (plus grants, which the tenant switcher uses). Clients gate on permissions, 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 kind must be allowed for you - someone with no staff role can only hand out client roles, because kind is 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 a customer role (self-service creates those) and never a platform role (console only). The default roles used by some flows (the member role 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), whatever X-Tenant-ID they 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_role record the grantor's role id; you can't undo a grant whose grantor held permissions you lack.

Managing roles​

EndpointPurpose
GET /roles, GET/PATCH/DELETE /roles/{id}, POST /rolesThe tenant's roles (tenant from X-Tenant-ID), roles:read / roles:manage
POST /roles/{id}/resetRestore a role created from a template to that template
GET /roles/catalogue, GET /roles/templatesThe permission catalogue (with platform_only) and the starting points
/platform/rolesThe 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-rolesThe 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 Permission member, 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.py fails if a permission is unused or a route is unmapped; tests/test_role_templates.py guards 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.