- Security & Policies
- Custom Roles & CEL Policies
Custom Roles & CEL Policies
Overview
Section titled “Overview”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
How It Works
Section titled “How It Works”API Key → Roles → RBAC (L1) → Policies → CEL Evaluator (L2, deny-only) → Allow / Deny- Each API key is assigned one or more roles
- Each role has zero or more policies attached
- 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
- Policies are filtered by action and resource pattern matching
- CEL conditions are evaluated for matching policies
- Any matching deny wins. L2 is subtractive: it can only take away what L1 RBAC already granted, never add to it
IRN (Ironflow Resource Names)
Section titled “IRN (Ironflow Resource Names)”Every resource is identified by an IRN with 7 colon-separated segments:
irn:ironflow:{org}:{project}:{type}:{environment}:{id}| Segment | Description |
|---|---|
irn:ironflow | Fixed 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/projectirn:ironflow:org_default:*:*:*:* # all resources in org_defaultirn:ironflow:*:*:event:*:order.* # order events in any org/project/envPolicies
Section titled “Policies”A policy defines an authorization rule with five fields:
| Field | Type | Description |
|---|---|---|
name | string | Unique name within the organization |
effect | string | "deny" only (#943 — "allow" returns 400) |
actions | string | Comma-separated action patterns |
resources | string | Comma-separated IRN patterns |
condition | string | Optional CEL expression (must return boolean) |
Actions
Section titled “Actions”Actions follow the {resource}:{operation} format:
| Action | Description |
|---|---|
functions:register | Register a function |
functions:invoke | Invoke a function |
functions:list | List functions |
functions:read | Read function details |
runs:read | Read runs |
runs:cancel | Cancel a run |
events:emit | Emit events |
outbox:manage | Requeue or discard outbox dead-letter entries (listing needs only events:subscribe) |
events:subscribe | Subscribe to events |
streams:read | Read entity streams |
entities:append | Append to streams |
entities:read | Read entity data |
projections:read | Read projections |
projections:manage | Manage projections |
secrets:read | Read secrets |
secrets:manage | Manage secrets |
users:read | Read users |
users:manage | Manage users |
apikeys:read | Read API keys |
apikeys:manage | Manage API keys |
orgs:read | Read organizations, roles, and policies |
orgs:manage | Manage organizations, roles, and policies |
policies:write | The 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:export | Export the org’s data snapshot (GET /api/v1/export) — admin-only, reached through the admin wildcard; never granted to developer or viewer |
agent:tools:read | List agent tools |
agent:tools:register | Register an agent tool |
agent:tools:invoke | Invoke an agent tool |
agent:tools:unregister | Unregister 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.
Example Policies
Section titled “Example Policies”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\"])"}CEL Expressions
Section titled “CEL Expressions”Policy conditions use the Common Expression Language (CEL). Conditions must return a boolean value.
Available Variables
Section titled “Available Variables”| Variable | Type | Description |
|---|---|---|
request["action"] | string | The requested action (e.g., functions:invoke) |
request["resource"] | string | The resource IRN |
request["environment"] | string | The environment ID |
request["org_id"] | string | Organization ID |
subject["id"] | string | User ID. Empty under API-key auth — use subject["api_key_id"] there |
subject["user_email"] | string | User email (JWT only) |
subject["roles"] | list(string) | Role slugs |
subject["groups"] | list(string) | Group slugs |
subject["org"] | string | Organization ID |
subject["project"] | string | Project ID |
subject["env"] | string | Environment ID |
subject["api_key_id"] | string | API key ID (API key auth only) |
subject["is_platform"] | bool | Whether the subject is a platform key |
Example Conditions
Section titled “Example Conditions”// Only allow in specific environmentrequest["environment"] == "env_staging"
// Deny if caller has only viewer rolesubject["roles"].exists(r, r == "viewer") && subject["roles"].size() == 1REST API
Section titled “REST API”| Method | Path | Description |
|---|---|---|
POST | /api/v1/roles | Create custom role |
GET | /api/v1/roles | List 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}/policies | List the role’s policies |
POST | /api/v1/roles/{id}/policies | Assign policy to role |
DELETE | /api/v1/roles/{id}/policies/{policy_id} | Remove policy from role |
Create Role
Section titled “Create Role”POST /api/v1/rolesContent-Type: application/jsonAuthorization: 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"}Assign Policy to Role
Section titled “Assign Policy to Role”POST /api/v1/roles/{id}/policiesContent-Type: application/jsonAuthorization: Bearer <api-key>{ "policy_id": "pol_x1y2z3"}Response: 204 No Content
Policies
Section titled “Policies”| Method | Path | Description |
|---|---|---|
POST | /api/v1/policies | Create policy |
GET | /api/v1/policies | List policies |
POST | /api/v1/policies/dry-run | Evaluate 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}/versions | List version history |
POST | /api/v1/policies/{id}/rollback/{version} | Roll back to a version — see Manage policy versions |
GET | /api/v1/policy-templates | List the built-in policy templates |
POST | /api/v1/policy-templates/{id}/install | Install a template as a policy in this org |
Create Policy
Section titled “Create Policy”POST /api/v1/policiesContent-Type: application/jsonAuthorization: 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"}Update Policy
Section titled “Update Policy”PATCH /api/v1/policies/{id}Content-Type: application/jsonAuthorization: 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.
Role Commands
Section titled “Role Commands”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>Policy Commands
Section titled “Policy Commands”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 listironflow policy template install <template_id>Caching & Performance
Section titled “Caching & Performance”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