Documentation / Exploitation / Explications / Security posture

Security posture

Meridian's security model today rests on three deliberate choices rather than one unified subsystem: the platform delegates end-user authentication to whatever sits in front of it, it refuses to let secrets enter version control by construction, and it treats plugins as trusted code rather than sandboxed extensions. None of this is accidental gap-filling — each choice trades a capability the platform could build for one the surrounding deployment (hospital network, reverse proxy, IAM) almost certainly already has. This page explains that reasoning and its consequences. For the mechanics of actually wiring ports, secrets, or plugins, follow the how-to links inline.

No built-in authentication

The proxy — the platform's one HTTP surface for events, runs, records, the catalog, and spec persistence — has no authentication layer today. It is a plain Node http server (apps/api/src/server/api.ts) with no session, token, or API-key check anywhere in the request path, and no CORS policy of its own. The console (ui) is a stateless Next.js app with the same absence of built-in login. This is not a bug that slipped through review; the platform simply has not built this layer yet, and the roadmap tracks it as future work.

Why the reverse-proxy model, for now

Meridian is designed to run inside a hospital's or integrator's internal network, where an authenticating reverse proxy (OIDC, mTLS, or both) is usually already the entry point for every internal tool — the hospital's own identity provider, session model, and audit trail. Building a bespoke authentication layer into the platform would duplicate that infrastructure, and duplicated auth is a classic source of divergence: two places that can disagree about who is logged in, two places to patch when a vulnerability surfaces. Deferring to the perimeter is the more conservative choice while the platform doesn't yet have a first-party answer, rather than shipping a partial one that creates a false sense of coverage.

The consequence is operational, not optional: never expose the proxy or the UI directly to the internet. Put both behind a reverse proxy that authenticates the caller, on an internal network, and terminate TLS there — the stack talks plain HTTP internally, which is only safe inside that perimeter.

What must stay unpublished

Some ports are not meant to be reached by end users at all, only by the stack's own components:

  • Restate's ingress (8080) and admin API (9070) — the control plane that every event and run passes through.
  • The endpoint (9080) — the engine process itself; the proxy never calls it directly, only through the Restate ingress (RESTATE_URL).

None of these three should be published on a network reachable by anything other than the other stack components. The full port and process inventory, including which ones compose actually publishes versus keeps internal, is in Topology; adapter-specific exposure (e.g. a FHIR warehouse's own auth) is covered in Connect a FHIR warehouse and Configure ports and adapters.

Secrets never enter version control

Instance manifests are meant to live in git — that's what makes a deployment's port wiring, plugin list, and terminology routing reviewable and diffable over time. Which is exactly why they must never carry a secret: a credential committed once stays in history forever, reviewable or not. The manifest schema (meridian/instance-v1) has no secret fields at all — adapter config blocks hold things like a FHIR baseUrl or a webhook URL, never a bearer token or a key.

The manifest/environment split

Anything that is sensitive — authenticated FHIR access, LLM provider keys, registry credentials — is read from the process environment instead, never from the manifest. apps/api/src/env.ts loads a repo-root .env file for convenience in a checkout, but an explicit shell export always wins over a value from that file, and in a container or Kubernetes deployment you inject variables through the service environment (compose env_file/environment, a Kubernetes Secret) rather than shipping a .env at all. See Environment variables for the full list of what each process reads, and Configure ports and adapters for why an adapter's auth header belongs in the environment rather than in config.

This split is deliberate rather than incidental: it keeps the manifest as a single, reviewable statement of what talks to what, while secrets live wherever your platform's own secret story already is. Meridian does not invent its own secret store or vault integration — it reads environment variables, which every deployment target (compose, Kubernetes, a cloud secret manager projected into env) already knows how to inject.

Where credentials actually resolve

Two integrations lean on Google's credential chain rather than a raw key baked in anywhere:

  • The plugin registry, when backed by GCS: the store requests an access token through google-auth-library's GoogleAuth client (libs/plugin-cli/src/registry/store.ts), which transparently resolves Application Default Credentials — a mounted service-account key file, or workload identity federation with no key file on disk at all. The IAM role granted to that identity (roles/storage.objectViewer for the instance reading plugins, roles/storage.objectCreator for authors publishing them) is what actually bounds access, not the scope the client happens to request. See Set up a plugin registry.
  • The Posos plugin's IAP-authenticated calls resolve the same way through GOOGLE_APPLICATION_CREDENTIALS (external-plugins/posos/lib/client.ts), again honoring ADC/workload identity rather than requiring a literal key in an environment variable.

Neither of these is Meridian-specific machinery — they are the standard Google credential resolution order, which is exactly the point: the platform doesn't re-invent credential handling, it defers to what the surrounding cloud identity already provides.

Plugins are trusted code, not sandboxed extensions

A plugin contributes behaviors, adapters, and agents as plain JavaScript modules, and the host loads them with a dynamic import() (apps/api/src/plugins.ts, applyManifest) — the same as any other module in the process. There is no VM, no worker isolation, no capability restriction: a loaded plugin runs with the full privileges of the engine process, including whatever network access, filesystem access, and environment variables that process has. "Install a plugin" and "give it the same trust level as first-party code" are the same action today.

The SDK-range check is a compatibility gate, not a security gate

At boot, each plugin's sdk: range is checked against the running PLUGIN_CONTRACT_VERSION with the npm semver package's satisfies (apps/api/src/plugins.ts). A mismatch is a soft failure — the plugin is logged and skipped, the rest of the host still boots — exactly like a peerDependencies check in any Node package. It tells you a plugin is compatible with this SDK's shape; it says nothing about whether the plugin's code is trustworthy. Don't read a clean SDK check as a security signal.

Registry immutability and IAM bound who publishes, not what

The registry (libs/plugin-cli/src/registry/registry.ts, store.ts) enforces that a published version is immutable — a put uses ifGenerationMatch: 0 (GCS) or an equivalent create-if-absent check (directory backend), so republishing an existing version is refused outright, you bump instead. That guarantees a version doesn't change under you after you've declared it. It does not verify a checksum or a signature of the artifact itself: nothing in the download path (apps/api/src/registry.ts, ensurePlugin) checks that a downloaded dist/ is byte-for-byte what a particular author intended, only that whatever is there won't silently change later. Today's actual control is therefore entirely procedural — grant roles/storage.objectCreator (or write access to a directory registry) only to authors you trust, and only declare plugins from a registry you control — not cryptographic.

This is a known, tracked gap rather than an oversight. The packaging plan already earmarks a signed-bundle distribution channel (OCI-based, with capabilities) as the enterprise-grade target, and calls out the plugin loader's dynamic import() as precisely the seam where a sandboxed execution tier (WASM or an isolated worker) would later replace trusting the loaded JavaScript outright — without changing how a plugin is declared or discovered. See Plugin host loading, Bundling and distribution, Autonomy and packaging, and the ADR on packaging and SDK externalization for where that trajectory is heading. Until it lands, Declare and install plugins and Set up a plugin registry are the levers you actually have: control who can publish, and control which registry an instance is pointed at.

What never reaches the logs

Structured logs (pino, apps/api/src/observability/logger.ts) are shaped around one hard rule: clinical events are logged only by type and id, never by payload — a patient's data simply never becomes a log argument in the first place, which is a stronger guarantee than any redaction rule could give after the fact. On top of that, a redaction list censors the usual secret field names (authorization, apiKey, api_key, password, token, req.headers.authorization, config.apiKey) as defense in depth, in case one ever ended up in a log call by mistake. Treat the redaction list as a backstop, not the primary control — the primary control is that patient payloads and secrets aren't passed to logger.* calls to begin with. See Enable observability and Observability signals for what does get logged and traced.

What's deliberately deferred, and why

Pulling the threads above together, three things are consciously not built yet, each with a stated reason rather than silence:

  • Built-in authentication — deferred to the reverse-proxy/gateway that a deployment almost always already runs, to avoid duplicating an identity system the surrounding network already provides.
  • Plugin signing / provenance verification — deferred behind a procedural control (registry IAM, immutability) while the signed-OCI distribution channel is designed; today's guarantee is "the version you declared won't change under you," not "this code was written by who it claims to be."
  • Plugin execution sandboxing — deferred behind the fact that a plugin author today is effectively a co-maintainer of the engine process; the packaging plan's dynamic-import() seam is where a WASM/worker tier would attach later without changing the plugin contract.

Each of these is a trade against building the platform's own version of something the operator's environment can often already supply, made visible here rather than left implicit. Revisit this page against the roadmap before treating any of the three as permanently out of scope.

Where to go next

75 documents12 sectionssource : /docs · généré au build