Roles & permissions
How Pact resolves a user's effective role and permission set — canonical roles, per-tenant permission overrides, custom roles, and just-in-time elevation.
Roles & permissions
Pact's authorization model has three layers, resolved in order for every request: a canonical role (the built-in default grant), per-tenant permission overrides (flip a single permission for a single role inside your tenant), and just-in-time (JIT) elevation (a temporary role with a hard expiry). The resolution logic lives in core/role_permissions.py; the static role→permission map is in core/permissions.py.
Canonical roles
Every tenant starts with eight built-in roles, each with a default permission grant declared in CANONICAL_GRANTS:
| Role | Default grant (summary) |
|---|---|
owner | Everything — the full permission set. |
admin | Everything except billing:manage (billing control is released deliberately). |
manager | Member permissions + team:read, team:invite. |
member | Read everything, plus write on accounts, contacts, opportunities, sequences, reports, consent, and enrichment:run. |
support | Member CRM access + read-only team:read (to pick a user to view as). No invite, no billing. |
compliance | Read-only (DPO). Includes consent:read and audit_log:read by default. |
viewer | Read-only across every surface. |
api | Member-equivalent, tuned for programmatic callers (sequences:send). |
The full permission vocabulary — the strings the override table and the FastAPI deps both speak — is declared as PERMISSION_KEYS: e.g. accounts:read, sequences:send, billing:manage, team:manage_roles, audit_log:read, api_keys:manage, consent:write, enrichment:run.
Per-tenant permission overrides
Rows in the role_permissions table flip one permission for one role inside your tenant without a code change. granted=true adds a permission the role lacks by default; granted=false removes one it holds. This is how you grant, for example, billing:manage to admin (denied by default) from the team UI.
PATCH /v1/team/roles/{role_name}/permissions
{ "toggles": [ { "key": "billing:manage", "granted": true } ] }
The list endpoint returns, per role, both the effective set and its diff from canonical:
GET /v1/team/roles →
{
"roles": [
{ "name": "admin", "kind": "canonical",
"permissions": [...], "canonical_permissions": [...],
"added_by_tenant": ["billing:manage"], "removed_by_tenant": [] }
],
"permission_keys": [ ... ]
}
Every toggle emits a team.role.permission_changed audit event.
Custom roles
Beyond the eight canonical roles, you can create tenant-specific roles cloned from a template:
POST /v1/team/roles
{ "name": "deal_desk", "template": "member" }
The name must match ^[a-z][a-z0-9_]*$. The role is seeded with the template's permissions (which you then adjust with the toggle endpoint above) and emits team.role.created. Custom roles surface in GET /v1/team/roles with "kind": "custom".
Just-in-time elevation
POST /v1/team/members/{user_id}/elevate grants a user a temporarily elevated role (1–168 hours) with a required reason. The elevation is stored in time_bounded_roles; effective_role is what permission checks actually use. When the window expires, observe_jit flips the row to reverted_at exactly once and emits an audit event — so the timeline honestly records who was elevated, when, and why. All role and permission mutations require team:manage_roles; member and role reads require team:read.
Check a permission live
GET /v1/team/permissions/check?key=billing:manage returns { granted, reason, role } for the calling user, resolving all three layers — useful when debugging why a UI control is hidden.