Files
Ekklesia/docs/development/security.md
T
ekklesiaandClaude Opus 5 44ba259f58
CI / verify (pull_request) Successful in 2m31s
CI / infrastructure-smoke (pull_request) Successful in 15s
CI / migration-smoke (pull_request) Successful in 49s
feat: establish the module product contract
Ekklesia's module model has been backend-only since ADR 0001: a module
owns domain behavior and data, and every presentation concern belongs to
a surface. That prevents a module author — the person who knows the
domain — from shipping the screen that makes their module useful, and it
guarantees a module and its own UI drift apart. It also pushes toward two
codebases for one domain the moment a module needs to run standalone.

ADR 0021 replaces that rule. A module is an independently versioned
product that owns its domain behavior, data, permissions, routes,
migrations, and its own UI, hostable inside Ekklesia and — from the same
implementation — by a later standalone host. Core keeps the containing
application shell and every platform policy: authentication,
authorization enforcement, platform context, top-level routing,
navigation, global theme, error boundaries, and trusted-origin/CORS. The
module graph stays flat: modules are peers, genuine module and capability
dependencies remain the only relationships, and Dashboard folders are
presentation configuration an administrator can reorganize, never module
architecture.

This pass implements the static contract only. `manifestVersion` moves to
4 and `entrypoints` gains one optional field, `ui`. Version 3's
`entrypoints` is strict and rejects `ui`, so this is an incompatible
shape change and version 3 is now rejected outright rather than
reinterpreted, exactly as version 1 was by version 2 and version 2 by
version 3. Every field version 4 adds is optional, so upgrading a
backend-only manifest is a one-character edit.

Discovery resolves and validates `ui` under the same rules as `server`
and `schema` — package-relative, inside the package before and after
symlink resolution, a real file, compiled ESM `.js`/`.mjs`, so source
TypeScript is rejected rather than assumed to be compiled later.
Discovery stays inert: nothing imports, evaluates, mounts, or renders a
UI entrypoint, and a test proves a throwing artifact still discovers
successfully. This follows `entrypoints.jobs`, declared and resolved
since ADR 0006 with no runtime consumer.

The name is `ui`, not `dashboard`, because a module's UI must be hostable
by Ekklesia's shell and by a standalone host, so the contract cannot be
named after one of them; and not `surface`, because `surfaces/*` is a
separate ownership layer. No widget, route, icon, folder, menu, layout,
or standalone-metadata field is added, and tests assert the manifest
still rejects every unknown entrypoint key and offers no hierarchy field.

Deliberately unchanged: `scripts/check-boundaries.ts` still enforces
`modules-do-not-own-ui`. Relaxing that guard depends entirely on the UI
authoring model a later pass chooses, and weakening a working protection
before that decision exists would trade real coverage for nothing. A
module may therefore declare `ui` today only for a framework-neutral
compiled artifact, and the docs say so rather than implying otherwise.
Restart-based discovery, explicit operator-invoked migrations, and the
trusted compiled-module model are untouched — a module that ships UI is
exactly as trusted as one that does not.

No Dashboard shell, UI runtime, renderer, framework choice, navigation
registry, grouping system, standalone host, installer, hot-loading, or
feature module is added by this pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 20:20:41 -05:00

14 KiB
Raw Blame History

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 (compiled module runtime), ADR 0016 (module database access), ADR 0020 (module routing/lifecycle and migration execution), and Module development.

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 lets a module declare its own UI entrypoint. That changes nothing on this page. A module that ships UI is exactly as trusted as one that does not: its server entrypoint still runs in the API process with the authority described above, and declaring entrypoints.ui neither adds nor implies any isolation. No host loads a UI entrypoint yet; when one does, the trust model for whatever it executes must be decided explicitly and recorded — it does not inherit a boundary from this section, because none exists.

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.