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.
What it grants
Section titled “What it grants”The GET /admin/* / POST /admin/* surface — read-mostly and cross-tenant:
| Endpoint | Description |
|---|---|
GET /admin/organizations | Every 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-password | Manage 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-password | Edit a user’s account-wide name/is_active, or reset their password, independent of any one org — see Managing members below |
GET /admin/usage | Platform-wide LLM spend this month, broken down by org |
GET /admin/metrics/summary | Platform-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/export | Structured audit log — one org or a cross-org rollup — see Auditing organizations below |
POST /admin/users/{user_id}/superadmin | Grant 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/settings | The admin-editable subset of the config file — see Settings below |
POST /admin/storage/prepare-mongodb | Ready a target MongoDB instance for use as the storage backend — see Preparing MongoDB below |
GET /admin/storage | Currently active backend/database plus the last MongoDB target prepared or switched to |
POST /admin/storage/switch | Hot-swap this running instance’s active storage backend — see Switching the storage backend live below |
POST /admin/retention/run, GET /admin/retention/status | Run or check the last result of the retention sweep below |
POST /admin/jobs/sweep, GET /admin/jobs/sweep-status | Run or check the last result of the stuck-job sweep below |
DELETE /admin/organizations/{org_id}, POST .../cancel-deletion, POST /admin/organizations/purge-pending | Schedule, 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.
Managing members
Section titled “Managing members”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-facingPATCH/DELETE /orgs/{org_id}/members/{user_id}:rolecan never beowner(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.
Settings
Section titled “Settings”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.
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:
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.)
Preparing MongoDB
Section titled “Preparing MongoDB”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.
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.
Switching the storage backend live
Section titled “Switching the storage backend live”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.
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"}'# Back to in-memorycurl -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.
Running a retention sweep manually
Section titled “Running a retention sweep manually”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.
# Routine heartbeat call — a no-op most of the timecurl -X POST http://localhost:8000/admin/retention/run -H "X-API-Key: <admin-key>"
# Force an immediate run regardless of the configured intervalcurl -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).
Running a stuck-job sweep manually
Section titled “Running a stuck-job sweep manually”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.
# Routine heartbeat call — a no-op most of the timecurl -X POST http://localhost:8000/admin/jobs/sweep -H "X-API-Key: <admin-key>"
# Force an immediate run regardless of the configured intervalcurl -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.
Deleting an organization
Section titled “Deleting an organization”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.
# Schedule — locks the org immediately, does not delete anything yetcurl -X DELETE http://localhost:8000/admin/organizations/{org_id} -H "X-API-Key: <admin-key>"
# Cancel any time before the grace period elapsescurl -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.
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.
Retrying a failed job
Section titled “Retrying a failed job”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.
curl -X POST http://localhost:8000/jobs/{job_id}/retry -H "X-API-Key: <org-key>"Auditing organizations
Section titled “Auditing organizations”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 downby_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 asGET /admin/organizations/{org_id}/members).GET /admin/audit— the cross-org audit feed. Omitorg_idfor 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.
Who has it
Section titled “Who has it”- 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 thedefaultorg 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(defaultfalse). It’s baked into the session JWT at login/switch time, exactly likeorg_id/role— a promotion or revocation takes effect on the next login orPOST /auth/session/switch, not live. - API keys never carry it — a stored key has no backing user to look the flag up on.
Bootstrapping the first superadmin
Section titled “Bootstrapping the first superadmin”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:
Declaratively, at startup
Section titled “Declaratively, at startup”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.
Against a running database
Section titled “Against a running database”The target user must already exist (POST /auth/register first). See Tools — grant_superadmin.py for the full option list.