How admin API writes are gated
Every mutation through /v1/admin/* passes four gates before the handler sees the request. Any one failing closes the request with a 401/403/404 and writes an audit entry. None of these can be toggled off from the tenant's own configuration — they're enforced in middleware compiled with the app.
1. JWT signature verification
Dashboard writes land via the platform edge (Authorization: Bearer <platform JWT>). The token is checked against PLATFORM_JWKS_URL configured at deploy time:
- Signature — RS256/ES256 against keys published by Clerk (or your configured IdP). A mismatched key = 401.
- Issuer —
issclaim must equalPLATFORM_JWT_ISSUER(when set). - Audience —
audclaim must equalPLATFORM_JWT_AUDIENCE(when set). - Expiry —
expclaim must be in the future.
JWKS URLs pointed at well-known IdPs (*.clerk.accounts.dev, *.auth0.com, *.okta.com) are auto-upgraded to HTTPS so a misconfigured http:// doesn't silently open a plaintext window. Any failure bubbles up through platform_validator.last_error; operators can see the exact reason at WARN level in the API logs.
Token forgery is not possible without the IdP's private key.
2. Membership on the tenant
After the token validates, the middleware reads the tenant id from X-Spotlight-Tenant-Id (the dashboard sends it on every request) and looks up the caller's row in Spotlight.Platform.Memberships:
(platform_user_id, tenant_id) → role ∈ {owner, admin, editor, viewer}- No row — 403 "No membership on this tenant".
- Row present — the role attaches to
AuthContext.platform_role.
A valid JWT does not grant access to tenants the user isn't a member of. Listing your own tenants uses a dedicated endpoint (/v1/platform/tenants) that runs without a tenant header and returns only rows the caller actually owns.
3. Role check on the handler
Each route declares the minimum role it accepts:
@router.post("/v1/admin/tours")
async def create_tour(auth: AuthContext = Depends(require_role("editor"))):
...The require_role dependency chains onto get_auth_context and compares auth.platform_role against the configured minimum. Roles stack top-down (Owner ≥ Admin ≥ Editor ≥ Viewer).
Role promotion beyond what you already hold is also gated:
- Only Owners can promote another member to Owner.
- Removing yourself is blocked (you have to ask another admin).
- Removing the last Owner is blocked (keeps the tenant reachable).
4. Origin lockdown (SDK edge only)
SDK-side requests (X-API-Key + JWT from the end user's IdP) get a fourth gate: the request's Origin or Referer header must match one of the tenant's allowed_origins. Requests from pages not on the list return 403 "Origin not allowed".
This gate doesn't apply to the platform edge — dashboard requests are same-origin to the API host by design.
5. Audit trail
Every mutation writes a row into Spotlight.Audit.AdminActions, hash-chained to the previous one:
(tenant_id, timestamp_event_id) → { actor_id, operation, entity_type,
entity_id, hash_prev, hash_this }Tampering with a single row invalidates every row after it. The chain is verifiable offline:
python scripts/verify_audit_chain.py --tenant-id <your_tenant>The dashboard's Audit log page reads this table with filters on actor, entity type, and operation — paginated, newest first.
What this protects against
| Threat | Gate |
|---|---|
| Forged JWT | 1 (JWKS verification) |
| Replayed JWT after logout | 1 (exp + Clerk revocation list) |
| Valid JWT against a tenant you don't belong to | 2 |
| Valid membership but role too low for the action | 3 |
| SDK key leaked, replayed from a different origin | 4 |
| Changes that shouldn't have happened | 5 (post-hoc detection) |
What this does NOT protect against
- Compromised IdP. If Clerk's private key leaks, the first defence falls — we trust the issuer.
- Admins doing damage within their role. A valid Owner can still delete every tour on purpose. Mitigated by the audit log (you'll know who) and by not handing out Owner freely.
- Stolen platform session in the browser. The same way any other signed-in app is vulnerable — Clerk handles session timeouts, MFA, and anomaly detection if you configure them.
Configuration gate summary
| Setting | Where | Effect |
|---|---|---|
PLATFORM_JWKS_URL | Deployment env | If unset, the platform edge returns 503 — dashboard can't reach the API at all. |
PLATFORM_JWT_ISSUER | Deployment env | Expected iss — any mismatch fails with a specific reason. |
PLATFORM_JWT_AUDIENCE | Deployment env | Expected aud — blank = skip the check. |
PLATFORM_SUPER_ADMIN_IDS | Deployment env | Comma-separated Clerk subs allowed to POST /v1/platform/tenants. Empty = nobody can create tenants from the UI. |
allowed_origins | Per-tenant (Sites) | Origin allow-list for the SDK edge. |
admin_strategy | Per-project or per-tenant | Which signal makes an end-user an admin (table lookup / JWT permission / JWT role). |