Skip to main content
A delegation token lets CheckAI act in a Slack gig channel as a known org member without holding that member’s Clerk session. The token is signed by api-events, lives at most ten minutes and only works on routes that touch its one gig. Every request still runs the handler’s normal role check for that member, so a token never grants more than the member already has.

Token format

A compact JWT signed with HMAC-SHA256 (api-events/src/auth/delegationToken.ts). Verification rejects anything else: a different header or extra header keys (alg: none, HS512, jku), extra or missing claims, a purpose outside the allowlist, an iat more than 60 seconds in the future, an expired token, a lifetime over 600 seconds, non-canonical base64url and a bad signature (compared in constant time). mintDelegationToken caps the requested TTL at 600 seconds.

Authentication

clerkAuthHandler checks the header kid first. A deleg-v1 token never reaches Clerk. After the signature and claims pass, hasActiveDelegationGrant (api-events/src/db/delegationGrant.ts) confirms in one query that:
  • sub is an active OrgMember of org;
  • the organization is not scheduled for deletion;
  • evt belongs to org and is not scheduled for deletion.
Any miss answers 401, so removing the member or deleting the gig revokes outstanding tokens at once. A database failure answers 503. The auth data carries authMethod: 'delegation', orgID: org and delegation: { eventId, purpose, jti }. It never carries platformAdmin or an org role. Clerk-only helpers (resolveClerkUserId, resolveCallerOrgId, requireClerkUserId, the service-console switches) refuse it, and provisionMiddleware skips it.

Route enforcement

delegationScopeMiddleware runs on every endpoint and acts only when authMethod is delegation. It looks up the endpoint in api-events/src/systems/shared/routeAuthz.generated.ts, which scripts/authz/generate-route-authz.mjs builds from each route’s openapiRoute(..., { authz }) declaration. It denies by default with a Go-envelope 403 (code: FORBIDDEN). The allowlist comes first, and it is the main boundary. A route under the gig can still name another object in its body: save-as-template on gig A accepts template B and overwrites it. So a token reaches only the endpoints listed for its purpose. For slack-channel (SLACK_CHANNEL_ENDPOINTS) these are reads of the gig, edits to the gig’s details, run of show, checklist, contacts, show notes and links, and the travel of the gig’s project. Deletes, cancels, duplicates, templates, invitations and settlement are not on it. Before adding an endpoint, check that its handler confirms every id in the body or query belongs to the gig. Three allowlisted handlers also refuse a delegated caller (isDelegatedRequest(), read from the audit context) where the body could reach another record:
  • replaceRunOfShow keeps documents already attached to the gig’s run of show but rejects new ones, because attaching any org document returns a signed URL for it.
  • eventsById rejects a change to venue_id, group_id or tour_id, and any place_id or place_details, because a new venue’s access code and notes would come back on the next read.
  • createEventChecklist rejects template mode, which copies another template’s contents.
Accepted: a delegated caller can assign an active org member to one of the gig’s tasks, or add one as a passenger or hotel guest on the project’s travel. These write only to the gig or its project, and the member is already visible to the poster. Hotels come from the global hotel catalog. Path params are compared as raw path segments, so an encoded id never matches the gig. To make a new route reachable, declare its authz to match what the handler enforces and regenerate the table:
test:route-authz fails when the committed table drifts from the declarations.

Audit

A delegated request is recorded with actor_type = 'CHECKAI', source = 'CHECKAI' and actor_user_id = the member (see Audit actor model). The request context carries delegation: { via: 'slack', jti, event_id }. Explicit recordAudit rows merge it into metadata.delegation, and the app.audit_ctx setting carries it for the trigger floor. No schema change was needed.

Secret and rotation

The signing key is the Secret Manager secret DelegationTokenSecret, mounted as DELEGATION_TOKEN_SECRET (enable_delegation_token_secret, on in dev, qa and prod). A value that is unset, PLACEHOLDER or shorter than 32 characters is treated as missing: minting throws and every token is rejected. To rotate:
  1. Generate a new value: openssl rand -base64 48.
  2. Add it as a new version: gcloud secrets versions add <env>-DelegationTokenSecret --data-file=-, or paste it in the service console.
  3. Roll the Cloud Run revision so api-events reads the new version.
There is one active key. Tokens signed with the old key fail with 401 from the moment the new revision serves traffic. A token lives at most ten minutes, so the caller only has to mint a fresh one and retry. Nothing calls mintDelegationToken yet. The CheckAI Slack flow that mints and sends these tokens is follow-up work.