Endpoints: Platform Admin
Platform admin
Section titled “Platform admin”Cross-tenant — deliberately not scoped to the caller’s own org, unlike every other endpoint on this page. Requires is_superadmin, a permission axis independent of OrgRole. See Platform Admin for the full model and how to bootstrap the first superadmin.
GET /admin/organizations
Section titled “GET /admin/organizations”Every organization on the instance.
Response 200
{ "organizations": [ { "org_id": "7c3c6e2e-...", "name": "Acme Corp", "slug": "7c3c6e2e-...", "plan_tier": "free", "contract": null, "contract_expired": false, "member_count": 3, "monthly_budget_usd": 50.0, "current_month_cost_usd": 12.34, "created_at": "2026-01-01T12:00:00Z", "locked": false, "require_2fa": false, "sso_enabled": true } ]}contract carries an enterprise org’s committed terms, and is null on every other tier — an org downgraded off enterprise keeps its stored contract but reports null here, so this list never shows terms that aren’t in force. contract_expired is the renewal prompt: advisory, and it revokes nothing.
sso_enabled is true only when the org has an SSO connection that’s both configured and enabled — not merely configured. Kept as a single flag here (rather than the full connection) so listing every org stays a lightweight query; see the detail endpoint below for the full parameters.
GET /admin/organizations/{org_id}
Section titled “GET /admin/organizations/{org_id}”Same shape as a single entry above, plus the org’s full SSO connection when one exists (sso: null otherwise) — same fields GET /orgs/{org_id}/sso itself returns, so the client secret is never included, only client_secret_set. 404 if org_id doesn’t exist.
Response 200
{ "org_id": "7c3c6e2e-...", "name": "Acme Corp", "slug": "7c3c6e2e-...", "plan_tier": "free", "contract": null, "contract_expired": false, "member_count": 3, "monthly_budget_usd": 50.0, "current_month_cost_usd": 12.34, "created_at": "2026-01-01T12:00:00Z", "locked": false, "require_2fa": false, "sso_enabled": true, "sso": { "provider": "oidc", "display_name": "Acme Corp", "issuer": "https://login.microsoftonline.com/{tenant-id}/v2.0", "client_id": "app-client-id", "client_secret_set": true, "default_role": "member", "allowed_domains": ["acme.com"], "enabled": true, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" }}GET /admin/organizations/{org_id}/members
Section titled “GET /admin/organizations/{org_id}/members”The superadmin drill-down mirror of GET /orgs/{org_id}/members —
same response shape, but reachable without the caller being a member of org_id at all.
This, not a separate cross-tenant user list, is how a platform admin finds a specific member:
pick the org first (here or in the dashboard), then its members — see
Platform Admin for why there’s deliberately no flat
/admin/users directory.
| Status | Description |
|---|---|
200 | Member list |
404 | Organization not found |
PATCH /admin/organizations/{org_id}/members/{user_id}
Section titled “PATCH /admin/organizations/{org_id}/members/{user_id}”Cross-tenant mirror of PATCH /orgs/{org_id}/members/{user_id}
— identical body, guardrails (no self-targeting, no demoting/deactivating the org’s only owner,
role can never be owner), and response shape.
| Status | Description |
|---|---|
200 | Membership updated |
400 | Targets the caller’s own membership |
404 | Organization or membership not found |
409 | Would demote/deactivate the org’s only remaining owner |
422 | role set to owner |
DELETE /admin/organizations/{org_id}/members/{user_id}
Section titled “DELETE /admin/organizations/{org_id}/members/{user_id}”Cross-tenant mirror of DELETE /orgs/{org_id}/members/{user_id}.
| Status | Description |
|---|---|
204 | Member removed |
400 | Targets the caller’s own membership |
404 | Organization or membership not found |
409 | Target is the org’s only remaining owner |
POST /admin/organizations/{org_id}/members/{user_id}/reset-password
Section titled “POST /admin/organizations/{org_id}/members/{user_id}/reset-password”Cross-tenant mirror of
POST /orgs/{org_id}/members/{user_id}/reset-password
— same one-time temporary_password reveal, not applicable to SSO-backed accounts.
| Status | Description |
|---|---|
200 | Password reset — temporary_password shown once |
404 | Organization or membership not found |
400 | Target account authenticates via SSO |
POST /admin/organizations/{org_id}/members/{user_id}/2fa-reset
Section titled “POST /admin/organizations/{org_id}/members/{user_id}/2fa-reset”Cross-tenant mirror of
POST /orgs/{org_id}/members/{user_id}/reset-2fa —
clears the member’s TOTP secret and recovery codes.
Response 200
{"detail": "Two-factor authentication reset"}| Status | Description |
|---|---|
200 | 2FA reset |
404 | Organization or membership not found |
GET /admin/usage
Section titled “GET /admin/usage”Platform-wide LLM spend this month, broken down by organization.
Response 200
{ "period": "2026-07", "total_cost_usd": 45.67, "by_org": [ {"org_id": "7c3c6e2e-...", "name": "Acme Corp", "cost_usd": 12.34} ]}GET /admin/metrics/summary
Section titled “GET /admin/metrics/summary”Platform-wide request/ingest/analysis telemetry, broken down by organization — the same aggregation GET /metrics/summary (org-admin, own org only) computes, rolled up across every org on the instance.
| Parameter | Type | Default | Description |
|---|---|---|---|
days | int | 7 | Aggregation window (1–365) |
Response 200
{ "period_days": 7, "requests_total": 1450, "resources_analyzed_total": 340, "findings_total": 210, "by_org": [ { "org_id": "7c3c6e2e-...", "name": "Acme Corp", "requests_total": 1240, "resources_analyzed_total": 310, "findings_total": 195 } ]}GET /admin/organizations/{org_id}/audit
Section titled “GET /admin/organizations/{org_id}/audit”Audit events for one organization — same filters and response shape as GET /metrics/audit, reachable cross-tenant. 404 if org_id doesn’t exist.
GET /admin/audit
Section titled “GET /admin/audit”Cross-org audit feed. Omit org_id for a rollup across every organization (including org-less platform actions like superadmin.grant); pass it to scope to one org — same result as the endpoint above.
| Parameter | Type | Default | Description |
|---|---|---|---|
org_id | string | — | Restrict to one org; omit for all orgs |
action, actor_id, target_type, since, until | — | — | Same filters as GET /metrics/audit |
limit | int | 100 | Number of results (1–1000) |
offset | int | 0 | Pagination offset |
GET /admin/audit/export
Section titled “GET /admin/audit/export”Download cross-org (or one-org, via org_id) audit events as CSV or JSON — same shape as GET /metrics/audit/export, just not scoped to the caller’s own org.
POST /admin/users/{user_id}/superadmin
Section titled “POST /admin/users/{user_id}/superadmin”Grant or revoke another user’s is_superadmin flag. 404 if user_id doesn’t exist.
Request body
{"enabled": true}Response 200
{"user_id": "11a75db9-...", "is_superadmin": true}PATCH /admin/users/{user_id}
Section titled “PATCH /admin/users/{user_id}”Edit a user’s account-wide name and/or is_active — distinct from a membership’s
org-scoped is_active above (PATCH /admin/organizations/{org_id}/members/{user_id}).
Deactivating a user here blocks login entirely, in every organization they belong to; the
org-scoped equivalent only blocks access to that one org. Both fields optional. 404 if
user_id doesn’t exist.
Request body
{"name": "Ada Lovelace", "is_active": false}Response 200 — the updated UserInfo, same shape as GET /auth/me’s user field.
| Status | Description |
|---|---|
200 | Account updated |
404 | user_id doesn’t exist |
POST /admin/users/{user_id}/reset-password
Section titled “POST /admin/users/{user_id}/reset-password”Account-wide password reset — unlike the org-membership reset endpoints above, doesn’t require
user_id to belong to any particular organization. Same one-time temporary_password reveal.
Not applicable to SSO-backed accounts.
Response 200
{"temporary_password": "kX9Qy2z..."}| Status | Description |
|---|---|
200 | Password reset — temporary_password shown once |
404 | user_id doesn’t exist |
400 | Target account authenticates via SSO |
POST /admin/users/{user_id}/2fa-reset
Section titled “POST /admin/users/{user_id}/2fa-reset”Account-wide 2FA reset — unlike the org-membership reset endpoint above, doesn’t require
user_id to belong to any particular organization. Clears the user’s TOTP secret and recovery
codes.
Response 200
{"detail": "Two-factor authentication reset"}| Status | Description |
|---|---|
200 | 2FA reset |
404 | user_id doesn’t exist |
PATCH /admin/organizations/{org_id}/plan
Section titled “PATCH /admin/organizations/{org_id}/plan”Set an organization’s plan tier, and — on enterprise — the contract terms behind it. There is no self-serve checkout: moving a customer between plans is a manual operator action until real billing is wired up.
Only meaningful on a cloud deployment (deployment.mode). A self-hosted deployment is unlimited unconditionally, which is why an enterprise agreement covering a self-hosted install is a support/SLA contract this endpoint can neither express nor enforce.
| Tier | Access | Billing |
|---|---|---|
free | Every analyzer, until the org’s 100-analysis lifetime demo quota runs out — then 402 | Never billed |
paid | Every analyzer, unlimited | Every resource, from the first, at the degressive rate |
enterprise | Every analyzer, unlimited | Only resources above contract.included_resources_per_month, flat at the cheapest tier |
Request body
{ "plan_tier": "enterprise", "contract": { "included_resources_per_month": 5000, "committed_amount_usd": 1500.0, "expires_at": "2027-01-01T00:00:00Z", "reference": "ORD-2026-0142" }}Every contract field is optional, and an enterprise org with no contract is unlimited and never expires — an operator who flipped the tier without recording terms has granted unlimited use, not zero.
demo_analyses_used may also be sent, to grant or reset an org’s remaining demo allowance (a sales exception); omit it to leave the counter untouched.
Response 200
{"org_id": "7c3c6e2e-...", "plan_tier": "enterprise", "contract": {"included_resources_per_month": 5000}}| Status | Description |
|---|---|
200 | Plan updated (audited as org.plan_update, with before/after) |
404 | Organization not found |
422 | contract sent with a non-enterprise plan_tier |
PATCH /admin/organizations/{org_id}/lock
Section titled “PATCH /admin/organizations/{org_id}/lock”Lock or unlock an organization. A locked org has every org-scoped endpoint reject with 423 regardless of caller role — analysis, membership, rules, keys, everything — and the dashboard shows a full-page lock message instead of the normal shell. Login itself still succeeds (GET /auth/me reports organization_locked: true, it isn’t a 401) so a member can at least see why nothing works. Unlocking is immediate and identical in mechanism — just flips the flag back.
Request body
{"locked": true}Response 200
{"org_id": "7c3c6e2e-...", "locked": true}| Status | Description |
|---|---|
200 | Lock state updated |
404 | Organization not found |
PATCH /admin/organizations/{org_id}/2fa-policy
Section titled “PATCH /admin/organizations/{org_id}/2fa-policy”Require or stop requiring two-factor authentication for an organization. Same lockout-blast-radius reasoning as PATCH .../lock — superadmin-only, not org-admin-editable, since an org-admin could otherwise lock themselves out by enabling it before enrolling, or weaken security post-compromise by disabling it unilaterally. While true, every org-scoped endpoint 403s for a human session whose user hasn’t enabled 2FA (dependencies.require_principal); API-key principals are exempt. Login itself still succeeds (GET /auth/me reports mfa_required: true, it isn’t a 401), so a member can see why org-scoped pages are blocked and reach POST /auth/me/2fa/enroll to fix it.
Request body
{"require_2fa": true}Response 200
{"org_id": "7c3c6e2e-...", "require_2fa": true}| Status | Description |
|---|---|
200 | Policy updated |
404 | Organization not found |
DELETE /admin/organizations/{org_id}
Section titled “DELETE /admin/organizations/{org_id}”Schedule an organization for deletion — a soft delete. Sets locked: true (same effect as
PATCH .../lock) and pending_deletion_at: now; does not delete anything yet. The org is
hard-deleted for real, cascading through every org-scoped collection, once
pending_deletion_at is more than org_offboarding.grace_period_days in the past and
POST /admin/organizations/purge-pending next runs — see that endpoint below and
Platform Admin — Deleting an organization.
Response 200
{"org_id": "7c3c6e2e-...", "pending_deletion_at": "2026-08-03T13:13:56Z", "purge_after": "2026-09-02T13:13:56Z"}purge_after is always derived from the current org_offboarding.grace_period_days — not
stored, so a later grace-period change is reflected immediately for any still-pending org.
| Status | Description |
|---|---|
200 | Deletion scheduled |
404 | Organization not found |
409 | Already scheduled for deletion |
POST /admin/organizations/{org_id}/cancel-deletion
Section titled “POST /admin/organizations/{org_id}/cancel-deletion”Cancel a scheduled deletion. Clears pending_deletion_at only — deliberately does not also
unlock the organization; that stays a separate PATCH .../lock call.
Response 200
{"org_id": "7c3c6e2e-...", "pending_deletion_at": null}| Status | Description |
|---|---|
200 | Deletion cancelled |
404 | Organization not found |
409 | Not currently scheduled for deletion |
GET /admin/settings
Section titled “GET /admin/settings”The admin-editable subset of AppConfig — logging, registration, server, session,
retention, github — with any stored overrides already applied. None of these are read from
the config file or environment at all; their only baseline is a fixed, hardcoded default, reset
unconditionally at process start (see Configuration Reference
for why). excluded_sections lists the rest, each with why it’s handled differently: storage,
cors, auth, jwt, llm are genuinely file/env-driven; mcp, telemetry are fixed at their
hardcoded default with no override mechanism at all (read too early in process startup for any
override, database or otherwise, to ever reach them) — telemetry here means only its enabled
flag; the retention duration for the spans it produces is the separate, covered retention
section.
Response 200
{ "effective": { "logging": {"level": "info"}, "registration": {"allow_public_org_creation": true}, "server": { "max_input_size_mb": 10, "public_base_url": null, "dashboard_base_url": null, "sync_analysis_max_units": 5 }, "session": {"access_ttl_minutes": 480}, "retention": { "telemetry_days": 30, "audit_days": 730, "finding_low_medium_days": 180, "finding_high_critical_days": 730, "resource_content_days": 90, "sweep_enabled": true, "sweep_interval_hours": 24 }, "github": {"app_id": null, "private_key": null, "webhook_secret": null} }, "overrides": {}, "excluded_sections": [ {"section": "storage", "reason": "..."}, {"section": "cors", "reason": "..."}, {"section": "auth", "reason": "..."}, {"section": "jwt", "reason": "..."}, {"section": "llm", "reason": "..."}, {"section": "mcp", "reason": "..."}, {"section": "telemetry", "reason": "..."} ], "updated_by": null, "updated_at": null}PATCH /admin/settings
Section titled “PATCH /admin/settings”Partial update — only fields present in the body are touched; set a field to null to clear
its override and fall back to the hardcoded default (never the config file — see
GET /admin/settings). Unknown field names (including any belonging to an excluded section)
422.
{"server_max_input_size_mb": 25, "logging_level": "debug"}{"retention_audit_days": 1825, "retention_resource_content_days": 30}logging_level, registration_allow_public_org_creation, every server_* field, every
github_* field, and every retention_* field apply to the very next request — no restart,
ever. retention_telemetry_days/retention_audit_days additionally get pushed live onto the
running MongoDB TTL indexes via collMod — see Data Retention. See
Platform Admin — Settings.
Response 200: same shape as GET /admin/settings, reflecting the just-applied change.
| Status | Description |
|---|---|
200 | Settings updated |
422 | Unknown field, or a value outside its valid range |
POST /admin/storage/prepare-mongodb
Section titled “POST /admin/storage/prepare-mongodb”The API/UX equivalent of tools/init_mongo.py — connects to the given uri/database
(independent of this instance’s current storage backend) and idempotently creates every
collection/index the app relies on, optionally also a dedicated app user. Does not itself change
what the running process is using — see POST /admin/storage/switch below, or
Platform Admin — Preparing MongoDB.
Request body
{ "uri": "mongodb://localhost:27017", "database": "opentremor", "admin_uri": "mongodb://admin:secret@localhost:27017", "app_username": "opentremor_app", "app_password": "changeme"}admin_uri, app_username, app_password are all optional — omit app_username/
app_password together to skip app-user creation entirely; admin_uri is used only for that
step (falls back to uri when omitted).
Response 200
{"database": "opentremor", "collections_ensured": ["allowed_models", "custom_rules", "..."], "app_user_created": true}app_user_created is null when user creation wasn’t requested, false when the user already existed.
| Status | Description |
|---|---|
200 | Target database prepared |
502 | Couldn’t connect to the target MongoDB, or the app-user creation step failed |
GET /admin/storage
Section titled “GET /admin/storage”Currently active backend/database, plus the last uri/database used with either
prepare-mongodb or switch (never admin_uri/app_username/app_password — those aren’t
remembered). Lets the dashboard’s form prefill instead of asking for the same values again.
Response 200
{ "active_backend": "memory", "active_database": null, "last_prepared": { "uri": "mongodb://localhost:27017", "database": "opentremor", "updated_at": "2026-07-21T00:00:00Z" }}last_prepared is null if neither prepare-mongodb nor switch has ever been called.
POST /admin/storage/switch
Section titled “POST /admin/storage/switch”Hot-swaps the storage backend for the running process — takes effect immediately, no
restart. Runs the same schema-preparation prepare-mongodb does first, so switching straight to
a never-prepared target is safe. See
Platform Admin — Switching the storage backend live
for the full cutover semantics (live, not persisted across a restart, not a data migration).
Request body
{"backend": "mongodb", "uri": "mongodb://localhost:27017", "database": "opentremor"}{"backend": "memory"}uri/database are required when backend is "mongodb", ignored (and may be omitted) for
"memory".
Response 200
{"backend": "mongodb", "database": "opentremor"}| Status | Description |
|---|---|
200 | Storage backend switched |
422 | backend: "mongodb" without uri/database |
502 | Couldn’t connect to the target MongoDB |
POST /admin/retention/run
Section titled “POST /admin/retention/run”Runs a retention sweep — see Data Retention for the policy and
Platform Admin — Running a retention sweep manually
for the full walkthrough. Self-gating by default: a call before retention.sweep_interval_hours
has elapsed since the last real run returns that previous result with skipped: true instead of
redoing work — safe for OpenTremor-task-scheduler to call
frequently. Pass force=true to always run immediately regardless of the interval.
Query parameters
| Parameter | Type | Description |
|---|---|---|
force | boolean | Bypass the interval gate, default false |
Response 200
{ "started_at": "2026-08-01T00:00:00Z", "duration_ms": 42, "counts": { "findings_purged": 3, "resources_redacted": 1 }, "error": null, "skipped": false}counts is keyed by sweep step, not a fixed pair of fields. The retention sweep itself is
generic — it times the run, contains partial failures and persists the result — while the work
it performs is registered by whichever modules the deployment has installed. A standard
OpenTremor install registers findings_purged and resources_redacted; treat the keys as
open-ended and render whatever you receive. {} means nothing ran (a never-run sweep, or a
deployment with no steps registered).
error is non-null if the sweep failed partway through (e.g. the findings purge succeeded but
the resource redaction threw) — counts still reflects whatever completed before the failure.
GET /admin/retention/status
Section titled “GET /admin/retention/status”The last real sweep’s result — same shape as POST /admin/retention/run’s response, or null
if a sweep has never run on this instance.
| Status | Description |
|---|---|
200 | Sweep result, or null |
POST /admin/jobs/sweep
Section titled “POST /admin/jobs/sweep”Runs a stuck-job sweep — marks every job still queued/running with no progress update in over
jobs.stuck_timeout_minutes as failed, across every org. See Platform Admin — Running a
stuck-job sweep manually for the full
walkthrough. Self-gating by default, same shape as POST /admin/retention/run: a call before
jobs.sweep_interval_hours has elapsed since the last real run returns that previous result with
skipped: true instead of redoing work. Pass force=true to always run immediately regardless of
the interval.
Query parameters
| Parameter | Type | Description |
|---|---|---|
force | boolean | Bypass the interval gate, default false |
Response 200
{ "started_at": "2026-08-03T00:00:00Z", "duration_ms": 3, "jobs_marked_failed": 2, "error": null, "skipped": false}A job this marks failed can be resumed via POST /jobs/{job_id}/retry.
GET /admin/jobs/sweep-status
Section titled “GET /admin/jobs/sweep-status”The last real sweep’s result — same shape as POST /admin/jobs/sweep’s response, or null if a
sweep has never run on this instance.
| Status | Description |
|---|---|
200 | Sweep result, or null |
POST /admin/organizations/purge-pending
Section titled “POST /admin/organizations/purge-pending”Hard-delete every organization whose deletion grace period has elapsed — cascades through every
org-scoped collection (resources, findings, custom rules, teams, API keys, and more).
audit_events/usage_events for a purged org are deliberately preserved (orphaned by org_id);
users documents are never deleted, only the membership row for that org. Irreversible.
A no-op, safe to call as often as needed, when nothing is due — no self-gating the way
POST /admin/retention/run has, since an empty match is already cheap. Normally driven by
OpenTremor-task-scheduler’s heartbeat. One organization’s failure
doesn’t abort the batch — see errors below.
Response 200
{ "purged": [{"org_id": "7c3c6e2e-...", "name": "Acme Corp"}], "errors": []}| Status | Description |
|---|---|
200 | Purge run — check purged/errors for the outcome, this always 200s |
Platform admin — cross-tenant custom rules
Section titled “Platform admin — cross-tenant custom rules”Mirrors Custom rules’ org-scoped CRUD one-for-one, minus the same-org check (a superadmin isn’t bound to one org) and with org_id taken explicitly in the path rather than off the caller’s own session. Carved out as a deliberate, narrow exception to this router’s otherwise read-only cross-tenant stance — a platform operator can audit and correct another org’s ruleset (e.g. a mis-configured or abusive custom rule) without switching credentials into that org. Every other org-scoped resource (members, keys, analyzers, LLM credentials, …) still requires switching into that org.
GET /admin/custom-rules
Section titled “GET /admin/custom-rules”Custom rules across every organization. Omit org_id to see every org’s rules; pass it to scope the view to one org (still cross-tenant — no membership required).
| Parameter | Type | Description |
|---|---|---|
org_id | string | Scope to one org |
analyzer | string | Only rules targeting this analyzer |
category | string | Exact category_id |
rule_type | string | Exact rule variant |
enabled | bool | Filter by enabled state |
Response 200 — {"rules": [...]}, each shaped like POST /orgs/{org_id}/rules’s response.
POST /admin/custom-rules/{org_id}
Section titled “POST /admin/custom-rules/{org_id}”Create a custom rule for a given organization. Same request/response shape and validation as POST /orgs/{org_id}/rules — the cross-tenant equivalent, for correcting or seeding another org’s ruleset without switching credentials into it.
| Status | Description |
|---|---|
201 | Created |
404 | Organization not found, or an analyzers entry isn’t registered |
422 | category doesn’t resolve to a category that org owns |
GET /admin/custom-rules/{org_id}/{rule_id}
Section titled “GET /admin/custom-rules/{org_id}/{rule_id}”Get one organization’s rule. 404 if not found.
PATCH /admin/custom-rules/{org_id}/{rule_id}
Section titled “PATCH /admin/custom-rules/{org_id}/{rule_id}”Partial update — same semantics as PATCH /orgs/{org_id}/rules/{rule_id}.
DELETE /admin/custom-rules/{org_id}/{rule_id}
Section titled “DELETE /admin/custom-rules/{org_id}/{rule_id}”Delete one organization’s rule. 404 if not found.