AUTH4D-19 user, session and audit management
Project owners and members manage a customer project’s end users through the management API on
{AUTH_ORIGIN}. All routes live under /management/tenants/{tenantId} and are implemented in
apps/api/src/modules/user-admin. Responses are JSON with Cache-Control: no-store.
Authorization
Each request is checked in this order:
- Management token (401). The bearer must be a control-plane management token that passes the
AUTH4D-17 verifier: fixed issuer
{AUTH_ORIGIN}/t/control-plane, audienceurn:auth4:management, console client,token_use: "management", a live ControlPlaneSessionAuthority session and a live control-plane user. Customer-realm tokens never authorize these routes. If the token lacks the route’s scope, the API also returns 401 (WWW-Authenticate: Bearer error="invalid_token"), the same as the AUTH4D-17 routes. - Path shape (404). Tenant, user and session IDs must match their contract formats.
- Per-actor budget (429). 120 requests per actor per minute (
managementRequestsPerActor), enforced in Durable Object state under a control-plane rate-limit key. - Live membership (403
forbidden). The actor must hold a membership in an active customer tenant. - Role (403
insufficient_role). Every mutation requires theownerrole.memberis a read-only viewer. A denied mutation is written to the project audit log with outcomedenied.
| Route | Scope | Role |
|---|---|---|
GET /overview |
tenant:read |
owner/member |
GET /users, GET /users/{userId} |
user:read |
owner/member |
GET /users/{userId}/sessions |
session:read |
owner/member |
GET /sessions |
session:read |
owner/member |
GET /audit-events |
audit:read |
owner/member |
POST /users/{userId}/disable |
user:write |
owner |
POST /users/{userId}/enable |
user:write |
owner |
POST /users/{userId}/deletion |
user:write |
owner |
POST /users/{userId}/sessions/revoke |
session:revoke |
owner |
POST /sessions/{sessionId}/revoke |
session:revoke |
owner |
The API boundary requires Content-Type: application/json and well-formed JSON for every POST.
Mutation bodies must be exactly {}. Anything else returns 400 invalid_request.
Errors use {"error":{"code","message","request_id"}}.
End users vs. project members
A project membership is backed by a users row in the customer tenant, because of the memberships
foreign key. Those rows are not end users. They are excluded from every listing, detail and
overview count, and lifecycle actions on them return 404. Because of this, an owner cannot disable
or delete another member’s backing row, which would cascade into their membership.
Pagination and filters
List routes use keyset pagination. limit defaults to 25 and must be from 1 to 100.
next_cursor is an opaque base64url value tied to its list kind. Pass it back unchanged as
cursor. A cursor carries position only. Every query still binds the path tenant, so a cursor can
never reveal rows from another project.
Unknown, repeated or oversized (over 512 characters) query parameters return 400.
GET /users
Filters:
status:all(default),active,disabledordeletion_pending.email: exact match after NFC normalization, trimming and lowercasing.provider:email,discordorguest.user_id: exact match.
Results are ordered by created_at DESC, id ASC.
{
"users": [
{
"id": "usr_...",
"status": "active",
"created_at": "2026-10-03T11:59:00.000Z",
"disabled_at": null,
"providers": ["discord", "email"],
"email": "player@example.test"
}
],
"next_cursor": "WyJ1c2VycyIs..."
}
GET /users/{userId}
Returns the summary, plus:
identities: each entry is{id, provider, email, email_verified, created_at}.active_sessions: the number of active sessions.deletion_job:{id, status, requested_at, started_at, completed_at}, ornull.
Identity provider subjects (Discord IDs, guest credential subjects), proofs and provider credentials are never returned.
GET /sessions and GET /users/{userId}/sessions
Filters:
status:active(default) orall.GET /sessionsalso acceptsuser_idandclient_id.
Results are ordered by created_at DESC, id ASC. Each session has this shape:
{
"id": "ses_...",
"user_id": "usr_...",
"client_id": "cli_...",
"status": "active",
"created_at": "...",
"last_seen_at": "...",
"idle_expires_at": "...",
"absolute_expires_at": "...",
"revoked_at": null
}
status is active, expired or revoked. Listings come from the D1 session_indexes
metadata. That table never holds browser handles, access tokens or refresh tokens, so no response
contains bearer values.
GET /audit-events
Filters:
event: matches^[a-z][a-z0-9_.-]{1,95}$.outcome:success,deniedorfailure.actor_id,target_type,target_id.since,until: ISO 8601, inclusive.sincemust not be afteruntil.
Results are ordered by occurred_at DESC, id DESC. Each event is
{id, request_id, event, outcome, actor_id, target_type, target_id, occurred_at, metadata}.
GET /overview
{
"overview": {
"generated_at": "...",
"users": {
"total": 7,
"active": 5,
"disabled": 2,
"deletion_pending": 1,
"created_last_24h": 7
},
"sessions": { "active": 0 },
"clients": { "total": 1, "enabled": 1 },
"audit": { "events_last_24h": 3, "denied_last_24h": 1 }
}
}
users.disabled includes users with a pending deletion.
Lifecycle actions
Session revocation uses the tenant-sharded SESSION_STATE Durable Object
(SessionStateStore) as the authority. After that, the D1 session index is marked revoked. If the
Durable Object binding is unavailable, revoking actions return 503 session_state_unavailable
before making any change.
-
Disable:
POST /users/{userId}/disable, body{}.- Sets
users.disabled_at. Sign-in, refresh and session creation then fail withuser_unavailable. - Revokes every session and refresh family for the user.
- Returns
{user, revoked_sessions}.
Repeating the call is safe: it keeps the original
disabled_atand runs revocation again, so a retry after a transient failure completes the work. - Sets
-
Re-enable:
POST /users/{userId}/enable, body{}. Clearsdisabled_at. It returns 409deletion_pendingwhile a deletion job is queued or running. Revoked sessions stay revoked, so the user must sign in again. -
Request deletion:
POST /users/{userId}/deletion, body{}. Returns202 {deletion_job, created, revoked_sessions}. Deletion is staged:- One D1 batch disables the user and enqueues a
deletion_jobsrow (udj_{userId}_{random}, statusqueued). Only one queued or running job can exist per user. Repeated requests return the same job withcreated: false. - All of the user’s sessions and refresh families are revoked.
- AUTH4D-27 lifecycle processing removes the data.
- One D1 batch disables the user and enqueues a
-
Revoke all sessions:
POST /users/{userId}/sessions/revoke, body{}. Returns{revoked_sessions}. -
Revoke one session:
POST /sessions/{sessionId}/revoke, body{}. Returns{session, revoked}. The session must belong to the path tenant; otherwise the API returns 404. Revoking an already-revoked session returnsrevoked: false.
Already-issued access tokens. Disabling a user or revoking sessions stops refresh and new
sign-ins immediately. Access JWTs that were already issued are self-contained. Resource servers
that validate them offline can keep accepting them until they expire, which is at most five
minutes (DEFAULT_EXPIRY_SECONDS.accessToken). Resource servers that need immediate cut-off
must check session or user state online.
Audit events
Each mutation appends one row to audit_events for the customer tenant:
actor_idis the control-plane user ID.metadata.actor_rolerecords the actor’s role.- Metadata never contains token, secret, code, credential or email keys. The database trigger enforces this.
| Event | Outcomes | Extra metadata |
|---|---|---|
management.user.disabled |
success, denied | already_disabled, revoked_sessions |
management.user.enabled |
success, denied, failure | already_enabled or result: deletion_pending |
management.user.deletion_requested |
success, denied | job_id, created, revoked_sessions |
management.user.sessions_revoked |
success, denied | revoked_sessions |
management.session.revoked |
success, denied | already_revoked, user_id |
The audit row is written after the state change. If the audit write fails, the API returns 503. Every action is idempotent, and a retry records the event.
Source: docs/api-spec/auth4d-19.md