Skip to content

Custom Roles & CEL Policies

The CEL policy engine extends Ironflow’s built-in RBAC with:

  • Custom roles — organization-scoped roles beyond the three built-in ones
  • Authorization policies — rules combining actions, resource patterns, and optional CEL conditions
  • Deny-always-wins evaluation — any matching deny overrides the RBAC allow
  • Decision caching — in-memory LRU cache with epoch-based invalidation
API Key → Roles → RBAC (L1) → Policies → CEL Evaluator (L2, deny-only) → Allow / Deny
  1. Each API key is assigned one or more roles
  2. Each role has zero or more policies attached
  3. On every request, L1 RBAC runs first — if the caller’s roles do not grant the action, the request is denied and no policy is evaluated
  4. Policies are filtered by action and resource pattern matching
  5. CEL conditions are evaluated for matching policies
  6. Any matching deny wins. L2 is subtractive: it can only take away what L1 RBAC already granted, never add to it

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, policy, user, project
{environment}Environment ID (e.g., env_default, env_prod, env_staging)
{id}Resource ID or * for wildcard

Wildcards (*) match any single segment at any position:

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

A policy defines an authorization rule with five fields:

FieldTypeDescription
namestringUnique name within the organization
effectstring"deny" only (#943 — "allow" returns 400)
actionsstringComma-separated action patterns
resourcesstringComma-separated IRN patterns
conditionstringOptional CEL expression (must return boolean)

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
events:emitEmit events
outbox:manageRequeue or discard outbox dead-letter entries (listing needs only events:subscribe)
events:subscribeSubscribe to events
streams:readRead entity streams
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
apikeys:readRead API keys
apikeys:manageManage API keys
orgs:readRead organizations, roles, and policies
orgs:manageManage organizations, roles, and policies
policies:writeThe action a policy save is evaluated against. L1 gates policy writes on orgs:manage; a policy whose actions match policies:write is what the self-lockout preflight tests — see Emergency bypass
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
*Wildcard (all actions)

A second platform:* family (platform:tenants:read, platform:users:manage, platform:keys:*, platform:roles:*, platform:policies:*, platform:audit:read, platform:impersonate) 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.

Deny all writes to production:

{
"name": "deny-prod-writes",
"effect": "deny",
"actions": "functions:register,functions:invoke,events:emit,entities:append",
"resources": "irn:ironflow:*:*:*:env_prod:*"
}

Deny destructive actions in production:

{
"name": "deny-prod-destructive",
"effect": "deny",
"actions": "secrets:manage,projections:manage,apikeys:manage",
"resources": "irn:ironflow:*:*:*:env_prod:*"
}

Deny non-oncall callers in staging via CEL condition:

{
"name": "deny-staging-non-oncall",
"effect": "deny",
"actions": "*",
"resources": "irn:ironflow:*:*:*:env_staging:*",
"condition": "!(\"oncall\" in subject[\"roles\"])"
}

Policy conditions use the Common Expression Language (CEL). Conditions must return a boolean value.

VariableTypeDescription
request["action"]stringThe requested action (e.g., functions:invoke)
request["resource"]stringThe resource IRN
request["environment"]stringThe environment ID
request["org_id"]stringOrganization ID
subject["id"]stringUser ID. Empty under API-key auth — use subject["api_key_id"] there
subject["user_email"]stringUser email (JWT only)
subject["roles"]list(string)Role slugs
subject["groups"]list(string)Group slugs
subject["org"]stringOrganization ID
subject["project"]stringProject ID
subject["env"]stringEnvironment ID
subject["api_key_id"]stringAPI key ID (API key auth only)
subject["is_platform"]boolWhether the subject is a platform key
// Only allow in specific environment
request["environment"] == "env_staging"
// Deny if caller has only viewer role
subject["roles"].exists(r, r == "viewer") && subject["roles"].size() == 1
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
GET/api/v1/roles/{id}/policiesList the role’s policies
POST/api/v1/roles/{id}/policiesAssign policy to role
DELETE/api/v1/roles/{id}/policies/{policy_id}Remove policy from 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"
}
POST /api/v1/roles/{id}/policies
Content-Type: application/json
Authorization: Bearer <api-key>
{
"policy_id": "pol_x1y2z3"
}

Response: 204 No Content

MethodPathDescription
POST/api/v1/policiesCreate policy
GET/api/v1/policiesList policies
POST/api/v1/policies/dry-runEvaluate a condition without saving — see Author a CEL policy
GET/api/v1/policies/{id}Get policy
PATCH/api/v1/policies/{id}Update policy
DELETE/api/v1/policies/{id}Delete policy
GET/api/v1/policies/{id}/versionsList version history
POST/api/v1/policies/{id}/rollback/{version}Roll back to a version — see Manage policy versions
GET/api/v1/policy-templatesList the built-in policy templates
POST/api/v1/policy-templates/{id}/installInstall a template as a policy in this org
POST /api/v1/policies
Content-Type: application/json
Authorization: Bearer <api-key>
{
"name": "deny-prod-writes",
"effect": "deny",
"actions": "functions:invoke,events:emit,entities:append",
"resources": "irn:ironflow:*:*:*:env_prod:*",
"condition": "request[\"environment\"] == \"env_prod\""
}

Response (201 Created):

{
"id": "pol_x1y2z3",
"org_id": "org_default",
"name": "deny-prod-writes",
"effect": "deny",
"actions": "functions:invoke,events:emit,entities:append",
"resources": "irn:ironflow:*:*:*:env_prod:*",
"condition": "request[\"environment\"] == \"env_prod\"",
"created_at": "2026-03-01T10:00:00Z",
"updated_at": "2026-03-01T10:00:00Z"
}
PATCH /api/v1/policies/{id}
Content-Type: application/json
Authorization: Bearer <api-key>
{
"actions": "functions:list,functions:read,runs:read",
"condition": ""
}

Only include fields you want to change. Set condition to "" to remove a condition.

Response (200 OK): Updated policy object.

Terminal window
ironflow role create <name> --org <org_id>
ironflow role list --org <org_id>
ironflow role get <id>
ironflow role delete <id>
ironflow role assign-policy <role_id> <policy_id>
ironflow role remove-policy <role_id> <policy_id>
Terminal window
ironflow policy create --name <name> --effect deny --actions "..." --resources "..." [--condition "CEL"]
ironflow policy list --org <org_id>
ironflow policy get <id>
ironflow policy delete <id>
ironflow policy update <id> [--name ...] [--actions ...] [--resources ...] [--condition "CEL"] [--clear-condition]
ironflow policy test --condition "CEL" --request '{...}' --subject '{...}'
ironflow policy versions list <policy_id>
ironflow policy rollback <policy_id> <version>
ironflow policy template list
ironflow policy template install <template_id>

The CEL evaluator uses an in-memory LRU decision cache:

  • Cache key: (api_key_id, action, resource, environment, epoch)
  • Invalidation: Every policy or role mutation increments the organization’s policy_epoch, which changes cache keys and effectively invalidates all cached decisions
  • Decision cache size: 16,384 entries (LRU eviction). Program cache size: 4,096 entries (LRU eviction).
  • Cold start: Cache rebuilds naturally as requests arrive; no warmup needed