Software Factories on AWS

Self-hosted platform for standing up and running software factories on AWS: complete DevSecOps environments with secure delivery pipelines, supply-chain controls, compliance evidence and multi-tenant governance — in minutes, not months.

Roles & Permissions

Access control is permission-based and single-source: a user’s effective permissions are the union of the Roles assigned to them.

Highlights

Overview

This reference documents the per-org, permission-based access control model: the fine-grained resource:action catalog, the built-in and custom Roles that bundle those permissions, and how they are enforced.

It is for admins managing Roles and for developers gating routes. Everything is scoped to an organization or team; platform-operator powers live behind the global Super Admin flag.

Read The model first, then the permission catalog and enforcement.

How it works

  1. A Role is a named set of resource:action permissions, stored in the roles and role_assignments collections.
  2. A user is assigned Roles within an organization.
  3. Their effective permission set is the deduplicated union of those Roles. A Super Admin short-circuits to all.
  4. A gate on the route checks it — one permission, all of a set, or a permission-or-service-principal — before the handler runs.
  5. A refused write is audited as authz.denied, so probing leaves a trail.
  6. Changing a Role takes effect on the next request, and the changes that must invalidate a session bump tokenVersion — see Session invalidation.

Configuration

  1. Start from the built-in Roles. New orgs seed Admin and Member; the system org also gets Super Admin.
  2. Author custom Roles with roles:manage, remembering the ceiling: you cannot grant a permission you do not hold.
  3. Assign Roles to members rather than editing the membership label, which grants nothing.
  4. Gate new routes on a permission, and declare the audit action on it — the per-service route-coverage test fails an ungated or undeclared write.
  5. Record sensitive routes in the permission contract in the same commit, or the parity test fails.
  6. For teams, grant downward authority deliberately: see Teams.

The model

The visibility ladder

Every catalog entity — pipelines, plugins, templates, dashboards — shares ONE three-rung sharing model, stored as a visibility column:

Rung Who can see it Who can edit it
private Only the author (createdBy) — a personal draft The author (plus super admins)
org Everyone in the owning org The resource’s :write permission
public The org and its child teams; from the system org, every org The resource’s :publish permission

public never crosses tenant boundaries on its own: it means “beyond just me, within my org tree”. The only globally-visible content is what lives in the system org, which is a superadmin action.

Create-time defaults differ per entity, deliberately — the ladder is identical, only the starting rung differs:

Enforcement lives in exactly two places, so no entity can drift: AccessControlQueryBuilder.buildAccessControl builds the read predicate, and requireVisibilityWriteAccess gates the writes.

Messages are not on the ladder. Their visibility is a bespoke sender/recipient/broadcast predicate plus per-user targeting, so they carry no visibility column at all.

Permission catalog

Category Permissions Notes
Pipelines pipelines:read, pipelines:write, pipelines:publish :publish allows the public rung
Templates templates:read, templates:write, templates:publish Golden-path pipeline templates. Split out of pipelines:* so a platform team can curate starters without pipeline write access. :write covers the private and org rungs; :publish is required for public. Instantiating a template creates a pipeline, so that still needs pipelines:write.
Plugins plugins:read, plugins:write, plugins:publish :publish allows the public rung, and submitting plugin-ecosystem publish requests for the org’s public versions
Plugin ecosystem plugins:install, plugin_installs:manage, publishers:manage Plugin Installing. plugins:install (member/admin/owner): install, upgrade (change version or policy) or uninstall listings, and withdraw one’s own request — it only requests an install when the org’s policy requires approval for the listing’s tier, and moving an approval-tier install across a major or breaking version also needs plugin_installs:manage. plugin_installs:manage (admin/owner): the org’s consumption policy (PUT /plugins/install-policy, step-up; org.plugin-install-policy.update), approving and denying install requests within the org (Plugins → Approvals), and installing approval-tier listings directly; holders are the org approvers who receive install notices (Plugin Installing). publishers:manage (admin/owner): the org’s publisher profile (claim a handle, accept terms, edit description/homepage) and submitting publisher-level requests — handle/name changes, transfers and their acceptance (step-up), claims, the Verified application; every decision belongs to the system org (Plugin Publishing). plugins:publish also submits new-listing, new-version, listing-update, yank and unpause requests and pauses the org’s own listings. Reading the catalog, installs, policy and shadowing needs only plugins:read. Installing is free on every plan and no consumption control is plan-gated.
Plugin ecosystem (system org only) plugins:moderate, publishers:verify System-org only — never grantable to a custom Role in any org; held only through the built-in Ecosystem Manager Role or as a Super Admin. See the carve-out below.
Compliance compliance:read, compliance:write  
Members & access members:manage, roles:manage, invitations:manage, service_accounts:manage service_accounts:manage covers org service accounts and their pb_sa_… keys — split out of members:manage because minting a durable machine credential is a different decision from managing the roster. It is also what gates issuing a SCIM provisioning key
Observability dashboards:read, dashboards:write, observability:read, observability:write, logs:export logs:export is admin/owner by default — viewing logs rides observability:read, but DOWNLOADING them is bulk egress an org may withhold from members. See Logs.
Insights reports:read, reports:rollup :rollup allows including descendant teams in reports
Messaging messages:read, messages:write messages:read also covers contacting support (POST /messages/support) — reaching support is self-service, and that route takes no recipient (the server always addresses the support desk). Sending to anyone else needs messages:write.
Billing & quotas billing:read, billing:manage, quotas:read  
Registry registry:read, registry:write Super Admin only — never grantable to a custom Role
Org settings org:settings General org settings + AI provider config; team lifecycle for a parent admin (delete a team, list and restore recently-deleted teams, export); the org’s plugin security notifications (PUT /plugins/security-notifications — recipients, rescan digest, webhook URL/secret, external address; POST /plugins/security-notifications/test; audited plugin.security_notifications.update / .test). Reading those settings needs only plugins:read (secret and address never returned)
SSO / IdP org:idp Per-org SSO/IdP configuration, OIDC and SAML (protocol selector, connection, certificates, attribute mapping) — sensitive (controls login); split out of org:settings
KMS org:kms Customer-managed KMS key configuration — sensitive (controls encryption); split out of org:settings
Impersonation org:impersonation The organization’s impersonation policy — sensitive (controls who may view the org’s data as one of its members); split out of org:settings so a role that manages general settings cannot also open the org to impersonation

:read permissions are enforced — everywhere. Withholding any :read from a custom Role blocks that read at the API, not just in the UI: every read route carries its :read gate, proven per service by the route-coverage test (see Route coverage). Built-in Admin and Member both include all reads, so only bespoke custom Roles that drop a read are affected. A Role that grants pipelines:write without pipelines:read can still write but no longer list — grant both.

Registry carve-out. registry:read / registry:write are in SUPERADMIN_ONLY_PERMISSIONS: they’re in no built-in Role bundle and can’t be requested by a custom Role (sanitizePermissions strips them), so the only holder is a Super Admin via implicit-all.

System-org-only carve-out. plugins:moderate / publishers:verify are in SYSTEM_ORG_ONLY_PERMISSIONS (predicate isSystemOrgOnlyPermission), a second non-assignable class next to the registry pair. They govern the plugin ecosystem, which only the system org may manage or approve (governance). They’re in no member/admin/owner bundle and a custom Role requesting either is rejected with RL_PERMISSION_NOT_ASSIGNABLE in every org — the system org and a Super Admin author included. The only holders are Super Admins (implicit-all) and members of the system org’s built-in Ecosystem Manager Role.

The class is enforced in four places, each tested (platform/test/ecosystem-manager-isolation.test.ts walks every path a tenant could try — custom Role, assignment, invitation, IdP mapping, token):

  1. Authoring — sanitizePermissions refuses them in any custom Role.
  2. Assignment — only a Super Admin may assign or unassign a Role carrying them, and only inside the system org (below).
  3. Token issue — confinePermissionsToOrg drops them from the permissions claim of every token whose active org is not the system org (user tokens, exchanged keys and service-account tokens alike) — even a Super Admin’s implicit-all, and even if a hand-written Role document carried them there.
  4. Routes — requireEcosystemPermission(...) (below) requires the system org as the caller’s active org, the permission, and an MFA-grade session.

Built-in Role bundles

Ecosystem Manager (system org only)

The built-in Role for the people who run the plugin ecosystem (moderation runbook): deciding publish requests, publisher tiers and profile changes, transfers, yanks and advisories, and moderating anonymous submissions and reviews. They are the only non-superadmins anywhere who hold the system-org-only permissions, and they get no platform superadmin powers.

Property Value
Exists in The system org only. Seeded by seedDefaultRoles next to Super Admin, Admin and Member when the system org is created; never seeded in a tenant org or team. The org creator is not added to it.
Immutable system: true — it can’t be renamed, edited or deleted (RL_SYSTEM_IMMUTABLE). The startup backfill re-syncs it to ECOSYSTEM_MANAGER_PERMISSIONS (it is marked seedBundle: 'ecosystem_manager', which is also what keeps it out of the Member-floor lookups it shares grantsRole: 'member' with).
grantsRole member — no admin rights over the system org, and it never sets User.isSuperAdmin.
Permissions plugins:read, plugins:moderate, publishers:verify, messages:read (in-app notices), observability:read (the moderation-SLA dashboard). Nothing tenant-facing: no registry:*, no members:manage.
Who can assign it Super Admins only. Assigning or unassigning any Role that carries a system-org-only permission is refused for everyone else — an admin or owner of the system org included — with RL_SYSTEM_ORG_ROLE_REQUIRES_SUPERADMIN (403). Such a Role can only be held inside the system org (RL_SYSTEM_ORG_ROLE_OUTSIDE_SYSTEM_ORG, 400). The same ceiling applies to service-account Role sets, and an IdP group mapping can never grant it (IGM_FORBIDDEN_GRANT, like the Super Admin Role).
Audit Assignment and removal are org.role.member.add / org.role.member.remove with affectedOrgId = the system org and details.role = 'Ecosystem Manager'.
Notifications Every Super Admin and the affected user get N23 (in-app + email, can’t be turned off) when someone is added or removed. Holders are the Moderators recipients of the other ecosystem notices (notifications); with nobody in the role, Super Admins receive them.
Console The admin console’s Ecosystem section (Ecosystem Manager assignment for Super Admins; the publish queue and publisher verification as the workflows land) appears only while the active org is the system org and the user holds plugins:moderate or publishers:verify. Tenant users never see it — this is a governance boundary, so the nav hides it rather than locking it. A holder without an MFA-grade session sees an enrol prompt instead.

Enforcement

Routes gate writes with permission middleware; a denied state-changing request also emits an authz.denied audit event.

Authority is the union of the caller’s Roles’ permissions, not the coarse Owner/Admin label. The label governs ownership and seats only: a route gated on org:settings (or org:impersonation, members:manage, …) admits a custom Role that holds that permission exactly as it admits an admin, and the controller behind it adds only the tenancy check — the target must be the caller’s active org or a team under it. So delegating org:settings to a custom Role lets its holders edit the org’s identity, security policies (MFA, password, authenticator), verified domains and join requests, export it, and delete or restore its teams; a member without the permission is refused by the route.

Not the same as the mesh. This page covers application authorization — per-org capability checks on API routes. Beneath it, the Istio ambient service mesh enforces network authorization (L4, by service identity): which services may reach which services at all. A request must pass both — the mesh AuthorizationPolicy (can sa/pipeline reach platform:3000?) and then requirePermission (does this user hold the capability?). Don’t conflate a mesh 403 (identity not allow-listed) with an app 403 (missing permission). For the INTERNAL routes the two layers name the same callers deliberately — the app gate because docker compose runs no mesh, the mesh policy as defence in depth where one exists.

Middleware Semantics
requirePermission(a, b, …) passes if the caller holds any of the listed permissions (Super Admin passes via implicit-all)
requireAllPermissions(a, b, …) passes only if the caller holds all listed permissions
requirePermissionOrService(a, …) like requirePermission, but ALSO admits an internal service:* principal — used on READ routes that both users and service-to-service callers hit (a service token carries no permission claims and would otherwise be wrongly denied)
requireSystemAdmin platform-operator routes: the isSuperAdmin claim only, never an org permission
requireServicePrincipal a signed service:* token, never a user
requireInternalService({ callers }) internal routes (/internal/*, the quota usage counters, the entity-event / audit ingests, the entitlement sync legs): a signed service:* token whose name is one of callers, never a user token — not even a superadmin’s. The name is bound to the signing key, so this is an identity check, not a claim check. Refusals increment internal_route_refused_total{service,route,reason,caller} and emit authz.denied
requireAssurance({ minAssurance: 2 }) the SESSION must have been opened with a second factor (aal: 2); a single-factor one gets 401 MFA_REQUIRED, a machine credential 403 HUMAN_SESSION_REQUIRED
requireOrgAdminAssurance({ machines }) the org’s “administrative actions require MFA” policy: while it is on (the org_admin_aal claim), a single-factor session gets 401 MFA_REQUIRED; machines: 'allow' lets PATs / service accounts through, 'refuse' answers them 403 HUMAN_SESSION_REQUIRED. A no-op while the policy is off
requireSystemOrg the caller’s active org (token organizationId) must be the system org — compared by id, never by name. A token minted in a tenant org is refused 403 SYSTEM_ORG_REQUIRED even for the same person, Super Admins included; refusals increment system_org_guard_refused_total and emit authz.denied (required: 'system-org')
requireEcosystemPermission(p, …) the gate for a plugin-ecosystem governance route: requireSystemOrg + requirePermission(p, …) + requireAssurance({ minAssurance: 2 }). Only takes system-org-only permissions (throws at definition time otherwise). Destructive actions add requireStepUp on top
audited('<action>') declares the audit action the route’s handler emits — metadata for the route table, a no-op at runtime

Assurance tiers

Permissions say who may do something; assurance says how strongly the session doing it was opened (see Assurance levels and required MFA). Two tiers sit on top of the permission gates:

Always aal: 2 (whatever the org’s settings) By policy (adminActionsRequireMfa)
Loosening the org MFA policy (mfa-policy: require-MFA off, admin-actions off, “IdP enforces MFA” on) — tightening stays open to aal: 1 Roles: create / update / delete, add / remove members (roles:manage)
Loosening the impersonation policy (less strict mode, self-approval on) IdP group → Role mappings (roles:manage)
Service-account create and key issue (service_accounts:manage) Members: add, bulk-add, remove, deactivate, activate (members:manage)
Ownership transfer Invitations: send, revoke, resend (invitations:manage)
Sysadmin user edit / delete / feature overrides / bulk delete Billing: subscription create / update / cancel / reactivate, checkout, portal, add-ons, discounts, Marketplace claim (billing:manage)
MFA reset: request, approve, sysadmin direct reset Log export (logs:export)
Impersonation, per-org KMS, IdP config, platform-admin grants (+ second-factor step-up) Access-key creation and opening a machine credential (person only)

The policy is set on the org’s two-factor card (PATCH /organization/:id/mfa-policy, adminActionsRequireMfa), inherited by teams (strictest wins), and carried to every service as the org_admin_aal token claim — changing it bumps every affected member’s session. Machine credentials pass the by-policy gates on routes automation legitimately drives and are refused where the action would mint another credential; the per-route decision is in the route table (orgAdminAssurance.machines).

Route coverage

Gates sit partly on router mounts and partly on individual routes, so grepping the source can’t prove coverage. Instead every gate middleware carries metadata and buildRouteTable(app) (api-core middleware/route-table.ts) walks the assembled Express app, resolving per method + path what runs before the handler: permissions, system-admin, service principal, the internal-route caller list, step-up (and, where it names them, which factors may earn it), the minimum assurance level and maxAge, feature flag, token scope and the declared audit action. Each service logs a summary of its own table at boot (Route table built).

Every service (and platform) has a test/route-coverage.test.ts that fails when:

The plugin and image-registry tests also run findSystemOrgGuardViolations (the governance check, governance): any route whose permission gate names a system-org-only permission must also run requireSystemOrg and require aal: 2 — again with no exception list. It passes on a table with no governance route, so it is wired in before the first one exists and bites the day one lands without requireEcosystemPermission.

An internal route counts as gated on its own (the gate is stricter than any user permission), and findInternalRouteViolations additionally checks each service’s declared internal routes and their caller lists against the code in both directions — so adding or re-scoping one is a visible change, and the Istio policies that name the same callers (deploy/*/k8s/istio-internal-routes.yaml) have one authoritative list to be reviewed against.

Routes that legitimately can’t satisfy a rule — liveness/readiness/metrics probes, signature-verified webhooks, pre-auth sign-in endpoints, internal service-principal hooks, ticket-gated SSE streams, and the handful of routes whose authorization is genuinely dynamic in the handler (e.g. “the row’s creator may write it”) — are listed in that test’s EXCEPTIONS array, each with a reason. An exception that stops waiving anything also fails the test, so the allowlist can’t rot.

Each test also writes its table to frontend/src/generated/route-table/<service>.json (regenerate with UPDATE_ROUTE_TABLES=1), and the frontend’s test/route-permissions.test.tsx asserts that every gated control matches the route it calls on EVERY dimension the table records — permissions, entitlements (features), step-up, scopes, assurance and the admin-actions MFA policy (orgAdminAssurance) — so a UI gate can’t drift from the API. Its GATED_ROUTES_WITHOUT_A_CONTROL registry closes the other direction: a route that gains a gate must be mapped to the control that calls it or listed there with a reason, or the test fails naming the route.

Hiding a sidebar link is not a gate — a bookmark, a shared URL or a post-login redirect lands on the page regardless. frontend/src/lib/page-access.ts declares what every dashboard route requires, DERIVED from the same NAV_SECTIONS entry the sidebar filters on (pages with no nav entry of their own are listed explicitly), and useAuthGuard applies it with no per-page options. A viewer who lacks it gets one <AccessDenied> state naming the missing permission, re-evaluated on every render so a role change or org switch mid-session flips the open page rather than leaving its panels to 403 one by one. frontend/test/page-access.test.ts asserts every page under pages/dashboard/ is declared and that every gated page renders the refusal.

Entitlements vs permissions in the UI

frontend/src/lib/feature-gates.ts records where each feature flag is actually enforced — route (a requireFeature middleware, visible in the route table), handler (checked inside a handler, e.g. the compliance set: tags and the SSO entitlement) or entitlement-only (nothing in the API checks it). Only the first two get a UI lock, rendered by FeatureLock / FeatureLockedAction, which name the entitlement and link to the matching add-on. entitlement-only flags (priority_support, custom_integrations) deliberately gate nothing: locking a control the API serves would take capability away from an org that has it. The parity test enforces both halves.

One catalog. The permission catalog (ids, labels, descriptions, categories, the picker grouping) lives only in api-core src/types/permissions.ts, a module with no runtime imports. The frontend imports it through the @pipeline-builder/api-core/permissions subpath; there is no hand-kept copy to drift.

Public-visibility is permission-based too: resolveVisibility grants public only to a caller holding the resource’s :publish permission — so a custom Role can be granted publish rights instead of being forced private by its coarse label. Downward report roll-up (?includeDescendants) requires reports:rollup.

Accounts are not org-scoped. A user account can belong to many organizations, so members:manage covers a member’s role in your organization and removing them from it — not the account itself. Changing a user’s username, email or password, and deleting an account, are platform-admin actions (PUT/DELETE /users/:id, both behind step-up on an aal: 2 session). Resetting a member’s second factors is an org action, but a two-person one: an owner/admin requests it and a different owner/admin (or a sysadmin) approves it — see Recovery when every factor is lost. An org admin who needs someone locked out removes or deactivates the membership.

Session invalidation

Access tokens are short-lived (15 min) and carry a tokenVersion. On any privilege change (role edit, permission change, superadmin grant/revoke), platform bumps the user’s tokenVersion and publishes it to a Redis-backed revocation store; requireAuth rejects a token whose version is behind (fail-open if Redis is unavailable — auth then degrades to natural token expiry, never a lockout).

Impersonation (view as user)

A read-only “view as user X” session reproduces what a tenant sees — for support work, bug reports, “it looks wrong on my account”.

POST /api/admin/impersonate/:userId
X-Step-Up-Token: <token>

Who can open one:

Caller Reach
Platform sysadmin Any user
Admin/owner of an ancestor org Users in their own subtree — a parent org’s teams

A parent organization already administers its descendant teams’ members, quotas and secrets, so no separate approval applies within the account. This is strictly downward: a team admin gets no authority over the parent, a sibling team is not in the subtree, and an admin of the user’s own org does not qualify — that would be “any admin may view any of their members”, which this is not. Members get nothing in either direction.

Every caller must also hold an MFA-grade session and pass a step-up earned by a passkey or an authenticator code — a password re-prompt proves nothing an attacker already holding the session doesn’t have. The response is an access token carrying the target user’s identity, valid for 15 minutes. No refresh token is issued.

When a parent-org admin opens a session, the team’s admins are notified — they are informed, not asked.

It is read-only, enforced server-side. The token carries an impersonationReadOnly claim, and every request that isn’t GET, HEAD or OPTIONS is rejected with IMPERSONATION_READ_ONLY. No state change can land under a borrowed identity.

What it will not do:

Refused Why
Impersonating yourself No purpose; rejected 400
Impersonating another sysadmin Stops a compromised sysadmin laundering authority through a peer’s identity
Impersonating from inside an impersonation session Keeps the audit trail a single hop — always requester → user, never chained
A user with no resolvable organization There is no org to pin the session to (non-sysadmin callers)

Tenancy. The issued token is built from the target’s identity, so it carries no isSuperAdmin claim — which means the x-org-id override is unavailable during impersonation. The session is pinned to one organization (today, the target’s active org) and resolved strictly: if the target has no live membership there — or that org is soft-deleted — the token is issued with no organization context rather than falling back to some other org they belong to. An operator who asked to view a specific organization is never silently placed in a different one. It is not a way to roam between tenants.

Visibility. Starting a session emits admin.impersonate.start, filed under the organization the session is pinned to rather than the system org — so that org’s admins see it in their own audit view, not just platform operators. The event carries a requestId and an approvalReason, and actions taken during the session carry the sysadmin in a first-class impersonatorId field. See Audit Events.

Every session is recorded. Each one creates an impersonation request that the token is then redeemed against — there is no path that issues a token without a record. An approval is single-use, so a request cannot be redeemed twice. The request’s approvalReason records why it was allowed: policy_open (nobody had to be asked), ancestor_authority (a parent org’s admin), consent (someone approved), or breakglass (emergency access).

Administrator access policy

Each organization chooses whether platform sysadmins may view its members’ accounts, under Settings → Organization → Administrator access. Changing it requires the org:impersonation permission and re-entering your password.

Policy Shown as What happens when a sysadmin asks
open Open The session starts immediately. It is still logged.
consent (default) Ask first The request waits. Someone must approve it within 1 hour.
denied Emergencies only Refused. Only emergency access remains.

Who is asked. By default the request goes to the member whose account it is. An organization can turn off Let members approve access to their own account; requests then go to its admins, and the first to answer decides. A sysadmin who explicitly asks for the member to approve, in an organization that forbids it, is refused rather than silently redirected.

Strictest wins across teams. A team can make its policy stricter than its parent’s, never looser: the policy that applies is the stricter of the two, and self-approval is allowed only if both allow it. The settings page shows when a parent’s policy is overriding the team’s own. If the parent’s policy can’t be read, the strictest policy applies until it can.

denied can’t be selected on a deployment with fewer than two sysadmins, because emergency access under it needs a second sysadmin to approve.

Approving. The request appears on the approver’s Access requests page, and they are notified in the app. Approving shows exactly what is being granted — who, whose account, for how long, and that it’s view-only. The requester is notified of the decision either way, and opens an approved session from their own Access requests page, which asks for their password again. An approval that isn’t opened within the hour lapses and shows as expired. A request nobody could be notified about is reported to the requester immediately rather than left waiting.

Admins of a parent organization are not subject to the policy when viewing their own teams’ members: the team’s admins are informed, not asked. They do this from Members → View a team member’s account, which appears for admins of an organization that has teams: pick a team, then View as user on a member. The request names that team, so the session is scoped to it regardless of which organization the member last had active.

Through the API, an impersonation request may name the organization explicitly:

POST /api/admin/impersonate/:userId
{ "orgId": "<team id>" }

The user must be an active member of that organization, or the request is refused. Without orgId, the session is for the user’s active organization.

Seeing and ending sessions. Every signed-in user has an Access requests page (/dashboard/access-requests, under Home in the sidebar). It lists live sessions on your own account, sessions you opened, and — for an organization admin — sessions on your organization’s members, each with an End session button. Ending a session takes effect immediately after one plain confirmation — never a step-up, so stopping access is never harder than granting it — because it can’t be undone: to look again the operator must ask, and be approved, again. Each list on the page is paged (GET /api/admin/impersonate/requests?view=…&limit=&offset=, newest first, returning pagination.total).

Stop impersonating ends the session on the server as well as in the browser, so the token stops working rather than remaining valid until it expires.

A live session can also be revoked through the API:

POST /api/admin/impersonate/requests/:id/revoke

Permitted to the impersonated user, an admin of the pinned organization, whoever approved it, or the requester. Each session’s token carries its own jti, so revoking ends that session only — the impersonated user’s own sessions are untouched. The next request under a revoked token is rejected, rather than the session running out its 15-minute TTL.

Revocation reaches every service. The platform refuses a revoked session’s token directly; ending a session also publishes it to Redis, which every other service checks on each impersonated request. If that publish fails, the response carries revokedEverywhere: false and the Access requests page warns that the session may keep working elsewhere until it expires, rather than reporting it ended.

A service that cannot read Redis rejects impersonation tokens — it cannot tell whether the session was ended, and a withdrawn session that keeps working is not withdrawn. Ordinary user sessions are unaffected by a Redis outage.

Emergency access. A sysadmin can take read-only access without waiting for approval, for incidents where the account’s owner can’t or shouldn’t be asked (Emergency access… on the user’s edit dialog):

POST /api/admin/impersonate/:userId/breakglass
{ "justification": "INC-1234: customer pipelines failing since 09:10" }

It is deliberately expensive rather than blocked:

No one — including a sysadmin — can approve their own request.

Managing Roles via the API

GET|POST    /api/organization/:id/roles                       # list / create Roles
PUT|DELETE  /api/organization/:id/roles/:roleId               # update / delete a custom Role
POST|DELETE /api/organization/:id/roles/:roleId/members/:uid  # add / remove a Role member

POST / PUT / DELETE require roles:manage. Custom-Role authoring validates the requested permissions against the org-assignable set (the registry and system-org-only carve-outs are rejected) and against the author’s own permissions (the permission ceiling above) — a request granting a permission the author lacks is rejected 403.

IdP group → Role mappings

GET|POST    /api/organization/:id/idp/group-mappings              # list / create a mapping
PUT|DELETE  /api/organization/:id/idp/group-mappings/:mappingId   # update / delete a mapping

Every route requires roles:manage, not org:idp: a mapping grants Roles at SSO sign-in, so it belongs to whoever governs Roles — an org can delegate the login connection (org:idp) and the Role policy (roles:manage) to different people. The controller adds the own-org / managed-team scope and the sso entitlement; the service applies the same assignment ceiling as a direct Role assignment, in both directions (what a rule would grant, and what it already grants, when it is edited or deleted).

Two refusals are specific to mappings:

Roles assigned by hand are tracked separately from mapped ones and are never removed when a group stops matching.

Service accounts

GET         /api/organization/:id/service-accounts                      # list accounts + their keys
POST        /api/organization/:id/service-accounts                      # create an account
GET         /api/organization/:id/service-accounts/:accountId           # one account
PATCH       /api/organization/:id/service-accounts/:accountId           # description / roles / budget / disabled
DELETE      /api/organization/:id/service-accounts/:accountId           # delete the account + every key
POST        /api/organization/:id/service-accounts/:accountId/keys      # issue a pb_sa_ key (shown once)
DELETE      /api/organization/:id/service-accounts/:accountId/keys/:id  # revoke one key

EVERY route here requires service_accounts:manage — the listing is an inventory of the org’s machine credentials, so it is not a member-level read (it carries no secret either way: a key is shown once, at creation). Writes add step-up, except key REVOCATION, which is deliberately left at the permission alone so a compromised key can be killed without a second factor.

A service account’s Roles go through the same assignment ceiling as a person’s: the caller must already hold every permission the Role grants, and only a platform superadmin may grant a superadmin-granting Role. A service account therefore can never exceed its creator’s permissions. It also can never satisfy step-up itself (requireStepUp refuses the principal), so one machine key can never be used to mint another.

SCIM (/api/scim/v2) — a capability SCOPE, not a permission

GET|POST          /api/scim/v2/Users            GET|PUT|PATCH|DELETE  /api/scim/v2/Users/:id
GET|POST          /api/scim/v2/Groups           GET|PUT|PATCH|DELETE  /api/scim/v2/Groups/:id
GET               /api/scim/v2/ServiceProviderConfig | /ResourceTypes | /Schemas

These routes carry no requirePermission, and that is deliberate rather than an omission. They are gated by requireScimScope, which admits only a service-account token carrying the scim capability scope — and a scoped token is minted with permissions: [], features: [], role: 'member' and no admin flags, whatever the account holds. There is therefore no permission for the caller to hold; the scope IS the authority, and the route-coverage test records it as such (scopes: ["scim"] on the generated route table, with a named exception).

Three consequences worth stating, because they are what keeps a leaked SCIM key small:

Issuing the key is the privileged act, and that is permission-gated: service_accounts:manage + step-up, on the ordinary key-mint route above. The UI for it sits on Settings → Single Sign-On, gated on the same permission. See SCIM 2.0 provisioning for the seat, verified-domain, session-revocation and post-downgrade rules.

Teams

A parent-org admin/owner can administer its teams (members, rules, quotas) without a separate membership — fine-grained delegation applies within the team’s own tenancy boundary, and team-local Roles still bind. See Org → Team Hierarchy.

Cross-organization reach

Except for platform sysadmins (the system organization), an organization cannot reach into another organization unless it is that organization’s child team.

This governs every way of acting on an organization you are not a member of:

Reaching into… From the same org From its parent From a sibling team From its child team From a separate account
Administering it admins admins ✅ ❌ ❌ ❌
Reading it members admins ✅ ❌ ❌ ❌
Impersonating a member ❌ admins ✅ ❌ ❌ ❌
Overriding the org via x-org-id sysadmin only        

A sysadmin can do all of these for any organization. Reach only ever flows down the tree, from a parent to its teams.

Switching organizations is different. Switching your active organization follows your own memberships: you can switch to any organization you are an active member of, including a separate one. Being a parent admin does not let you switch into a team you don’t belong to — you administer and view it from the parent instead.