`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]>
475 lines
30 KiB
Markdown
475 lines
30 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 now exists
|
||
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)). It honours the
|
||
rule this section demanded of it: **the existence of an artifact on disk is not what makes it
|
||
servable.** A module is publishable only if it is in the validated set the API accepted at startup
|
||
*and* its manifest declares `entrypoints.ui`.
|
||
|
||
### Publishing a module UI artifact exposes one file, not a directory
|
||
|
||
`GET /api/v1/module-ui/<module-id>/<version>` serves an installed module's compiled UI artifact. Both
|
||
segments are **lookup keys, not path components**: they are matched against a registry Core built
|
||
from discovered modules, and only an exact match causes the canonical path discovery already resolved
|
||
and containment-checked to be read. No request value is composed into a filesystem path and the route
|
||
declares no wildcard segment, so traversal, encoded separators, sibling bundle chunks, package-root
|
||
files (`package.json`, `.env`), and **source maps** are unreachable — not filtered out, but with no
|
||
code path that could reach them. `@ekklesia/module-ui-build` emits a real `.map` next to every
|
||
artifact, and it is deliberately not served: the manifest declares the entrypoint and nothing else,
|
||
and a source map embeds `sourcesContent`.
|
||
|
||
The server never executes what it publishes. The route reads bytes and writes bytes — no import, no
|
||
evaluation, no transformation — so a module UI whose top level throws is served like any other, and
|
||
ADR 0021's rule that discovery never runs `entrypoints.ui` is unchanged.
|
||
|
||
**The route requires no authentication, and that is a decision with a stated cost.** Retrieving the
|
||
artifact confers no authority: the bundle carries no credential, the host (not the artifact) supplies
|
||
the principal and permissions at mount time, and every module API remains behind `requirePermission`.
|
||
Requiring a session would also break cross-origin dynamic `import()`, which sends no cookie. The
|
||
cost is that **an anonymous caller who already knows a module id *and* its exact installed version can
|
||
download that module's UI bundle**, and thereby learn the module is installed. That is information
|
||
disclosure, not authority. Unknown module, no declared UI, and wrong version return one identical
|
||
`404`, so the route is not an enumeration oracle — but an operator who needs the bundle itself
|
||
confidential must keep the API off untrusted networks, exactly as the deployment assumptions below
|
||
already require.
|
||
|
||
Trust in a published artifact rests on discovery-time validation plus a request-time read of that
|
||
exact path. There is no signing, hashing, or provenance verification — Ekklesia has none to verify
|
||
against — so **publication is not evidence that a module is safe.** Only installed, reviewed modules
|
||
may be published or mounted. 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.
|
||
|
||
### The browser host mounts module UI; it grants nothing
|
||
|
||
Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through
|
||
`GET /api/v1/module-ui` and mounts them with the generic loader
|
||
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). Three properties are
|
||
security-relevant.
|
||
|
||
**The inventory is authenticated, deliberately.** Module UI *bytes* are anonymous because fetching
|
||
compiled code confers no authority, and that route is carefully not an enumeration oracle. The
|
||
inventory is that enumeration — it lists which module products an installation runs — so it requires
|
||
`core.modules.ui.read` (baseline `authenticated`): any signed-in user, and nobody else. It carries no
|
||
filesystem path, package root, or entrypoint.
|
||
|
||
**Permission hints are computed by the server and remain hints.** For each listed module the API
|
||
evaluates that module's *own* declared permissions against the caller using Core's one authorization
|
||
evaluator, and returns only the granted ids — never a default of `true`, never inferred from a
|
||
client-side role, and never another module's or Core's authorization state. Every module route stays
|
||
enforced by `requirePermission` regardless; the test suite proves it by having a UI that was told
|
||
"denied" call its API anyway and still receive `403`.
|
||
|
||
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
|
||
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the
|
||
shell owns the container, the loading state, and every error, so a failing module costs its own panel
|
||
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
||
JavaScript, exactly as described above.
|
||
|
||
### Dashboard arrangement is not an access control
|
||
|
||
Dashboard groups and placements are presentation configuration
|
||
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). Organizing the
|
||
Dashboard requires the platform permission `core.dashboard.navigation.manage` (baseline
|
||
`restricted`), and holding it **grants no module authority whatsoever**: moving a module into a
|
||
folder changes one presentation row, and every module route stays enforced against its own declared
|
||
permission exactly as before. Nothing in the navigation configuration is read by discovery,
|
||
dependency resolution, module startup, or the authorization evaluator.
|
||
|
||
Navigation is also not a way to hide anything. A module absent from a viewer's navigation is a
|
||
rendering decision, never a restriction — server authorization remains the only thing that decides
|
||
what a request may do. Reading navigation requires the same `core.modules.ui.read` the module UI
|
||
inventory requires, so it discloses the same installed-module enumeration and nothing more; it
|
||
carries no filesystem path, package root, or manifest internal.
|
||
|
||
### Disabling a module is an operational control, not a security boundary
|
||
|
||
A module can be turned off, and that decision survives restarts
|
||
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): a disabled module is
|
||
never imported, so its code never runs, it mounts no routes, registers no capabilities, and publishes
|
||
no UI. That is a real and useful operational property — it is how an operator recovers from a module
|
||
that misbehaves or breaks startup.
|
||
|
||
**It is not containment.** The module's compiled code is still on disk, still trusted, and still
|
||
carries every authority listed above the moment anything starts it. Enablement is a row in
|
||
`ekklesia_core_module_state`; it constrains the platform's own composition, not the module. Anyone
|
||
who can put a module package on this filesystem, or who holds the database or operating-system access
|
||
to change that row, decides what runs. Nothing about disabling a module sandboxes, isolates, or
|
||
reduces the privileges of a module that *is* enabled.
|
||
|
||
Changing enablement requires `core.modules.lifecycle.manage` (baseline `restricted`) and — for the
|
||
mutation — an exact trusted `Origin` and a bounded body. Reading is gated by the same permission
|
||
because the lifecycle view is the only place a disabled module, a blocked module, and the
|
||
required-dependency edges between them are visible; `core.modules.ui.read` deliberately exposes only
|
||
the mountable set. Neither response carries a filesystem path, package name, manifest path,
|
||
entrypoint, caught error, or stack — only module ids, capability ids, and fixed reason codes.
|
||
|
||
Holding the lifecycle permission grants no authority *inside* any module: every module route stays
|
||
enforced against that module's own declared permission, and a disabled module's permissions remain
|
||
registered and grantable precisely so that an enable/disable cycle changes nothing about who holds
|
||
what.
|
||
|
||
### Ekklesia does not store module secrets, and does not pretend to
|
||
|
||
A module declares a `secret` configuration key; the **deployment** supplies its value, and Ekklesia
|
||
never persists it ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||
|
||
This is a deliberate limit, not an oversight. **There is no encryption at rest in this repository and
|
||
no key management** — no cipher, no KMS, no keyring. Storing credentials in a PostgreSQL column
|
||
because one is easy to add would be worse than not offering the feature, and calling such a column a
|
||
"secret store" would be worse still.
|
||
|
||
What that buys, structurally rather than by discipline:
|
||
|
||
- a `secret` declaration has no `default` field, so a checked-in default credential is
|
||
unrepresentable;
|
||
- the resolver never reads a secret from storage, so a row planted directly in the database by
|
||
someone with database access is ignored rather than used;
|
||
- the HTTP view has no `value` field for a secret, so a secret is **absent from responses by
|
||
construction**, not filtered by a branch someone could later delete;
|
||
- the write API refuses a `secret` key outright (`CONFIGURATION_SECRET_NOT_WRITABLE`);
|
||
- Doctor prints keys and reasons, never values.
|
||
|
||
A module reaches its own secrets through `config.getSecret("key")` and nothing else. There is no
|
||
`getEnv`, no arbitrary variable lookup, and no cross-module accessor: the secret source is asked for a
|
||
module id **and** a key that module's own manifest declares, so a module cannot read `DATABASE_URL`,
|
||
the identity signing secret, or another module's credential.
|
||
|
||
**What this does not claim.** A module is still trusted code in the API process (see above), so a
|
||
malicious module could read `process.env` directly and obtain anything the process can. Scoped secret
|
||
delivery is a correctness and portability boundary, exactly like every other module-facing mechanism
|
||
here — not a sandbox. What it does guarantee is that Ekklesia itself never writes a credential to
|
||
disk, never serves one over HTTP, and never prints one in diagnostics.
|
||
|
||
### Module configuration is operator input, and is treated as such
|
||
|
||
Ordinary configuration values *are* persisted, and are validated against the declaring module's own
|
||
manifest before anything reads them. A stored value that does not satisfy its declaration blocks the
|
||
module rather than being coerced, truncated, or silently replaced by a default.
|
||
|
||
Configuration keys are **data, never SQL identifiers** — there is no dynamic SQL in this domain — and
|
||
are rejected unless they match a closed lower-camelCase grammar that also excludes reserved object
|
||
property names (`__proto__`, `constructor`, `prototype`, ...). Core additionally never indexes a plain
|
||
object with a module-supplied key; every lookup goes through a `Map`. Store errors never carry the
|
||
offending value, because an operator-entered value may be a credential that ended up in the wrong
|
||
field.
|
||
|
||
Reading or changing configuration requires `core.modules.configuration.manage` (baseline
|
||
`restricted`), separately from `core.modules.lifecycle.manage`. Mutations require an exact trusted
|
||
`Origin` and a bounded body, like every other Core mutation. Module UI receives no configuration at
|
||
all in this phase.
|
||
|
||
### Object storage is scoped by binding, and modules never hold credentials
|
||
|
||
A module declares a logical bucket; the host resolves it to a physical bucket on an S3-compatible
|
||
service and hands the module a facade scoped to exactly that
|
||
([ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md)).
|
||
|
||
What that gives, structurally rather than by discipline:
|
||
|
||
- **No credential reaches a module.** The endpoint, region, access key, and secret key live in the
|
||
host's validated configuration and reach Core's provider only. `ModuleStorage` has no accessor that
|
||
could return any of them, and none appears in any error, diagnostic, or API response.
|
||
- **No module can open another module's bucket.** The facade closes over exactly the bindings that
|
||
module's manifest declared. Knowing another module's bucket name, module id, or physical bucket
|
||
name buys nothing, because none of those is an input to anything — an undeclared name is simply not
|
||
a key in the map.
|
||
- **No module chooses a physical bucket or an endpoint.** Both come from host configuration and the
|
||
deterministic binding, never from module input, so a module cannot aim the platform's S3 client at
|
||
a server of its choosing.
|
||
- **Two modules declaring the same logical name are isolated.** Physical names are derived from
|
||
module id *and* bucket id with a separator that cannot occur inside either, plus a digest over the
|
||
full identity — so `assets` in two modules is two namespaces, not one.
|
||
- **Object keys cannot escape a bucket**, because the bucket never comes from the key.
|
||
- **Nothing deletes a bucket.** There is no such operation to call, so disabling or removing a module
|
||
cannot take data with it.
|
||
- **Provider errors are translated, never forwarded.** No endpoint, request id, bucket name, or XML
|
||
body survives into a `ModuleStorageError` a module might log or return.
|
||
|
||
Endpoints must be absolute `http`/`https`, must not embed credentials, and must use `https` in
|
||
production — the same standard the database transport holds. Credentials are never persisted in
|
||
PostgreSQL and never routed through module configuration, which has no encryption at rest to offer
|
||
them (ADR 0029).
|
||
|
||
**What this does not claim.** A module is still trusted code in the API process, so a malicious module
|
||
could read `process.env` directly and obtain the provider credentials that way. Scoped storage is a
|
||
correctness and portability boundary like every other module-facing mechanism here — not a sandbox.
|
||
What it does guarantee is that Ekklesia never hands a module another module's data, never gives module
|
||
UI or a browser a credential, and never prints one in diagnostics.
|
||
|
||
### 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.
|