- Platform & Tenancy
- Platform API
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.
Bootstrap
Section titled “Bootstrap”POST /api/v1/platform/bootstrap
Section titled “POST /api/v1/platform/bootstrap”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.
| Status | Description |
|---|---|
| 201 | Admin user created |
| 400 | Missing email, password or name |
| 409 | Platform already bootstrapped (users exist) |
Authentication
Section titled “Authentication”POST /api/v1/platform/auth/login
Section titled “POST /api/v1/platform/auth/login”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.
| Status | Description |
|---|---|
| 200 | Login successful |
| 400 | Missing email or password |
| 401 | Invalid credentials |
| 403 | Account is inactive |
GET /api/v1/platform/users
Section titled “GET /api/v1/platform/users”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.
POST /api/v1/platform/users
Section titled “POST /api/v1/platform/users”Create a platform user.
Request:
{ "email": "ops@example.com", "password": "secure-password", "name": "Ops User", "role_ids": ["role_platform_admin"]}| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Unique email address |
password | string | Yes | User password |
name | string | Yes | Display name |
role_ids | string[] | No | Platform role IDs to assign (names must start with platform_) |
Response (201): Returns the created user object.
| Status | Description |
|---|---|
| 201 | User created |
| 400 | Missing required fields, or a role_ids entry is not a platform role |
| 409 | Email already exists |
GET /api/v1/platform/users/{id}
Section titled “GET /api/v1/platform/users/{id}”Get a single platform user by ID.
Response (200): Returns the user object.
| Status | Description |
|---|---|
| 200 | Success |
| 404 | User not found |
PUT /api/v1/platform/users/{id}
Section titled “PUT /api/v1/platform/users/{id}”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.
| Status | Description |
|---|---|
| 200 | User updated |
| 404 | User not found |
| 409 | Email already exists |
DELETE /api/v1/platform/users/{id}
Section titled “DELETE /api/v1/platform/users/{id}”Delete a platform user.
| Status | Description |
|---|---|
| 204 | User deleted |
| 404 | User not found |
API Keys
Section titled “API Keys”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).
GET /api/v1/apikeys?platform=true
Section titled “GET /api/v1/apikeys?platform=true”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 /api/v1/apikeys/{id}
Section titled “GET /api/v1/apikeys/{id}”Get a single key (same shape as a list entry).
| Status | Description |
|---|---|
| 200 | Success |
| 404 | Key not found (also returned when the caller lacks platform:keys:read) |
POST /api/v1/apikeys
Section titled “POST /api/v1/apikeys”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"]}| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Key name |
platform | bool | No | Set to true for platform keys |
role_ids | string[] | No | Role IDs to assign — must be platform-scoped roles (400 otherwise) |
expires_in | string | No | Go 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.
PUT /api/v1/apikeys/{id}/roles
Section titled “PUT /api/v1/apikeys/{id}/roles”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 /api/v1/apikeys/{id}
Section titled “DELETE /api/v1/apikeys/{id}”Delete (revoke) an API key.
| Status | Description |
|---|---|
| 204 | Key deleted |
| 404 | Key not found |
POST /api/v1/apikeys/{id}/rotate
Section titled “POST /api/v1/apikeys/{id}/rotate”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"}GET /api/v1/platform/roles
Section titled “GET /api/v1/platform/roles”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 /api/v1/platform/roles/{id}
Section titled “GET /api/v1/platform/roles/{id}”Get a single role.
| Status | Description |
|---|---|
| 200 | Success |
| 404 | Role not found |
POST /api/v1/platform/roles
Section titled “POST /api/v1/platform/roles”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"}| Status | Description |
|---|---|
| 201 | Role created |
| 400 | Missing name |
| 409 | Role name already exists |
PUT /api/v1/platform/roles/{id}
Section titled “PUT /api/v1/platform/roles/{id}”Update a custom role.
Request:
{ "name": "renamed-role"}| Status | Description |
|---|---|
| 200 | Role updated |
| 400 | Cannot update built-in role |
| 404 | Role not found |
| 409 | Role name already in use |
DELETE /api/v1/platform/roles/{id}
Section titled “DELETE /api/v1/platform/roles/{id}”Delete a custom role. Built-in roles cannot be deleted.
| Status | Description |
|---|---|
| 204 | Role deleted |
| 400 | Cannot delete built-in role |
| 404 | Role not found |
Policy set
Section titled “Policy set”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.
| Method | Path | Description |
|---|---|---|
GET | /api/v1/platform/policy-set | Get the active set. |
PUT | /api/v1/platform/policy-set | Replace the set. Needs If-Match: <version>. |
GET | /api/v1/platform/policy-set/versions | List versions. |
GET | /api/v1/platform/policy-set/versions/{n} | Get one version with its files. |
POST | /api/v1/platform/policy-set/versions/{n}/rollback | Store version n as a new version. Needs If-Match. |
POST | /api/v1/platform/policy-set/validate | Validate a candidate set. |
POST | /api/v1/platform/policy-set/dry-run | Evaluate a candidate set, or the active set, for one input. |
POST | /api/v1/platform/orgs/{org}/policy-set/monitor | Break-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.
| Status | Description |
|---|---|
| 200 | The set is in monitor mode. An organization that was already in monitor mode gets its current version, with no new version. |
| 400 | Missing or too long reason, or {org} is the platform organization. |
| 403 | Platform access required. |
| 404 | The organization does not exist or has no set. |
| 412 | The set changed while the request ran. Send it again. |
| 429 | The organization’s write limit. |
| 503 | policy_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.
Tenants
Section titled “Tenants”GET /api/v1/platform/tenants
Section titled “GET /api/v1/platform/tenants”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 /api/v1/platform/tenants/{id}
Section titled “GET /api/v1/platform/tenants/{id}”Get a single tenant.
| Status | Description |
|---|---|
| 200 | Success |
| 404 | Tenant not found (also for org_platform) |
POST /api/v1/platform/tenants
Section titled “POST /api/v1/platform/tenants”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.
| Status | Description |
|---|---|
| 201 | Tenant created |
| 400 | Missing or empty name |
DELETE /api/v1/platform/tenants/{id}
Section titled “DELETE /api/v1/platform/tenants/{id}”Delete a tenant organization. Cannot delete the platform sentinel org (org_platform).
| Status | Description |
|---|---|
| 204 | Tenant deleted |
| 400 | Cannot delete platform org |
| 404 | Tenant not found |
POST /api/v1/platform/orgs
Section titled “POST /api/v1/platform/orgs”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.
GET /api/v1/platform/audit
Section titled “GET /api/v1/platform/audit”Query platform audit events.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
event_type | string | Filter by event type (e.g., platform.user.created) |
from | string | Start timestamp (RFC 3339 or YYYY-MM-DD, inclusive) |
to | string | End timestamp (RFC 3339 or YYYY-MM-DD, inclusive) |
platform_key_id | string | Filter by acting platform API key |
impersonated_org_id | string | Filter by impersonated organization |
cursor | string | Pagination cursor from previous response |
limit | int | Max 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}| Status | Description |
|---|---|
| 200 | Success |
| 400 | Invalid cursor, or from/to not RFC 3339 / YYYY-MM-DD |
Event Types:
| Event Type | Description |
|---|---|
platform.user.created | Platform user created |
platform.user.updated | Platform user updated |
platform.user.deleted | Platform user deleted |
platform.key.created | Reserved — declared but not currently emitted |
platform.key.revoked | Reserved — declared but not currently emitted |
platform.role.changed | A delete or update of a built-in platform role was blocked |
platform.impersonated | Impersonated request initiated |
Impersonation Headers
Section titled “Impersonation Headers”Any non-platform endpoint can be called with platform credentials by adding the X-Ironflow-Org header:
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.
See Also
Section titled “See Also”- Platform CLI Reference — CLI equivalents for all endpoints
- Platform Roles Reference — role and action tables
- Managing Platform Users & Keys — usage guide
Instance barrier and drain administration
Section titled “Instance barrier and drain administration”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 endpoint | Connect InstanceAdminService method | Action |
|---|---|---|
GET /api/v1/platform/instance | ReadInstance | platform:instance:read |
POST /api/v1/platform/instance/freeze | FreezeInstance | platform:instance:freeze |
POST /api/v1/platform/instance/thaw | ThawInstance | platform:instance:freeze |
POST /api/v1/platform/instance/obligations/{id}/reconcile | ReconcileInstanceObligation | platform:instance:reconcile |
POST /api/v1/platform/instance/coverage/attest | AttestInstanceCoverage | platform: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.