Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Custom Roles & Policy Sets

Ironflow decides each request in two layers:

  • Custom roles. Organization-scoped roles beyond the three built-in ones. A custom role grants no actions in Layer 1; it is a name that policy-set rules can test in input.subject.roles.
  • The policy set. A group of Rego files that can deny a request that a role allowed. It is Layer 2. It cannot allow a request that RBAC denied.
API key -> roles -> RBAC (Layer 1) -> Rego policy set (Layer 2, deny only) -> allow or deny
  1. Each API key is assigned one or more roles.
  2. On every request, Layer 1 RBAC runs first. If the roles of the caller do not grant the action, the request is denied and Layer 2 does not run.
  3. Layer 2 evaluates the policy set of the organization against the request and the caller.
  4. A non-empty deny set, a false decision rule or an evaluation error denies the request.

Layer 2 can only take away what Layer 1 granted. Role scoping happens in Rego. For example, "admin" in input.subject.roles limits a rule to admins. There is no link between a role and a policy.

Every resource is identified by an IRN with 7 colon-separated segments:

irn:ironflow:{org}:{project}:{type}:{environment}:{id}
SegmentDescription
irn:ironflowFixed prefix
{org}Organization ID (e.g., org_default)
{project}Project ID (e.g., proj_default_default)
{type}Resource type: function, run, event, stream, projection, secret, org, role, user, project, file; any other API path uses its URL segment as-is (e.g., apikeys, policy-set)
{environment}Environment ID (e.g., env_default, env_default_prod, env_default_staging)
{id}Resource ID or * for wildcard

A * replaces one whole segment. It is not a prefix or a suffix: order.* matches the literal segment order.*, not order.created.

irn:ironflow:*:*:function:env_default_prod:* # all functions in prod, any org/project
irn:ironflow:org_default:*:*:*:* # all resources in org_default
irn:ironflow:*:*:event:*:* # all events in any org/project/env

Actions follow the {resource}:{operation} format:

ActionDescription
functions:registerRegister a function
functions:invokeInvoke a function
functions:listList functions
functions:readRead function details
runs:readRead runs
runs:cancelCancel a run
runs:deleteDelete a terminal run — admin-only, reached through the admin wildcard; never granted to developer or viewer
events:emitEmit events
outbox:manageRequeue or discard outbox dead-letter entries (listing needs only events:subscribe)
events:subscribeSubscribe to events
streams:readRead entity streams
streams:deleteDelete an entity stream — admin-only, reached through the admin wildcard; never granted to developer or viewer
events:redactIrreversibly replace a stored event, step or run payload with a placeholder — admin-only, reached through the admin wildcard; never granted to developer or viewer
entities:appendAppend to streams
entities:readRead entity data
projections:readRead projections
projections:manageManage projections
secrets:readRead secrets
secrets:manageManage secrets
users:readRead users
users:manageManage users
users:passwordChange your own password (PATCH /api/v1/users/{id}/password). Every built-in role holds it
apikeys:readRead API keys
apikeys:manageManage API keys
orgs:readRead organizations, roles, and the policy set
orgs:manageManage organizations, roles, and the policy set
policies:writeA legacy alias, kept working. Layer 1 gates policy-set writes on orgs:manage. The self-lockout preflight evaluates a candidate set for both orgs:manage and policies:write. Write new rules against orgs:manage. See Recover from a lockout
org:exportExport the org’s data snapshot (GET /api/v1/export) — admin-only, reached through the admin wildcard; never granted to developer or viewer
agent:tools:readList agent tools
agent:tools:registerRegister an agent tool
agent:tools:invokeInvoke an agent tool
agent:tools:unregisterUnregister an agent tool
files:readRead and list file buckets and objects, and create download URLs
files:writePut, delete, move and copy file objects, and create upload URLs
files:manageCreate, change and delete file buckets
mcp:useConnect to the remote MCP endpoint (/api/v1/mcp)
*Wildcard (all actions)

A second platform:* family of 13 actions (platform:tenants:*, platform:users:*, platform:keys:*, platform:roles:*, platform:policies:*, platform:audit:read, platform:impersonate and platform:impersonate:read) gates the control-plane surface reached with an ifplatform_ key. Those actions are not part of an organization’s own authorization surface — do not name them in a tenant policy set. A fourteenth action, platform:bootstrap, runs the one-time platform bootstrap. Only an admin of the default organization holds it. Four platform:instance:* actions (read, freeze, attest, reconcile) gate the instance routes under /api/v1/platform/instance. The cluster: actions (cluster:rotate-token, cluster:log-level, cluster:health, cluster:inspect) are instance-scoped in the same way: only an admin of the default organization or a platform principal whose role grants them (built in: platform_admin) can pass them.

Each rule is a deny rule in package ironflow.authz. See Author a policy set for how to test and push them.

Deny all writes to production:

package ironflow.authz
import data.ironflow.lib.irn
deny contains "no writes to production" if {
irn.match_action("functions:register,functions:invoke,events:emit,entities:append", input.request.action)
irn.match_resource("irn:ironflow:*:*:*:env_default_prod:*", input.request.resource)
}

Deny destructive actions in production:

package ironflow.authz
import data.ironflow.lib.irn
deny contains "no destructive operations in production" if {
irn.match_action("secrets:manage,projections:manage,apikeys:manage", input.request.action)
irn.match_resource("irn:ironflow:*:*:*:env_default_prod:*", input.request.resource)
}

Deny non-oncall callers in staging:

package ironflow.authz
import data.ironflow.lib.irn
deny contains "staging is for the on-call rotation" if {
irn.match_resource("irn:ironflow:*:*:*:env_default_staging:*", input.request.resource)
not "oncall" in input.subject.roles
}

The policy set is documented in its own pages:

MethodPathDescription
POST/api/v1/rolesCreate custom role
GET/api/v1/rolesList roles
GET/api/v1/roles/{id}Get role
PATCH/api/v1/roles/{id}Update role
DELETE/api/v1/roles/{id}Delete role
POST /api/v1/roles
Content-Type: application/json
Authorization: Bearer <api-key>
{
"name": "billing-team"
}

Response (201 Created):

{
"id": "role_a1b2c3d4",
"org_id": "org_default",
"name": "billing-team",
"is_default": false,
"created_at": "2026-03-01T10:00:00Z"
}
MethodPathDescription
GET/api/v1/policy-setGet the active set. Version 0 means no set. The answer has enforcement_active.
PUT/api/v1/policy-setReplace the set. Needs If-Match: <version>.
GET/api/v1/policy-set/versionsList versions.
GET/api/v1/policy-set/versions/{n}Get one version with its files.
POST/api/v1/policy-set/versions/{n}/rollbackStore version n as a new version. Needs If-Match.
POST/api/v1/policy-set/validateValidate a candidate set.
POST/api/v1/policy-set/dry-runEvaluate a candidate set, or the active set, for one input.

The same seven routes exist under /api/v1/platform/policy-set for the platform organization’s set. A platform principal can also call POST /api/v1/platform/orgs/{org}/policy-set/monitor to force a tenant’s set into monitor mode. See Policy set errors for the error codes.

Terminal window
ironflow role create <name> --org <org_id>
ironflow role list --org <org_id>
ironflow role get <id>
ironflow role delete <id>
Terminal window
ironflow policy pull <dir>
ironflow policy validate <dir>
ironflow policy dry-run [dir] --input request.json [--explain]
ironflow policy push <dir> [--mode enforce|monitor] [--decision <rule>]
ironflow policy versions
ironflow policy rollback <version> --dir <dir>
ironflow policy force-monitor <org> --reason "<why>" # platform credentials, break-glass

See ironflow policy for every flag.

  • Each node compiles the set of an organization once for each version and keeps the compiled query.
  • A write increments the epoch of the organization (policy_epoch). Every node that sees the new epoch drops its compiled query and compiles the new set on the next request.
  • There is no decision cache. Each request evaluates the compiled set, with a 50 ms limit.
  • A role change does not increment the epoch. Each API request reads the caller’s roles from the store when it authenticates, so a role change applies to the caller’s next request.