Skip to main content

Admin API (/admin/v1/...)

The platform-operator/tenant-admin REST surface — a separate concern from the data plane: SSO-JWT auth only (never an app service-JWT), role-gated rather than publish/subscribe-gated, and focused on registry/config CRUD rather than event ingestion. See Authz planes for the role model this section assumes.

Auth & role gate

Every route below requires Authorization: Bearer <SSO-JWT> — the same identity adapter and verification path as the data plane. Tenant scope is always derived from the token; the tenant registry itself is the one resource that isn't scoped to the caller's own tenant. Every route requires a minimum role (po = platform-operator, ta = tenant-admin); platform-operator is a superset of tenant-admin except on the three PII-gated routes below. Failure modes: no/invalid token → 401; insufficient role → 403; resource not found or not visible in the caller's tenant → 404.

:::info The PII gate GET /admin/v1/activity, GET /admin/v1/audit, and GET /admin/v1/notifications/feed/{subject_id} require an explicit tenant-admin grant — platform-operator alone is not sufficient and gets 403 on these three specifically. Full rationale: Authz planes. :::

:::info Bootstrap The very first admin-role grant can't come through this API (nothing could authorize it yet) — the CNS_ADMIN_BOOTSTRAP_SUBJECT environment variable grants platform-operator to one subject at server boot. See Self-Hosting → Environment variables. :::

All bodies are application/json; DELETE/revoke routes return 204 No Content.

Me

The one exception to the role-gate rule: valid token required (401 without one), but no minimum role — a caller with zero admin-role grants still gets 200 with an empty roles array. This route's entire purpose is telling a frontend what the caller may do; roles always comes from the same role source every other route's gate consults — never decoded from the JWT itself.

MethodPathRoleResponse
GET/admin/v1/meany authenticated200
{ "subject_id": "sub_01HXYZ...", "tenant_id": "acme", "roles": ["tenant-admin"] }

Tenants

Platform-operator only — the tenant registry itself, the one resource not scoped to the caller's own tenant. See Tenants.

MethodPathRoleBody → Response
GET/admin/v1/tenantspo200 [Tenant]
POST/admin/v1/tenantspo{id, name}201
GET/admin/v1/tenants/{id}po200 / 404
PATCH/admin/v1/tenants/{id}po{name?, status?}200
DELETE/admin/v1/tenants/{id}po204. Does not cascade-delete the tenant's apps/policies/catalog/RBAC.
{ "id": "acme", "name": "Acme Retail", "status": "active", "created_at": "...", "updated_at": "..." }

Apps / publishers

Tenant-admin, own tenant only. See Apps & credentials and Guides → Managing apps.

MethodPathRoleBody → Response
GET/admin/v1/appsta200 [App]
POST/admin/v1/appsta{app_id, name, allowed_event_types[]}201. Empty/omitted list = unrestricted. 409 if app_id already registered (globally — one id resolves one keypair).
GET/admin/v1/apps/{app_id}ta200 / 404
PATCH/admin/v1/apps/{app_id}ta{name?, allowed_event_types?}200. Omitted list = unchanged.
POST/admin/v1/apps/{app_id}/rotate-keysta200, new key_version/public_key_base64. Old key verifies until any already-issued token expires.
DELETE/admin/v1/apps/{app_id}ta204. Deregisters publish-authz immediately; does not delete key material (flagged Phase-1 gap).
GET/admin/v1/apps/{app_id}/public-keyta200 {app_id, key_version, public_key_base64}
{
"app_id": "billing-svc", "tenant_id": "acme", "name": "Billing Service",
"allowed_event_types": ["invoice.paid"],
"created_at": "...", "rotated_at": "...",
"key_version": 1, "public_key_base64": "..."
}

key_version/public_key_base64 are live reads from the secrets adapter at request time, never cached — a transient secrets-adapter outage degrades these two fields to zero value rather than failing the request.

Notification policies

Tenant-admin. event_type → notify/lane/channel/recipient rules, evaluated for every stored event. See Policies and Guides → Authoring policies.

MethodPathRoleBody → Response
GET/admin/v1/policiesta200 [Rule]
POST/admin/v1/policiestaRule (upsert by event_type) → 201
GET/admin/v1/policies/{event_type}ta200 / 404
PUT/admin/v1/policies/{event_type}taRule (path wins over body's event_type) → 200
DELETE/admin/v1/policies/{event_type}ta204 / 404
{
"event_type": "invoice.paid", // or "*" for the tenant default
"notify": true, "delivery": "instant", "channels": ["inapp"],
"extra_recipients": ["sub_finance_team"]
}

Event catalog

Tenant-admin. Registered event types + an optional JSON Schema — validated only as well-formed JSON in Phase 1 (not enforced against POST /v1/events payloads yet). Precursor to the Event Registry.

MethodPathRoleBody → Response
GET/admin/v1/event-catalogta200 [EventCatalogEntry]
POST/admin/v1/event-catalogta{event_type, description?, schema?}201. 409 if already registered.
GET/admin/v1/event-catalog/{event_type}ta200 / 404
PUT/admin/v1/event-catalog/{event_type}ta{description?, schema?}200 / 404
DELETE/admin/v1/event-catalog/{event_type}ta204 / 404

Delivery / notification log

Tenant-wide read access to the event store (not limited to the caller's own subject, unlike GET /v1/feed) — gated by admin role rather than subscribe-authz.

MethodPathRoleQuery/Body → Response
GET/admin/v1/notificationstasubject_id?, event_type? (repeatable), since?/until?, cursor?, limit?200 PageResult
GET/admin/v1/notifications/feed/{subject_id}ta, explicit (PII gate)same filters, scoped by path → 200 PageResult
POST/admin/v1/notifications/replayta{subject_id, event_id}200 Event. Re-drives policy → channel → realtime for an already-stored event (does not re-append to the store). 404 if not found.

:::caution PageResult casing Same struct, same caveat as GET /v1/feed: the actual response is {"Events": [...], "NextCursor": "", "HasMore": false} (PascalCase). :::

POST /admin/v1/notifications/replay scans the subject's most recent 500 events and matches event_id client-side (no direct event_id lookup in the store adapter yet) — an event outside that window returns 404 even if it exists further back.

RBAC

Tenant-admin. Two grant kinds — the publish plane is the app registry above, not duplicated here.

MethodPathRoleBody → Response
GET/admin/v1/rbac/rolesta200 [RoleGrant] (caller's tenant grants + every global grant)
POST/admin/v1/rbac/rolesta*{subject_id, role} ("tenant-admin" | "platform-operator") → 201
DELETE/admin/v1/rbac/rolesta*{subject_id, role}204
GET/admin/v1/rbac/groupsta200 [GroupGrant] (subscribe-plane group grants)
POST/admin/v1/rbac/groupsta{subject_id, group_scope}201
DELETE/admin/v1/rbac/groupsta{subject_id, group_scope}204

* Granting/revoking platform-operator additionally requires the caller to already hold platform-operator — checked inside the handler, so a tenant-admin can still reach these routes for its own tenant's tenant-admin/group grants.

{ "tenant_id": "acme", "subject_id": "sub_alice", "role": "tenant-admin" }
// tenant_id is "" for a global (platform-operator) grant
{ "tenant_id": "acme", "subject_id": "sub_alice", "group_scope": "group:ops" }

Deployment profile & analytics

Tenant-admin, read-only.

MethodPathRoleResponse
GET/admin/v1/profileta200 RedactedProfile — adapter implementations + non-secret settings only. Every settings key resembling a credential (substring match on secret, password, token, key, dsn, credential, private) is stripped entirely, not masked.
GET/admin/v1/analytics/summaryta200 {tenant_id, events_total, notifications_total, active_subjects}

:::caution Process-local counters events_total/notifications_total/active_subjects are in-memory counters incremented as events happen — they reset on restart. Fine for a live dashboard tile, not an audited metric (the store adapter has no aggregate/COUNT query in Phase 1). :::

Activity timeline

Tenant-admin, explicit grant (PII gate). The actor-keyed chronological stream for one subject — distinct from the notification log above, which matches actor-or-target.

MethodPathRoleQuery → Response
GET/admin/v1/activityta, explicitsubject (required), cursor? (opaque), limit? (default 100, max 1000) → 200 PageResult

Events are ordered by the merged cross-session position (session_start_time, session_id, seq) — not a bare since_seq (per-session seq alone would silently skip events once a subject has more than one session; see Events & the envelope). cursor is an opaque token — pass the prior page's NextCursor verbatim.

Bounded-scan gap: this queries the subject's most recent 1000 events and paginates that in-memory result (no store-level actor index yet) — if actor-filtering exhausts the scanned window before satisfying limit and more unseen data exists, HasMore reports false rather than a cursor that can't make forward progress. Acceptable for a "recent activity" view; not a general unbounded history.

Audit log

Tenant-admin, explicit grant (PII gate). Records every mutating admin-API call (tenant/app/policy/catalog CRUD, RBAC grants/revokes, notification replay) — actor_subject_id is always the authenticated caller, never client-supplied; actor_role is resolved via RBAC, not decoded from the JWT.

MethodPathRoleQuery → Response
GET/admin/v1/auditta, explicitactor?, action?, resource_type?, since?/until?, cursor?, limit? (default 50) → 200 AuditPageResult, most-recent-first
{
"id": "audit_01HXYZ...", "ts": "2026-09-20T10:00:00Z",
"actor_subject_id": "sub_01HABC...", "actor_role": "tenant-admin",
"action": "app.register", "resource_type": "app", "resource_id": "billing-svc",
"tenant_id": "acme", "result": "success", "metadata": {}
}

action/resource_type values recorded in Phase 1: tenant.{create,update,delete}; app.{register,update,rotate_keys,delete}; policy.{create,update,delete}; event_catalog.{create,update,delete}; rbac.{grant_role,revoke_role}; rbac.{grant_group,revoke_group}; notification.replay. Every GET route is read-only and therefore unaudited.

:::caution Only successes are audited result is always "success" in Phase 1 — a denied (403) or failed (5xx) admin action leaves no audit trail yet. The field exists for forward compatibility. :::

Cross-references

POST /v1/events (data plane) accepts an app/publisher service-JWT in addition to an end-user SSO-JWT — see Data plane API. Every route in this document authenticates the same way regardless of which IdP issued the Bearer JWT — see Wiring a non-native IdP.