Skip to main content

Documentation

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 keyModelTierWhat it declares
cp_permissionsRBACoperatorPermission codes that gate /cp/* routes
cp_rolesRBACoperatorOperator roles bundling cp_permissions
portal_permissionsRBACtenantPermission codes that gate /portal/* routes
portal_rolesRBACtenantOrg-member roles bundling portal_permissions
resource_typesReBACtenantInstance-scoped resource types, their actions and instance roles
role_derivationsReBACtenantRules that grant a role on one instance from a relation to another
permission_catalog—tenantDisplay-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_roles cover the operator surface — the codes gate your /cp/* routes and are granted to the platform operator roles (super_admin / admin / user).
  • portal_permissions + portal_roles cover 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 keyRequiredPurpose
codeyesRole code, ^[a-z][a-z0-9_]*$.
nameyesDisplay name.
permissionsyesPermission 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.

egav-authz/app.manifest.yamlyaml
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:

egav-support/app.manifest.yamlyaml
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.create

ReBAC — 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 keyRequiredPurpose
codeyesDotted <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.
nameyesDisplay name.
descriptionnoFree text.
actionsnoAction verbs (read, claim, decide, …), each ^[a-z][a-z0-9_]*$. AuthZ registers one Permission per verb.
instance_rolesnoInstance-scoped roles for this type.
InstanceRole keyRequiredPurpose
codeyes^[a-z][a-z0-9_]*$.
nameyesDisplay name.
descriptionnoFree text.
permissionsnoPermission 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:

egav-support/app.manifest.yamlyaml
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.delete
A 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 keyRequiredPurpose
source_role_codeyesRole the user already holds on the source instance.
source_resource_type_codeyesResource type of the source instance.
target_role_codeyesRole granted on the target instance.
target_resource_type_codeyesResource type of the target instance.
link_conditionyesJSONLogic expression gating the derivation.
descriptionnoFree 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.

app.manifest.yamlyaml
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 a role_derivations block yet; the shape above follows the schema and the link_condition form 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

PathTypeMeaning
user.guidstringThe user's stable identifier.
user.emailstring
user.display_namestring
user.statusstring"active" or "inactive".
user.flags.<key>anyFree-form JSON flags, e.g. user.flags.beta_tester.
user.phonestringStandard profile field.
user.job_titlestringStandard profile field.
user.departmentstringStandard profile field.
user.localestringBCP-47 tag, e.g. en-GB.
user.timezonestringIANA zone name, e.g. Europe/Sofia.
user.preferred_languagestringBCP-47 tag.
membership.rolestringThe user's role code in the org.
membership.is_ownerboolean
membership.joined_atstringISO-8601.
membership.manager_user_guidstringGuid 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_numberstring
membership.cost_centerstring
membership.attributes.<code>per definitionAn 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_statusstringDenormalised from the org so org.* and subscription.* are interchangeable.
subscription.features.<code>.enabled / .configboolean / objectThe plan's feature catalog.
subject.user_guid, subject.org_guid, subject.authenticated_user_guidstringSet 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.

json
{ "==": [ { "var": "membership.attributes.region" }, "emea" ] }
json
{ "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 roleCustom role
Declared incp_roles / portal_rolescreated by the tenant at runtime
is_systemtruefalse
Owned bythe manifest — reconciled on every publishthe tenant
Editable by the tenantno (403)yes
Removed when a publish stops declaring ityesnever

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 reviewer and 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:

text
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:

text
GET /v1/authz/portal/roles?filters[application_instance_guid][is_null]=true

An 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 keyRequiredPurpose
codeyesThe resource-type or action code being labelled, ^[a-z][a-z0-9_.]*$, ≤150 chars.
namenoFriendly label shown in the UI.
descriptionnoTooltip text.
egav-automation/app.manifest.yamlyaml
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 keyBecomes in AuthZ
cp_permissions / portal_permissionsCatalog permissions, granted to the built-in roles of the matching tier.
cp_roles / portal_rolesBuilt-in catalog roles with their grants (globs expanded against the tier's permission list). Only super_admin may hold *.
resource_typesA catalog resource type, one action per verb, its permissions and its instance roles (such as owner) with their grants.
role_derivationsCatalog role derivations, consumed by the authorize() walk so a relation between a source and target instance grants the derived role automatically.
permission_catalogNothing 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.