Roles & Permissions
AxonFlow ships five roles: owner, admin, policy_admin, developer, viewer. Each one does something distinct and enforced.
The single most important thing to understand before reading the tables: a role name acts on two different planes, and it does not mean the same thing on both.
| Plane | What carries the role | What the role decides |
|---|---|---|
| Fleet | The role claim inside a per-user token (X-User-Token) | Whether the caller reads the whole tenant's governance rows or only their own |
| Portal | A role assignment in the customer portal, which carries a set of permissions | Which portal capabilities the session may use — configure SSO, edit policies, manage users, rotate its own token |
The two are configured separately. Assigning someone the portal policy_admin role does not mint them a policy_admin fleet token, and vice versa. A person who needs both gets both.
The five-role model as described here is Enterprise-only and requires platform v9.12.0 or later. On earlier releases a sixth role, member, was accepted and several of the permissions below were not enforced — see Upgrading from the six-role model.
Plane 1 — Fleet token role: who reads whose rows
When a fleet request carries a validated per-user token, the token's role claim decides the read scope applied to audit events, decision history, and policy overrides — through the MCP read tools or a direct API call to the same endpoints, identically.
| Fleet token role | Audit / decision / override reads |
|---|---|
owner | Full tenant — every user's rows |
admin | Full tenant — every user's rows |
policy_admin | Full tenant — every user's rows |
developer | Own rows only — rows attributed to their own identity |
viewer | Own rows only — rows attributed to their own identity |
| No per-user token (shared credential alone) | Zero rows — fail-closed |
policy_admin is the "read everything, change no identity" tier: it sees the whole tenant trail so it can review what the policies it administers actually did, but it holds none of the user- or identity-administration capabilities admin does.
developer and viewer are identical on this plane. Their differences live entirely on the portal plane (see Table 2), and neither dominates the other: developer can rotate its own token but has no audit:read; viewer cannot rotate a token but does hold audit:read.
An unrecognized role string is not trusted at its face value. Any role outside the five is normalized to empty (least privilege, own-rows) before the scope check, so no minting path can smuggle an unrecognized-but-privileged role past the gate.
The full read-scoping model — the enforcement layers, the covered surfaces, admin-gated compliance exports, and the token-less-caller behavior change — is in Per-Developer Identity → Role-scoped reads.
Plane 2 — Portal role: seeded permissions and what they gate
Every organization is seeded with the five roles below. The permission bundles are exactly what each role gets; there is no hidden grant.
| Portal role | Seeded permissions | What it can do |
|---|---|---|
owner | *, sso:configure | Everything admin can, plus SSO / SCIM / detection-posture configuration |
admin | * | Full portal operations except owner-reserved configuration: manage roles and user role assignments, edit policies, query deployment-wide usage |
policy_admin | policy:write, policy:delete, audit:read, token:rotate:self | Create, edit, and delete governance policies; tenant-wide audit reads — but no role/user administration and no deployment-wide usage |
developer | token:rotate:self | Rotate its own per-user token. No audit:read — its portal console is own-rows |
viewer | audit:read | Read-only, but tenant-wide: the full audit console |
owner ⊃ admin ⊃ policy_admin is a genuine hierarchy. developer and viewer are not ranked against each other — each holds one permission the other lacks (see the pitfalls below).
The * wildcard does not grant everything
sso:configure is owner-reserved. The * wildcard grants every permission except the owner-reserved set. This is the mechanism that makes owner genuinely different from admin rather than a synonym for it:
owner's bundle is["*", "sso:configure"]— a true superset ofadmin. It is never weaker thanadminon any permission.admin's bundle is["*"]alone, so anadminsession is denied on SSO configuration, SCIM token and group→role-mapping management, and detection-posture management.
A user assigned both admin and owner holds sso:configure explicitly and keeps the capability — the reservation is about what the wildcard implies, not about stacking roles.
What each permission actually gates
Every permission seeded into the five roles gates real behavior. There are no advertised-but-unenforced permissions in the bundles above. (This is not true of the wider custom-role palette — see Custom roles.)
| Permission | Gates |
|---|---|
sso:configure | SSO configuration (POST/PUT/PATCH/DELETE /api/v1/sso/config, config test), SCIM token create/list/revoke, SCIM group→role mapping, and detection-posture read/set/delete |
policy:write | Create, update, and import a governance policy (POST /api/v1/policies, PUT /api/v1/policies/{id}, POST /api/v1/policies/import) |
policy:delete | Delete a governance policy (DELETE /api/v1/policies/{id}) |
audit:read | Tenant-wide (cross-user) reads through the portal's proxied governance read endpoints |
roles:read / roles:write | List/read roles and permissions · create, update, delete roles (/api/v1/roles, /api/v1/permissions) |
user:read / user:write | Read a user's role assignments · assign and remove roles (/api/v1/users/{email}/roles, /api/v1/users-with-roles) |
usage:platform_wide | Deployment-wide (cross-organization) usage and cost queries — self-hosted only |
token:rotate:self | Self-serve rotation of the caller's own per-user token (POST /api/v1/me/user-token/rotate) |
roles:*, user:* and usage:platform_wide are not listed explicitly in any seeded bundle — admin and owner hold them through the * wildcard, and no other role holds them at all. That is precisely what separates admin from policy_admin: a policy_admin session receives 403 on every role- and user-management route.
Policy reads are deliberately not permission-gated — the policy list and settings pages need them for any authenticated session, and the response carries no secret. No role seeds policy:read.
Self-serve token rotation is not an escalation path
POST /api/v1/me/user-token/rotate rotates only the caller's own token — the identity comes from the session, never from the request body — and the replacement token is always minted with the developer role regardless of the caller's current role. A viewer (no token:rotate:self) receives 403; a role-less session receives 403 as well.
Anti-escalation: you cannot confer what you do not hold
Creating, updating, or assigning a role that would confer an owner-reserved permission requires the actor to hold that permission themselves. Otherwise the request fails with 403.
Concretely: an admin can manage roles and assignments (via *), but does not hold sso:configure. So an admin cannot
- create a custom role containing
sso:configure, - add
sso:configureto an existing custom role, or - assign the
ownerrole to anyone — including themselves
as a route to acquiring SSO/SCIM/detection-posture control. Every one of those returns 403.
Non-reserved permissions are not restricted this way: an actor holding * is already able to exercise them, so re-granting one is not an escalation.
How you get your first owner
Because an admin cannot grant owner to itself, the first owner is always created out-of-band. Four paths cover it, and for most deployments one of the first three has already run before you notice:
1. On upgrade — automatic backfill.
The upgrade grants owner to exactly those principals who already held sso:configure under the previous semantics: anyone with a live assignment to a role whose bundle contained * or sso:configure. In practice that means your existing admins become owners, so the capability sso:configure never disappears from your organization.
This is capability-preserving, never widening. Roles that never carried sso:configure (policy_admin, developer, viewer) do not qualify. Already-expired assignments are skipped, and a time-limited admin's owner grant carries the same expiry.
2. On organization creation — automatic.
A newly created organization is seeded with an owner assignment alongside the role definitions, keyed on the identity that organization's password login actually presents. If you later change the organization's contact email, the grant is moved onto the new identity — the new address is granted owner and the superseded one's owner grant is revoked — through the authenticated admin API that performs the change, not a database trigger (see Changing the contact email moves ownership).
3. On a default self-hosted deployment — automatic at boot.
In enterprise and in-VPC deployment modes the portal grants the deployment organization's bootstrap operator the owner role on every boot, immediately after provisioning its password. This is deliberately not fail-closed: a portal whose operator lacks the role is degraded but still usable, and break-glass remains available.
Which identity gets the grant
Paths 2 and 3 both key the grant on the identity the organization's password login actually presents — presenting one identity while granting owner to another is how an organization ends up locked out, so the two are derived from the same place and cannot drift apart.
That identity is:
| Organization has… | Login presents, and owner is granted to |
|---|---|
| a contact email | that address |
| no contact email | AXONFLOW_PORTAL_ADMIN_EMAIL, defaulting to [email protected] |
Set AXONFLOW_PORTAL_ADMIN_EMAIL on the portal to your operator's real address. It is the identity that appears in the audit trail for every action that operator takes, so the shipped default is a deliberately non-routable placeholder (.invalid is reserved by RFC 6761) rather than something that looks like a real mailbox. A configured value that is blank, is not an address, or lands on a reserved internal domain is rejected with a loud log and the default is used.
ownerBefore v9.12.0, an organization with no contact email presented an empty login identity, and the bootstrap owner grant was keyed on it. Permissions resolve on (organization, user_email), so an empty identity was not a principal but a wildcard — any session in that organization that resolved to an empty identity inherited owner, including the owner-reserved sso:configure. The most likely such session is SSO/OIDC with a missing or unmapped email claim.
From v9.12.0 the platform refuses to grant owner to a non-identity, on every first-owner path — the upgrade backfill, organization creation, the boot grant, break-glass, and the contact-email move alike. Refused targets are the empty string and the platform's own reserved synthetic identities (mcp-client:*, anything on the reserved internal domains, and the shared evaluator identity), since each of those is shared across callers rather than being a person.
The upgrade cleans up after itself. Any empty-keyed or synthetic-keyed owner grant a previous release created is deleted, and the operator is re-granted owner on the real identity at the portal's next boot. No action is required and there is no lockout — but if you set AXONFLOW_PORTAL_ADMIN_EMAIL, set it before upgrading so the grant lands on the address you want in the audit trail.
If your identity provider is configured such that some users' email claim is missing or unmapped, those sessions previously inherited owner silently. They no longer do — map the claim to a real address and assign those users a role explicitly.
Path 3 covers one organization. An organization that has no role assignments at all — the role-less-operator shape — gets nothing from the upgrade backfill in path 1, because there is no pre-existing capability to preserve.
So on a multi-organization deployment, every organization other than the bundled deployment org may find its operator unable to edit policies after upgrade (see The role-less operator), with no in-product recovery. Use break-glass below, per organization.
4. Break-glass — explicit, for anything the first three miss.
# Grant owner
curl -X POST "$PORTAL_URL/api/v1/admin/organizations/{org_id}/owner" \
-H "X-Admin-API-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_email": "[email protected]"}'
# See who currently holds owner
curl "$PORTAL_URL/api/v1/admin/organizations/{org_id}/owner" \
-H "X-Admin-API-Key: $ADMIN_API_KEY"
It is idempotent, and every attempt — success or failure — is written to the admin audit log.
ADMIN_API_KEY is required, in every modeGranting owner is strictly more privileged than minting a role-bearing token, so this endpoint requires an authenticated admin even in the deployment modes where admin auth is otherwise optional (in-VPC, SaaS staging). With ADMIN_API_KEY unset it returns 401 rather than falling open.
Omit the body entirely to grant owner to the organization's own login identity (see Which identity gets the grant) — the right answer for a single-operator self-hosted deployment.
If the call returns 400 INVALID_OWNER_IDENTITY, the user_email you supplied was blank or one of the platform's reserved synthetic identities. Those are shared across callers rather than being a person, so they can never hold owner; supply a real address.
If the call returns 409 NO_SYSTEM_OWNER_ROLE, a custom role is occupying the canonical name owner, so there is no system role to assign. Rename that custom role, then re-run the system-role seeder for the organization and retry:
SELECT ensure_org_system_roles('<org_id>');
Restarting the portal or agent does not re-run the seeder for an existing organization — the seeder runs on organization creation and on migration, so an existing organization needs the statement above.
The anti-escalation gate is not weakened by any of this. An admin self-assigning owner through the RBAC API still gets 403. First-owner creation is out-of-band by design.
Changing the contact email moves ownership
The organization's contact email is the identity its password login presents, so it is also what the bootstrap owner grant is keyed on. A genuine change to it therefore moves the grant: the new address is granted owner, and the superseded address's system owner grant is revoked, so ownership does not accumulate on stale addresses.
- This happens in the authenticated admin API handler that performs the change, not in a database trigger — so it is reachable only behind the strict authenticated-admin check, and it records a real ownership-assignment audit row naming the actor and the target instead of being buried inside a generic organization-update event.
- Only a real change moves anything: a case- or whitespace-only rewrite is a no-op, and clearing the contact email is refused (the identity guard rejects a blank target), so that path revokes nothing and cannot strand the organization without an owner.
- The revoke of the superseded owner runs only when the new grant actually landed — revoking the old owner while the new grant failed would manufacture the very lockout this exists to prevent. If the re-grant fails it is audited as a failure so the gap is visible, and the previous owner is left in place; recover with break-glass.
The role-less operator
A self-hosted deployment's default portal login authenticates as the organization (org id plus the portal admin password) rather than as a person, and carries no role assignment.
Its behavior is deliberately asymmetric, and worth stating plainly:
- Reads: tenant-wide. A session with no role assignments at all is treated as the org-level operator and keeps the tenant-wide console view it has always had.
- Writes: denied wherever a permission is required. With no role, it holds no permissions — so it cannot create, edit, or delete policies (
policy:write/policy:delete), cannot manage roles or users, cannot configure SSO, and cannot rotate a per-user token (it has no per-user identity to rotate).
A session that does hold a role is evaluated on that role's permissions alone — the tenant-wide read default applies only to the genuinely role-less operator. A role-bearing user without audit:read gets own-rows, so an organization can craft a deliberately restricted role and have it honored.
Errors fail closed: a transient permission-lookup failure yields own-rows, never a wider view.
The fix is to give that operator a role. On a default self-hosted deployment the bundled organization's operator is granted owner automatically at portal boot, so it is not role-less for long. For any other organization, grant a role explicitly — break-glass for owner, or the RBAC assignment API from an existing admin for anything else.
Cross-plane pitfalls
Four places where a role name means something other than it looks. Each has caused a real support question.
viewer reads tenant-wide in the portal but own-rows on the fleet plane
The portal viewer role carries audit:read, which grants tenant-wide reads through the portal console. A viewer fleet token reads own-rows only. Same name, two scopes, depending on which plane the request arrives through. If you need someone restricted to their own rows everywhere, do not give them a portal role carrying audit:read.
developer and viewer are indistinguishable on the fleet plane, and neither outranks the other
Both read own-rows on the fleet plane, so choosing between them for a token role changes nothing about what that token can read.
On the portal plane they differ in both directions, which surprises people who read the names as a ranking:
developer | viewer | |
|---|---|---|
Rotate own per-user token (token:rotate:self) | ✅ | ❌ |
Tenant-wide portal audit console (audit:read) | ❌ — own-rows | ✅ |
So developer is narrower than viewer in the console. Someone who needs both capabilities needs both roles, or a custom role carrying both permissions.
A dev token is not a per-person identity
POST /api/v1/dev/token mints a quickstart token, defaulting to the developer role, and accepts an optional {"role": "..."} to select any of the five.
That token scopes to the shared dev-credential identity, not to a person. Every caller who mints a dev token from the same deployment credential resolves to the same identity, so "own-rows" for a dev token means the rows of that shared identity — not one engineer's rows. Do not rely on a dev token for per-developer isolation. True per-person isolation requires a provisioned per-user token via Path A or Path B.
Requesting any tenant-wide role on a dev token — admin, owner, or policy_admin — is an opt-in escalation and is logged loudly as such. All three grant tenant-wide reads. admin additionally relaxes enforcement on governed traffic: the proxied request plane skips admin-access detection for an admin-role token specifically. That one is a literal match on admin, so owner does not inherit it despite being a superset elsewhere. Keep tenant-wide dev tokens out of any environment carrying real data.
A role name does not grant access to PII
Response PII redaction is permission-driven, not role-driven. No role — including admin and owner — sees unredacted PII in an LLM response by virtue of its name.
What a caller may see is determined solely by the PII permissions on its role:
| Permission | Unredacts |
|---|---|
view_full_pii | everything: SSN, credit card, bank account, email, phone, address |
view_basic_pii | email, phone |
view_financial | credit card, bank account |
view_medical | medical record, diagnosis |
None of these is seeded on any default role — view_full_pii is deliberately held by no system role and is excluded from *-wildcard expansion, so even admin/owner do not receive it — so out of the box every role receives redacted responses. To let a role see raw PII, grant it the permission deliberately; that grant is visible in the role's permission set and is therefore auditable, which is the point.
Earlier code had a role literal on this path — a caller whose role was literally admin was handed the LLM response completely unredacted. It carried two defects: it was a string compare on admin, so owner did not match and an owner received less than an admin — inverting the superset relationship the rest of the model guarantees; and on a regulated deployment "the administrator sees raw PII" is a compliance claim that should be made deliberately, not inherited from a role name.
This was a latent gap, not a live leak. The role literal sat on a legacy fallback branch of the response processor that runs only when the shared policy engine is absent; a database-backed deployment installs that engine and never takes the branch, so its responses were already redacted regardless of role. The literal is removed anyway, and allowance is now derived only from explicit permissions.
A portal view_full_pii grant does not yet reach the response plane. The response plane reads a caller's permissions off the request, and nothing yet resolves a portal role assignment into that request. So granting view_full_pii on a role in the portal does not currently un-redact anything on that plane — the visibility claim there is caller-asserted, exactly as the role literal was. This change makes the grant explicit and role-agnostic; server-side resolution of the response plane's permissions is separate, later work. Do not describe a portal view_full_pii grant as a working control over response PII until that lands.
Custom roles
Alongside the five seeded system roles, an organization can author custom roles from a broader permission palette. Three constraints apply:
- Only the permissions in What each permission actually gates are enforced. The custom-role editor still offers a wider palette —
policy:read,connector:read/connector:write/connector:execute,settings:read/settings:write— and those gate nothing on the portal plane today. A custom role built from them grants no capability. Build custom roles from the enforced set. - The anti-escalation rule applies to custom roles exactly as it does to seeded ones — a custom role containing
sso:configurecan only be created or assigned by an actor holdingsso:configure. - On the fleet plane, only the five system role names are meaningful. Role resolution keys on the name, so a SCIM group mapped to a differently-named custom role is rejected (
400) rather than silently resolving every member to least privilege. Custom roles are a portal-permission tool, not a fleet read-scope tool.
Seeded system roles are reconciled to the canonical definitions above on upgrade. That reconciliation never overwrites a custom role, and never touches manual or SCIM-synced role assignments.
Upgrading from the six-role model
Earlier releases exposed a sixth role, member, and enforced less than the tables above describe.
The role migrations verify their own work and abort the upgrade rather than leave the role tables half-reconciled. Because migrations run at agent start-up, an abort means the agent does not come up, and a retry fails the same way until the underlying row is corrected. Two conditions trigger it, both rare and both fixable in advance:
-
A user holding a live wildcard role and an expired
ownerassignment. The backfill skips them as already-granted, then the completeness check — which only counts unexpired grants — finds them missing.SELECT ra.org_id, ra.user_email, ra.expires_at
FROM role_assignments ra
JOIN custom_roles cr ON cr.id = ra.role_id
WHERE cr.name = 'owner'
AND ra.expires_at IS NOT NULL AND ra.expires_at <= NOW();Delete or extend any row this returns for a user who still holds an admin-equivalent role.
-
A system role row with an empty
org_id. Reconciliation skips it but the verification step still counts it.SELECT id, name, org_id FROM custom_roles
WHERE is_system AND (org_id IS NULL OR org_id = '');A row this returns belongs to no organization. Before deleting one, check that nothing is assigned to it — the assignment foreign key cascades, so deleting a referenced role silently drops user assignments:
SELECT * FROM role_assignments WHERE role_id = '<id from above>';Delete the role only when that returns nothing.
Both queries returning zero rows means the migrations will complete. Take a database backup first regardless.
What changes on upgrade:
member is gone.
Minting a token with role: "member" now returns 400. Existing member tokens normalize to least privilege — own-rows — which is exactly the scope they already had, so there is no runtime change for anyone holding one; re-issue them as developer or viewer at your convenience.
Existing member portal role assignments need attention, though. The seeded member role is deleted only where nothing references it — a member role still referenced by a role assignment or a SCIM group mapping is left in place, deliberately, rather than cascading a delete through those references. A surviving member role keeps its legacy permission bundle, which includes audit:read (tenant-wide console) and user:read — and user:read now gates a live route that was previously unreachable, so the assignment slightly widens on upgrade. Audit your member assignments and move those users to one of the five roles.
policy_admin now reads tenant-wide — a visibility expansion.
Previously policy_admin collapsed to own-rows on the fleet plane. It now reads the full tenant audit and decision trail. Review who holds policy_admin before upgrading if that widening is not what you want.
Admins no longer configure SSO, SCIM, or detection posture.
sso:configure became owner-reserved and the * wildcard no longer satisfies it. A session or token holding only admin is now denied on those routes. Assign owner to whoever needs them. This is the change most likely to surface as "SSO settings stopped working for my admin".
developer portal reads narrow from tenant-wide to own-rows.
The developer role previously seeded audit:read; it no longer does. Every existing developer assignment therefore loses whole-tenant visibility in the portal audit console and sees only its own rows. Assign viewer — or a custom role carrying audit:read — to anyone who needs the tenant-wide console. (This is a portal-plane change; developer tokens were already own-rows on the fleet plane and are unaffected.)
Policy CRUD is now permission-gated across every policy write route.
Creating, updating, importing, toggling, overriding, or deleting a policy previously required only an authenticated portal session — any session, including viewer. It now requires policy:write (and policy:delete for deletes), enforced at the route layer across every policy family the portal exposes: /api/v1/policies, /unified-policies, /static-policies, /policy-overrides, and /dynamic-policies. Sessions that will lose write access: viewer, developer, and the role-less operator. admin, owner, and policy_admin retain it. Policy reads (list/get/effective/versions/export and the dry-run evaluation endpoints) stay session-only so the policy screens still load for a read-only user. A route-coverage test derives the write-route list from the source on every run and fails CI if a new mutating policy route ships ungated, so a family cannot silently escape the gate again.
The five seeded roles are fully gated on the policy write routes above. The one place portal RBAC still under-enforces is the custom-role authoring palette, which offers permissions that gate nothing on the portal plane today — see Custom roles. It does not affect who can change a policy's effective action through the portal routes.
Check this per organization. Your existing admins are carried across automatically by the upgrade backfill, and the bundled deployment organization's operator is granted owner at portal boot. But an organization whose only login is a role-less operator is remediated by neither — it will find policy editing denied after upgrade and cannot grant itself a role. Run break-glass for each such organization.
Admins no longer see unredacted PII in LLM responses.
The implicit role == "admin" bypass on response PII redaction is removed; unredaction is now permission-driven. This was a latent gap rather than a live leak — the bypass lived on a legacy fallback branch a database-backed deployment does not take — so most deployments see no behavior change. Grant view_full_pii to the role that should hold it if you were relying on the old behavior, noting that a portal grant does not yet reach the response plane (see the pitfall).
The default self-hosted operator gets a real identity.
An organization with no contact email previously presented an empty login identity, and the bootstrap owner grant was keyed on it — which meant any session resolving to an empty identity inherited owner. Those grants are removed on upgrade and re-created on a real identity at portal boot; set AXONFLOW_PORTAL_ADMIN_EMAIL before upgrading so the audit trail names your operator. No action is required and there is no lockout — see Which identity gets the grant.
Permissions that gated nothing were removed from the seeds. Several permissions were previously seeded — and therefore advertised in the portal — without anything enforcing them. Those are no longer seeded. A permission appearing in one of the five bundles now means it is genuinely enforced somewhere; the mapping is the table in What each permission actually gates. Note this cleanup covered the seeded roles, not the wider custom-role authoring palette, which still offers unenforced permissions — see Custom roles.
Related
- Per-User Token Provisioning — minting, rotating, and revoking the tokens that carry the fleet role (Path A and Path B).
- Per-Developer Identity → Role-scoped reads — the complete read-scoping model and its enforcement layers.
- Group to Role Mapping — mapping SCIM directory groups onto these role names.
- SSO Configuration — the surface
sso:configuregates. - Customer Portal — the console these portal permissions apply to.
