feat(server): migration grants and a grant-bound migration assertion

Draft
KJ
9d ago
+2387-2

Purpose

A one-time export job will upload historical sessions from Neon to the 96Bulls ingest API under each original user's identity. It must not reuse POST /v1/tokens/relayhistory-assertion, whose key can mint for any org (see the separate finding below). This PR adds a narrow, grant-bound, migration-only assertion.

Contract

Scopes

  • relayauth:assertion:create:migration: carried, alone, by the dedicated minting key.
  • relayauth:api-key:manage:*: required to create or revoke a grant (same as API-key management).

POST /v1/migration-grants (bearer or x-api-key)

{ "apiKeyId": "...", "planDigest": "<64 lowercase hex>", "audience": "https://ingest.example.com",
  "allowedUserIds": ["user_a"], "notAfter": "<ISO 8601, future, <= 7 days>" }

→ 201 { grantId, orgId, apiKeyId, planDigest, audience, allowedUserIds, notAfter, createdBy, createdAt, revokedAt: null }

Refusals: insufficient_scope 403; api_key_not_found 404 (unknown or other org); api_key_revoked 409; migration_scope_missing 400 (includes wildcard-only keys); migration_key_not_dedicated 400 (extra scopes, or a workspace token); invalid_plan_digest, invalid_audience, invalid_allowed_users (1 to 1000), invalid_not_after 400; source_user_not_found / source_user_org_mismatch / source_user_not_human / source_user_inactive 403 with userId.

POST /v1/migration-grants/:grantId/revoke: same scope, same org. → 200 grant with revokedAt; 404 migration_grant_not_found; 409 migration_grant_revoked.

POST /v1/tokens/migration-assertion (x-api-key = the dedicated key)

{ "grantId": "mgr_...", "sourceUserId": "user_a", "workspaceId": "ws_...", "nonce": "[A-Za-z0-9_-]{16,128}", "expiresIn": 60 }

→ 201 { accessToken, accessTokenExpiresAt, tokenType: "Bearer" }, with no refresh token.

Claims: sub: agent_relayhistory_migration, org = grant org, wks = workspaceId, sponsorId = sourceUserId, sponsorChain: [sourceUserId, sub], scopes: ["rth:sync"], aud: [grant.audience], token_type: access, jti, sid, exp - iat <= 60 and exp <= grant.notAfter, meta: { tokenClass: "migration_assertion", grantId, planDigest, nonce, sourceUserId, actorId, actorOrgId, workspaceId }.

Refusals: insufficient_scope 403 (including a relayhistory-scoped key); assertion_key_required 403 (bearer token, workspace token, or a wildcard key without the literal scope); migration_grant_not_found 404 (unknown or other org); migration_grant_key_mismatch, migration_grant_revoked, migration_grant_expired, source_user_not_allowed, source_user_* 403; invalid_nonce, invalid_expires_in, grantId_required, sourceUserId_required, workspaceId_required 400.

Security properties

  • Only the one key a grant names can mint under it. That key must be an unrevoked operator API key of the grant's org whose only scope is the migration scope, so it cannot create its own grants.
  • There is no cross-org path. The grant org is the caller's org at creation, and a mint from another org's key reads the grant as not found.
  • Source users fail closed. At grant creation every allowed user, and on every mint the source user, must be an active human RelayAuth identity of the grant's org. There is no fallback to the allow-list alone. Retiring, suspending, deleting, re-typing or re-homing the identity after the grant stops further mints.
  • The assertion is bound to one audience (exact https origin; private http only behind RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN=true, and no loopback http without it), carries only rth:sync, lives at most 60s and never past the grant deadline, and has no refresh token. Because its aud never contains the shared relayhistory value, a History verifier refuses it.
  • The relayhistory scope cannot mint a migration assertion, and the migration scope cannot mint a relayhistory assertion. Both directions are tested.
  • Grant create and revoke, and every mint, persist atomically with their audit entry (migration_grant.created, migration_grant.revoked, and token.issued on resource migration-assertion). Every refusal after authentication is audited with result: denied and the reason, always in the caller's org.
  • The threat model is documented in docs/self-hosting.md under "Migration assertions / Threat model".

Not included

  • No verifier replay ledger. RelayAuth does not record jti or nonce consumption. Single-use enforcement, plus the aud, exp and planDigest checks, is the 96Bulls ingest verifier's job.
  • No deploy, no package publish, no migration applied to any real database, and no keys or grants created anywhere.
  • No change to the cloud repo's AuthStorage adapter. migrationGrants is optional on AuthStorage, and the routes answer 501 migration_grants_unsupported until an adapter provides it.

Separate security finding for explicit review (not changed here)

POST /v1/tokens/relayhistory-assertion takes orgId and sponsorId from the request body, and nothing ties them to the calling key's org. Any key with relayauth:assertion:create:relayhistory, including the self-host bootstrap key, can mint rth:read/rth:sync assertions for any org and any sponsor. The existing test asserts this (tokens-route.test.ts, around line 2306: actorOrgId: "org_tokens_route", requestedOrgId: "org_relayhistory_target"). This may be the intended trust model for a single control plane, but it deserves an explicit decision. This PR leaves that endpoint untouched; the only change in its code path is that normalizeRelayhistoryAssertionOrigin is now exported for reuse.

Notes for reviewers

  • Adds two audit actions to @relayauth/types AuditAction, and to the server's three action allow-lists and the OpenAPI enum.
  • The source-user refusal codes distinguish "unknown" from "other org" (as requested). A grant administrator can therefore learn that an identity id exists in another org. This is noted in the threat model.
  • A revoked grant does not invalidate assertions already minted under it (each lives at most 60s).

Validation

On Node 22.14, matching CI:

  • turbo build, turbo typecheck: pass
  • turbo test --force: all 15 tasks pass. The server suite is 733/733, including 47 new tests in src/__tests__/migration-assertion.test.ts.
  • Root tests/*.test.ts: 8/8. scripts/check-contract-surface.sh: pass.

šŸ¤– Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Agent Relay sessions

  • claude session a94ee5a0 Ā· opened via gh pr create Ā· last active 2026-10-30