CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m1s
The CI documentation, the workflow header, the README, ADR 0047 and the sweep's comment all said the server was Gitea 1.24.2, which ignores `permissions`, `concurrency` and `timeout-minutes`. The server reports 1.27.2 (GET /api/v1/version). Each claim is now stated from what is verified on this server: - `concurrency` is honoured, observed: Ekklesia runs 133 and 135 were cancelled when a newer run started for the same pull request; - `permissions` is supported by 1.27 with Gitea's own scopes, but the token's actual authority is not visible through the API and not yet proven, so nothing claims the job token is read-only; - `timeout-minutes` is no longer listed as unsupported, but is not verified, and teardown still relies on the stack sweep and a reaper. Requirement 1 for executing untrusted contributions (upgrade to the 1.26 line or later) is marked done; items 2 and 3, configuring and proving token authority, remain. ADR 0047 (still Proposed) restates its "docker run, not a reusable workflow" reasons for 1.27, where private sources are supported. The reasons still hold without the 1.24 argument. Co-Authored-By: Claude Opus 5.5 <[email protected]>
817 lines
55 KiB
Markdown
817 lines
55 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),
|
||
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md)
|
||
(native module realtime),
|
||
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)
|
||
(authenticated display hosting), 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 |
|
||
| Scoped realtime channels, publishers, and subscriptions | Correct module-local state wiring, permission enforcement, and finite fanout — not process isolation |
|
||
| Display declaration, permission, and exact publication record | Correct role selection and normal authorization for an eligible/running module — not browser isolation |
|
||
| `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 the resolved
|
||
`GET /api/v1/dashboard/navigation` view and mounts each embedded host-neutral descriptor 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 dynamic `import()` from the API's origin (the page's own in the same-origin topology, the
|
||
API hostname in the split one, ADR 0045) — 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 authentication stays outside module code
|
||
|
||
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md) makes the Dashboard
|
||
surface — not a module — the owner of login, logout, and session-transition UX. Principal resolution
|
||
still precedes every protected navigation request and module mount. The public module UI context
|
||
remains `moduleId`, scoped API, narrow principal, permission hints, and realtime; it receives no
|
||
credential, cookie, token, auth endpoint, or sign-out callback.
|
||
|
||
The browser posts email/password to Better Auth's existing `/api/auth/sign-in/email` endpoint with
|
||
credentials included. Better Auth establishes the HttpOnly cookie, but its native success JSON also
|
||
contains a redundant session token and user object. `apps/api` therefore normalizes any body-bearing
|
||
successful `2xx` on that exact route to fixed `{"ok":true}` while preserving the status and
|
||
`Set-Cookie`; browser application JavaScript never receives the token and must resolve
|
||
`/api/v1/principal` before authenticated rendering. The richer token-bearing native paths
|
||
`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
|
||
`/api/auth/change-password` are deliberately unavailable behind the same sanitized `404` as an
|
||
unsupported route. The last path is denied even though password/account management is out of scope:
|
||
the pinned endpoint may return a new raw session token when revoking other sessions. The form clears
|
||
the submitted password, suppresses duplicate attempts, uses fixed error messages, and does not log
|
||
credentials, raw responses, or caught exceptions.
|
||
|
||
Sign-out calls `/api/auth/sign-out` and then re-resolves the principal; hiding the shell alone is
|
||
never treated as invalidation. Leaving authenticated state removes the active module frame, so its
|
||
existing unmount closes API/realtime work without a module-specific auth event. A `401` from the
|
||
platform-owned bootstrap contract maps directly to unauthenticated; a platform navigation `403`
|
||
receives a principal recheck for the principal/navigation expiry race. Module responses are not host
|
||
session authority: either module `401` or `403` triggers one deduplicated principal revalidation,
|
||
while the module retains the original response. Only anonymous unmounts the module and shows login;
|
||
authenticated preserves the session and mounted module. A stale response from an already-unmounted
|
||
frame is ignored, and network/server failures do not fabricate logout.
|
||
|
||
The flow remains at the current Dashboard URL and accepts no callback/return target, so it adds no
|
||
open-redirect input. It exposes **no signup** link, because production signup is prohibited. It does
|
||
expose a recovery link, and the screens it leads to still accept no destination: the emailed link's
|
||
landing is parsed by a pure function that recognizes exactly two Ekklesia-owned values and names no
|
||
URL at all, and the link itself was built by the server from validated configuration rather than
|
||
from anything a request supplied (ADR 0038). The existing cookie stays host-only; exact trusted
|
||
origins, credentialed CORS, and explicitly enabled Better Auth origin/CSRF checks remain the
|
||
boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a
|
||
separate deployment and threat-model decision.
|
||
|
||
### A display page is authorized; its artifact bytes are not authority
|
||
|
||
The manifest binds a display artifact to one permission declared by the same module
|
||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
|
||
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
|
||
and public/device identity is not approximated with an anonymous exception. Both the stable
|
||
`/display/<module-id>` page and `/api/v1/module-displays/<module-id>` descriptor evaluate that exact
|
||
permission through the normal Core evaluator. Client permission hints remain presentation only.
|
||
|
||
The versioned display artifact is anonymous code just like primary UI code. It is served only from
|
||
the frozen eligible/running publication registry, by exact module identity and version, and never
|
||
falls back to primary UI or opens the module directory. Fetching it grants no module HTTP, realtime,
|
||
storage, or data authority. Disabled and dependency-blocked modules receive no page, descriptor, or
|
||
artifact route; this is operational exposure control, not sandboxing.
|
||
|
||
The full-viewport host is a separate same-realm surface, not an iframe or containment boundary. It
|
||
uses normal cookie authorization, the same scoped API/realtime context, and opaque signed object URLs
|
||
without logging, proxying, extending, or re-signing them. It does not invoke the browser Fullscreen
|
||
API. Display inventory, device credentials, public principals, variants, and targeting are deferred
|
||
rather than silently granted.
|
||
|
||
### 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.
|
||
|
||
### Delivery URLs are bearer capabilities, and are treated as secrets
|
||
|
||
A module can ask a bucket for a temporary URL that lets a browser fetch one object directly
|
||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)). Whoever holds that URL can
|
||
read that object until it expires, with no further check of who they are.
|
||
|
||
- **Authorization happens before issuance, not inside the URL.** A module's route authorizes the
|
||
request and only then asks for a URL. The storage contract has no notion of a principal and offers
|
||
no hook that would let it make a permission decision; it bounds *reach*, not *who*.
|
||
- **Lifetimes are bounded and cannot be waived.** One minute at the shortest, six hours at the
|
||
longest, fifteen minutes by default. Out-of-range requests are refused rather than clamped, so
|
||
"expires forever" is unreachable rather than merely discouraged.
|
||
- **One object, one bucket.** The signature covers the URL's own path, so editing the key or the
|
||
bucket in a delivery URL yields a rejection rather than a different object — including another
|
||
module's object.
|
||
- **Never logged, never printed.** A complete delivery URL is treated exactly like a token: it is not
|
||
logged, and Doctor never prints one. Where a URL must be identified in a log, identify it by module
|
||
id, logical bucket id, key, and expiry — never by its query parameters.
|
||
- **Reads only.** There are no signed write or delete URLs. Uploads go through a module route where
|
||
the module can validate what it accepts.
|
||
- **Never canonical state.** Modules persist logical object keys and generate URLs on demand. A
|
||
stored URL is a stored credential with an expiry date in it.
|
||
|
||
**What a browser sees.** A delivery URL reveals the storage origin, the physical bucket name, the
|
||
object key, and signing parameters. Physical bucket names are implementation metadata, not secrets —
|
||
nothing is authorized by knowing one, since the bucket is not publicly readable and a tampered URL is
|
||
rejected. The guarantee is that *module code* cannot reach those details, not that a browser cannot
|
||
see the URL it is fetching.
|
||
|
||
**Buckets stay private.** Direct delivery deliberately does **not** work by making buckets anonymous;
|
||
an unsigned request for the same object is refused, and an integration test asserts it so that a
|
||
future change cannot "fix" delivery by opening a bucket up.
|
||
|
||
### Realtime is permission-gated state delivery, not a trust boundary
|
||
|
||
A module may declare a local realtime channel protected by one of its own manifest permissions. Core
|
||
closes both the server publisher and browser subscription over that module id: neither API accepts a
|
||
caller-supplied module namespace, global topic, wildcard, broker address, origin, URL, permission, or
|
||
credential. This prevents accidental cross-module wiring by a cooperating module. It does not
|
||
sandbox hostile installed code; trusted same-process backend code could bypass the facade, and
|
||
trusted same-realm UI could call browser globals directly.
|
||
|
||
Ekklesia's SSE endpoint resolves the ordinary session cookie and evaluates the channel's fixed
|
||
permission before allocating a subscriber. There is no query-string token and no second realtime
|
||
authentication path. A stream is closed after five minutes so native EventSource reconnects and
|
||
session, grant-expiry, revocation, and baseline policy are evaluated again; authorization is not
|
||
continuously queried between those boundaries. Disabled and dependency-blocked modules have no
|
||
stream route at all. Public or device/display identity remains deliberately unsolved, so no channel
|
||
is made anonymous merely to support a future kiosk.
|
||
|
||
Fanout is finite: 16 channels per module, 100 subscribers per channel, 64 KiB of validated serialized
|
||
JSON per publication, one latest snapshot per channel, and one coalescing pending slot per client.
|
||
A slow browser cannot block a publisher or create an unbounded queue. The endpoint rejects implicit
|
||
`HEAD` before allocation, disconnect/cancel closes its Core subscription, and server shutdown closes
|
||
streams before finite HTTP drain. Each scoped publisher becomes inert immediately before its own
|
||
module's stop hook.
|
||
|
||
Core never adds configuration, secret values, database values, storage credentials, or delivery URLs
|
||
to a realtime document. It transports only what trusted module code explicitly publishes. A module
|
||
must therefore treat realtime like any other authorized response: include only data every principal
|
||
holding the channel permission may receive. If it intentionally includes a signed delivery URL, that
|
||
URL remains a bearer capability and is forwarded byte-for-byte; fixed transport errors never reflect
|
||
payloads, URLs, permissions, runtime exceptions, paths, or stacks.
|
||
|
||
### 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 running
|
||
reverse proxy, no public listener, and no deployment automation in this repository — no deploy
|
||
script, no CI deployment step, no container publish. The repository provides a **reference deployment**
|
||
([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which
|
||
CI builds and smoke-tests end to end on every change against disposable services. Nothing pushes,
|
||
publishes, or runs it against a real installation. No `.env` has ever been committed; only
|
||
`.env.example`, which the image build context excludes.
|
||
|
||
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 forwards the Dashboard and `/api` on the intended origin, or an explicitly
|
||
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
|
||
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
|
||
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
|
||
maps every item here to its configuration). A split installation (ADR 0045) puts its Dashboard
|
||
and API hostnames on one registrable domain and uses `Caddyfile.split.example`, which refuses
|
||
`/api/*` on the Dashboard's hostname so the session cookie has exactly one home.
|
||
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.
|
||
9. The deployed login/logout flow preserves the host-only HttpOnly cookie, returns fixed
|
||
`{"ok":true}` rather than a token on successful sign-in, keeps the four token-bearing native
|
||
session/password paths unavailable, and accepts no external/protocol-relative post-login
|
||
destination.
|
||
10. The installation has a supported, audited way to create its first production identity before
|
||
consuming the single-use platform-owner bootstrap. This repository does not provide that way
|
||
yet.
|
||
|
||
### The initial-owner command is trusted local tooling
|
||
|
||
A fresh installation needs a first administrator, and public registration is disabled everywhere and
|
||
prohibited in production. `pnpm identity:create-initial-owner` fills that gap
|
||
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)). What makes it safe
|
||
is **where it can be invoked**, not a weaker policy:
|
||
|
||
- **It is not reachable from a browser.** No `/signup`, `/register`, or `/setup` route, no
|
||
unauthenticated bootstrap endpoint, no temporary setup route, and no setup token. It serves no HTTP
|
||
route and nothing in `apps/api` imports it. Its authority is already having the database
|
||
credentials.
|
||
- **Public signup stays disabled.** The command constructs its own in-process identity instance
|
||
rather than relaxing the server's posture. A test asserts that a freshly bootstrapped deployment
|
||
still refuses `POST /api/auth/sign-up/email`.
|
||
- **The password never lands anywhere durable.** Prompted invisibly or read from standard input;
|
||
`--password` is refused rather than ignored, because an argument is visible in shell history and in
|
||
the process list. No environment variable is read for it and none is in `.env.example`, so it
|
||
cannot become permanent deployment configuration. It is never printed, logged, or placed in an
|
||
error.
|
||
- **It writes no password hash of its own.** Hashing, salting, normalization, and the 8–128 bound are
|
||
the identity system's, applied through the same sign-up path a browser uses — so a CLI-created
|
||
account can never diverge from a browser-created one.
|
||
- **It cannot become a standing superuser factory.** Ownership bootstrap is permanently single-use
|
||
and this command consumes the same sentinel; there is no `--force`, reset, or override. A second
|
||
run creates no owner, changes no password, and creates no stray identity.
|
||
- **It never takes over an account.** An existing usable identity is adopted — granted ownership with
|
||
its password untouched — and reported as an adoption. A password reset is never performed. An
|
||
identity that has no password at all is refused rather than made an owner nobody can sign in as,
|
||
which would consume the single use and leave the installation unusable.
|
||
- **Races cannot mint two owners.** Whole invocations are serialized by an advisory lock, on top of
|
||
the grant's own locked, single-use transaction.
|
||
- **Errors stay sanitized.** No SQL, connection string, driver detail, Better Auth response body, or
|
||
cause reaches the operator's terminal — only a stable code and a message describing what is still
|
||
true of the installation.
|
||
|
||
It deliberately does not solve creating *additional* users or owners. That is user administration,
|
||
which does not exist yet.
|
||
|
||
### A display device is a principal, not a person and not a shortcut
|
||
|
||
An unattended screen has its own identity, paired by an administrator and revocable by one
|
||
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). What makes that
|
||
safe is what it is *not* allowed to be:
|
||
|
||
- **Not a user.** No device row exists in the identity tables and no device has an email or a
|
||
password. A device satisfies the `authenticated` baseline **never**, so every route that settles
|
||
for "somebody is signed in" stays closed to a television.
|
||
- **Not a bypass.** Device authority is decided by the same `createAuthorizationEvaluator` as
|
||
everyone else's, with the same deny-by-default. There is no device branch that returns allowed
|
||
before consulting an explicit permission, and no device is ever a platform owner.
|
||
- **Not broadly granted.** Approval grants exactly the one display permission the host derived from
|
||
the module's own validated display declaration. A browser cannot ask for a permission of its
|
||
choosing, and a device paired for one display has no authority over another.
|
||
- **Not a Dashboard login.** `/api/v1/principal` reports a device as anonymous, and the shell tests
|
||
for `=== "authenticated"` rather than "not anonymous". A device cookie cannot open the Dashboard.
|
||
- **Not the pairing code.** The six-digit code is displayed on a wall and is treated as public. It
|
||
identifies a request to an administrator; it never yields a credential. Collecting the credential
|
||
additionally requires the HttpOnly claim cookie only the requesting browser holds, so reading a
|
||
code off a screen is worth nothing.
|
||
- **Not stored in a usable form.** The server keeps SHA-256 of the credential. The raw value exists
|
||
only in the `Set-Cookie` that issues it and in the device's own cookie jar; it is never logged, put
|
||
in a query string, returned in JSON, or written to `localStorage`. The cookie is `HttpOnly`, so the
|
||
module display code running in that very page cannot read it.
|
||
- **Not revocable only at the browser.** Revocation is a server-side column checked on every
|
||
principal resolution. A revoked screen keeps its cookie and stops working; re-pairing creates a
|
||
**new** identity, so an old credential can never be reactivated.
|
||
|
||
Approval, listing, and revocation require the restricted `core.devices.manage` permission and an
|
||
exact trusted `Origin`, exactly like every other Core mutation. A device cannot approve a pairing —
|
||
including its own.
|
||
|
||
**Bounded, not instantaneous.** A revoked device fails its next request and its next realtime
|
||
connection immediately, but an already-open SSE stream is not torn down at that moment: realtime's
|
||
five-minute forced reconnect is the upper bound on how long a just-revoked screen can keep receiving
|
||
a channel. That window is stated rather than described as instant.
|
||
|
||
### Outbound email is host-owned, and cannot be turned into a relay
|
||
|
||
Ekklesia sends transactional email through a host-configured SMTP transport
|
||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). What keeps it from
|
||
becoming a liability:
|
||
|
||
- **No feature can choose a sender.** A message has no `from` field at all; the sender is host
|
||
configuration. Feature-chosen senders are an impersonation primitive and a deliverability
|
||
catastrophe at the same time.
|
||
- **No HTTP route can send.** There is deliberately no `POST /api/v1/send-email` and no route that
|
||
touches the mail provider — an authenticated relay endpoint is an open relay with extra steps. A
|
||
test asserts no route file even imports the mail module.
|
||
- **No module can send.** `ModuleRuntimeServices` carries no mail service, and a test asserts the
|
||
module SDKs have no way to. Browser code has none either: the SMTP dependency exists only in Core,
|
||
which a surface cannot import.
|
||
- **Header injection is refused, not repaired.** Control characters in addresses, display names,
|
||
subjects, and reply-to values are rejected. The first implementation trimmed before checking and
|
||
silently accepted a trailing carriage return; a test caught it and the check now runs on the raw
|
||
value.
|
||
- **Bounded everything.** Recipients (10), subject (200), text (64 KiB), HTML (256 KiB), and
|
||
connection, greeting, and socket timeouts are all explicit. A silent server returns a structured
|
||
error rather than hanging a request.
|
||
- **No downgrade.** Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than
|
||
inferred from a boolean and a port. `starttls` **requires** the upgrade, so a server that stops
|
||
offering it — or an attacker who strips it — fails the send instead of continuing in the clear.
|
||
Production refuses `plaintext`. Certificate verification is always on and there is no setting that
|
||
disables it.
|
||
- **The password is a deployment secret.** Never in PostgreSQL, never in module configuration, never
|
||
in an API response, never in Doctor output, never in an error, and no value in `.env.example`.
|
||
- **The SMTP client identity is not a secret.** `EKKLESIA_SMTP_HELO_NAME` is announced in the clear
|
||
in `EHLO` on every connection, so it is shown in Doctor output where an operator can compare it
|
||
with what the relay expects. It is validated as a fully qualified domain name — no scheme, port,
|
||
brackets, whitespace, or control characters — so nothing arbitrary reaches the wire.
|
||
- **Nothing is logged.** The transport logs nothing at all. Transactional mail carries verification
|
||
and reset links, which are exactly the credentials those flows depend on.
|
||
- **No blind retry.** One bounded attempt. A failure after the message data has been sent is
|
||
ambiguous, and retrying it would send someone several copies of the same reset email.
|
||
|
||
Deliverability — SPF, DKIM, DMARC, and the relay's own authorization policy — is a deployment
|
||
responsibility this repository cannot verify, and Doctor says so rather than implying a successful
|
||
connection means mail arrives.
|
||
|
||
### Email verification and password recovery
|
||
|
||
Both flows are Better Auth's, delivered over that transport
|
||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
|
||
|
||
- **A verified address is a precondition for a session.** An unverified sign-in fails after the
|
||
password check and before any session exists, so a correct password on an unverified address is
|
||
worth exactly as much as no password. There is no "partly authenticated" principal to misread.
|
||
- **The initial platform owner is the one exception**, created already verified by an operator
|
||
command whose authority is possession of the database. It is exactly one address, exactly once per
|
||
installation, with no mail prerequisite — the alternative is an installation nobody can ever sign
|
||
in to.
|
||
- **Ekklesia stores no token.** No token table, no token hashing, no expiry clock, no consumption
|
||
logic. Verification tokens are signed JWTs valid for one hour and replayable but idempotent (mail
|
||
scanners dereference links); reset tokens are stored rows, valid for one hour, and consumed.
|
||
- **Following a link never creates a session.** `autoSignInAfterVerification` is off, so a URL
|
||
fetched by a link scanner, a prefetcher, or a mail gateway hands out nothing.
|
||
- **Links are built from configuration.** Only the token is taken from the identity system; the
|
||
destination comes from a validated Dashboard origin that must be canonical, https and non-loopback
|
||
in production, and a trusted origin — checked at startup. No request parameter can steer where a
|
||
recipient lands.
|
||
- **Tokens never reach a log, an error, or a diagnostic**, and the browser strips the reset token
|
||
from the address bar with `history.replaceState` before rendering the form. It is never written to
|
||
`localStorage`, `sessionStorage`, or a cookie.
|
||
- **Recovery is enumeration-safe, including when mail is broken.** Every user-facing answer is
|
||
identical for an address with an account, without one, already verified, or undeliverable. The
|
||
delivery adapter never throws, because the identity system rethrows what its callback throws and
|
||
that would turn a failed relay into an account oracle. Failures are reported out of band with a
|
||
sanitized code and no address, token, or link.
|
||
- **Password reset revokes that person's sessions**, including the one performing it. Managed device
|
||
sessions are untouched: a device holds a separate credential digest, no email, and no password.
|
||
- **Verification does not imply registration.** Public signup remains disabled and the browser offers
|
||
no way to ask for an account.
|
||
|
||
### 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.
|
||
|
||
The same limiter covers the two paths that trigger an email — password-reset requests and
|
||
verification resends — at **3 requests per 60 seconds per IP**. That is a real control against
|
||
turning the platform into someone's mail cannon from one source, and it is deliberately **not**
|
||
claimed to be distributed: a multi-process deployment gets that budget per process.
|
||
|
||
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.
|
||
Its production sign-in/sign-up-prefixed rule is three attempts per ten seconds, and the
|
||
server-owned client address prevents a spoofed forwarding header from selecting a fresh bucket.
|
||
Those facts still do not make counters durable across restart or sufficient across replicas; the
|
||
public Dashboard login must be assessed with the actual edge/proxy controls before launch.
|
||
|
||
## Continuous integration boundary
|
||
|
||
CI runs on self-hosted Gitea Actions runners. The server reports **Gitea 1.27.2** (`GET
|
||
/api/v1/version`, checked 2026-10-06; this document previously said 1.24.2, which was stale). The
|
||
server's actual version, not the documentation of an earlier one, 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 this server honours, and what is not yet verified
|
||
|
||
Each item separates what Gitea documents for 1.27
|
||
(<https://docs.gitea.com/1.27/usage/actions/comparison>) from what has been observed on this server:
|
||
|
||
- **`concurrency` is honoured.** Gitea supports it since 1.26 (go-gitea/gitea#32751), and on this
|
||
server a newer push to a pull request cancels the run it supersedes. Observed: Ekklesia run 133 was
|
||
cancelled when run 134 started for the same pull request, and run 135 when run 137 did. A
|
||
superseded run's own cleanup is therefore never guaranteed to run, which is one reason for the
|
||
stack sweep below.
|
||
- **`permissions` is supported by 1.27, with Gitea's own scopes, but is not verified here.** The
|
||
server-side token-permission settings are not visible through the API, and nothing has yet proven
|
||
what the job token can and cannot do on this server. Until that proof exists (requirement 3
|
||
below), this repository still does not claim the `GITEA_TOKEN` is read-only.
|
||
- **`timeout-minutes` is no longer listed as unsupported for 1.27, but is not verified here.**
|
||
Teardown does not rely on it: the stack sweep and a trusted reaper do.
|
||
- **Still ignored:** `jobs.<job_id>.environment`, and any `runs-on` other than a single label.
|
||
|
||
The declarations stay in the workflow either way: they state the intent, and are correct on GitHub
|
||
Actions as well.
|
||
|
||
### Disposable stacks never outlive their job
|
||
|
||
Every CI script removes its own Compose stack in an `EXIT` trap, but a trap never runs when the runner
|
||
kills the job container: a cancelled job, or the runner's own time limit. One such stack ran on the
|
||
shared runner for two days. So stack cleanup has three layers:
|
||
|
||
- **The script's own trap**, for every ordinary failure.
|
||
- **A final `if: always()` step** in every job that starts stacks: `scripts/ci-sweep.sh --job` removes
|
||
everything that job started, at any age, even after a failed step.
|
||
- **A sweep before every such job starts**: `scripts/ci-sweep.sh --stale` removes anything a CI job
|
||
left more than three hours ago, which catches whatever a killed job could not clean up itself.
|
||
|
||
A stack is found by name and label, never by guesswork (`scripts/lib/ci-project.sh`):
|
||
|
||
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job key>-…`, and Compose labels
|
||
its containers, networks, and volumes with that name;
|
||
- anything a script starts outside Compose carries `org.riverofglorymn.ekklesia.ci.project`;
|
||
- images also carry the time CI built them, because an image's own creation time can be its base's.
|
||
|
||
The job key is per matrix leg, so one leg's cleanup cannot match another's running stack. Nothing
|
||
outside the `ekklesia-ci-` prefix is ever removed. A job container attached to a stack's network is
|
||
disconnected, never removed.
|
||
|
||
**This is not a substitute for a runner-side reaper.** A stack killed with its job is removed only
|
||
when another job that sweeps runs on the same runner. Bounded execution and teardown are still the
|
||
runner host's to enforce, as above.
|
||
|
||
### 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.~~ **Done:** the server runs 1.27.2. Items 2 and 3 now configure and prove
|
||
what it makes possible.
|
||
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 and teardown in the trusted provisioner/reaper. `concurrency` is honoured
|
||
by the server; `timeout-minutes` is not yet verified, and the in-workflow stack sweep only
|
||
complements a reaper.
|
||
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.
|