Skip to content

Platform Admin

is_superadmin is a second, orthogonal permission axis alongside the per-org OrgRole ladder (viewer < member < admin < owner) — deliberately not a fifth rung above owner. OrgRole answers “what can this principal do within one org”; is_superadmin answers “can this principal see across every org,” which owning even every org you happen to belong to doesn’t imply.

A user can be owner of three organizations and a superadmin of none, or a viewer everywhere and a superadmin of the whole instance.


The GET /admin/* / POST /admin/* surface — read-mostly and cross-tenant:

EndpointDescription
GET /admin/organizationsEvery organization on the instance — member count, spend, budget, and whether SSO is enabled
GET /admin/organizations/{org_id}One organization’s summary, plus its full SSO connection parameters if one is configured (never the client secret — only whether one is set)
GET/PATCH/DELETE /admin/organizations/{org_id}/members[/{user_id}], POST .../reset-passwordManage one org’s members without being a member of it — see Managing members below
PATCH /admin/users/{user_id}, POST /admin/users/{user_id}/reset-passwordEdit a user’s account-wide name/is_active, or reset their password, independent of any one org — see Managing members below
GET /admin/usagePlatform-wide LLM spend this month, broken down by org
GET /admin/metrics/summaryPlatform-wide request/ingest/analysis telemetry, broken down by org — see Auditing organizations below
GET /admin/organizations/{org_id}/audit, GET /admin/audit, GET /admin/audit/exportStructured audit log — one org or a cross-org rollup — see Auditing organizations below
POST /admin/users/{user_id}/superadminGrant or revoke another user’s is_superadmin flag
GET/POST /admin/models, PATCH/DELETE /admin/models/{llm_backend}/{model}Manage the platform-wide allowed-models catalog — not org-scoped data, so unlike everything above, this genuinely writes shared state every org’s /analyze and GitHub integration calls are checked against. Pre-populated with 9 curated defaults (3 each for anthropic/openai/mistral) at every startup — see the caution note at that link.
GET/PATCH /admin/settingsThe admin-editable subset of the config file — see Settings below
POST /admin/storage/prepare-mongodbReady a target MongoDB instance for use as the storage backend — see Preparing MongoDB below
GET /admin/storageCurrently active backend/database plus the last MongoDB target prepared or switched to
POST /admin/storage/switchHot-swap this running instance’s active storage backend — see Switching the storage backend live below
POST /admin/retention/run, GET /admin/retention/statusRun or check the last result of the retention sweep below
POST /admin/jobs/sweep, GET /admin/jobs/sweep-statusRun or check the last result of the stuck-job sweep below
DELETE /admin/organizations/{org_id}, POST .../cancel-deletion, POST /admin/organizations/purge-pendingSchedule, cancel, or hard-execute organization deletion below

A cross-org GET /orgs/{org_id}/members call still 404s for a superadmin who isn’t actually in that org, same as for anyone else — reaching another org’s members always goes through the /admin/organizations/{org_id}/members mirror above, never the org-scoped route directly. The admin surface is a deliberately narrow set of cross-tenant exceptions (member management, custom-rules management — see API Endpoints), not a blanket override of org boundaries.

In the dashboard, a “Platform Admin” section appears in the sidebar only when GET /auth/me reports is_superadmin: true — pure UX, not the real gate, since every /admin/* page renders its own access-denied message if hit directly without the flag.

An organization’s detail page (Platform Admin → Organizations → (an org)) splits into five tabs — Overview (plan, spend, budget, lock state, 2FA policy), SSO, Members, Activity, and Danger zone (scheduled deletion). The open tab is in the URL as ?tab= (…/admin/organizations/{org_id}?tab=members), so a link to one panel survives a reload and can be handed to another admin. Anything that stays true of the org regardless of tab — pending deletion, locked, plan tier — is badged in the page header rather than hidden inside a panel.


A superadmin can act on any org’s members — change role, toggle org-scoped active status, reset password, or remove — from that org’s own detail page in the dashboard (Platform Admin → Organizations → an org → Members tab), reachable without being a member of that org at all. This is a deliberate design choice, not an oversight: there is still no flat, cross-tenant “all users on this instance” directory to browse — the same reasoning that already applied to POST /admin/users/{user_id}/superadmin, which has always required knowing a user_id rather than picking one from a list. A superadmin finds a member the same way: pick the org first (here, or from the member’s own Members page in their org), then act on them from there.

The dashboard’s Grant / revoke platform admin card (Platform Admin → Organizations) follows exactly that path rather than working around it: choose an organization from the list already on the page, then filter its members by name or email and pick the person — the user_id the endpoint needs comes from GET /admin/organizations/{org_id}/members, never from a global user search, which still does not exist. A raw-user_id field remains one click away (“Enter a user ID instead”) for a user who belongs to no org you can see, or an ID copied out of an audit event.

The endpoints split along the same line the data model already does — Membership vs. User:

  • /admin/organizations/{org_id}/members[/{user_id}] — org-scoped fields (role, is_active). Identical guardrails to the org-admin-facing PATCH/DELETE /orgs/{org_id}/members/{user_id}: role can never be owner (transfer ownership directly instead), the action can’t target the caller’s own membership, and an org’s last remaining owner can’t be demoted, deactivated, or removed. Deactivating a member here blocks only their access to this one org — any other org they belong to, and their account itself, are untouched.
  • /admin/users/{user_id} — account-wide fields (name, is_active) and account-wide password reset, independent of any single org. Deactivating a user here blocks login entirely, everywhere, and ends every session they currently hold — a materially bigger action than the org-scoped toggle above, which is why the dashboard renders these as a visually separate column on the same Members card rather than folding them into the per-membership row of actions.

Both password-reset endpoints (org-scoped and account-wide) generate a random password server-side and return it once — the same one-time-reveal pattern already used for API keys and invitation links. There is no email-based delivery: this codebase has no outbound-email infrastructure, so relaying the generated password to the affected user is a manual, out-of-band step.

Both resets also end every session the target currently holds, as does the two-factor reset and POST /admin/users/{user_id}/superadmin. Session cookies are stateless JWTs, but they are not unconditional: each request re-reads the account behind the cookie, so an account that has been deactivated or had its sessions revoked is rejected with 401 on its very next call rather than at the end of session.access_ttl_minutes. That also covers the is_superadmin flag — the claim is still baked into the token at mint time, but revoking the flag revokes the tokens carrying it, so a demoted platform admin loses /admin/* immediately rather than at their next login. See Session lifetime and revocation for the full list of what revokes and what does not.

Not applicable to SSO-backed accounts (auth_provider != "local") — both password-reset endpoints 400 for one, since there is no local password to reset.


logging, registration, social_login, server, session, rate_limit (its thresholds — not enabled), retention, org_offboarding, and github aren’t read from the config file or environment at all — their only baseline is a fixed, hardcoded default, and GET/PATCH /admin/settings is the only way to change any of them. GET /admin/settings returns each field’s current effective value (hardcoded default, or override if one’s been set), which fields are currently overridden, and which sections are excluded (and why — some genuinely file/env-driven, others fixed with no override mechanism at all, see below). PATCH /admin/settings takes a partial body: only the fields you include are touched, and setting a field to null clears its override back to the hardcoded default.

Terminal window
curl -X PATCH http://localhost:8000/admin/settings \
-H "X-API-Key: <admin-key>" \
-H "Content-Type: application/json" \
-d '{"server_max_input_size_mb": 25, "logging_level": "debug"}'

One field worth calling out on its own: registration_allow_public_org_creation — set to false to stop anyone from unilaterally creating a new organization (POST /auth/register with org_name, and POST /orgs), forcing invite-only growth, without touching who can register an account at all:

Terminal window
curl -X PATCH http://localhost:8000/admin/settings \
-H "X-API-Key: <admin-key>" \
-H "Content-Type: application/json" \
-d '{"registration_allow_public_org_creation": false}'

A superadmin is exempt from this — POST /orgs still works for them, so a platform operator can still create an org on a customer’s behalf. See Configuration Reference — registration for exactly what is and isn’t affected (org-less registration and invite acceptance never are, since neither creates a new org).

The dashboard follows the setting on its own: with it off, the sign-up page drops the “create an organization” choice and its org-name field entirely, and the onboarding page an org-less account lands on replaces the create-organization form with a “use an invite link, or ask an administrator” note. Both read GET /auth/registration-options, a public endpoint mirroring this one field, and pick it up on the next page load with no restart or re-login. That is presentation only — the two endpoints above stay the enforcement points.

Platform Admin → Settings → Sign-in Providers (a General tab plus one tab per provider) holds social_login_*: the platform-wide Continue with Google / Microsoft buttons, their OAuth client IDs and write-only client secrets, and the master switch social_login_enabled, which hides every button without discarding the stored credentials. See Quick Start: Sign in with Google or Microsoft for registering the two apps and for how a provider sign-in interacts with an org’s SSO connection.

logging.level, registration.*, social_login.*, every server.* field, and github.* apply to the very next request — no restart, ever. session.access_ttl_minutes applies to the very next login, registration, or session-switch (it doesn’t retroactively extend a cookie a user is already holding). retention.* fields are read fresh per request the same way; retention_telemetry_days/retention_audit_days additionally get pushed live onto the running MongoDB TTL indexes via collMod — see Data Retention and Running a retention sweep manually below. org_offboarding_grace_period_days is the same “read fresh per request” story — see Deleting an organization below.

The five rate_limit_* thresholds (Platform Admin → Settings → Security) apply to the very next request too. They are the brute-force attempt budgets: failed logins per account, second-factor attempts per account, and failed credentials per source IP across every credential type at once. rate_limit.enabled is deliberately not on this endpoint — a PATCH naming it is rejected with a 422, and GET never returns it. Whether the protection runs at all stays a config-file decision, because switching it off is the one change a stolen superadmin session would most want to make. Note this is only the application half; per-source-IP request-rate limiting belongs to whatever ingress fronts the deployment. See Configuration Reference — rate_limit. storage, cors, auth, jwt, and llm are never part of this endpoint — genuinely file/env-driven, for reasons specific to each (see Configuration Reference — Platform settings). mcp and telemetry are also never part of this endpoint, but for a different reason: both are read once, synchronously, inside create_app() itself — before the FastAPI app object even exists for an async database read to ever reach them — so there is no restart, no admin API call, that can change either one. They’re fixed at their hardcoded default for the life of the process. (telemetry here means just its enabled flag — the retention duration for the spans it produces is the separate, covered retention section above.)

POST /admin/storage/prepare-mongodb is the API/UX equivalent of tools/init_mongo.py: given a target uri/database (independent of whatever backend this running instance currently uses — a fresh install is typically still on memory the first time this is called), it idempotently creates every collection and index the app relies on, and optionally a dedicated app user.

Terminal window
curl -X POST http://localhost:8000/admin/storage/prepare-mongodb \
-H "X-API-Key: <admin-key>" \
-H "Content-Type: application/json" \
-d '{
"uri": "mongodb://localhost:27017",
"database": "opentremor",
"app_username": "opentremor_app",
"app_password": "changeme"
}'

This does not itself change what the running process is using — see Switching the storage backend live for that. admin_uri, if given, is used only for the app-user-creation step, in case the uri you’re preparing with lacks the privileges to create other users on that server.

POST /admin/storage/switch hot-swaps the storage backend for the running process — immediately, no restart. It runs the same schema-preparation prepare-mongodb does first, so switching straight to a never-prepared target is safe; prepare-then-switch is the recommended flow only for visibility into what’s about to happen, not a hard prerequisite.

Terminal window
curl -X POST http://localhost:8000/admin/storage/switch \
-H "X-API-Key: <admin-key>" \
-H "Content-Type: application/json" \
-d '{"backend": "mongodb", "uri": "mongodb://localhost:27017", "database": "opentremor"}'
Terminal window
# Back to in-memory
curl -X POST http://localhost:8000/admin/storage/switch \
-H "X-API-Key: <admin-key>" -H "Content-Type: application/json" -d '{"backend": "memory"}'

GET /admin/storage returns the currently active backend/database, plus whichever uri/ database was last used with either prepare-mongodb or switch (never admin_uri/ app_username/app_password — those are one-shot bootstrap secrets, not remembered) — this is what lets the dashboard’s Platform Admin → Settings page prefill the form instead of asking for the same values again on every visit.


See Data Retention for the full policy — this section is just the two admin actions. POST /admin/retention/run purges resolved (acknowledged/suppressed/ false-positive) findings past their configured severity-tiered window and redacts raw resource content past its own, then records the outcome. It’s self-gating: called again before retention.sweep_interval_hours has elapsed, it returns the previous result with skipped: true instead of redoing work — this is what lets OpenTremor-task-scheduler’s heartbeat call it every few minutes without knowing the configured interval itself. Pass force=true (the dashboard’s Platform Admin → Settings → Retention sweep → Run sweep now button does) to bypass the gate and always run immediately.

Terminal window
# Routine heartbeat call — a no-op most of the time
curl -X POST http://localhost:8000/admin/retention/run -H "X-API-Key: <admin-key>"
# Force an immediate run regardless of the configured interval
curl -X POST "http://localhost:8000/admin/retention/run?force=true" -H "X-API-Key: <admin-key>"

GET /admin/retention/status returns the last real run’s result (null if a sweep has never run on this instance) — duration_ms, error if the last run failed partway through, and counts: a map of how many records each sweep step touched, e.g. {"findings_purged": 3, "resources_redacted": 1}. The keys are open-ended (see the endpoint reference).


Core has no durable job queue — an async .../analyze job runs as a plain in-process background task, so a process crash or restart mid-run leaves it frozen at queued/running forever, with nothing else to notice. POST /admin/jobs/sweep marks every job in that state with no progress update in over jobs.stuck_timeout_minutes (default 60) as failed, across every org. Same self-gating shape as the retention sweep above: called again before jobs.sweep_interval_hours has elapsed, it returns the previous result with skipped: true instead of redoing work; pass force=true (the dashboard’s Platform Admin → Settings → Jobs → Jobs sweep → Run sweep now button does) to bypass the gate.

Terminal window
# Routine heartbeat call — a no-op most of the time
curl -X POST http://localhost:8000/admin/jobs/sweep -H "X-API-Key: <admin-key>"
# Force an immediate run regardless of the configured interval
curl -X POST "http://localhost:8000/admin/jobs/sweep?force=true" -H "X-API-Key: <admin-key>"

GET /admin/jobs/sweep-status returns the last real run’s result (null if a sweep has never run on this instance) — jobs_marked_failed, duration_ms, and error if the last run failed partway through. A job the sweep marks failed can be resumed afterward — see Retrying a failed job below.


Soft delete, not immediate: DELETE /admin/organizations/{org_id} only ever sets locked: true (every org-scoped endpoint 423s right away, same effect as locking an org manually) and pending_deletion_at: now — nothing is actually removed yet. The org is hard-deleted for real, cascading through every org-scoped collection (resources, findings, custom rules, teams, API keys, and more — see Configuration Reference — org_offboarding), once pending_deletion_at is more than org_offboarding.grace_period_days (default 30) in the past and POST /admin/organizations/purge-pending next runs.

Terminal window
# Schedule — locks the org immediately, does not delete anything yet
curl -X DELETE http://localhost:8000/admin/organizations/{org_id} -H "X-API-Key: <admin-key>"
# Cancel any time before the grace period elapses
curl -X POST http://localhost:8000/admin/organizations/{org_id}/cancel-deletion -H "X-API-Key: <admin-key>"

Cancelling clears pending_deletion_at only — it deliberately does not also unlock the org; that stays a separate PATCH .../lock call, since locking may have been independently desired. 409 either way if the org is already in the state you’re asking to change it out of (scheduling an already-pending org, or cancelling one that isn’t pending).

POST /admin/organizations/purge-pending is the endpoint that actually does the irreversible work — normally driven by OpenTremor-task-scheduler’s heartbeat, same “core has no scheduler of its own” reasoning as the retention sweep above. It’s a no-op, safe to call as often as needed, when nothing is actually due — no self-gating needed the way POST /admin/retention/run has one.

Terminal window
curl -X POST http://localhost:8000/admin/organizations/purge-pending -H "X-API-Key: <admin-key>"
{
"purged": [{"org_id": "...", "name": "..."}],
"errors": []
}

One organization’s purge failing doesn’t abort the batch — its org_id/error message show up in errors instead, and every other due organization is still attempted.


Not superadmin-only — any org member can retry their own org’s job, same permission as launching one in the first place. Covered here because it’s the natural companion to the stuck-job sweep above: a job the sweep marked failed (or one that failed for any other reason mid-run) can be resumed via POST /jobs/{job_id}/retry — see API Reference — POST /jobs/{job_id}/retry for the full contract. It creates a new job_id (retry_of links back to the original, which is left untouched) and only processes whatever the original namespace hasn’t already had analysed — the dashboard’s Jobs page surfaces a Retry button on any eligible failed job.

Terminal window
curl -X POST http://localhost:8000/jobs/{job_id}/retry -H "X-API-Key: <org-key>"

Every org-admin already has GET /metrics/{summary,spans,usage} scoped to their own org (see API Endpoints → Metrics) and a structured audit log of privileged actions (settings changes, key create/revoke, plan/lock changes, superadmin grants, SSO changes, finding triage) — also scoped to their own org. A superadmin gets the same two things cross-tenant:

  • GET /admin/metrics/summary — the same request/ingest/analysis rollup, broken down by_org, across every organization on the instance.
  • GET /admin/organizations/{org_id}/audit — one org’s audit log, reachable without being a member of it (same drill-down precedent as GET /admin/organizations/{org_id}/members).
  • GET /admin/audit — the cross-org audit feed. Omit org_id for a rollup across every org (including org-less platform actions like a superadmin grant, which touches a user, not an org); pass it to scope to one org.
  • GET /admin/audit/export — download the cross-org (or one-org) feed as CSV or JSON, up to 10,000 rows.

In the dashboard, this is Platform Admin → Audit Log — an org picker (default “All organizations”) plus the same filter/export controls the org-scoped Audit Log page has. An organization’s own detail page (Platform Admin → Organizations → (an org) → Activity) also shows its 5 most recent audit events, with a link through to the full filtered view.


  • The bootstrap key (config.auth.api_key) and auth-disabled mode (api_key: null) both resolve as superadmin automatically — both are already root-equivalent (owner of the default org with no further checks), so gating the platform-admin surface behind a separate, harder-to-obtain credential would be a false sense of security, not an extra one.
  • Human accounts carry users.is_superadmin: bool (default false). It’s baked into the session JWT at login/switch time, exactly like org_id/role — a promotion or revocation takes effect on the next login or POST /auth/session/switch, not live.
  • API keys never carry it — a stored key has no backing user to look the flag up on.

POST /admin/users/{user_id}/superadmin itself requires an existing superadmin, so there’s no in-app way to mint the first one. Two options:

Set auth.default_admin_email/auth.default_admin_password in your config (or DEFAULT_ADMIN_EMAIL/DEFAULT_ADMIN_PASSWORD env vars) — see Configuration Reference. The account is created once, idempotently, the first time the server starts with them set. Convenient for docker-compose/quickstart; rotate away from the shipped defaults ([email protected] / local-dev-password) before anything resembling production.

Terminal window
python src/tools/grant_superadmin.py --email [email protected]

The target user must already exist (POST /auth/register first). See Tools — grant_superadmin.py for the full option list.