Skip to content

Endpoints: 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.

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.

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"
}
}

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.

StatusDescription
200Member list
404Organization 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.

StatusDescription
200Membership updated
400Targets the caller’s own membership
404Organization or membership not found
409Would demote/deactivate the org’s only remaining owner
422role 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}.

StatusDescription
204Member removed
400Targets the caller’s own membership
404Organization or membership not found
409Target 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.

StatusDescription
200Password reset — temporary_password shown once
404Organization or membership not found
400Target 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"}
StatusDescription
2002FA reset
404Organization or membership not found

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}
]
}

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.

ParameterTypeDefaultDescription
daysint7Aggregation 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
}
]
}

Audit events for one organization — same filters and response shape as GET /metrics/audit, reachable cross-tenant. 404 if org_id doesn’t exist.

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.

ParameterTypeDefaultDescription
org_idstringRestrict to one org; omit for all orgs
action, actor_id, target_type, since, untilSame filters as GET /metrics/audit
limitint100Number of results (1–1000)
offsetint0Pagination offset

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.

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}

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.

StatusDescription
200Account updated
404user_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..."}
StatusDescription
200Password reset — temporary_password shown once
404user_id doesn’t exist
400Target account authenticates via SSO

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"}
StatusDescription
2002FA reset
404user_id doesn’t exist

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.

TierAccessBilling
freeEvery analyzer, until the org’s 100-analysis lifetime demo quota runs out — then 402Never billed
paidEvery analyzer, unlimitedEvery resource, from the first, at the degressive rate
enterpriseEvery analyzer, unlimitedOnly 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}}
StatusDescription
200Plan updated (audited as org.plan_update, with before/after)
404Organization not found
422contract sent with a non-enterprise plan_tier

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}
StatusDescription
200Lock state updated
404Organization 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}
StatusDescription
200Policy updated
404Organization not found

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.

StatusDescription
200Deletion scheduled
404Organization not found
409Already 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}
StatusDescription
200Deletion cancelled
404Organization not found
409Not currently scheduled for deletion

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
}

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.

StatusDescription
200Settings updated
422Unknown field, or a value outside its valid range

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.

StatusDescription
200Target database prepared
502Couldn’t connect to the target MongoDB, or the app-user creation step failed

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.

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"}
StatusDescription
200Storage backend switched
422backend: "mongodb" without uri/database
502Couldn’t connect to the target MongoDB

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

ParameterTypeDescription
forcebooleanBypass 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.

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.

StatusDescription
200Sweep result, or null

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

ParameterTypeDescription
forcebooleanBypass 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.

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.

StatusDescription
200Sweep result, or null

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": []
}
StatusDescription
200Purge 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.

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).

ParameterTypeDescription
org_idstringScope to one org
analyzerstringOnly rules targeting this analyzer
categorystringExact category_id
rule_typestringExact rule variant
enabledboolFilter by enabled state

Response 200{"rules": [...]}, each shaped like POST /orgs/{org_id}/rules’s response.


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.

StatusDescription
201Created
404Organization not found, or an analyzers entry isn’t registered
422category 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.