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:

  1. 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, audience urn: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.
  2. Path shape (404). Tenant, user and session IDs must match their contract formats.
  3. Per-actor budget (429). 120 requests per actor per minute (managementRequestsPerActor), enforced in Durable Object state under a control-plane rate-limit key.
  4. Live membership (403 forbidden). The actor must hold a membership in an active customer tenant.
  5. Role (403 insufficient_role). Every mutation requires the owner role. member is a read-only viewer. A denied mutation is written to the project audit log with outcome denied.
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, disabled or deletion_pending.
  • email: exact match after NFC normalization, trimming and lowercasing.
  • provider: email, discord or guest.
  • 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}, or null.

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) or all.
  • GET /sessions also accepts user_id and client_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, denied or failure.
  • actor_id, target_type, target_id.
  • since, until: ISO 8601, inclusive. since must not be after until.

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 {}.

    1. Sets users.disabled_at. Sign-in, refresh and session creation then fail with user_unavailable.
    2. Revokes every session and refresh family for the user.
    3. Returns {user, revoked_sessions}.

    Repeating the call is safe: it keeps the original disabled_at and runs revocation again, so a retry after a transient failure completes the work.

  • Re-enable: POST /users/{userId}/enable, body {}. Clears disabled_at. It returns 409 deletion_pending while 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 {}. Returns 202 {deletion_job, created, revoked_sessions}. Deletion is staged:

    1. One D1 batch disables the user and enqueues a deletion_jobs row (udj_{userId}_{random}, status queued). Only one queued or running job can exist per user. Repeated requests return the same job with created: false.
    2. All of the user’s sessions and refresh families are revoked.
    3. AUTH4D-27 lifecycle processing removes the data.
  • 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 returns revoked: 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_id is the control-plane user ID.
  • metadata.actor_role records 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