Files
Ekklesia/docs/development/security.md
T
ekklesiaandClaude Opus 5 a2db774b8c
CI / verify (pull_request) Successful in 29s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Failing after 39s
docs: correct Gitea Actions guarantees
The security document claimed the CI workflow "is least privilege" because it
declares `permissions: contents: read`. That is not true on the server this
repository actually uses.

The inventoried server is Gitea 1.24.2, and its official comparison page lists
`permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
`jobs.<job_id>.timeout-minutes` under unsupported workflow syntax — verified
against https://docs.gitea.com/1.24/usage/actions/comparison rather than assumed.
On this server those keys are inert, so the branch cannot claim the in-job
GITEA_TOKEN is read-only, and cannot claim YAML-enforced timeout or concurrency
behavior. 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).

The document now separates what is actually true of this repository — full-SHA
action pins, frozen-lockfile install, no pull_request_target, no referenced
secret, `persist-credentials: false` on every checkout, and the declared
`permissions` block — from what the server enforces. It also states precisely
what `persist-credentials: false` proves: checkout leaves no credential in
`.git/config` for a later step, which is not the same as removing or reducing
the job token. The CI blockers are recorded: upgrade to a reviewed supported
release, configure repository/owner defaults and a maximum permission, prove
against the real server what the token cannot do, move timeout/cancellation/
teardown into the trusted provisioner and reaper, and finish the disposable-guest
proof before opening the real pull request.

The workflow keeps every declaration — they are already correct for a supported
Gitea release and for GitHub Actions — and gains a comment saying plainly that
this server ignores them, so no future reader mistakes them for enforcement.

Nothing about runners, proxies, firewalls, or deployment is asserted here.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 20:10:20 -05:00

208 lines
12 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.
## 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.
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.
### 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.
### CI blockers before the real pull request is opened
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 the disposable-guest runner isolation proof before opening the real pull request.