Machine API keys
Give an external overseer such as Hermes authenticated, least-privilege access to ScrumPulse from its own VM.
An external supervisor — Hermes on its own VM, a monitoring job, a CI step — cannot mint a Clerk session token. It authenticates with a machine API key instead.
A key carries identity and tenancy only. Its role lives in the same org_role_assignments table that governs humans, under the synthetic principal apikey:<id>. Machines therefore authorize through exactly the same RBAC path as people, and there is no second privilege system to audit.
Two protections follow from that automatically:
- A machine can never be an admin.
get_access_context()demotes any database-sourcedadmintomember, because admin is a Clerk-owned boundary. - Machine principals cannot be retargeted through the member endpoints.
/api/rbac/membersiterates Clerk organization memberships, so a machine never appears there andPUT /api/rbac/members/{id}cannot reach it.
Minting a key#
Requires roles:manage, which is not delegable — so key management stays with Clerk organization admins.
curl -X POST https://scrumpulse-api.vercel.app/api/org/api-keys \
-H "Authorization: Bearer <clerk-session-token>" \
-H "Content-Type: application/json" \
-d '{
"name": "hermes-overseer",
"role": "scrum_master",
"allowed_ips": ["203.0.113.7"],
"expires_in_days": 90
}'
| Field | Notes |
|---|---|
name | Label shown in listings. Required. |
role | manager, scrum_master, product_manager, or member. admin is rejected. |
allowed_ips | Optional IP/CIDR allowlist. See the caveat below. |
expires_in_days | Optional, 1–3650. Prefer a bounded lifetime and rotate. |
The response contains secret exactly once. Only a SHA-256 hash is stored and lookup is by hash, so it cannot be recovered later and a stolen database yields no usable keys. Put it straight into the VM's secret store.
Using a key#
Either header works:
curl https://scrumpulse-api.vercel.app/api/oversight/summary -H "X-API-Key: sp_live_..."
curl https://scrumpulse-api.vercel.app/api/oversight/summary -H "Authorization: Bearer sp_live_..."
Clerk sessions are unaffected: only credentials carrying the sp_ prefix take the machine path, so JWTs fall through untouched.
Choosing a role#
Pick the least privilege that does the job.
| Role | Can | Cannot |
|---|---|---|
member | Read everything (app:read) | Any mutation |
scrum_master | Approve/reject actions, trigger and execute runs, schedule | Backlog authoring, credentials, billing |
product_manager | Backlog and work items | Approvals, runs, credentials, billing |
manager | The above plus org settings and feature flags | Credentials, billing, role administration |
An overseer that only watches should be member. One that also clears the approval queue should be scrum_master. No role can manage keys or billing — those need Clerk admin.
The oversight endpoint#
GET /api/oversight/summary?window_hours=24 answers "is the bot healthy, and is anything waiting on a human?" in one call, so a supervisor does not have to stitch together six endpoints:
{
"health": "healthy",
"agent": { "is_running": false, "write_mode": "dry_run", "is_suspended": false },
"runs": { "total": 12, "failed": 0, "by_status": { "completed": 12 }, "last_run_at": "..." },
"attention_required": { "actions_pending_approval": 3, "actions_failed": 0 },
"caller": { "auth_method": "api_key", "role": "scrum_master", "principal_id": "apikey:..." }
}
health is one of healthy, warning, degraded, idle, or suspended, computed server-side so the overseer does not encode its own thresholds. From there Hermes acts through the normal endpoints its role permits — /api/runs, /api/approvals/inbox, /api/runs/{id}/actions/{id}/approve.
IP allowlisting caveat#
allowed_ips compares against the source address the API observes. X-Forwarded-For is honored only when the immediate peer is listed in TRUSTED_PROXY_CIDRS; otherwise any caller could spoof past the allowlist by setting the header themselves.
Behind a platform edge with TRUSTED_PROXY_CIDRS unset, the observed address is the edge, not your VM — so the allowlist will not do what you expect. Either set TRUSTED_PROXY_CIDRS to the platform's proxy range, or rely on the secret plus a short expiry instead.
Managing keys#
GET /api/org/api-keys # list; never returns secrets
POST /api/org/api-keys # mint
DELETE /api/org/api-keys/{id} # revoke
Revocation also deletes the key's role assignment, so the principal holds no permissions even if a stale credential were somehow presented.
Security properties#
- Secret shown once; only a SHA-256 hash persisted, and lookup is by hash.
- Revocation and optional expiry, both checked on every request.
- Optional per-key IP/CIDR allowlist.
- Unknown, revoked, expired, and IP-blocked keys all return an identical bare
401— the response never reveals which condition failed. - Each key gets its own rate-limit bucket, so a runaway polling loop cannot exhaust the limit for the humans in its organization.
last_used_atandlast_used_ipare stamped on every accepted request for audit.