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.
| Method | Path | Role | Response |
|---|---|---|---|
GET | /admin/v1/me | any authenticated | 200 |
{ "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.
| Method | Path | Role | Body → Response |
|---|---|---|---|
GET | /admin/v1/tenants | po | → 200 [Tenant] |
POST | /admin/v1/tenants | po | {id, name} → 201 |
GET | /admin/v1/tenants/{id} | po | → 200 / 404 |
PATCH | /admin/v1/tenants/{id} | po | {name?, status?} → 200 |
DELETE | /admin/v1/tenants/{id} | po | → 204. 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.
| Method | Path | Role | Body → Response |
|---|---|---|---|
GET | /admin/v1/apps | ta | → 200 [App] |
POST | /admin/v1/apps | ta | {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} | ta | → 200 / 404 |
PATCH | /admin/v1/apps/{app_id} | ta | {name?, allowed_event_types?} → 200. Omitted list = unchanged. |
POST | /admin/v1/apps/{app_id}/rotate-keys | ta | → 200, new key_version/public_key_base64. Old key verifies until any already-issued token expires. |
DELETE | /admin/v1/apps/{app_id} | ta | → 204. Deregisters publish-authz immediately; does not delete key material (flagged Phase-1 gap). |
GET | /admin/v1/apps/{app_id}/public-key | ta | → 200 {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.
| Method | Path | Role | Body → Response |
|---|---|---|---|
GET | /admin/v1/policies | ta | → 200 [Rule] |
POST | /admin/v1/policies | ta | Rule (upsert by event_type) → 201 |
GET | /admin/v1/policies/{event_type} | ta | → 200 / 404 |
PUT | /admin/v1/policies/{event_type} | ta | Rule (path wins over body's event_type) → 200 |
DELETE | /admin/v1/policies/{event_type} | ta | → 204 / 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.
| Method | Path | Role | Body → Response |
|---|---|---|---|
GET | /admin/v1/event-catalog | ta | → 200 [EventCatalogEntry] |
POST | /admin/v1/event-catalog | ta | {event_type, description?, schema?} → 201. 409 if already registered. |
GET | /admin/v1/event-catalog/{event_type} | ta | → 200 / 404 |
PUT | /admin/v1/event-catalog/{event_type} | ta | {description?, schema?} → 200 / 404 |
DELETE | /admin/v1/event-catalog/{event_type} | ta | → 204 / 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.
| Method | Path | Role | Query/Body → Response |
|---|---|---|---|
GET | /admin/v1/notifications | ta | subject_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/replay | ta | {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.
| Method | Path | Role | Body → Response |
|---|---|---|---|
GET | /admin/v1/rbac/roles | ta | → 200 [RoleGrant] (caller's tenant grants + every global grant) |
POST | /admin/v1/rbac/roles | ta* | {subject_id, role} ("tenant-admin" | "platform-operator") → 201 |
DELETE | /admin/v1/rbac/roles | ta* | {subject_id, role} → 204 |
GET | /admin/v1/rbac/groups | ta | → 200 [GroupGrant] (subscribe-plane group grants) |
POST | /admin/v1/rbac/groups | ta | {subject_id, group_scope} → 201 |
DELETE | /admin/v1/rbac/groups | ta | {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.
| Method | Path | Role | Response |
|---|---|---|---|
GET | /admin/v1/profile | ta | 200 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/summary | ta | 200 {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.
| Method | Path | Role | Query → Response |
|---|---|---|---|
GET | /admin/v1/activity | ta, explicit | subject (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.
| Method | Path | Role | Query → Response |
|---|---|---|---|
GET | /admin/v1/audit | ta, explicit | actor?, 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.