Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]>
437 lines
27 KiB
Markdown
437 lines
27 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.
|
||
|
||
### 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.
|