Author a CEL policy
This guide walks through writing a single deny policy end-to-end. By the end you will have a saved, active policy denying functions:invoke on production functions outside business hours, plus an understanding of the dry-run and self-lockout preflight steps.
For the conceptual model, read Authorization Policies first.
Prerequisites
Section titled “Prerequisites”- A tenant with at least one policy admin (a role granting
policies:write). - An API key for that admin, or dashboard access for a user mapped to that role.
- (Dashboard path) The dashboard at
/policieswith the “New policy” button visible. - (CLI path) The
ironflowbinary on your PATH.
Step 1 — Decide the deny rule
Section titled “Step 1 — Decide the deny rule”Phrase the rule in plain English first. Authorization rules that don’t translate to a single English sentence usually want to be split into two policies.
Deny
functions:invokeon resources in theprodenvironment for callers who are not on the on-call rotation.
Three parts: a verb (functions:invoke), a resource pattern (production functions), a CEL condition (the on-call check).
Step 2 — Write the CEL Condition
Section titled “Step 2 — Write the CEL Condition”request.environment == "prod" && !("oncall" in subject.roles)CEL gotchas worth flagging:
requestandsubjectare the top-level map vars declared ininternal/auth/rbac/cel/env.go::NewCELEnv(). Always prefix:request.environment,subject.roles. Bare identifiers (e.g.,environment) won’t compile.- The v1
requestmap exposes{action, resource, environment, org_id}. There is no IRN-parsed convenience map — match againstrequest.resourcewith string ops if you need to filter by ID suffix. - The v1 env does not expose
nowor any time function. Use the policy-rowvalid_from/valid_untilfields for time-bounded windows; recurring time conditions inside CEL are out of scope until the env adds a time binding. - String matching uses
.contains(...),.startsWith(...),.endsWith(...)— there is no regex in the v1 environment. - Lists support
in:"oncall" in subject.roles. Membership is faster than iteration.
Step 3 — Test the expression
Section titled “Step 3 — Test the expression”The validator runs against the same CEL env as the evaluator (internal/auth/rbac/cel/env.go — NewCELEnv()), so what compiles here will compile in production.
CLI:
ironflow policy test \ --condition 'request.environment == "prod" && !("oncall" in subject.roles)' \ --request '{"action":"functions:invoke"}' \ --subject '{"id":"user_alice","roles":["developer"]}'HTTP:
curl -sS -X POST http://localhost:9123/api/v1/policies/dry-run \ -H "Authorization: Bearer ifkey_..." \ -H "Content-Type: application/json" \ -d '{"condition":"request.environment == \"prod\" && !(\"oncall\" in subject.roles)","request":{"action":"functions:invoke"},"subject":{"id":"user_alice","roles":["developer"]}}'Validator failures you might see:
condition is empty— T1 rejects empty conditions at write. Use an L1 role change instead, or write a real condition.undeclared reference to 'subjet'— typo. cel-go’s checker catches misspelled identifiers becauserequestandsubjectare declared in the env.condition exceeds 4 KiB— length-cap. Refactor the policy or split into multiple smaller ones.
Step 4 — Dry-run against a real subject
Section titled “Step 4 — Dry-run against a real subject”Validation says the expression compiles. Dry-run says what it does to a specific subject + resource at a specific time. Always dry-run before saving unless you enjoy debugging deny-everywhere policies in production.
CLI:
ironflow policy test \ --condition 'request.environment == "prod" && !("oncall" in subject.roles)' \ --request '{"action":"functions:invoke","resource":"irn:ironflow:org_acme:proj_default:function:prod:fn_payments"}' \ --subject '{"id":"user_alice","roles":["developer"]}'Output:
Condition: request.environment == "prod" && !("oncall" in subject.roles)Matched: trueRe-run with --subject '{"id":"user_alice","roles":["developer","oncall"]}' to confirm an on-call developer is allowed through. The dashboard exposes the same dry-run as a sandbox panel beside the editor.
Step 5 — Save the policy
Section titled “Step 5 — Save the policy”The save path runs three preflights:
- With your own subject (saver-subject preflight).
- With a synthetic subject for each admin in your org (per-admin preflight).
- With a synthetic role-only subject (no specific identity, just the admin role).
If any preflight returns DENY for policies:write on the policy resource, the save fails with a structured error and a list of affected admins. The dashboard hard-blocks the save; the CLI prints the error and exits non-zero.
CLI:
ironflow policy create \ --name deny-prod-invoke-non-oncall \ --effect deny \ --actions functions:invoke \ --resources 'irn:ironflow:*:*:function:prod:*' \ --condition 'request.environment == "prod" && !("oncall" in subject.roles)' \ --description "Deny prod fn invoke for callers not on the on-call rotation"Step 6 — Verify it’s live
Section titled “Step 6 — Verify it’s live”Cache invalidation is epoch-keyed: the save bumps the tenant epoch in NATS KV; in-memory caches across the cluster pick up the new epoch on next lookup and self-purge. You can confirm propagation by checking the cache hit ratio metric (ironflow_authz_cache_hit_ratio — drops briefly after a save) or by running a real functions:invoke from the affected window and reading the audit log in the dashboard.
What can go wrong
Section titled “What can go wrong”- The save preflight blocks you. A previous policy denies
policies:writefor your role under the time you are saving in. Either wait for the window to close, edit the prior policy first, or use--bypass-self-lockout-preflightfrom the CLI if you understand what you are doing — see emergency-bypass.md. - The dry-run says ALLOW but production denies. Check the policy is actually active:
ironflow policy get <id>and compare tonow. - Eval latency p99 spikes. Likely a regex-via-
.contains()storm or a NATS KV partition. See runbook-policy-eval-slow.md.
Next steps
Section titled “Next steps”- Debug a surprising DENY: debug-deny.md
- Roll back a policy: manage-versions.md
- Install a template bundle: dashboard “Templates” button (UI-8) or
ironflow policy template install <bundle>