Skip to content

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 — iss claim must equal PLATFORM_JWT_ISSUER (when set).
  • Audience — aud claim must equal PLATFORM_JWT_AUDIENCE (when set).
  • Expiry — exp claim 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:

python
@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:

bash
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

ThreatGate
Forged JWT1 (JWKS verification)
Replayed JWT after logout1 (exp + Clerk revocation list)
Valid JWT against a tenant you don't belong to2
Valid membership but role too low for the action3
SDK key leaked, replayed from a different origin4
Changes that shouldn't have happened5 (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

SettingWhereEffect
PLATFORM_JWKS_URLDeployment envIf unset, the platform edge returns 503 — dashboard can't reach the API at all.
PLATFORM_JWT_ISSUERDeployment envExpected iss — any mismatch fails with a specific reason.
PLATFORM_JWT_AUDIENCEDeployment envExpected aud — blank = skip the check.
PLATFORM_SUPER_ADMIN_IDSDeployment envComma-separated Clerk subs allowed to POST /v1/platform/tenants. Empty = nobody can create tenants from the UI.
allowed_originsPer-tenant (Sites)Origin allow-list for the SDK edge.
admin_strategyPer-project or per-tenantWhich signal makes an end-user an admin (table lookup / JWT permission / JWT role).

Spotlight