- Posts
- Who is allowed to emit this event?
Who is allowed to emit this event?
A deploy function waits for a human to approve it. The approval is an event
named deploy-api.approved. The release manager’s key emits it, and the
deploy goes ahead.
So can the CI key. Any key with the events:emit permission can emit any
event name, and a CI pipeline needs that permission for its own events. The
approval can also say "approver": "release-manager", because that field is
text the caller writes.
Ironflow v0.50.0 added two engine features for this.
Every new event records emitted_by. For an emit through the API, that is
the credential that sent it, and the caller cannot set it. And a Rego policy
set can deny events:emit for one event name. This walkthrough runs both on a local engine with a Node worker.
It takes about ten minutes and needs no model API key.
Start an engine that enforces policies
Section titled “Start an engine that enforces policies”Use Ironflow v0.53.0, Node.js 24, and fish. The installation guide has the CLI downloads. Keep port 9123 free. In your first terminal:
mkdir who-can-approvecd who-can-approveironflow versionset -e IRONFLOW_DATABASE_URLset -e NATS_URLset -e NATS_CREDS_FILEset -e NATS_STORE_DIRset -e IRONFLOW_BOOTSTRAP_ADMIN_KEYset -gx IRONFLOW_POLICY_ENFORCEMENT trueironflow serve --host 127.0.0.1 --port 9123 --nats-port -1Keep this terminal running. The engine is ready when its log shows
HTTP server starting. The set -e lines clear settings that would point
the engine at another database or NATS server.
Policy enforcement is off by default. Without IRONFLOW_POLICY_ENFORCEMENT, a
policy set is saved and no request meets it, even after a push that reports
mode enforce. In a config file, the setting is
spec.auth.policyEnforcement: true. Do not add --dev. Dev mode treats every
request as one admin user with no key ID, so the rule below would deny every
approval, from any key.
On its first boot the engine creates an admin key and writes it to
.ironflow/.ironflow_bootstrap_key.json.
Write a deploy that waits for approval
Section titled “Write a deploy that waits for approval”Open a second terminal in the directory where you ran mkdir. Read the admin
key and install the SDK:
cd who-can-approveset -gx IRONFLOW_SERVER_URL http://127.0.0.1:9123set -gx IRONFLOW_API_KEY (node -p 'JSON.parse(require("node:fs").readFileSync(".ironflow/.ironflow_bootstrap_key.json", "utf8")).key')npm init -ynpm install --save-exact @ironflow/node@0.53.0Save this as worker.mjs:
import { createWorker } from "@ironflow/node";import { agent } from "@ironflow/node/agent";
const deploy = agent( { id: "deploy-api", triggers: [{ event: "deploy-api.requested" }] }, async ({ event, approve }) => { const decision = await approve("deploy-api.approved", { ttl: "1h", payload: { version: event.data.version }, }); console.log( `approved=${decision.approved} emittedBy=${decision.emittedBy} approver=${decision.approver ?? "(none)"}`, ); return decision; },);
await createWorker({ functions: [deploy] }).start();node worker.mjsWait until the worker logs Connected to server at http://127.0.0.1:9123.
approve() is a durable wait. It waits for an event with the name you pass
whose data.runId is this run’s ID, for up to an hour here. The run does not
hold the worker while it waits. The engine has no approval concept:
deploy-api.approved is an ordinary event name, and anything here works the
same for a refund or a release.
decision.approver comes from the event’s data. decision.emittedBy comes
from the engine.
Create two keys
Section titled “Create two keys”Open a third terminal in the starting directory. It uses the admin key to create a key for the CI pipeline and a key for the release manager:
cd who-can-approveset -gx IRONFLOW_SERVER_URL http://127.0.0.1:9123set -gx IRONFLOW_API_KEY (node -p 'JSON.parse(require("node:fs").readFileSync(".ironflow/.ironflow_bootstrap_key.json", "utf8")).key')set out (ironflow apikey create ci-bot --role role_developer)set ci_id (string match -r 'id: (ak_[^)]+)' -- $out)[2]set ci_key (string match -r 'Key: (\S+)' -- $out)[2]set out (ironflow apikey create release-bot --role role_developer)set release_id (string match -r 'id: (ak_[^)]+)' -- $out)[2]set release_key (string match -r 'Key: (\S+)' -- $out)[2]echo "ci: $ci_id"echo "release: $release_id"Both keys get the built-in developer role, so RBAC treats them the same.
Both can emit events. The key ID (ak_...) names a key. The key value
(ifkey_...) is the secret, and the CLI shows it only once.
Let the CI key approve
Section titled “Let the CI key approve”Request a deploy and keep the run ID:
set run_id (ironflow emit deploy-api.requested --data '{"version": "v1.4.0"}' --json | node -p 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).runs[0].run_id')echo $run_idNow approve it with the CI key, and have the event claim the release manager sent it:
env IRONFLOW_API_KEY=$ci_key ironflow emit deploy-api.approved \ --data "{\"runId\": \"$run_id\", \"approved\": true, \"approver\": \"release-manager\"}"The CLI says No functions matched this event. That is expected: no function
is triggered by deploy-api.approved. The event resolves the waiting run
instead. In the second terminal, the worker prints:
approved=true emittedBy=apikey:ak_fb0c3f7d-d089-4c70-962a-d39928e0a226 approver=release-managerYour key ID differs. It is the ci: ID from the third terminal.
The deploy went ahead on the CI key’s word. The two fields disagree about who
approved it, and only one of them can be trusted. approver is whatever the
caller wrote. emittedBy is taken from the credential that authenticated the
emit, and the request body cannot change it. Its value is apikey:<id> for an
API key and user:<id> for a dashboard user. A webhook delivery records
webhook:<source-id>.
Recording the emitter tells you afterwards who approved. A policy can refuse the approval before it is stored.
Write the rule
Section titled “Write the rule”Pull the organization’s policy set into a directory. There is no set yet, so this writes only the helper files:
ironflow policy pull ./policiesSave this as policies/authz.rego:
package ironflow.authz
import data.acme.release
# Only a release key may emit the approval for deploy-api.deny contains "only a release key approves deploy-api" if { input.request.action == "events:emit" endswith(input.request.resource, ":deploy-api.approved") not input.subject.api_key_id in release.keys}Every emit is checked as the action events:emit on the event’s IRN,
irn:ironflow:{org}:{project}:event:{env}:{name}. The name is the last
segment. The raw name is encoded once: literal % becomes %25, then :
becomes %3A. Matchers do not decode the resource, so endswith on
:deploy-api.approved matches that name and no other. deny is a set of
reasons: when a rule adds one, the request is denied.
The engine also checks each ConnectRPC call at its route, before it reads the
event name. That check’s resource is empty, so endswith is false and the
route check passes. The check on the event IRN decides.
The list of release keys goes in its own file, built from the ID the third terminal holds:
printf 'package acme.release\n\nkeys := {"%s"}\n' $release_id > policies/release.regocat policies/release.regoacme.release is your package. Only ironflow.authz and
ironflow.authz_test are open to you under ironflow. To add a key later, add
its ID to the set.
Dry-run it
Section titled “Dry-run it”A dry-run evaluates the set in a directory against one input, on the server,
and stores nothing. Save this as ci.json:
{ "request": { "action": "events:emit", "resource": "irn:ironflow:org_default:proj_default_default:event:env_default:deploy-api.approved", "environment": "env_default", "org_id": "org_default" }, "subject": { "api_key_id": "ak_not_a_release_key", "roles": ["developer"] }}Run it, then run the same input with the release key’s ID:
ironflow policy dry-run ./policies --input ci.jsonstring replace ak_not_a_release_key $release_id < ci.json > release.jsonironflow policy dry-run ./policies --input release.jsondecision: deny (set mode: monitor)denied by: deny - only a release key approves deploy-apidecision: allow (set mode: monitor)The rule denies every caller that is not on the list. That includes the admin key and every dashboard user, since a dashboard user has no key ID.
Push in monitor mode first
Section titled “Push in monitor mode first”A new set starts in monitor mode. A request the set would deny goes through,
and the engine writes a would_deny row to the audit log. Push the set,
request a second deploy, and approve it with the CI key again:
ironflow policy push ./policiesset run_id (ironflow emit deploy-api.requested --data '{"version": "v1.5.0"}' --json | node -p 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).runs[0].run_id')env IRONFLOW_API_KEY=$ci_key ironflow emit deploy-api.approved --data "{\"runId\": \"$run_id\", \"approved\": true}"The approval goes through, and the worker prints a second line with the CI key’s ID. Read what the set would have done:
ironflow audit auth-trail --org org_default --action events:emit --json \ | node -p 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).events.filter(e => e.event_type === "authz.decision.would_deny").map(e => `${e.payload.api_key_id}: ${e.payload.reason}`).join("\n")'ak_fb0c3f7d-d089-4c70-962a-d39928e0a226: only a release key approves deploy-apiThat is the CI key, with the reason your rule gave. These rows are sampled: one for each organization, action and reason in a 10-second window on each node, whoever the caller is. A second caller that hits the same reason inside that window leaves no row. So the rows show some of the callers a rule would stop, not all of them. Before you enforce, also dry-run the callers you mean to keep.
Enforce it
Section titled “Enforce it”Switch the set to enforce, request a third deploy, and try the CI key once more:
ironflow policy push ./policies --mode enforceset run_id (ironflow emit deploy-api.requested --data '{"version": "v1.6.0"}' --json | node -p 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).runs[0].run_id')env IRONFLOW_API_KEY=$ci_key ironflow emit deploy-api.approved --data "{\"runId\": \"$run_id\", \"approved\": true}"Error: failed to emit event: permission_denied: emit denied: events:emit is not allowed for event "deploy-api.approved"Hint: The API key lacks this permission, or it belongs to another environment.The command exits with status 5. The hint is the CLI’s text for any
permission error. Here the key does hold events:emit. Your rule denied this
one name, and the error does not say which rule did it. The reason is in the
audit log, on a denied row.
The engine checks the name before it stores the event, so a denied emit stores nothing and wakes nothing. The deploy is still waiting:
ironflow run get $run_idIts status is waiting. Now approve it with the release key:
env IRONFLOW_API_KEY=$release_key ironflow emit deploy-api.approved --data "{\"runId\": \"$run_id\", \"approved\": true}"The worker prints:
approved=true emittedBy=apikey:ak_79c05a40-7d09-4207-9c3a-7f0ccefa4bec approver=(none)This time the ID is the release: one. Run ironflow run get $run_id again
and its status is completed.
Read the record
Section titled “Read the record”The events API returns emitted_by on every event stored since v0.50.0. List
the approvals:
curl -s -H "Authorization: Bearer $IRONFLOW_API_KEY" "$IRONFLOW_SERVER_URL/api/v1/events?name=deploy-api.approved" \ | node -p 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).events.map(e => e.emitted_by).join("\n")'apikey:ak_79c05a40-7d09-4207-9c3a-7f0ccefa4becapikey:ak_fb0c3f7d-d089-4c70-962a-d39928e0a226apikey:ak_fb0c3f7d-d089-4c70-962a-d39928e0a226Newest first: the release key, then the CI key’s approval in monitor mode, then its first one. The attempt the policy denied is not there.
When you are done, stop the worker and the engine with Ctrl+C.
One rule for every way in
Section titled “One rule for every way in”The rule you wrote covers more than ironflow emit. The engine runs the same
check, events:emit on the event IRN, for:
Emit,TriggerandTriggerSync, the ConnectRPC calls that the CLI and the SDKs use.- Each item of a
TriggerBatch. A denied item reportspermission_deniedin itserrorfield, and the other items still emit. - The pub/sub
Emit. - Entity stream appends. An append needs
events:emitas well asentities:append. - Webhook deliveries. A delivery is checked as its source, with
input.subject.webhook_source_idset and no roles, so a rule can say “the Stripe source emits onlystripe.*names”. A denied delivery gets403.
The rule lives in the engine, not in approve(). A direct REST call, a script
or another SDK meets it too.
What the rule does not cover
Section titled “What the rule does not cover”- NATS ingest, cron and file events are not checked. A publisher that holds
NATS credentials can emit any name that Ironflow does not reserve. The NATS
credentials are the gate on that path. These events record
emitted_byasnats, and cron and file events asplatform, so neither names a publisher. approverstays free text. ReademittedBywhen the answer matters.- A wait result is not the record. An operator with
runs:cancelcan inject or patch a step’s output, and a patched wait result can carry anyemittedBy. The event row is the record of who emitted it. - Events stored before v0.50.0 have no emitter. There is no backfill.
- Groups are empty today. A rule such as “only the
release-managersgroup” has nothing to match yet, so key onapi_key_id.
- Restrict who can emit an event has the webhook-source rule.
- Roll out with monitor mode covers reading the audit rows before you enforce.
- Policy set input lists every field a rule can read.
- Authorization policy sets explains how RBAC and the policy set combine.