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