Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

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.

One check for every way in Five ways to emit an event, Emit and Trigger, TriggerBatch items, pub/sub emit, entity stream appends and webhook deliveries, all pass one check: events:emit on the event's IRN, evaluated against your deny rules. An allowed emit is stored with its emitter and can wake a waiting run. A denied emit gets 403 and nothing is stored. NATS ingest, cron and file events do not pass this check. Emit, Trigger, TriggerSyncTriggerBatch, each itemPub/sub emitEntity stream appendWebhook deliveryevents:emiton the event IRNagainst your deny rulesNATS ingest, cron and file events skip this check Stored with emitted_bycan wake a waiting run403 to the callernothing stored

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:

Terminal window
mkdir who-can-approve
cd who-can-approve
ironflow version
set -e IRONFLOW_DATABASE_URL
set -e NATS_URL
set -e NATS_CREDS_FILE
set -e NATS_STORE_DIR
set -e IRONFLOW_BOOTSTRAP_ADMIN_KEY
set -gx IRONFLOW_POLICY_ENFORCEMENT true
ironflow serve --host 127.0.0.1 --port 9123 --nats-port -1

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

Open a second terminal in the directory where you ran mkdir. Read the admin key and install the SDK:

Terminal window
cd who-can-approve
set -gx IRONFLOW_SERVER_URL http://127.0.0.1:9123
set -gx IRONFLOW_API_KEY (node -p 'JSON.parse(require("node:fs").readFileSync(".ironflow/.ironflow_bootstrap_key.json", "utf8")).key')
npm init -y
npm install --save-exact @ironflow/node@0.53.0

Save 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();
Terminal window
node worker.mjs

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

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:

Terminal window
cd who-can-approve
set -gx IRONFLOW_SERVER_URL http://127.0.0.1:9123
set -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.

Request a deploy and keep the run ID:

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

Now approve it with the CI key, and have the event claim the release manager sent it:

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

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

Pull the organization’s policy set into a directory. There is no set yet, so this writes only the helper files:

Terminal window
ironflow policy pull ./policies

Save 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:

Terminal window
printf 'package acme.release\n\nkeys := {"%s"}\n' $release_id > policies/release.rego
cat policies/release.rego

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

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:

Terminal window
ironflow policy dry-run ./policies --input ci.json
string replace ak_not_a_release_key $release_id < ci.json > release.json
ironflow policy dry-run ./policies --input release.json
decision: deny (set mode: monitor)
denied by: deny
- only a release key approves deploy-api
decision: 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.

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:

Terminal window
ironflow policy push ./policies
set 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:

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

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

Switch the set to enforce, request a third deploy, and try the CI key once more:

Terminal window
ironflow policy push ./policies --mode enforce
set 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:

Terminal window
ironflow run get $run_id

Its status is waiting. Now approve it with the release key:

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

The events API returns emitted_by on every event stored since v0.50.0. List the approvals:

Terminal window
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-7f0ccefa4bec
apikey:ak_fb0c3f7d-d089-4c70-962a-d39928e0a226
apikey:ak_fb0c3f7d-d089-4c70-962a-d39928e0a226

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

The rule you wrote covers more than ironflow emit. The engine runs the same check, events:emit on the event IRN, for:

  • Emit, Trigger and TriggerSync, the ConnectRPC calls that the CLI and the SDKs use.
  • Each item of a TriggerBatch. A denied item reports permission_denied in its error field, and the other items still emit.
  • The pub/sub Emit.
  • Entity stream appends. An append needs events:emit as well as entities:append.
  • Webhook deliveries. A delivery is checked as its source, with input.subject.webhook_source_id set and no roles, so a rule can say “the Stripe source emits only stripe.* names”. A denied delivery gets 403.

The rule lives in the engine, not in approve(). A direct REST call, a script or another SDK meets it too.

  • 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_by as nats, and cron and file events as platform, so neither names a publisher.
  • approver stays free text. Read emittedBy when the answer matters.
  • A wait result is not the record. An operator with runs:cancel can inject or patch a step’s output, and a patched wait result can carry any emittedBy. 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-managers group” has nothing to match yet, so key on api_key_id.