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/http-server.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 same goes for the proxy's /api/internal/* routes, called by the endpoint only: through them a caller reads and writes the workflow memory of any subject and any workflow. The authenticating reverse proxy in front of the console should refuse that prefix. 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) holds no secret value: a connection's URL and parameters sit in the manifest, its secret fields are written env:NAME and a literal secret makes the connection unusable (instanceAuthIssues, libs/shared/src/plugin/connections.ts).

The manifest/environment split

Anything that is sensitive — the secret of a connection declared in the manifest, 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: environment variables, which every deployment target (compose, Kubernetes, a cloud secret manager projected into env) already knows how to inject.

Connections created in the console are the exception: their secrets are credentials of the store, encrypted at rest with the MERIDIAN_SECRET_KEYS key ring (AES-256-GCM, each secret bound to its credential id). The endpoint, which does not open the database, receives them sealed from the proxy and opens them with the same key ring; a secret is never served in clear on the internal route, and a console connection cannot reference an environment variable — otherwise anyone with console access could send any variable of the process to a URL of their choice. See Manage connections and Manage credentials. An external KMS is not supported yet.

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 go through a posos-api connection with the scheme ambient: the host adds nothing to the request, and the plugin's client resolves the process identity through GOOGLE_APPLICATION_CREDENTIALS / ADC (external-plugins/posos/lib/client.ts), again honoring 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.

A plugin that uses declared events under a range below ^0.1.1 is not skipped: it loads, with the warning « plage sdk insuffisante », because this host understands those manifest keys. A host older than contract 0.1.1 refuses a plugin that declares ^0.1.1 instead of loading it without its events.

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

The contract of a declared event follows the same rule: the messages built when a value does not match its declared type name the field and the expected type, never the value received (libs/shared/src/engine/event-contract.ts). Contracts are an input-validation control only under EVENT_CONTRACTS=enforce, which refuses an event of a declared type that does not conform to its contract. Under observe, the default, such an event is accepted and its issues are recorded; the warning log carries their number, never the event's content. In every mode, undeclared fields and events of types without a contract pass through as received. See Enable observability and Observability signals for what does get logged and traced.

What the reception keeps

The inbound reception keeps what it receives, as received, in the inbound_messages table of the application database (DEFINITION_STORE_URL): HL7 v2 and PN13 messages, and the records polled by event sources (FHIR resources for fhir-poll). That table holds health data and nothing purges it. Restrict access to the application database like any store of patient data, and include the table in the deployment's backup and retention policy. See Back up and restore and Connect an event source.

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

97 documents13 sectionssource : /docs · généré au build