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

Platform API

Base URL: http://localhost:9123/api/v1/platform

All endpoints except login and bootstrap require platform authentication via Authorization: Bearer <token> header, where <token> is either a JWT from login or a platform API key (ifplatform_*); any other credential gets 403 platform access required. Each endpoint then checks a platform action: GET needs platform:<resource>:read, every other method needs platform:<resource>:manage (resource = users, keys, roles, policies, tenants; audit is platform:audit:read) — see Platform Roles for which built-in role holds which action. Instance controls use the dedicated actions documented below. Platform API keys are created with ironflow apikey create --platform — see API Keys for details.


One-time bootstrap endpoint to initialize the platform. Creates the first admin user (assigned the built-in platform_admin role). Requires an admin API key of the default organization (org_default) — no platform role is checked, because none can exist yet. A key of another organization, or a non-admin key, gets 403. The endpoint also refuses once any platform user exists.

Request:

{
"email": "admin@example.com",
"password": "secure-password",
"name": "Admin User"
}

Response (201): { "id": ..., "email": ..., "name": ... } — deliberately narrower than the full platform user object.

StatusDescription
201Admin user created
400Missing email, password or name
409Platform already bootstrapped (users exist)

Authenticate a platform user and receive a JWT token. The response also sets the token in the HttpOnly ironflow_dashboard_token cookie. A cross-site browser post gets 403 and no cookie. A client that is not a browser, such as the CLI or an SDK, sends no Sec-Fetch-Site or Origin header and is not affected.

Request:

{
"email": "admin@example.com",
"password": "your-password"
}

Response (200):

{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "puser_a1b2c3d4",
"email": "admin@example.com",
"name": "Platform Admin",
"roles": ["platform_admin"]
}
}

Also sets an ironflow_dashboard_token HttpOnly, SameSite=Strict cookie (24h TTL). The cookie is Secure unless the server runs with --dev.

StatusDescription
200Login successful
400Missing email or password
401Invalid credentials
403Account is inactive

List all platform users.

Response (200):

[
{
"id": "puser_a1b2c3d4",
"email": "admin@example.com",
"name": "Platform Admin",
"is_active": true,
"created_at": "2026-03-12T10:00:00Z",
"updated_at": "2026-03-12T10:00:00Z"
}
]

last_login_at is also present once the user has logged in.

Create a platform user.

Request:

{
"email": "ops@example.com",
"password": "secure-password",
"name": "Ops User",
"role_ids": ["role_platform_admin"]
}
FieldTypeRequiredDescription
emailstringYesUnique email address
passwordstringYesUser password
namestringYesDisplay name
role_idsstring[]NoPlatform role IDs to assign (names must start with platform_)

Response (201): Returns the created user object.

StatusDescription
201User created
400Missing required fields, or a role_ids entry is not a platform role
409Email already exists

Get a single platform user by ID.

Response (200): Returns the user object.

StatusDescription
200Success
404User not found

Update a platform user. All fields are optional.

Request:

{
"name": "New Name",
"email": "new@example.com",
"password": "new-password",
"is_active": false
}

Response (200): Returns the updated user object.

StatusDescription
200User updated
404User not found
409Email already exists

Delete a platform user.

StatusDescription
204User deleted
404User not found

Platform API keys are now managed through the unified /api/v1/apikeys endpoint with platform=true. See API Keys for the full API key documentation. Listing and reading platform keys needs platform:keys:read; creating, rotating, deleting and re-assigning roles needs platform:keys:manage (403 otherwise).

List all platform API keys. The full key value is never returned.

Response (200):

[
{
"id": "ak_e5f6g7h8",
"name": "my-ci-key",
"key_prefix": "ifplatform_a1b2c3d4",
"role_ids": ["role_platform_admin"],
"created_at": "2026-03-12T10:00:00Z"
}
]

expires_at and last_used_at are present when set.

Get a single key (same shape as a list entry).

StatusDescription
200Success
404Key not found (also returned when the caller lacks platform:keys:read)

Create a platform API key by setting "platform": true in the request body.

Request:

{
"name": "my-ci-key",
"platform": true,
"role_ids": ["role_platform_admin"]
}
FieldTypeRequiredDescription
namestringYesKey name
platformboolNoSet to true for platform keys
role_idsstring[]NoRole IDs to assign — must be platform-scoped roles (400 otherwise)
expires_instringNoGo duration, e.g. "720h"

Response (201):

{
"id": "ak_e5f6g7h8",
"name": "my-ci-key",
"key_prefix": "ifplatform_a1b2c3d4",
"role_ids": ["role_platform_admin"],
"key": "ifplatform_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"created_at": "2026-03-12T10:00:00Z"
}

The key field is only returned on creation and rotation.

Replace a key’s role assignments. Body: { "role_ids": ["..."] } (1–50 roles, all from the key’s organization). A key cannot change its own roles (403).

Response (200): the key object with the new role_ids.

Delete (revoke) an API key.

StatusDescription
204Key deleted
404Key not found

Rotate a key — creates a replacement key with a new ID and a new key value. The replacement keeps the name, role assignments and expiry. The old key is immediately invalidated. An expired key cannot be rotated (400).

Response (200):

{
"id": "ak_i9j0k1l2",
"name": "my-ci-key",
"key_prefix": "ifplatform_x9y8z7w6",
"key": "ifplatform_x9y8z7w6v5u4t3s2r1q0p9o8n7m6l5k4",
"created_at": "2026-03-12T10:00:00Z"
}

List all platform roles (built-in and custom). Only roles whose name starts with platform_ are returned.

Response (200):

[
{
"id": "role_platform_admin",
"org_id": "org_platform",
"name": "platform_admin",
"is_default": false,
"created_at": "2026-03-12T10:00:00Z"
}
]

Get a single role.

StatusDescription
200Success
404Role not found

Create a custom platform role. The name is prefixed with platform_ if it does not already start with it (support-role is stored as platform_support-role); the ID is prole_<uuid>.

Request:

{
"name": "support-role"
}
StatusDescription
201Role created
400Missing name
409Role name already exists

Update a custom role.

Request:

{
"name": "renamed-role"
}
StatusDescription
200Role updated
400Cannot update built-in role
404Role not found
409Role name already in use

Delete a custom role. Built-in roles cannot be deleted.

StatusDescription
204Role deleted
400Cannot delete built-in role
404Role not found

The platform organization has its own Rego policy set. It is authorization Layer 2 for platform principals, and it can only deny. These routes need platform:policies:read (GET) or platform:policies:manage (every other method). They take the same bodies and return the same answers as the tenant routes in the REST API. See Authorization policy sets.

MethodPathDescription
GET/api/v1/platform/policy-setGet the active set.
PUT/api/v1/platform/policy-setReplace the set. Needs If-Match: <version>.
GET/api/v1/platform/policy-set/versionsList versions.
GET/api/v1/platform/policy-set/versions/{n}Get one version with its files.
POST/api/v1/platform/policy-set/versions/{n}/rollbackStore version n as a new version. Needs If-Match.
POST/api/v1/platform/policy-set/validateValidate a candidate set.
POST/api/v1/platform/policy-set/dry-runEvaluate a candidate set, or the active set, for one input.
POST/api/v1/platform/orgs/{org}/policy-set/monitorBreak-glass. Switch the set of a tenant organization to monitor mode.

A platform principal on a tenant route acts for the impersonated organization. The platform routes are the only way to reach the platform organization’s set.

POST /api/v1/platform/orgs/{org}/policy-set/monitor

Section titled “POST /api/v1/platform/orgs/{org}/policy-set/monitor”

Write a new version of the same files of the organization’s set, in monitor mode. Use it for a set that denies the policy-set routes for all admins, or that no longer compiles. The request is evaluated against the platform set, never against the set of the organization. The set is not compiled to make the write.

Request:

{ "reason": "enforce set denies policy routes, ticket 4412" }

reason is required, from 1 to 200 bytes. The audit row of the change records it.

StatusDescription
200The set is in monitor mode. An organization that was already in monitor mode gets its current version, with no new version.
400Missing or too long reason, or {org} is the platform organization.
403Platform access required.
404The organization does not exist or has no set.
412The set changed while the request ran. Send it again.
429The organization’s write limit.
503policy_set_not_propagated. The switch is saved. Send the same request again.

The CLI command is ironflow policy force-monitor <org> --reason "<text>". See Recover from a lockout.


List all tenant organizations (excludes the platform sentinel org).

Response (200):

[
{
"id": "org_x1y2z3w4",
"name": "Acme Corp",
"created_at": "2026-03-12T10:00:00Z",
"updated_at": "2026-03-12T10:00:00Z"
}
]

Get a single tenant.

StatusDescription
200Success
404Tenant not found (also for org_platform)

Create a tenant organization. This writes the organization row only — no environment, no default project, no admin API key. For a fully provisioned tenant (org + built-in roles + default project + environment + admin key), use the tenant-scoped POST /api/v1/tenants/provision.

Request:

{
"name": "Acme Corp"
}

Response (201): Returns the created tenant object.

StatusDescription
201Tenant created
400Missing or empty name

Delete a tenant organization. Cannot delete the platform sentinel org (org_platform).

StatusDescription
204Tenant deleted
400Cannot delete platform org
404Tenant not found

Platform-side alias for POST /api/v1/orgs — the same handler, on a path the impersonation middleware admits. Creates an organization row only; it bundles no environment and no admin key, exactly like POST /api/v1/platform/tenants above. Exists because the impersonation middleware rejects an unimpersonated platform token on any non-/api/v1/platform/* route, and a platform admin cannot usefully impersonate an org that does not exist yet. The tenant-facing POST /api/v1/orgs stays registered so a tenant credential gets a 403 platform credential required rather than a 404.

Request:

{
"name": "Acme Corp"
}

Response (201): Returns the created organization object.


Query platform audit events.

Query Parameters:

ParameterTypeDescription
event_typestringFilter by event type (e.g., platform.user.created)
fromstringStart timestamp (RFC 3339 or YYYY-MM-DD, inclusive)
tostringEnd timestamp (RFC 3339 or YYYY-MM-DD, inclusive)
platform_key_idstringFilter by acting platform API key
impersonated_org_idstringFilter by impersonated organization
cursorstringPagination cursor from previous response
limitintMax results per page (default: 100)

Response (200):

{
"events": [
{
"id": "01HXYZ...",
"event_type": "platform.user.created",
"scope": "platform",
"platform_key_id": "ak_e5f6g7h8",
"platform_user_id": "puser_a1b2c3d4",
"impersonated_org_id": "",
"payload": { "user_id": "puser_d4e5f6g7", "email": "ops@example.com" },
"created_at": "2026-03-12T10:00:00Z"
}
],
"total": 42
}
StatusDescription
200Success
400Invalid cursor, or from/to not RFC 3339 / YYYY-MM-DD

Event Types:

Event TypeDescription
platform.user.createdPlatform user created
platform.user.updatedPlatform user updated
platform.user.deletedPlatform user deleted
platform.key.createdReserved — declared but not currently emitted
platform.key.revokedReserved — declared but not currently emitted
platform.role.changedA delete or update of a built-in platform role was blocked
platform.impersonatedImpersonated request initiated

Any non-platform endpoint can be called with platform credentials by adding the X-Ironflow-Org header:

Terminal window
curl -H "Content-Type: application/json" -d '{}' http://localhost:9123/ironflow.v1.IronflowService/ListFunctions \
-H "Authorization: Bearer $PLATFORM_TOKEN" \
-H "X-Ironflow-Org: org_x1y2z3w4"

On the /ws WebSocket upgrade, a browser cannot set a header, so the org_id query parameter is also accepted there. On an authenticated route, a tenant credential that names another organization gets 403.

See Impersonating Tenants for details.


These controls require platform authentication and dedicated actions. Appliance P1 integration and operational verification remain pending; this API is not an operational freeze-readiness claim. Tenant keys and organization admin JWTs cannot administer an instance. An impersonation header does not confer instance authority.

REST endpointConnect InstanceAdminService methodAction
GET /api/v1/platform/instanceReadInstanceplatform:instance:read
POST /api/v1/platform/instance/freezeFreezeInstanceplatform:instance:freeze
POST /api/v1/platform/instance/thawThawInstanceplatform:instance:freeze
POST /api/v1/platform/instance/obligations/{id}/reconcileReconcileInstanceObligationplatform:instance:reconcile
POST /api/v1/platform/instance/coverage/attestAttestInstanceCoverageplatform:instance:attest

Platform viewers can read, operators can also freeze/thaw, and admins can perform all five operations. Unrecognized custom roles grant no instance permissions. Every mutation requires a present, nonnegative expected_generation (including zero), nonblank reason (up to 1024 bytes) and evidence (up to 8192 bytes). Attestation also requires boundary_id from the snapshot; REST reconciliation takes its opaque obligation ID from the path, RPC from obligation_id. Actor identity comes from authentication. REST rejects unknown fields, including actor, token and hash. No request accepts execution credentials.

Freeze and thaw return only the barrier: cluster/lifetime identity, frozen state, generation and change metadata. Repeating the desired state at the current generation is a no-op; an older generation conflicts even if the desired state matches. A state change advances generation. Reconciliation and coverage evidence are bound to that exact frozen generation and instance identity.

Read returns one coherent snapshot with barrier, disposition, coverage, counts (unresolved, queued_undelivered, reserved_undelivered) and up to 100 opaque unresolved obligation details. Disposition is unfrozen, coverage_unknown, outstanding or drained; there is no separate drained flag. Zero obligations never automatically attest coverage. Expired or released attempts remain unresolved until accepted terminal evidence or explicit reconciliation establishes remote execution stopped. Coverage attestation is an operator assertion that historical remote work stopped, every member uses compatible ledger/barrier code, and old processes are excluded. The database cannot verify that external truth. A barrier and an identity anchor provide no host fencing.

Existing results, heartbeat and cancellation remain enabled while frozen. Retained work must resume after thaw under the integrated admission and dispatch rules. This transport alone does not establish those rules or cron catch-up.

Errors map to HTTP 401/403/400/409/503 and Connect Unauthenticated/PermissionDenied/InvalidArgument/Aborted/Unavailable. Unavailable mutation errors carry outcome and recovery (Connect: InstanceOperationOutcome error detail). unknown is not a rollback promise: inspect state before retrying. Keep the original expected generation when retrying; a generation conflict may mean the operation already committed. Do not advance the expected generation just to force a retry through. Each accepted control operation and its audit commit in one database transaction. Same-generation no-ops and stale-generation rejections also persist an audit before responding. An audit insertion failure rolls back the operation; a failed commit acknowledgement can still leave the outcome uncertain. Responses exclude storage fault text, raw evidence and fence hashes.