Identity & access
How an app declares its authorization surface — permissions and roles (RBAC), instance-scoped resource types and role derivations (ReBAC) — synced into AuthZ on publish.
Your manifest declares who can do what
A runtime request is allowed or denied at one of the five gates — but a gate only has something to check because your app declared its authorization surface up front, in app.manifest.yaml. That declaration is a set of permission codes, the roles that bundle them, and — optionally — the resource types and derivation rules that make authorization instance-aware. At manifest publish the Control Plane syncs all of it into the AuthZ service, per org, so every tenant starts with your roles and grants already seeded.
There are seven spec keys, split across two authorization models:
| Spec key | Model | Tier | What it declares |
|---|---|---|---|
cp_permissions | RBAC | operator | Permission codes that gate /cp/* routes |
cp_roles | RBAC | operator | Operator roles bundling cp_permissions |
portal_permissions | RBAC | tenant | Permission codes that gate /portal/* routes |
portal_roles | RBAC | tenant | Org-member roles bundling portal_permissions |
resource_types | ReBAC | tenant | Instance-scoped resource types, their actions and instance roles |
role_derivations | ReBAC | tenant | Rules that grant a role on one instance from a relation to another |
permission_catalog | — | tenant | Display-only names and descriptions for the codes above |
Every key is optional. A backend with no admin surface may ship none of them; egav-template ships the flat RBAC set and nothing else. Flat permissions plus roles is classic RBAC — the core model of NIST INCITS 359, including role inheritance and groups. resource_types and role_derivations add ReBAC — grants that depend on a user's relationship to a specific record, in the style of Google's Zanzibar, though roles here stay first-class rather than being reduced to relation tuples. Conditional grants layer on top as ABAC, with deny overriding allow (the XACML 3.0 deny-overrides rule).
RBAC — flat permissions and roles
The two tiers are structurally identical; they differ only in which routes they gate and which built-in roles receive them.
cp_permissions+cp_rolescover the operator surface — the codes gate your/cp/*routes and are granted to the platform operator roles (super_admin/admin/user).portal_permissions+portal_rolescover the tenant surface — the codes gate your/portal/*routes and are granted to org roles (org_owner/org_admin/ …).
A permission is a plain string code; the convention is <group>.<verb>, e.g. support_tickets.read, automation_executions.cancel. A role bundles codes:
| Role key | Required | Purpose |
|---|---|---|
code | yes | Role code, ^[a-z][a-z0-9_]*$. |
name | yes | Display name. |
permissions | yes | Permission codes or glob patterns this role grants. |
The binding rule is a cross-field one: every entry in a role's permissions[] must resolve within its own tier's permission list — a cp_roles entry resolves against cp_permissions, a portal_roles entry against portal_permissions. Globs are allowed: '*' (everything in the tier), '*.read' (every read verb), 'egav.*' (a namespace). A role that references a code no permission in its tier produces is a manifest error.
cp_permissions:
- authz_roles.read
- authz_roles.create
- authz_roles.update
- authz_roles.delete
- authz_role_derivations.read
- authz_role_derivations.create
cp_roles:
- code: super_admin
name: Super Admin
permissions:
- '*'
- code: user
name: User
permissions:
- '*.read'The portal tier looks the same, but a tenant role usually lists an explicit least-privilege bundle rather than a glob. This is egav-support's org-member user — read and create tickets and chat, no delete:
portal_permissions:
- support_tickets.read
- support_tickets.create
- support_tickets.update
- support_chat.read
- support_chat.create
portal_roles:
- code: admin
name: Admin
permissions:
- '*'
- code: user
name: User
permissions:
- support_tickets.read
- support_tickets.create
- support_tickets.update
- support_chat.read
- support_chat.createReBAC — resource types and instance roles
Flat roles answer "may this user read tickets?" They cannot answer "may this user edit this comment because they wrote it?" That is a relationship between a user and one specific record, and it is what resource_types declares. Each resource type carries its own actions and a set of Permit.io-style instance roles — roles a user holds on a single instance, not org-wide.
| ResourceType key | Required | Purpose |
|---|---|---|
code | yes | Dotted <service>.<entity> (e.g. support.comment), ^[a-z][a-z0-9_.]*$, ≤100 chars. The dotted form lets multiple services share one flat namespace without collision. |
name | yes | Display name. |
description | no | Free text. |
actions | no | Action verbs (read, claim, decide, …), each ^[a-z][a-z0-9_]*$. AuthZ registers one Permission per verb. |
instance_roles | no | Instance-scoped roles for this type. |
| InstanceRole key | Required | Purpose |
|---|---|---|
code | yes | ^[a-z][a-z0-9_]*$. |
name | yes | Display name. |
description | no | Free text. |
permissions | no | Permission codes this role grants on a specific instance of the parent type. |
An instance role's permission codes take the form <resource_type_code>.<action> — they name actions of the parent type. Holding the role on one instance grants exactly those actions on that one instance; the bare org-wide permission (support.comment.update) stays the admin override. This is egav-support's owner-protected comment:
resource_types:
- code: support.comment
name: Support Comment
description: A reply on a support ticket. Owner = author_guid.
actions:
- read
- create
- update
- delete
instance_roles:
- code: owner
name: Owner (author)
description: The agent or customer who wrote the comment.
permissions:
- support.comment.update
- support.comment.deleteA route consumes an instance role via RequireAuthz("support.comment", "update", resource_instance_guid_from="<path_param>") — that request-time side lives on Auth & the five gates.Deriving roles across relations
role_derivations is the second half of ReBAC: a rule that grants a role on a target instance to anyone who already holds a role on a related source instance. Model a hierarchy — a space that contains comments — and one rule says "an owner of a space is an editor of every comment linked to it," with no per-comment grant.
| RoleDerivation key | Required | Purpose |
|---|---|---|
source_role_code | yes | Role the user already holds on the source instance. |
source_resource_type_code | yes | Resource type of the source instance. |
target_role_code | yes | Role granted on the target instance. |
target_resource_type_code | yes | Resource type of the target instance. |
link_condition | yes | JSONLogic expression gating the derivation. |
description | no | Free text. |
The link_condition is a JSONLogic expression evaluated over {source_resource, target_resource, link}, where link = {relation_code, attributes} of the resource relation joining the two instances. It returns true to grant the derived role, false to skip.
role_derivations:
- source_role_code: owner
source_resource_type_code: support.space
target_role_code: editor
target_resource_type_code: support.comment
link_condition:
"==":
- var: relation_code
- parent_space
description: A space owner may edit comments linked to that space.No first-party manifest ships arole_derivationsblock yet; the shape above follows the schema and thelink_conditionform AuthZ's JSONLogic evaluator accepts.
ABAC — conditions over the attribute bundle
RBAC and ReBAC decide from roles and relations. A policy condition can also read attributes — facts about the user, their membership in the org, the org itself and its subscription — through the same JSONLogic form link_condition uses. AuthZ evaluates the condition against one document, the attribute bundle, and a var path selects a value from it.
What a var path can reach
| Path | Type | Meaning |
|---|---|---|
user.guid | string | The user's stable identifier. |
user.email | string | |
user.display_name | string | |
user.status | string | "active" or "inactive". |
user.flags.<key> | any | Free-form JSON flags, e.g. user.flags.beta_tester. |
user.phone | string | Standard profile field. |
user.job_title | string | Standard profile field. |
user.department | string | Standard profile field. |
user.locale | string | BCP-47 tag, e.g. en-GB. |
user.timezone | string | IANA zone name, e.g. Europe/Sofia. |
user.preferred_language | string | BCP-47 tag. |
membership.role | string | The user's role code in the org. |
membership.is_owner | boolean | |
membership.joined_at | string | ISO-8601. |
membership.manager_user_guid | string | Guid of the member's manager — another active member of the same org. membership.manager_user_guid == subject.user_guid is the org-chart shape. |
membership.employee_number | string | |
membership.cost_center | string | |
membership.attributes.<code> | per definition | An org-defined attribute, keyed by its code. *Only definitions the org marked available to access policies (abac_exposed) appear here*; every other org-defined value is absent from the bundle, so a condition on it resolves as missing. |
org.guid, org.name, org.plan_code, org.plan_tier, org.subscription_status, org.flags.<key> | The organization. | |
subscription.plan_code, subscription.plan_tier, subscription.subscription_status | string | Denormalised from the org so org.* and subscription.* are interchangeable. |
subscription.features.<code>.enabled / .config | boolean / object | The plan's feature catalog. |
subject.user_guid, subject.org_guid, subject.authenticated_user_guid | string | Set by AuthZ itself, after the bundle is merged, so a caller-supplied bundle can never shadow them. Under delegation user_guid is the effective subject and authenticated_user_guid the caller. |
resource.type, resource.id, resource.attributes.<key> | The resource under evaluation; stored instance attributes win over caller-supplied ones. |
Every profile and membership value is null when unset. Org-defined values keep the type their definition declares: int and float are numbers, bool is a boolean, multiselect is an array of option values, date is the dashed ISO string, string / select / url / email are strings. A bool is never accepted where a number is expected, so membership.attributes.headcount > 0 cannot be satisfied by true.
{ "==": [ { "var": "membership.attributes.region" }, "emea" ] }{ "and": [
{ "in": [ { "var": "user.department" }, ["finance", "audit"] ] },
{ "==": [ { "var": "subscription.features.advanced_reports.enabled" }, true ] }
] }A path that does not resolve is reported by the user-set and resource-set dry-run endpoints (POST …/user-sets/{guid}/test, …/resource-sets/{guid}/test) under missing, which is how to find out that a definition is not exposed or a field is unset.
Where the bundle comes from
The Control Plane builds the bundle per (user, org) from the user record, the membership row, the org and its active subscription, and serves it to AuthZ over an internal callback. The authorize routes load it: from the caller's inline context.attribute_bundle when a host service already holds one, otherwise from a shared cache keyed by (user, org) with a five-minute lifetime, otherwise from the Control Plane. The cache is invalidated the moment a definition is written (for the whole org) or a member's value changes (for that member) — through the portal, an operator, SCIM provisioning or directory sign-in alike — so a policy sees a new value on its next evaluation rather than after the timeout. The single /authorize route wiring of this loader is landing under the rich-user-data epic; the bundle shape above is what it evaluates against.
An unreachable Control Plane is a 503 with AUTHZ_ATTRIBUTE_SOURCE_UNREACHABLE, never a silent allow or deny.
The values behind the paths
The standard profile fields and membership facts are edited on the portal profile and member pages, and arrive automatically from provisioning: SCIM stores the core attributes (phoneNumbers, title, locale, timezone, preferredLanguage) and the urn:ietf:params:scim:schemas:extension:enterprise:2.0:User extension (department, manager, employeeNumber, costCenter), and an org can map any further SCIM path onto one of its own attribute codes. Both schemas are advertised on the tenant SCIM /Schemas endpoint and /ResourceTypes declares the enterprise extension as an optional schemaExtensions entry, so Okta and Entra ID send them without custom mapping. See Users & organizations for the profile endpoints.
Roles your tenants define themselves
Everything above declares roles that ship with your app. A tenant can also create roles of their own, from the portal — POST /v1/authz/portal/roles — and the distinction between the two matters more than it first appears.
| Manifest role | Custom role | |
|---|---|---|
| Declared in | cp_roles / portal_roles | created by the tenant at runtime |
is_system | true | false |
| Owned by | the manifest — reconciled on every publish | the tenant |
| Editable by the tenant | no (403) | yes |
| Removed when a publish stops declaring it | yes | never |
The reconcile sweep retires roles the current manifest no longer declares, along with every user and group assignment on them. A custom role is absent from every manifest by construction, so is_system is what keeps the sweep off it. Two consequences worth planning for:
- Do not edit a manifest role through the API. The write is refused with a
403 naming the reason; if it were allowed, the next publish would silently revert it.
- A code collision transfers ownership. If a tenant creates
reviewerand a
later manifest declares reviewer, the seed claims the existing row (is_system flips to true) rather than creating a duplicate. The manifest wins, and the role comes back under reconcile.
Custom roles are scoped exactly like manifest ones: application_instance_guid NULL makes the role org-wide, set makes it local to one app instance. The same code may exist in both scopes.
Groups — granting a role to many people at once
A role answers what may be done; a group answers who. Groups are org-owned (the portal defines them, not the manifest), and a group grants roles to every member:
user ─┬─────────────────────────────► role (direct assignment)
└──► group ──► role assignment ─┘ (inherited via membership)Both legs are unioned by the authz_effective_role_assignments view, so authorize() evaluates them in one pass — membership is never fanned out into per-user rows. Adding someone to a group grants the access; removing them revokes it, everywhere, with no assignment hunt.
Group role assignments carry the same tri-state scope as roles — org-wide (application_instance_guid NULL) or pinned to one app instance — plus optional valid_from / valid_until for a time-boxed grant.
Groups are also the join point for directory sync: SCIM and LDAP push group membership, not your role codes, so an enterprise customer's directory groups map onto platform groups, and the roles those groups carry do the rest.
No default groups are seeded. A group named "Administrators" granting exactly the admin role is a wrapper with no information in it; groups earn their place when they are org-specific ("Warsaw support", "Contractors") and carry several roles across several scopes.Reading roles and permissions over the API
The portal list routes are org-scoped from the bearer token, and narrowed to one app instance by the X-App-Instance-Guid header. That header is the implicit default, not an override — clients embedded in an app instance send it ambiently on every request, so a caller that genuinely wants the org-wide (NULL-instance) rows must say so:
GET /v1/authz/portal/roles?filters[application_instance_guid][is_null]=trueAn explicit filters[application_instance_guid][…] beats the ambient header, the same way an explicit filters[org_guid] beats scope_id elsewhere. Without it, a page meant to be org-wide silently receives another scope's rows — and a paginated read must repeat the filter on every page, not just the first.
permission_catalog — display metadata only
The catalog attaches a friendly name and description to the codes the sections above already produce. It is pure presentation for the portal permissions UI — it declares nothing and grants nothing, and an unlisted code simply falls back to an auto-humanized name. It holds two arrays, resource_types and actions, each a list of entries:
| Catalog entry key | Required | Purpose |
|---|---|---|
code | yes | The resource-type or action code being labelled, ^[a-z][a-z0-9_.]*$, ≤150 chars. |
name | no | Friendly label shown in the UI. |
description | no | Tooltip text. |
permission_catalog:
resource_types:
- code: automation_executions
name: Runs
description: Individual runs of a workflow, including their step history.
actions:
- code: read
name: View
description: View / read existing records.
- code: cancel
name: Cancel
description: Cancel an in-progress workflow run.How the Control Plane syncs it into AuthZ
At manifest publish CP writes every authorization key into a new version of the AuthZ catalog and activates it. Every organization reads that catalog by code and version; nothing is copied into each organization. An organization's own AuthZ database holds only what the organization creates: custom roles and their grants, role assignments, groups, policies, relations between records and decision logs. None of this is a runtime call — publish writes the catalog; requests read it later at the gates.
| Manifest key | Becomes in AuthZ |
|---|---|
cp_permissions / portal_permissions | Catalog permissions, granted to the built-in roles of the matching tier. |
cp_roles / portal_roles | Built-in catalog roles with their grants (globs expanded against the tier's permission list). Only super_admin may hold *. |
resource_types | A catalog resource type, one action per verb, its permissions and its instance roles (such as owner) with their grants. |
role_derivations | Catalog role derivations, consumed by the authorize() walk so a relation between a source and target instance grants the derived role automatically. |
permission_catalog | Nothing enforced — stored as display metadata only. |
Declaration lives here; the request-time counterpart is Auth & the five gates, and this block is one section of the app manifest.