Skip to content

The Platform Base

opentremor-platform is the multi-tenant half of OpenTremor, split out as its own package and repository. It provides users, organizations, memberships, local/SSO/SCIM authentication, teams, invitations, service-account API keys, platform admin (organizations, users, the audit log, storage management, live-editable settings), pricing/billing/metering, telemetry and the audit trail — and no opinion about what your product does.

opentremor-core is the reference consumer: the security-analysis engine, mounted on top.

Core used to bundle two unrelated things: multi-tenant SaaS plumbing, and infrastructure-as-code security analysis. Only the second is specific to OpenTremor. Splitting them means a future project can depend on the first and get “multi-tenant SaaS with users, orgs, SSO, billing and an admin panel” without inheriting an analysis engine it has no use for.

The split is real, not notional: the platform’s test suite runs in an environment where opentremor-core is not installed at all.

from opentremor_platform.controllers.app import create_platform_app
app = create_platform_app() # a complete, runnable multi-tenant API

Run it directly:

Terminal window
CONFIG_FILE=configs/opentremor-platform-local.yaml \
uvicorn opentremor_platform.controllers.app:app --reload

Two example configs ship with the repo: configs/opentremor-platform-local.yaml (in-memory, for development) and configs/opentremor-platform.yaml (MongoDB, with every platform collection named).

Only storage, cors, auth, jwt, two_factor, llm.credential_encryption_key and deployment are read from the file — everything else is managed live through GET/PATCH /admin/settings and reset to its hardcoded default at boot, so a value for those sections in the file is silently discarded. That is deliberate: it means two identically-unconfigured instances can never disagree about what a setting is. See Configuration reference for the full rationale — it applies identically to a platform-only deployment.

The platform’s own config model is PlatformConfig; a host that mounts a product widens it (see Sections that span both halves) and passes its own model to create_platform_app(config=...).

create_platform_app() returns a FastAPI application you add your own routers to. Everything your domain contributes goes through a registration point, so you never edit the platform:

What your product contributesWhere it registers
Admin-editable config sectionscontrollers/services/settings_registry.py
Retention/cleanup sweep stepscontrollers/services/sweep_registry.py
Rows to delete when an organization is purgedcontrollers/services/org_purge_registry.py
MongoDB index declarationscontrollers/storage/index_registry.py
“a new organization was created” seedingcontrollers/shared/org_bootstrap.py
One-time startup seedingcontrollers/shared/startup.py
A storage backend spanning both halvescontrollers/storage/mongo_admin.configure_storage_classes()

This mirrors the analyzer plugin system, which has always worked this way — the base defines an interface, installed packages register against it.

Three config sections legitimately belong to both: storage.mongodb (collection names), server (public URLs vs. ingest limits) and retention (telemetry/audit TTLs vs. the product’s own purge windows). Each is declared by the platform at its own narrower type and widened by the host, so a combined deployment still sees one flat config at the same attribute paths.

Keeping retention whole is deliberate: it means one sweep, with one state document, one endpoint and one task-scheduler entry, rather than two of each.

llm is a platform section despite the name, and it is the base only: llm.enabled, and llm.credential_encryption_key — the Fernet key this package encrypts SSO connection client secrets under, and that a mounted product typically reuses for its own per-org provider credentials.

What stays with the product is everything that isn’t generic: the provider clients, which force a response schema the product defines. This split is why a platform-only deployment can use SSO at all — before it, routers/sso.py reached for a config section only opentremor-core declared.

llm.enabled says whether this deployment offers LLM-backed features. Enforce it with the require_llm_enabled dependency from controllers/dependencies.py on any route that cannot work without a provider call; it answers 501 Not Implemented, since no credential or permission would make the capability appear.

It is admin-editable (PATCH /admin/settings {"llm_enabled": false}), taking effect on the next request with no restart — so, like every admin-editable field, a value for it in the config file is discarded at boot. Its sibling credential_encryption_key is not editable and is never returned by the API.

That is the section’s one unusual property, and the registry supports it directly: register() takes the model declaring a domain’s editable fields, so registering LLMToggleConfig rather than LLMConfig leaves the key unregistered. An unregistered field is absent from the PATCH body model, absent from effective, and left at its file/env value by with_hardcoded_platform_defaults() — which is what stops a boot from replacing an operator’s key with a fresh random one. See SettingsRegistry.editable_fields().

PlatformInMemoryStorage (dev/tests) and PlatformMongoBackend (Motor) both implement PlatformStorageBackend in full, so a platform-only deployment gets working persistence rather than an interface it has to implement itself. PlatformMongoBackend.setup() creates exactly the platform’s own indexes.

A host that composes both halves builds its backend from PlatformMongoBackend plus its own mixin, and registers it so live storage switching hands the running app a backend that still has every method the product calls.

GNU AGPL v3 or later, with the OpenTremor Analyzer Plugin Exception — the same as the rest of the OpenTremor server. See LICENSE and LICENSE-EXCEPTION in the repo root.