Two gaps stood between the module UI contract and a host that could use
it. ADR 0023 recorded the first honestly but never diagnosed it: a module
UI file compiled with `"types": []` could still typecheck `process.env`.
The second was simply missing — nothing imported a compiled artifact,
checked its identity, mounted it, or tore it down.
The leak was one type-only import, bisected against the real type graph
rather than guessed. `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing
contracts/identity alone is clean and importing Zod alone is clean, but
contracts exposes a single barrel entry, so that one import reached
module-database, whose Drizzle types re-introduce Node's globals. The
runtime was never affected — the import was erased and host-context.js is
`export {}` — but a type-graph leak is still a real boundary failure,
because it silently permits code that cannot run in its target
environment.
The fix is not a narrower import path. The SDK now has no dependencies at
all, so there is no graph to leak through and no future contracts change
can reopen this. It owns `ModuleUiPrincipal`: the same shape ADR 0022
chose, with the Zod brand dropped. That brand encodes a server-side
parsing guarantee a browser cannot produce, and it is the same reason
`permissions.has` already took a plain string. An Ekklesia host still
passes its own `EkklesiaPrincipal` straight through — a branded string is
assignable to `string` — while a standalone host builds the literal.
Proven the way an author experiences it: two fixtures compiled under the
exact project a module uses, one supported and one deliberately reaching
for `process`, with the second required to fail on that specific name.
`@ekklesia/module-ui-host` is the generic loader, in its own package
because the SDK is bundled into every module artifact and host-only
loader code has no business shipping there. The boundary checker enforces
both directions: modules may not depend on the loader, and the loader may
depend only on the SDK plus dev-time tooling.
The distinction that shaped the design: Core resolves `entrypoints.ui` to
an absolute path on the server's disk, and a browser needs a URL. A
loader that accepted the path would test green on a server and fail in
every real host, and would quietly own asset publication. So the loader
takes a browser-loadable URL and knows nothing about how one is obtained.
Ekklesia has no route that publishes installed artifacts, so this is
precisely: loader complete, publication deferred. Nothing can mount an
installed module UI yet, and the docs say so rather than implying
otherwise.
Dynamic import is `(url) => import(url)` with a non-analyzable specifier,
kept as a runtime import so a host does not statically bundle every
installed module or need a hardcoded registry. It is injectable for
bundlers needing their own escape hatch, and deliberately not `eval` or
`new Function`, which would cost a host `unsafe-eval` in its CSP.
Identity is validated through the SDK's existing parser before `mount`
runs, so a stale or mismatched bundle never renders. Contract violations
propagate unwrapped; the loader adds only import and mount failure codes,
sanitized, with the cause attached. Unmount is idempotent, safe when a
module returned no cleanup, and marks an instance unmounted before
running cleanup so a throwing cleanup surfaces once and never re-runs. A
failed mount registers no cleanup. Each instance owns its own state, so
two mounts coexist. The loader never mutates the host's container — not
on success, failure, or unmount.
Tests prove all of it against real module URLs (`data:`, which both
browsers and Node import natively) rather than filesystem paths,
including one artifact built by the canonical Pass 2 pipeline and run
under both an Ekklesia-shaped and a standalone-shaped context. Core,
apps/api, and the contracts package are untouched; discovery remains
inert.
Co-Authored-By: Claude Opus 5 <[email protected]>
285 lines
17 KiB
Markdown
285 lines
17 KiB
Markdown
# Security model and deployment trust boundaries
|
||
|
||
The one canonical statement of what Ekklesia's boundaries actually protect against today, and what
|
||
they do not. Read it before installing a module, exposing the API, or reasoning about any other
|
||
document's guarantees.
|
||
|
||
Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md)
|
||
(compiled module runtime), [ADR 0016](../architecture/decisions/0016-module-database-access.md)
|
||
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
|
||
(module routing/lifecycle and migration execution), and
|
||
[Module development](../module-development/README.md).
|
||
|
||
## Installed modules are trusted compiled backend code
|
||
|
||
**Installing a module is equivalent to deploying trusted server code.** It is not equivalent to
|
||
installing a browser extension, a plugin from a marketplace, or anything else with a security
|
||
boundary around it. There is no such boundary here.
|
||
|
||
A module's `dist/server` entrypoint is imported into, and runs arbitrary JavaScript inside, the
|
||
`apps/api` process. Within the operating-system account that process runs as, a module can:
|
||
|
||
- read and write any file the account can reach, including `.env`, TLS material, and the source tree;
|
||
- read `process.env`, `process.argv`, and any other process state;
|
||
- open outbound network connections and bind listeners;
|
||
- import any installed dependency, including ones Ekklesia never intended it to use;
|
||
- monkey-patch Core's own exports, Node built-ins, `globalThis`, or another module's objects;
|
||
- keep running work after its lifecycle hooks return.
|
||
|
||
It also **shares the API's single PostgreSQL role and connection pool**. `ModuleDatabase.execute`
|
||
accepts arbitrary SQL, so a module has exactly the database authority the API has — every table in
|
||
the database, including `ekklesia_core_user`, `ekklesia_core_grant`,
|
||
`ekklesia_core_platform_owner`, `ekklesia_core_platform_owner_bootstrap`,
|
||
`ekklesia_core_authorization_audit`, and every other module's tables.
|
||
|
||
## What the module-facing mechanisms actually are
|
||
|
||
These are **correctness and coordination boundaries**. Every one of them assumes the module is
|
||
cooperative. None of them constrains a module that is not:
|
||
|
||
| Mechanism | What it actually provides |
|
||
| --- | --- |
|
||
| `ekklesia_*` table prefixes and schema discovery | Ownership clarity and collision avoidance between cooperating modules |
|
||
| TypeScript types, branded ids, contracts | Compile-time correctness; erased at runtime |
|
||
| `ekklesia.module.json` manifest and `manifestVersion` | Declared compatibility and dependency ordering |
|
||
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
|
||
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
|
||
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
|
||
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
|
||
| Capability registry | Explicit inter-module wiring, not access control |
|
||
| Bounded stop/rollback deadlines (ADR 0020) | The platform stops *waiting*; module code is not cancelled or terminated |
|
||
|
||
**There is no process, container, VM, seccomp/syscall, filesystem, network, PostgreSQL role,
|
||
schema-permission, table-ACL, row-level-security, tenant, or resource sandbox between a module and
|
||
Core.** A module is not isolated from Core, from other modules, or from the host.
|
||
|
||
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) lets a module declare its
|
||
own UI entrypoint. **That changes nothing about the authority described above.** A module that ships
|
||
UI is exactly as trusted as one that does not: its server entrypoint still runs in the API process
|
||
with that authority, and declaring `entrypoints.ui` neither adds nor implies any isolation.
|
||
|
||
### Module UI is trusted browser code, in the host's own realm
|
||
|
||
[ADR 0022](../architecture/decisions/0022-module-ui-authoring-and-host-contract.md) decides the
|
||
browser-side model that ADR 0021 deliberately left open. Module UI runs **in the same browser
|
||
JavaScript realm as the host shell**: no iframe, no sandbox, no cross-origin isolation, no capability
|
||
confinement. A module's UI can reach the host page, the DOM, `window`, and any global the browser
|
||
exposes, exactly like any other script the application loads.
|
||
|
||
That is a deliberate choice for a first-party, trusted-module platform, and it is consistent rather
|
||
than lax: a module whose server code already runs in the API process with the API's full PostgreSQL
|
||
authority is not meaningfully constrained by sandboxing its browser half. Do not describe module UI
|
||
as sandboxed, isolated, or confined — none of those are true, and no mechanism in this repository
|
||
implements them.
|
||
|
||
The contract does leave room for a future sandboxed tier without a redesign: `mount(container,
|
||
context)` passes an element and a plain-data context rather than shared object graphs, so a host
|
||
could implement it against an isolated realm. Nothing does today.
|
||
|
||
### Loading a module UI executes trusted code in the host page
|
||
|
||
`@ekklesia/module-ui-host` dynamically imports a module's compiled artifact from a URL and mounts it
|
||
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)). Once imported,
|
||
that artifact is ordinary same-realm JavaScript: it can reach the DOM, `window`, and anything else
|
||
the page can. **The loader is not a sandbox.** It validates that the artifact is the module the host
|
||
expected — the right id, the right version, a well-formed default export — and refuses to mount
|
||
anything else, but that is an integrity check against a *stale or mismatched* bundle. It is not a
|
||
judgement about whether a module is safe, and it must never be described as one.
|
||
|
||
The publication side carries the real obligation, and it is not built yet. When Ekklesia gains a
|
||
route that serves installed module artifacts as browser URLs, **the existence of an artifact on disk
|
||
must not be what makes it servable.** Only installed, reviewed modules may be published or mounted
|
||
under this trust model. A module UI that mounts has gained no privilege it did not already have —
|
||
its server half already runs in the API process with the API's database authority — and server APIs
|
||
remain the sole authority for authorization regardless of what a browser loaded.
|
||
|
||
### Client-side permission checks are not a security boundary
|
||
|
||
A host may tell a module UI what permissions it believes the viewer holds
|
||
(`ModuleUiPermissions.has`), so the UI can hide an action or disable a control. **This is
|
||
presentation only.** Server-side authorization remains the sole authority: every module route is
|
||
enforced by `requirePermission` against the composed platform permission registry, and that
|
||
enforcement neither knows nor cares what a browser decided. Hiding a control is a usability
|
||
improvement, never an access control. Treat any claim that a UI check protects anything as a bug.
|
||
|
||
## What this platform must not claim
|
||
|
||
Ekklesia today has **none** of the following, and no document, README, or marketing surface may
|
||
imply otherwise:
|
||
|
||
- module signing, provenance attestation, or verified publishers;
|
||
- a marketplace, registry, or review process for third-party modules;
|
||
- sandboxed or hot-installed modules;
|
||
- per-module database roles, schemas, or credentials;
|
||
- tenant isolation;
|
||
- runtime resource limits (CPU, memory, file descriptors, connections) per module.
|
||
|
||
## Operator rule
|
||
|
||
**Only install modules whose source, provenance, and build output you have reviewed and whose
|
||
authors you trust with your production server and database.** Treat a module the way you would
|
||
treat a direct commit to `apps/api`: read the code, verify what artifact was built from it, pin it,
|
||
and re-review on upgrade. Do not install a module because a manifest looks well-formed — every
|
||
validation Ekklesia performs on a manifest is satisfied trivially by hostile code.
|
||
|
||
Installing, removing, or editing a module requires an API restart; there is no hot install path,
|
||
and none should be added while this model holds.
|
||
|
||
## What would have to change before accepting untrusted modules
|
||
|
||
Accepting third-party modules that have not been reviewed is an architectural change, not a
|
||
configuration change. At minimum it would require:
|
||
|
||
- process or container isolation per module, with the API as a supervisor;
|
||
- capability-based RPC across that boundary instead of in-process object passing;
|
||
- a separate PostgreSQL role and schema per module, with no shared superuser-equivalent pool;
|
||
- restricted filesystem and network access per module;
|
||
- CPU/memory/connection limits and enforced timeouts that can actually terminate work;
|
||
- signed, verifiable module provenance and a defined review/revocation process.
|
||
|
||
Until that exists, the trusted-code model above is the security model.
|
||
|
||
## Current deployment state
|
||
|
||
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
|
||
proxy, no public listener, and no deployment automation of any kind in this repository — no deploy
|
||
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever
|
||
been committed; only `.env.example`.
|
||
|
||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||
|
||
- `apps/api` defaults its listen address to loopback (`127.0.0.1`), so it is not reachable off-host
|
||
unless an operator explicitly changes it;
|
||
- production configuration refuses to enable public signup at all
|
||
(`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`).
|
||
|
||
A loopback default is not a firewall, and a config default is not a verified boundary.
|
||
|
||
### Launch checklist — each item must be proven against the real deployment before public exposure
|
||
|
||
Until every one of these is verified on the actual hosts, the deployment is not launch-ready. None
|
||
of them can be satisfied by reading this repository.
|
||
|
||
This checklist gates a **public launch**. It does not gate development: Surfaces, modules, and Core
|
||
work all proceed against the local development infrastructure while every item below is still
|
||
outstanding.
|
||
|
||
1. The reverse-proxy path serves the API successfully.
|
||
2. The origin port is unreachable from the public Internet.
|
||
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
|
||
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
||
4. The proxy **overwrites** (never appends to, never passes through) the client-IP header Ekklesia
|
||
is configured to trust.
|
||
5. Rotating a caller-supplied `Forwarded`, `X-Forwarded-For`, `CF-Connecting-IP`, `X-Real-IP`, or
|
||
the platform's own internal sanitized header cannot rotate the authentication rate-limit bucket.
|
||
6. The actual immediate proxy peer is in Ekklesia's explicit trusted-proxy set, and an untrusted
|
||
direct peer's forwarded header is ignored.
|
||
7. Proxy logs do not record cookies, authorization values, request bodies, or raw forwarding chains.
|
||
8. PostgreSQL is reachable only from the API host, over TLS, with the certificate and hostname
|
||
verification Ekklesia already requires.
|
||
|
||
### Rate limiting is per-process today
|
||
|
||
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
||
only while **both** of the following hold, which is the current state:
|
||
|
||
- production public signup is disabled (and cannot be enabled — see above); and
|
||
- exactly one API instance serves traffic.
|
||
|
||
If either changes — public signup is enabled, or a second instance is added — a durable shared or
|
||
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
|
||
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.
|
||
Ekklesia does not ship one. Do not describe the current limiter as distributed throttling.
|
||
|
||
## Continuous integration boundary
|
||
|
||
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that
|
||
version determines which parts of the workflow are real guarantees and which are not.
|
||
|
||
### The current operating rule: trusted code only
|
||
|
||
**This CI installation is a trusted-code-only boundary.** Only code controlled and reviewed by
|
||
trusted repository collaborators may execute on the self-hosted runner. An unknown or untrusted pull
|
||
request must **not** execute on it automatically — a maintainer reviews the diff, including any
|
||
change to the workflow itself, before it runs.
|
||
|
||
This is an operating rule, not an enforced technical control, and it is the reason the rest of this
|
||
section is safe today. Everything below describes what the repository can and cannot prove about
|
||
that runner; none of it is a substitute for the rule.
|
||
|
||
### What is true of the repository today
|
||
|
||
These are properties of files in this repository, enforced by the action or the package manager,
|
||
independent of server version:
|
||
|
||
- every action reference is pinned by a full immutable commit SHA;
|
||
- dependencies install with `pnpm install --frozen-lockfile`;
|
||
- there is no `pull_request_target` workflow;
|
||
- no repository, organization, or instance secret is referenced anywhere in the workflow;
|
||
- every checkout step sets `persist-credentials: false`;
|
||
- the workflow declares `permissions: contents: read`.
|
||
|
||
### What `persist-credentials: false` actually proves
|
||
|
||
It proves exactly one thing: `actions/checkout` does not write its credential into `.git/config`,
|
||
so a later step in the same job cannot read or reuse it from there.
|
||
|
||
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
||
reduce that token's server-side authority. A step that wants the token can still read it.
|
||
|
||
### What Gitea 1.24 ignores — so the branch cannot claim it
|
||
|
||
Per the official comparison for this version
|
||
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
|
||
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
|
||
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
|
||
|
||
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
|
||
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
|
||
1.24. Job-token permission controls, repository/owner defaults, and server-side
|
||
maximum-permission clamping arrive in the 1.26 feature line
|
||
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
|
||
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
|
||
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
|
||
|
||
The declarations are kept deliberately: they are already correct for a supported Gitea release and
|
||
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
|
||
are documented as ignored, not removed.
|
||
|
||
### Runner topology is not verified
|
||
|
||
**The runner topology is not verified in this repository and is not documented here as though it
|
||
were.** A pull request can edit `runs-on` and select any runner label its repository can reach, so a
|
||
workflow file cannot be the boundary. Whether untrusted pull-request jobs execute on a disposable
|
||
guest, and whether any runner reachable from this repository holds deployment authority or a host
|
||
Docker socket, must be established against the actual Gitea instance and runner hosts. Until that
|
||
evidence exists, treat pull-request CI as executing with whatever authority the runner host actually
|
||
grants.
|
||
|
||
### Requirements before executing untrusted contributions
|
||
|
||
These five items are the prerequisites for **relaxing the trusted-code-only rule above** — that is,
|
||
for letting code this project has not reviewed execute on this Gitea installation. They are backlog
|
||
requirements for that specific future capability. **They do not block Phase 6 Surface work**, and
|
||
they are not conditions on merging trusted, reviewed work.
|
||
|
||
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
||
and runner hosts, and each must be proven there.
|
||
|
||
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the
|
||
1.26 feature line. Select the actual release during infrastructure work, after backup and
|
||
compatibility review; do not pin a version from this document.
|
||
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
||
code cannot request more than read-only repository access.
|
||
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
||
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
||
private repository.
|
||
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted
|
||
provisioner/reaper rather than relying on the ignored workflow keys above.
|
||
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
||
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
||
reusable key.
|
||
|
||
Moving future public contribution workflows to a service that provides isolated hosted runners — for
|
||
example GitHub — is an accepted alternative to satisfying items 1–5 on this installation.
|