Skip to content

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.

  • 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 /policies with the “New policy” button visible.
  • (CLI path) The ironflow binary on your PATH.

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:invoke on resources in the prod environment 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).

request.environment == "prod" && !("oncall" in subject.roles)

CEL gotchas worth flagging:

  • request and subject are the top-level map vars declared in internal/auth/rbac/cel/env.go::NewCELEnv(). Always prefix: request.environment, subject.roles. Bare identifiers (e.g., environment) won’t compile.
  • The v1 request map exposes {action, resource, environment, org_id}. There is no IRN-parsed convenience map — match against request.resource with string ops if you need to filter by ID suffix.
  • The v1 env does not expose now or any time function. Use the policy-row valid_from/valid_until fields 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.

The validator runs against the same CEL env as the evaluator (internal/auth/rbac/cel/env.goNewCELEnv()), so what compiles here will compile in production.

CLI:

Terminal window
ironflow policy test \
--condition 'request.environment == "prod" && !("oncall" in subject.roles)' \
--request '{"action":"functions:invoke"}' \
--subject '{"id":"user_alice","roles":["developer"]}'

HTTP:

Terminal window
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 because request and subject are declared in the env.
  • condition exceeds 4 KiB — length-cap. Refactor the policy or split into multiple smaller ones.

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:

Terminal window
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: true

Re-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.

The save path runs three preflights:

  1. With your own subject (saver-subject preflight).
  2. With a synthetic subject for each admin in your org (per-admin preflight).
  3. 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:

Terminal window
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"

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.

  • The save preflight blocks you. A previous policy denies policies:write for 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-preflight from 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 to now.
  • Eval latency p99 spikes. Likely a regex-via-.contains() storm or a NATS KV partition. See runbook-policy-eval-slow.md.
  • 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>