@bongos/core 1.19.709 → 1.19.710
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +25 -20
- package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +2 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +2 -0
- package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +56 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/ops-gotchas.md +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo.js +181 -0
- package/scripts/gds/provision-units.js +33 -5
- package/scripts/gds/provision.js +35 -3
- package/src/bongos/pool.js +14 -0
- package/src/module-api.js +1 -1
- package/tests/provision.mjs +227 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.710",
|
|
6
|
+
"core_contract": "1.19.710",
|
|
7
|
+
"source_commit": "ff37fbc25d95dd2884f9f0158cdb5338ed673774",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-13T07:06:06.980Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 478,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
14
|
"functional_verbatim": 2140,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2642,
|
|
20
|
+
"tree_sha256": "11ec07e4ffff8f23d2aeb9a35181c59d87255a2a80010d858b071858726a3acc",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1132,7 +1132,7 @@
|
|
|
1132
1132
|
{
|
|
1133
1133
|
"path": "docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md",
|
|
1134
1134
|
"mode": "0000644",
|
|
1135
|
-
"sha256": "
|
|
1135
|
+
"sha256": "1b144566a9166db2a54c07241970d12621273e29691f799799fc5b6a39b3c440"
|
|
1136
1136
|
},
|
|
1137
1137
|
{
|
|
1138
1138
|
"path": "docs/adr/0129-cloudbongos-control-plane-pull-deploy.md",
|
|
@@ -1212,7 +1212,7 @@
|
|
|
1212
1212
|
{
|
|
1213
1213
|
"path": "docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md",
|
|
1214
1214
|
"mode": "0000644",
|
|
1215
|
-
"sha256": "
|
|
1215
|
+
"sha256": "0eaedcf642882a857024cf68e900ecfca457ba379150a8afbd16a374c4a16c82"
|
|
1216
1216
|
},
|
|
1217
1217
|
{
|
|
1218
1218
|
"path": "docs/adr/0146-configurable-reward-mode-cost-plus-only.md",
|
|
@@ -1914,10 +1914,15 @@
|
|
|
1914
1914
|
"mode": "0000644",
|
|
1915
1915
|
"sha256": "630f6460b3484f4cc629cce9c431f824bb8363d30a249db9e21e89863995c7ac"
|
|
1916
1916
|
},
|
|
1917
|
+
{
|
|
1918
|
+
"path": "docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md",
|
|
1919
|
+
"mode": "0000644",
|
|
1920
|
+
"sha256": "96d78083fc4b04b8bdce61dee59e05465ec273759cfac325b160f68c8e5184a4"
|
|
1921
|
+
},
|
|
1917
1922
|
{
|
|
1918
1923
|
"path": "docs/adr/README.md",
|
|
1919
1924
|
"mode": "0000644",
|
|
1920
|
-
"sha256": "
|
|
1925
|
+
"sha256": "dd5c5de0fb4c01c355d4adc9cd550717bc6d6c074bedaf6e05fbbd0385fd9138"
|
|
1921
1926
|
},
|
|
1922
1927
|
{
|
|
1923
1928
|
"path": "docs/api-reference.md",
|
|
@@ -2802,7 +2807,7 @@
|
|
|
2802
2807
|
{
|
|
2803
2808
|
"path": "docs/module-api-changelog.md",
|
|
2804
2809
|
"mode": "0000644",
|
|
2805
|
-
"sha256": "
|
|
2810
|
+
"sha256": "94214ff6dfe72959e4aa130b0f40efde676215cae1cf8dcdd66ec9ca59972f0c"
|
|
2806
2811
|
},
|
|
2807
2812
|
{
|
|
2808
2813
|
"path": "docs/modules-contract.md",
|
|
@@ -2992,7 +2997,7 @@
|
|
|
2992
2997
|
{
|
|
2993
2998
|
"path": "docs/recipes/ops-gotchas.md",
|
|
2994
2999
|
"mode": "0000644",
|
|
2995
|
-
"sha256": "
|
|
3000
|
+
"sha256": "dadf37b7916c96aeb6e5a1d068a8b59b7b73648dbf6816f949f63fe1220fad16"
|
|
2996
3001
|
},
|
|
2997
3002
|
{
|
|
2998
3003
|
"path": "docs/recipes/overnight-builder.md",
|
|
@@ -7787,12 +7792,12 @@
|
|
|
7787
7792
|
{
|
|
7788
7793
|
"path": "package-lock.json",
|
|
7789
7794
|
"mode": "0000644",
|
|
7790
|
-
"sha256": "
|
|
7795
|
+
"sha256": "566cea60dfb2c1088d418f3ca787364b72cc4c83fdb73d7397f5cfbaa4a54981"
|
|
7791
7796
|
},
|
|
7792
7797
|
{
|
|
7793
7798
|
"path": "package.json",
|
|
7794
7799
|
"mode": "0000644",
|
|
7795
|
-
"sha256": "
|
|
7800
|
+
"sha256": "5e946e11f0cf01f78c59df42dec1f4903b79bb19b31e603be63e7997bddad050"
|
|
7796
7801
|
},
|
|
7797
7802
|
{
|
|
7798
7803
|
"path": "public-docs/index.html",
|
|
@@ -8607,17 +8612,17 @@
|
|
|
8607
8612
|
{
|
|
8608
8613
|
"path": "scripts/gds/provision-repo.js",
|
|
8609
8614
|
"mode": "0000644",
|
|
8610
|
-
"sha256": "
|
|
8615
|
+
"sha256": "3612ebe8f54da7498a6c4998e0d1d1c4596a80d54f70b5543b4a46dfca2ffbcf"
|
|
8611
8616
|
},
|
|
8612
8617
|
{
|
|
8613
8618
|
"path": "scripts/gds/provision-units.js",
|
|
8614
8619
|
"mode": "0000644",
|
|
8615
|
-
"sha256": "
|
|
8620
|
+
"sha256": "a59615231f2843a11cf2d9d2f6f07c7aa2dacb6e83fb6126fdfeae9cfdc19abd"
|
|
8616
8621
|
},
|
|
8617
8622
|
{
|
|
8618
8623
|
"path": "scripts/gds/provision.js",
|
|
8619
8624
|
"mode": "0000644",
|
|
8620
|
-
"sha256": "
|
|
8625
|
+
"sha256": "007c5cfa3b42567df0016038fc7ec5c7dbdab60ec6ec8ecd9d06386933e5641d"
|
|
8621
8626
|
},
|
|
8622
8627
|
{
|
|
8623
8628
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -9412,7 +9417,7 @@
|
|
|
9412
9417
|
{
|
|
9413
9418
|
"path": "src/bongos/pool.js",
|
|
9414
9419
|
"mode": "0000644",
|
|
9415
|
-
"sha256": "
|
|
9420
|
+
"sha256": "5c6121de5ba67f23b4609ddf06cad6fcda1dd5e19f09996616a49e23bcca38a1"
|
|
9416
9421
|
},
|
|
9417
9422
|
{
|
|
9418
9423
|
"path": "src/bongos/prelaunch-gate.js",
|
|
@@ -9572,7 +9577,7 @@
|
|
|
9572
9577
|
{
|
|
9573
9578
|
"path": "src/module-api.js",
|
|
9574
9579
|
"mode": "0000644",
|
|
9575
|
-
"sha256": "
|
|
9580
|
+
"sha256": "fb8072c2a17b700fa08472c181b9e7beda25058987ecfcfea20825cb86849e0d"
|
|
9576
9581
|
},
|
|
9577
9582
|
{
|
|
9578
9583
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12142,7 +12147,7 @@
|
|
|
12142
12147
|
{
|
|
12143
12148
|
"path": "tests/provision.mjs",
|
|
12144
12149
|
"mode": "0000644",
|
|
12145
|
-
"sha256": "
|
|
12150
|
+
"sha256": "e247471cb6b11000b0e2bcb41166c24adbbe0db902513832f5e40fe1ba655ef8"
|
|
12146
12151
|
},
|
|
12147
12152
|
{
|
|
12148
12153
|
"path": "tests/provision_restart.mjs",
|
|
@@ -39,6 +39,8 @@ Add a single config knob, **`PROVISION_SUDO=1`** (alias `PROVISION_PRIVILEGED=1`
|
|
|
39
39
|
- **The dedicated-droplet cloud-init path is deliberately unchanged** (`dedicatedUserData`). It runs as root on a fresh droplet — a different privilege + file-perms model — and is out of the co-tenant/standalone scope this task exercised; escalating it is a separate change.
|
|
40
40
|
- **Follow-ups noted from the standup:** the live `/etc/caddy/Caddyfile` drifts from `infra/Caddyfile` with no auto-sync (a deploy-time reconcile is worth considering); the migrate-as-postgres readable-checkout precondition should be asserted by the box provisioning, not just documented.
|
|
41
41
|
|
|
42
|
+
> **Followed up 2026-09-12 by [ADR 0281](<redacted>.md)** (task [1003369](https://cloudbongos.com/builders#/task/1003369)), which takes the last alternative below from the other end: the *instance* now holds per-instance DB credentials (its own password-authed role) rather than the runner holding app creds. **This ADR is unchanged** — the runner still escalates via `sudo -u postgres` for `createdb`/`migrate`, and `PROVISION_SUDO` still means exactly what it means here.
|
|
43
|
+
|
|
42
44
|
## Alternatives considered
|
|
43
45
|
|
|
44
46
|
- **Grant `lars` a PG login role + writable `/etc`.** Rejected: non-portable per-box setup, and it weakens the trust boundary — the runner shouldn't *own* `/etc` or be a standing PG superuser; `sudo -u postgres` is scoped to the two DB commands.
|
|
@@ -21,6 +21,8 @@ The co-tenant instances that federate on cloudbongos.com subdomains today (e.g.
|
|
|
21
21
|
|
|
22
22
|
A `standalone` runs **on the hub box** ([scripts/gds/provision.js](../../scripts/gds/provision.js): `standaloneBase`, control-plane-local IP) as the **same UID as the hub** (`serviceUnit` hardcodes `User=lars`), and its deploy runs `npm ci` (not `--ignore-scripts`) in a **passwordless-sudo-capable** runner context. A malicious owner could ship a `postinstall` and get code execution as `lars` with sudo on the hub box → the hub's signing key, every instance's secrets, the hub DB. This is a pre-existing property of running owner code on the hub box at all, independent of federation, but any free "run your code for you" tier would expose it at scale.
|
|
23
23
|
|
|
24
|
+
> **Amended 2026-09-12 ([ADR 0281](<redacted>.md), task [1003369](https://cloudbongos.com/builders#/task/1003369)):** `serviceUnit` no longer hardcodes `User=lars` — each provisioned instance now runs as its own unix account with its own password-authed PG role, so the *running* instance is no longer the same UID as the hub. **Finding 2 stands regardless:** the exposure it names is the DEPLOY, not the steady state — `npm ci` still runs owner `postinstall` code in the passwordless-sudo-capable runner context, before the per-instance account is ever involved. The decision below is unchanged.
|
|
25
|
+
|
|
24
26
|
The federation protocol itself is sound: the hub binds the minted audience to the instance's exact origin (the `#1002108` fix; asymmetric Ed25519 so one instance can't forge for another). A leaked per-instance client secret is contained to its own origin. The weakness is **placement** (shared cookie domain) and **execution** (owner code on the hub box), not the protocol.
|
|
25
27
|
|
|
26
28
|
## Decision
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# ADR 0281 — An instance's identity is its own unix account and its own password-authed Postgres role
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted — implemented 2026-09-12 (task [1003369](https://cloudbongos.com/builders#/task/1003369) · goal 1000094, audit ref `B3` of the 2026-08-29 security audit).
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
[ADR 0111](<redacted>.md) made instance standup automatic and [ADR 0128](<redacted>.md) settled how the *runner* escalates to perform it. Neither settled who the *instance* is once it is running, and the answer was: everybody is the same person.
|
|
8
|
+
|
|
9
|
+
`serviceUnit` hardcoded `User=lars` / `Group=lars`, and **co-tenant is the default shape** — so on a shared box every instance's Node process ran under one uid. Two consequences, both reachable by any code an instance runs, neither of them a bug in any route:
|
|
10
|
+
|
|
11
|
+
- **Sibling environments.** A unix user may read `/proc/<pid>/environ` of its own processes. Every instance's `EnvironmentFile` carries `GITHUB_APP_PRIVATE_KEY`, `<PREFIX>_HUB_CLIENT_SECRET` and its OAuth client secret ([ADR 0141](<redacted>.md) §4, [ADR 0133](<redacted>.md)). One uid meant every instance could read every sibling's.
|
|
12
|
+
- **Sibling databases.** `src/bongos/pool.js` connected by **passwordless peer auth** over the local socket, with `PGDATABASE` supplied as a systemd `Environment=` default. Peer auth answers "which unix user are you", so any code running as `lars` could open a pool against **any** database on the box by naming it — including the control plane's own `builders` and `builder_sessions`.
|
|
13
|
+
|
|
14
|
+
The per-instance `web.env` (`chmod 600`) and the per-instance database were already correct. They were sound walls with one door, and every instance had the key.
|
|
15
|
+
|
|
16
|
+
ADR 0128 anticipated the DB half in its own alternatives: *"Do createdb/migrate via the app `DATABASE_URL` … Left open for a future instance that wires DB creds into the runner's box.env."* This is that change, from the other end — the instance gets credentials, not the runner.
|
|
17
|
+
|
|
18
|
+
## Decision
|
|
19
|
+
|
|
20
|
+
**Each instance gets its own unix account and its own password-authenticated Postgres role, both derived from its slug, both created by the provision run.**
|
|
21
|
+
|
|
22
|
+
**Naming.** `instanceUser(inst)` → `bongos-<slug>`, folded to `bongos-<head>-<8 hex of sha256(slug)>` when the slug would push it past utmp's 32 characters. Prefixed, never the bare slug: a project may legally be called `postgres` or `lars`, and a bare-slug scheme would hand it an existing account. The hash suffix is not decoration — two slugs sharing a 16-character prefix would otherwise fold onto one account, which is precisely the shared identity being removed. An invalid slug **throws** rather than producing a name.
|
|
23
|
+
|
|
24
|
+
**The unix account** is a system account with `/usr/sbin/nologin` and a home at `/var/lib/<user>` — deliberately **not** under `/home`, because the unit sets `ProtectHome=read-only` and the server writes inside its config home on first boot (`secret-box.js` provisions `builder-secret.key` there). A `/home` account would make that write `EROFS` and the instance would come up unable to hold a secret.
|
|
25
|
+
|
|
26
|
+
**The unit** carries the hardening block the sibling backup unit already had — `ProtectSystem=strict`, `ProtectHome=read-only`, `PrivateTmp=true`, alongside the existing `NoNewPrivileges=true` — with `ReadWritePaths` as the entire allow-list: the state dir, plus an **optional** `-<workDir>/config` (the modules toggle writes `config/modules.json` at runtime; the `-` keeps a shape without one from failing to mount). **The checkout the service runs is deliberately not writable by it.**
|
|
27
|
+
|
|
28
|
+
**The Postgres role** is created or rotated per run: `LOGIN` with a password, owner of its own database, `REVOKE CONNECT … FROM PUBLIC` — and **not** `SUPERUSER`, `CREATEDB`, `CREATEROLE` or `BYPASSRLS`. The revoke is the half that isolates: Postgres grants `CONNECT` on every database to `PUBLIC` by default, so per-role passwords **alone** would leave a sibling one peer-auth hop from any other instance's data. The grants run in a **second** statement against the instance DB, **after** the migrate, because the privileged migrate runs as `postgres` and therefore owns every table it creates; `ALTER DEFAULT PRIVILEGES` aims the same grant forward so the next migration needs no second visit.
|
|
29
|
+
|
|
30
|
+
**The password** is minted per provision run (32 bytes, base64url) and never stored: the same run sets it on the role and writes it into the `0600` `web.env`, so **every provision rotates the credential** and no plaintext copy outlives the run outside that file. `pool.js` reads `PGUSER`/`PGPASSWORD` **explicitly** rather than leaving them to `pg`'s env defaults — the whole defect was a connection whose identity nothing in the code stated, so the identity is now stated where a test can assert it.
|
|
31
|
+
|
|
32
|
+
**Where the credential may travel is part of the decision, not an afterthought.** Two paths carry the rendered command, and both would have persisted the password somewhere this ADR cannot lock down:
|
|
33
|
+
|
|
34
|
+
- **The runner’s own log.** `makeExec`’s `run()` prints the raw command on both the dry-run and the success path, so the role DDL — which embeds the password — would land in the journal. The call site passes **`shown`**, the same mechanism `makeWriteFile` already uses for the `web.env` body, and `dbRoleCmdShown()` lives beside `dbRoleCmd` so a label cannot outlive the secret it hides. A `shown` call site also suppresses stderr, since a failing shell may echo the line back.
|
|
35
|
+
- **DigitalOcean cloud-init user-data.** DO **retains** user-data and serves it back through its API, its console and the droplet’s own metadata endpoint — a permanent second copy. So the dedicated shape takes **`peerOnly`**: the role, its DB ownership and the `PUBLIC` revoke are all still created, and only the password is omitted. A dedicated droplet is **single-tenant**, so the co-tenancy vector B3 describes does not exist on it, and the role peer-auths to its own unix account. Baking a credential into a retained blob to defend against siblings that are not there would be strictly worse than the thing it protects against.
|
|
36
|
+
|
|
37
|
+
**Absence is the old behavior.** No password ⇒ no `PGUSER`/`PGPASSWORD` lines in `web.env` and no keys in the pool config, so an instance provisioned before this keeps exactly the peer auth it had. This cannot brick a box mid-rollout.
|
|
38
|
+
|
|
39
|
+
## Consequences
|
|
40
|
+
|
|
41
|
+
- **The two named vectors close.** `/proc/<pid>/environ` is readable only by the process's own uid, so sibling environments are gone; a sibling holds no password for another's role and `PUBLIC` no longer carries `CONNECT`, so sibling databases are gone.
|
|
42
|
+
- **The SQL contains no double quote, and that is load-bearing.** Every literal is dollar-quoted and every identifier goes through `format(%I)`, because these commands are embedded in the dedicated droplet's cloud-init as `- [ bash, -lc, "…" ]` — a YAML **double-quoted** scalar. A plain `"role"` identifier reads fine in `psql` and silently truncates the cloud-init line. `seedFirstVersionCmd` dollar-quotes for the same reason; a test now asserts the absence rather than leaving it to habit.
|
|
43
|
+
- **A partial, stated honestly: on-disk co-tenant reads.** The instance account is added to the app-user group (`usermod -aG`) so it can read the checkout it runs from — without it the unit fails at exec with `EACCES`. That grants exactly the read the app user's group already had, so **file-level** co-tenant separation is not what this change delivers; the process and database boundaries are. Narrowing it further means per-instance ownership of the checkout, which belongs with the shared-checkout question, not here.
|
|
44
|
+
- **A generated secret is only as private as the narrowest channel it crosses.** Both leaks above were found by the ship grade, not by the design, and neither was a typo: each was a call site that never opted into a redaction mechanism the codebase already had. The regression guard is written as an **invariant** rather than an assertion about one line — `provisionInstance` is run and *every* command embedding a dollar-quoted secret must carry a clean `shown` — so the next secret-bearing call site is caught by the same test.
|
|
45
|
+
- **Re-provision is the rotation path**, and it is idempotent throughout: `id -u` short-circuits an existing account, `install -d` re-asserts the state dir, and the role DDL upserts. A re-provision repairs a hand-edited box rather than failing on it.
|
|
46
|
+
- **`provision.js` sits two lines under the 1,500-line ratchet.** The generators and their full rationale live in `provision-repo.js`; the runner carries the wiring and a pointer. The next change there will have to move something out.
|
|
47
|
+
- **The non-privileged path is unchanged in kind.** The DDL escalates through the same `asPostgres()` helper ADR 0128 introduced, so a runner without `PROVISION_SUDO` renders the same commands without `sudo` — as broken, and no more broken, than it already was for `createdb`.
|
|
48
|
+
|
|
49
|
+
## Alternatives considered
|
|
50
|
+
|
|
51
|
+
- **A `DATABASE_URL` in `web.env` instead of discrete `PG*` vars.** `pool.js` already prefers it, so it would have needed no pool change at all. Rejected: the socket path has to ride as `?host=…` or a percent-encoded authority, and the exact parse is `pg-connection-string`'s business rather than something the test could pin — trading a stated, assertable identity for a URL whose behavior we would be trusting.
|
|
52
|
+
- **Per-role passwords without `REVOKE CONNECT … FROM PUBLIC`.** Rejected on the mechanism: it reads like isolation and is not, because peer auth plus the default `PUBLIC` grant walks straight around the password.
|
|
53
|
+
- **`ProtectSystem=full`, or `strict` with the working directory writable.** Rejected: the first leaves everything outside `/usr` writable, and the second makes the code the service executes writable by the service — an obvious foothold in a change whose whole subject is containment.
|
|
54
|
+
- **Keeping `User=lars` and isolating with a second mechanism** (a PG role alone, or namespaces). Rejected: the shared uid *is* the `/proc` vector; nothing layered on top removes it.
|
|
55
|
+
- **Generating the dedicated droplet’s password on the droplet** (`openssl rand` inside a `runcmd`), instead of `peerOnly`. Rejected: it would have to be written into a `web.env` that cloud-init does not currently produce at all, so it buys a password for a single-tenant box by adding an untested file-writing leg to the one path ADR 0128 explicitly left alone.
|
|
56
|
+
- **Storing the generated password in `provisioning_instances`.** Rejected: it would put a live credential in the control-plane database in plaintext to buy stability the rotation does not need — the role is upserted and the env file rewritten in the same pass, so nothing needs to remember it.
|
package/docs/adr/README.md
CHANGED
|
@@ -372,3 +372,4 @@ This keeps the decision history honest and traceable.
|
|
|
372
372
|
| 0278 | [**A gated project still takes applications, and the exemption is scoped to the verb** ([task 1003525](https://cloudbongos.com/builders#/task/1003525) · the apply write itself in [task 1003624](https://cloudbongos.com/builders#/task/1003624) · goal 1000106 — *Working area 1, Project creation*; owner decision 2026-09-11). A project's owner sets who may SEE it (`platformVisibility`, [ADR 0192](<redacted>.md)) and who may JOIN it (`joinability`, [ADR 0194](<redacted>.md)) independently — and set to their middle values, members-only AND apply-to-join, the project took no applications at all: the member door refused every cookie-less request with `401` before the public `POST <api>/access-requests` could answer, because that write was not on the exempt list. The two settings composed into **"nobody can apply"**, which nobody chose. It survived because nothing LIED about it — the hub's join box relayed the project's own `401` honestly as `members_only`, and the hall's landing, where the apply form lives, is itself behind the door; the composition was simply unreachable. Found by the R14 proof ([task 1002333](https://cloudbongos.com/builders#/task/1002333)). ADR 0192 §3 had fixed the exempt list at "the door, the manifest, the probes and the downloads — and nothing wider" and left widening it as an owner call, which is what this is. **Decision: yes — and BOTH halves are exempted, each scoped to one path and one verb.** `POST <api>/access-requests` (it grants nothing — an application is a row in a queue the owner still reviews, [ADR 0201](<redacted>.md), already public on every non-gated project) and `GET <api>/access-requests/status` (without it the answer is half an answer: `bongos login` cannot re-poll the device flow after a `not_approved` — the `device_code` is spent — so an applicant would file a request and then wait on an approval they can never observe). `EXEMPT` entries may now be `{ re, methods }` beside the bare `RegExp`s, and `isExempt(path, method)` takes the verb as an OPTIONAL second argument that **fails closed** for a scoped entry when none is given, so the one-argument static callers cannot accidentally widen. **The verb is load-bearing, not tidiness:** the bare `GET` on `<api>/access-requests` is the OWNER'S QUEUE (`requireBuilder` + `access_request.review`), the surface listing would-be builders by name with their vouch state — a path-only exemption would have silently taken the member door off the front of it, leaving one layer where there were two, and the queue's own `requirePermission` still holding is exactly what makes that loss easy to miss. **It opens no oracle the gate was closing:** the status route's boolean twin `GET <api>/auth/web/admission-status` is ALREADY reachable on a gated project inside the `auth/*` subtree §3 exempts whole (§3 records that cost in as many words), and the two share ONE per-IP budget on purpose ([ADR 0209](<redacted>.md)) so neither can be alternated against the other. What it DOES add, stated as the honest cost: applicant detail — `pending`/`dismissed`/`none` over the twin's bare `admitted`. Whether that answer should collapse is ADR 0209's still-open owner question and is deliberately NOT decided here. No hub change: `joinRelayOutcome` maps the RELAYED status, so it carries the project's real answer the moment the `401` stops. Rejected: "gated means gated" — hide *Apply to join* and say so in the manage blurb (coherent, and the call went the other way); exempting the path without the verb; exempting the write alone; collapsing the status response while the route happened to be open (that is how a deferred decision gets made by accident).](<redacted>.md) | project visibility / join door / member door |
|
|
373
373
|
| 0279 | [**An upgrade is proven by the served version, not by a health check** ([task 1002884](https://cloudbongos.com/builders#/task/1002884) · goal 1000090 — *Working area 4, Bongos Core distribution*; from idea 1000682). On 2026-08-11 the auto-upgrade sweep printed `✓ upgrade complete — core 1.19.13 → 1.19.56`, wrote a success row to `core_upgrades` and exited 0 while live kept serving **1.19.13**. Three shipped checks formed a closed loop that could not see the failure they existed to catch: the `systemctl restart` failed with `Interactive authentication required` (a `User=` unit, no TTY) and `upgrade.js` treated it as a WARNING and fell through; `readInstalledCoreVersion()` then confirmed the version on **disk**, where `npm install` had correctly put it; and `pollHealth()` got a 200 from the **still-running old process**, because a health check confirms a port is served, never *what* serves it. A lying tool is worse than a broken one — nothing goes looking. The damage outlived the incident: `subscribe.js` had already routed the unattended lane around `bongos upgrade` in favour of `go-live.js`, citing this false-pass in a comment. **Decision: a bump is confirmed by asking the running process what version it is.** (1) A failed restart enters the same auto-rollback path as a failed install/migrate/health and exits non-zero — rolling back rather than merely erroring keeps disk and process consistent, since disk-ahead-of-process is the state that made the incident invisible. (2) The served version is read back from `/version` (`coreVersion`, since 1.17.2, prelaunch-gate exempt), defaulting to the `--health-url` origin and overridable via `--version-url` — derived rather than opt-in because every existing call site passes only `--health-url`, and a check you must opt into is off exactly where it is needed. (3) A **mismatch** fails (proof of failure → roll back); an **unreadable** endpoint only warns (absence of proof — refusing every such bump would regress harder than the false-pass), except under an explicit `--version-url`, which asks for proof and therefore gets a failure. That strict mode is what the unattended subscription lane now passes, its roster already carrying the URL. **Escalation is `sudo -n`, not a hand-placed polkit rule:** `restartService()` retries a failed restart through `sudo -n` when not root — the idiom `dev.js`/`dev-lib.js` already use and consistent with `provision.js`, `upgrade.js` having been the one place that restarted without escalating. The live `/etc/polkit-1/rules.d/<redacted>.rules` mitigation is superseded: a rebuilt box inherits code, not hand-placed `/etc` files (polkit route kept in [`docs/recipes/instance-service-restart.md`](../recipes/instance-service-restart.md)). `--no-health-check` stays the single escape hatch and now waives the read-back too. Rejected: comparing `startedAt` (cannot distinguish a restart onto the same old core from one onto the new); making an unreadable endpoint fatal by default; requiring `--version-url` everywhere (absent from every current call site — the same "off where it matters" failure in a new costume); keeping polkit as the answer.](<redacted>.md) | core distribution / upgrade verification |
|
|
374
374
|
| 0280 | [**The unattended lane may drive a co-tenant, because the roster entry IS the per-instance decision** ([task 1003843](https://cloudbongos.com/builders#/task/1003843) · goal 1000106; from [task 1003521](https://cloudbongos.com/builders#/task/1003521), which built co-tenant mode into `go-live.js` and deliberately left this caller alone). `subscribe.js` keeps a SECOND COPY of go-live's target contract, and it drifted: `GO_LIVE_REQUIRED` still listed `deployTimer` after 1003521 made it optional, so `goLiveTargetFor()` returned `missing: ['deployTimer']` for every provisioned co-tenant, and `rawTopologyFor()`'s fixed field list dropped `pinMode` before the target was built — the same stale-normalizer trap that function's own comment was written to fix, one field later. **1003521 deferred this as a policy judgement call; reading the fallback shows the premise was wrong.** `mapped.ok === false` never meant "skip this instance" — it meant "deploy it the WEAKER way": a bare `bongos upgrade`, no backup, no deploy-timer guard, and a pin COMMITTED AND PUSHED into the co-tenant's own repo, which is exactly what `pinMode` exists to prevent. The lane was already auto-deploying co-tenants; the only open question was whether it did so well. A THIRD defect sat behind the other two and would have turned the silent downgrade into a hard failure had only the first been fixed: the normalizer yields `deployTimer: null`, an explicit null SURVIVES `JSON.stringify` into `--target-json`, and go-live's `validateTarget` runs `isSafeToken(null)` on it and rejects the whole target. A FOURTH sat PAST the go-live call and is what separates "reachable" from "safe": after any successful upgrade the lane runs its own pin net, `commitPin()`, which commits the pin files and PUSHES them to `origin` — a net that predates `pinMode`. Under `local-commit` go-live leaves the tree clean so it no-ops by luck; under **`leave-dirty`** the pin is in the working tree BY DESIGN, so the net committed it and pushed it into the customer's own repo — the lane honouring `pinMode` right up to the moment it undid it. **Decision: the lane may drive a co-tenant and needs no new gate, because listing an instance in `config/update-subscriptions.json` already IS the explicit per-instance operator decision** — the roster ships empty, nothing is touched until an operator lists it, and the routine is autonomy-gated (`requiresAutonomy: true`, default OFF) on top of that; a second consent gate asks the same person the same question twice. So: `GO_LIVE_REQUIRED` now MIRRORS go-live's `REQUIRED_FIELDS`, with a test reading BOTH source files and failing on drift; `deployTimer` moves to the optional-field loop so a co-tenant OMITS the key rather than carrying a null; `rawTopologyFor` carries `pinMode`; `resolveGoLive` gains a `needsCoTenant` probe leg; and `goLiveEngineFor()` makes the decision ONCE for both the dry run and the apply path, which had re-derived it separately. The pin net now obeys the same `pinMode` go-live was given — `commitPin()` takes `push` (default true, so the platform instance is unchanged), `local-commit` commits without pushing, `leave-dirty` skips the net, the rollback tidy follows the same rule, and the "not durable" advice stops telling an operator to `git push` a repo we do not own; its refusal to touch a worktree carrying NON-pin changes is unchanged in every mode. **The probe is CONDITIONAL on purpose** — demanding `pinMode` of every target would strip the go-live path from any instance between the `--target-json` release and 1003521's, trading a live regression for a hypothetical one. Rejected: a second consent gate (an `allowCoTenant` flag); deferring to the unspecced fleet control plane ([task 1001948](https://cloudbongos.com/builders#/task/1001948)) — which does not leave co-tenants alone, it leaves them pushing pins into customer repos; dropping `deployTimer` from `GO_LIVE_REQUIRED` and nothing else (the fix as proposed — trades a silent downgrade for a hard rejection); probing unconditionally; reaching co-tenant mode WITHOUT teaching the durability net about it (nominal support that breaks its own promise seconds later); and teaching `loadSubscriptions()` about `pinMode` instead, since that normalizer ships in the control plane's vendored core that nobody bumps — the exact trap `rawTopologyFor` routes around.](<redacted>.md) | core distribution / unattended deploy |
|
|
375
|
+
| 0281 | [**An instance’s identity is its own unix account and its own password-authed Postgres role** ([task 1003369](https://cloudbongos.com/builders#/task/1003369) · goal 1000094, audit ref `B3` of the 2026-08-29 security audit). [ADR 0111](<redacted>.md) made standup automatic and [ADR 0128](<redacted>.md) settled how the RUNNER escalates; neither settled who the INSTANCE is once it runs, and the answer was that everybody was the same person. `serviceUnit` hardcoded `User=lars`, and **co-tenant is the default shape** — so on a shared box every instance’s process ran under one uid, and `pool.js` connected by **passwordless peer auth**. Two consequences, neither a bug in any route: a unix user may read `/proc/<pid>/environ` of its own processes, so every instance could read every sibling’s `GITHUB_APP_PRIVATE_KEY`, hub client secret and OAuth secret; and peer auth answers “which unix user are you”, so any code as `lars` could open a pool against **any** database on the box by naming it — including the control plane’s own `builders` and `builder_sessions`. The `0600` `web.env` and the per-instance DB were already correct: sound walls with one door, and every instance had the key. **Decision: each instance gets its own unix account and its own password-authed PG role, both derived from its slug, both created by the provision run.** The account is `bongos-<slug>`, hash-folded past utmp’s 32 chars — prefixed because a project may legally be called `postgres`, and hash-suffixed because two slugs sharing a 16-char prefix would otherwise fold onto ONE account, which is the shared identity being removed. Its home is `/var/lib/<user>` and deliberately **not** under `/home`, since the unit sets `ProtectHome=read-only` and the server writes `builder-secret.key` inside its config home on first boot — a `/home` account would make that `EROFS`. The unit gains `ProtectSystem=strict` / `ProtectHome=read-only` / `PrivateTmp=true` with `ReadWritePaths` as the whole allow-list; **the checkout the service runs is deliberately not writable by it**. The role is `LOGIN` + password, owns its own DB, and is NOT superuser/CREATEDB/CREATEROLE — with **`REVOKE CONNECT … FROM PUBLIC` as the half that actually isolates**, because PG grants `CONNECT` to `PUBLIC` by default and per-role passwords alone leave a sibling one peer-auth hop away. Grants run in a SECOND statement **after** the migrate, since the privileged migrate runs as `postgres` and owns every table it creates; `ALTER DEFAULT PRIVILEGES` aims the same grant forward. The password is minted per run and never stored — the same run sets it on the role and rewrites the `0600` env file, so **every provision rotates it** — and `pool.js` reads `PGUSER`/`PGPASSWORD` **explicitly** rather than via `pg`’s env defaults, because the whole defect was a connection whose identity nothing in the code stated. **Absence is the old behavior:** no password ⇒ no credential lines and no pool keys, so an instance provisioned before this keeps its peer auth and the rollout cannot brick a box. Two consequences worth the record. (1) **The generated SQL contains no double quote, and that is load-bearing** — every literal is dollar-quoted and every identifier goes through `format(%I)`, because these commands are embedded in the dedicated droplet’s cloud-init as `- [ bash, -lc, "…" ]`, a YAML DOUBLE-QUOTED scalar: a plain `"role"` identifier reads fine in `psql` and silently truncates the line. A test asserts the absence rather than leaving it to habit. (2) **A partial, stated honestly** — the account is added to the app-user group so it can read the checkout it runs from (without it the unit fails at exec with `EACCES`), so FILE-level co-tenant separation is not what this delivers; the process and database boundaries are. Rejected: a `DATABASE_URL` in `web.env` (needs no pool change, but trades a stated, assertable identity for a socket-path parse that is `pg-connection-string`’s business); per-role passwords WITHOUT the `PUBLIC` revoke (reads like isolation, and peer auth walks around it); `ProtectSystem=full`, or `strict` with the working directory writable (makes the code the service executes writable by the service, in a change whose subject is containment); keeping `User=lars` and isolating with a second mechanism (the shared uid IS the `/proc` vector); storing the password in `provisioning_instances` (a live credential in plaintext, to buy stability the rotation does not need).](<redacted>.md) | tenant isolation / provisioning / DB auth |
|
|
@@ -1877,5 +1877,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1877
1877
|
landed since 1.19.707 with no explicit bump. run 34742245823. (task 1002620)
|
|
1878
1878
|
1.19.709 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1879
1879
|
landed since 1.19.708 with no explicit bump. run 34743928060. (task 1002620)
|
|
1880
|
+
1.19.710 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1881
|
+
landed since 1.19.709 with no explicit bump. run 34744369035. (task 1002620)
|
|
1880
1882
|
---------------------------------------------------------------------------
|
|
1881
1883
|
```
|
|
@@ -25,6 +25,20 @@ Organized by topic, not chronology. New entries go in the matching section; crea
|
|
|
25
25
|
- **Why it bites:** The `pg` library's defaults assume a standard TCP+password setup, which is the most common configuration but not the cheapest one for a single-machine deploy. Peer auth is more secure and frictionless once you know the socket path. The error message points at SASL, not at the host setting, which buries the real cause.
|
|
26
26
|
- **Where this lives in the codebase:** [`src/db.js`](../../src/db.js) has the explicit socket host.
|
|
27
27
|
|
|
28
|
+
### Re-provisioning an EXISTING instance moves its `HOME`, orphaning any stored builder secrets
|
|
29
|
+
|
|
30
|
+
- **Symptom:** after a re-provision, an instance that had builder API keys stored can no longer decrypt them. Nothing errors at startup — the instance comes up healthy and simply behaves as though no key was ever saved.
|
|
31
|
+
- **Root cause:** task [1003369](https://cloudbongos.com/builders#/task/1003369) ([ADR 0281](../adr/<redacted>.md)) gives each instance its own unix account with `HOME=/var/lib/bongos-<slug>`. [`src/bongos/secret-box.js`](../../src/bongos/secret-box.js) keeps its master key at `~/.config/<configDir>/builder-secret.key`, so the key an instance wrote under the OLD shared home (`/home/lars/.config/…`) is no longer on its `HOME` path. `provisionMasterKey()` finds nothing, mints a fresh key, and every previously encrypted secret is now undecryptable ciphertext.
|
|
32
|
+
- **Who this hits:** only a **re-provision of an instance that has already stored secrets**. A fresh standup has nothing to lose, and an instance whose `web.env` sets `BUILDER_SECRET_KEY` is unaffected — that env wins over the file.
|
|
33
|
+
- **Fix (before the re-provision, one command as root):**
|
|
34
|
+
```bash
|
|
35
|
+
install -D -o bongos-<slug> -g bongos-<slug> -m 600 \
|
|
36
|
+
/home/lars/.config/<configDir>/builder-secret.key \
|
|
37
|
+
/var/lib/bongos-<slug>/.config/<configDir>/builder-secret.key
|
|
38
|
+
```
|
|
39
|
+
`<configDir>` is the instance’s `branding.configDir` (vanilla: `cloudbongos`). Do it **first** — `provisionMasterKey()` writes with the `wx` flag and refuses to clobber, so a key placed after the instance has already minted its own will not be picked up.
|
|
40
|
+
- **Why it bites:** the failure is silent on both ends. The old key is not deleted, the new one is valid, and the only visible symptom is secrets that quietly stopped working.
|
|
41
|
+
|
|
28
42
|
---
|
|
29
43
|
|
|
30
44
|
## Colyseus
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.710",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.710",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.710",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
+
const crypto = require('node:crypto');
|
|
13
14
|
const fs = require('node:fs');
|
|
14
15
|
const os = require('node:os');
|
|
15
16
|
const path = require('node:path');
|
|
@@ -111,6 +112,177 @@ function standaloneMigrateCmd(inst, { privileged = false } = {}) {
|
|
|
111
112
|
// transfer from primea"): its bespoke pull-deploy regenerated nothing.
|
|
112
113
|
function standaloneRegenDocsCmd() { return `node ${CORE_PKG_DIR}/scripts/gds/regen-instance-docs.js`; }
|
|
113
114
|
|
|
115
|
+
// ── Per-instance identity: its OWN unix account + its OWN password-authed PG role ──
|
|
116
|
+
// (task 1003369, audit ref B3 of the 2026-08-29 security audit.)
|
|
117
|
+
//
|
|
118
|
+
// THE HOLE THIS CLOSES. Every instance service used to run `User=lars/Group=lars`, and
|
|
119
|
+
// co-tenant is the DEFAULT shape — so on a shared box every instance ran as the SAME
|
|
120
|
+
// unix user. A unix user may read /proc/<pid>/environ of its own processes, which means
|
|
121
|
+
// any instance could read every SIBLING's environment: GITHUB_APP_PRIVATE_KEY, the
|
|
122
|
+
// <PREFIX>_HUB_CLIENT_SECRET, every OAuth secret web.env carries. The DB half was the
|
|
123
|
+
// same shape one layer down: pool.js connected by PASSWORDLESS peer auth, so any code
|
|
124
|
+
// running as lars could open a pool against any sibling database — including the control
|
|
125
|
+
// plane's own `builders` and `builder_sessions`. Neither was a bug in a route; it was the
|
|
126
|
+
// identity every instance shared. Giving each instance its own unix account and its own
|
|
127
|
+
// password-authed role is what makes the boundary real.
|
|
128
|
+
//
|
|
129
|
+
// NAME SHAPE. A unix account name is capped at 32 chars (utmp) and must match
|
|
130
|
+
// [a-z_][a-z0-9_-]*; a slug is 2–40 of [a-z0-9-] (isValidSlug). So the name cannot be
|
|
131
|
+
// the slug verbatim, and it must not be the BARE slug either — that would let a project
|
|
132
|
+
// named `postgres` or `lars` claim an existing account. Prefixed + length-folded:
|
|
133
|
+
// short slug → bongos-<slug>
|
|
134
|
+
// long slug → bongos-<slug truncated>-<8 hex of sha256(slug)> (still <= 32)
|
|
135
|
+
// The hash suffix keeps two slugs that share a 16-char prefix from folding onto one
|
|
136
|
+
// account — which would hand each the other's identity, the very thing being fixed.
|
|
137
|
+
const INSTANCE_USER_PREFIX = 'bongos-';
|
|
138
|
+
const INSTANCE_USER_MAX = 32; // utmp's cap; useradd enforces it
|
|
139
|
+
|
|
140
|
+
// The unix account an instance's service (and only that instance's service) runs as.
|
|
141
|
+
// Deterministic — the runner derives it fresh every run rather than storing it, so a
|
|
142
|
+
// re-provision can never address a different account than the one it created. PURE.
|
|
143
|
+
function instanceUser(inst) {
|
|
144
|
+
const slug = String((inst && inst.slug) || '');
|
|
145
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug)) {
|
|
146
|
+
throw new Error(`instanceUser: refusing to derive an account name from an invalid slug ${JSON.stringify(slug)}`);
|
|
147
|
+
}
|
|
148
|
+
const whole = INSTANCE_USER_PREFIX + slug;
|
|
149
|
+
if (whole.length <= INSTANCE_USER_MAX) return whole;
|
|
150
|
+
const digest = crypto.createHash('sha256').update(slug).digest('hex').slice(0, 8);
|
|
151
|
+
// Budget: prefix + head + '-' + 8 hex === INSTANCE_USER_MAX. Trailing dashes are
|
|
152
|
+
// stripped so the fold can never emit `--` (which useradd's NAME_REGEX rejects).
|
|
153
|
+
const head = slug.slice(0, INSTANCE_USER_MAX - INSTANCE_USER_PREFIX.length - 9).replace(/-+$/, '');
|
|
154
|
+
return `${INSTANCE_USER_PREFIX}${head}-${digest}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The instance account's HOME — and the ONE directory the hardened unit leaves
|
|
158
|
+
// writable. It is deliberately NOT under /home: the unit sets ProtectHome=read-only
|
|
159
|
+
// (so one instance cannot read the operator's or a sibling's home), and the server DOES
|
|
160
|
+
// write inside its config home at runtime — src/bongos/secret-box.js provisions
|
|
161
|
+
// builder-secret.key under ~/.config/<configDir> on first boot. A /home account would
|
|
162
|
+
// make that write EROFS and the instance would come up unable to hold a secret. PURE.
|
|
163
|
+
function instanceStateDir(inst) { return path.posix.join('/var/lib', instanceUser(inst)); }
|
|
164
|
+
|
|
165
|
+
// The instance's OWN Postgres login role. Same name as the unix account so an operator
|
|
166
|
+
// reading `ps` and `pg_stat_activity` sees one identity, not two — a dash is legal in a
|
|
167
|
+
// quoted PG identifier, and every SQL site below quotes it. PURE.
|
|
168
|
+
function instanceDbRole(inst) { return instanceUser(inst); }
|
|
169
|
+
|
|
170
|
+
// A fresh password for that role. base64url ([A-Za-z0-9_-]) on purpose: it needs no
|
|
171
|
+
// escaping in the env file that carries it, in the dollar-quoted SQL literal that sets
|
|
172
|
+
// it, or in a connection URL — the three places it travels. 32 bytes ~ 256 bits.
|
|
173
|
+
// Generated per provision run, not stored: every run ALTERs the role and rewrites
|
|
174
|
+
// web.env in the same pass, so the credential rotates on its own.
|
|
175
|
+
function generateDbPassword() { return crypto.randomBytes(32).toString('base64url'); }
|
|
176
|
+
|
|
177
|
+
// Create the instance's unix account if it is not already there, with its state dir.
|
|
178
|
+
// IDEMPOTENT by construction — `id -u` short-circuits an existing account, and the
|
|
179
|
+
// `install -d` re-asserts ownership and mode on every run, so a re-provision repairs a
|
|
180
|
+
// hand-edited box instead of failing on it. A SYSTEM account with /usr/sbin/nologin:
|
|
181
|
+
// nothing should ever log in as an instance.
|
|
182
|
+
function ensureInstanceUserCmd(inst, { privileged = false } = {}) {
|
|
183
|
+
const user = instanceUser(inst), dir = instanceStateDir(inst), sudo = privileged ? 'sudo ' : '';
|
|
184
|
+
return `id -u ${user} >/dev/null 2>&1 || ${sudo}useradd --system --shell /usr/sbin/nologin --home-dir ${dir} ${user}; ` +
|
|
185
|
+
`${sudo}install -d -o ${user} -g ${user} -m 0700 ${dir}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Let the instance account READ the checkout it runs from. The co-tenant shape's
|
|
189
|
+
// WorkingDirectory is this control-plane checkout and the standalone shape's is
|
|
190
|
+
// /srv/<base>/<slug>; both are owned by the app user, and a fresh system account is in
|
|
191
|
+
// neither the owner nor the group, so without this the unit fails at exec with EACCES.
|
|
192
|
+
// Group membership grants exactly the read the app user's group already has — it does
|
|
193
|
+
// NOT re-open the two vectors this task closes: /proc/<pid>/environ stays readable only
|
|
194
|
+
// by the process's own uid, and the DB now demands a password no sibling holds.
|
|
195
|
+
function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
|
|
196
|
+
return `${privileged ? 'sudo ' : ''}usermod -aG ${APP_USER} ${instanceUser(inst)}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Create-or-rotate the instance's PG login role and hand it its OWN database — and
|
|
200
|
+
// nothing else. Three statements, one psql, ON_ERROR_STOP:
|
|
201
|
+
// 1. CREATE ROLE ... LOGIN PASSWORD, or ALTER ... PASSWORD when it already exists (the
|
|
202
|
+
// re-provision path — this is what rotates the credential).
|
|
203
|
+
// 2. ALTER DATABASE ... OWNER TO — the role owns its own DB, so the app's DDL needs no
|
|
204
|
+
// superuser, and in PG15+ it inherits the public schema through pg_database_owner.
|
|
205
|
+
// 3. REVOKE CONNECT ... FROM PUBLIC — the half that actually isolates. Postgres grants
|
|
206
|
+
// CONNECT on every database to PUBLIC by default, so per-role passwords ALONE would
|
|
207
|
+
// still leave a sibling one peer-auth hop from any other instance's data.
|
|
208
|
+
// NOT a superuser and NOT CREATEDB/CREATEROLE: an instance that is compromised gets its
|
|
209
|
+
// own data and no path to anyone else's.
|
|
210
|
+
//
|
|
211
|
+
// Runs against `postgres`, not the instance DB: ALTER DATABASE ... OWNER cannot run from
|
|
212
|
+
// inside the database it renames the owner of.
|
|
213
|
+
//
|
|
214
|
+
// QUOTING, which is load-bearing in a way that is easy to undo by accident. Every literal
|
|
215
|
+
// is DOLLAR-QUOTED and every identifier goes through format(%I), so the rendered command
|
|
216
|
+
// contains no single quote and NO DOUBLE QUOTE. It has to survive three executors
|
|
217
|
+
// unchanged — inline /bin/sh, `bash -s` over SSH, and, the strict one, `- [ bash, -lc,
|
|
218
|
+
// "..." ]` inside the dedicated droplet's cloud-init, which is a YAML DOUBLE-QUOTED
|
|
219
|
+
// scalar. A plain "role" identifier reads fine in psql and silently truncates the YAML.
|
|
220
|
+
// seedFirstVersionCmd dollar-quotes for the same reason; a test asserts the absence.
|
|
221
|
+
// `peerOnly` is the DEDICATED shape's mode, and it exists because of where that
|
|
222
|
+
// command TRAVELS (task 1003369). A dedicated droplet's steps ride DigitalOcean
|
|
223
|
+
// cloud-init user-data, which DO RETAINS and serves back through its API, its console
|
|
224
|
+
// and the droplet's own metadata endpoint — so a password embedded there is a second,
|
|
225
|
+
// permanent copy of the credential in a place this task cannot lock down, which is
|
|
226
|
+
// strictly worse than what it protects against. A dedicated droplet is SINGLE-TENANT:
|
|
227
|
+
// there are no siblings on it, so the co-tenancy vector B3 describes does not exist,
|
|
228
|
+
// and the role can peer-auth to its own unix account. The role, its DB ownership and
|
|
229
|
+
// the PUBLIC revoke are still created — only the password is omitted, deliberately.
|
|
230
|
+
function dbRoleCmd(inst, { password = null, peerOnly = false, privileged = false } = {}) {
|
|
231
|
+
const role = instanceDbRole(inst), db = dbName(inst);
|
|
232
|
+
if (peerOnly) {
|
|
233
|
+
if (password) throw new Error('dbRoleCmd: peerOnly takes no password — it exists to keep one out of cloud-init user-data');
|
|
234
|
+
} else if (!/^[A-Za-z0-9_-]+$/.test(String(password || ''))) {
|
|
235
|
+
throw new Error('dbRoleCmd: password must be a non-empty base64url string (generateDbPassword)');
|
|
236
|
+
}
|
|
237
|
+
// Two shapes of the same upsert. peerOnly omits the PASSWORD clause entirely rather
|
|
238
|
+
// than passing an empty one — `WITH LOGIN` leaves any existing password untouched.
|
|
239
|
+
const login = peerOnly
|
|
240
|
+
? { clause: 'WITH LOGIN', args: '' }
|
|
241
|
+
: { clause: 'WITH LOGIN PASSWORD %L', args: `, $p$${password}$p$` };
|
|
242
|
+
const sql =
|
|
243
|
+
`DO $do$ BEGIN ` +
|
|
244
|
+
`IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $r$${role}$r$) THEN ` +
|
|
245
|
+
`EXECUTE format($f$ALTER ROLE %I ${login.clause}$f$, $r$${role}$r$${login.args}); ` +
|
|
246
|
+
`ELSE ` +
|
|
247
|
+
`EXECUTE format($f$CREATE ROLE %I ${login.clause}$f$, $r$${role}$r$${login.args}); ` +
|
|
248
|
+
`END IF; ` +
|
|
249
|
+
`EXECUTE format($f$ALTER DATABASE %I OWNER TO %I$f$, $d$${db}$d$, $r$${role}$r$); ` +
|
|
250
|
+
`EXECUTE format($f$REVOKE CONNECT ON DATABASE %I FROM PUBLIC$f$, $d$${db}$d$); ` +
|
|
251
|
+
`END $do$;`;
|
|
252
|
+
return asPostgres(`psql -v ON_ERROR_STOP=1 -d postgres -c '${sql}'`, privileged);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// What the runner LOGS in place of dbRoleCmd, whose SQL embeds the password. Lives here,
|
|
256
|
+
// beside the command, so the two cannot drift: a `shown` that outlived the secret it hides
|
|
257
|
+
// would be a silent re-leak. Names every effect the real command has. PURE.
|
|
258
|
+
function dbRoleCmdShown(inst) {
|
|
259
|
+
return `psql -d postgres -c '<upsert role ${instanceDbRole(inst)} + own ${dbName(inst)} + revoke PUBLIC connect>' (password redacted)`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Hand the instance role the objects the migrate just created. This is NOT belt-and-braces:
|
|
263
|
+
// the privileged migrate runs as `postgres`, so every table and sequence it creates is
|
|
264
|
+
// OWNED BY postgres. Owning the DATABASE does not carry ownership of objects inside it, so
|
|
265
|
+
// without these grants the instance would authenticate perfectly and then fail its first
|
|
266
|
+
// SELECT. The ALTER DEFAULT PRIVILEGES pair is the same statement aimed forward: it applies
|
|
267
|
+
// to objects the CURRENT role creates later, which is exactly what the next migrate does —
|
|
268
|
+
// so a schema migration shipped six months from now needs no second visit here.
|
|
269
|
+
//
|
|
270
|
+
// Runs against the instance DB (schema grants are per-database), and AFTER the migrate.
|
|
271
|
+
function dbRoleGrantsCmd(inst, { privileged = false } = {}) {
|
|
272
|
+
const role = instanceDbRole(inst), db = dbName(inst);
|
|
273
|
+
// Same quoting rule as dbRoleCmd: format(%I), never a double-quoted identifier.
|
|
274
|
+
const g = (stmt) => `EXECUTE format($f$${stmt} %I$f$, $r$${role}$r$); `;
|
|
275
|
+
const sql =
|
|
276
|
+
`DO $do$ BEGIN ` +
|
|
277
|
+
g('GRANT ALL ON SCHEMA public TO') +
|
|
278
|
+
g('GRANT ALL ON ALL TABLES IN SCHEMA public TO') +
|
|
279
|
+
g('GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO') +
|
|
280
|
+
g('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO') +
|
|
281
|
+
g('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO') +
|
|
282
|
+
`END $do$;`;
|
|
283
|
+
return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${db} -c '${sql}'`, privileged);
|
|
284
|
+
}
|
|
285
|
+
|
|
114
286
|
// ── STANDALONE scaffold (ADR 0108 / 0125, task 2055) ──────────────────────────
|
|
115
287
|
// The MISSING first leg of the greenfield "one guided flow": provision.js used to
|
|
116
288
|
// THROW if the instance repo wasn't already cloned. These generate + run the scaffold
|
|
@@ -831,13 +1003,22 @@ module.exports = {
|
|
|
831
1003
|
buildCorePinRefreshCommit,
|
|
832
1004
|
dbCreateCmd,
|
|
833
1005
|
dbName,
|
|
1006
|
+
dbRoleCmd,
|
|
1007
|
+
dbRoleCmdShown,
|
|
1008
|
+
dbRoleGrantsCmd,
|
|
834
1009
|
deployKeyPath,
|
|
835
1010
|
deployKeyTitle,
|
|
1011
|
+
ensureInstanceUserCmd,
|
|
836
1012
|
ensurePrivateRepoAccess,
|
|
1013
|
+
generateDbPassword,
|
|
1014
|
+
grantInstanceRepoReadCmd,
|
|
837
1015
|
installedCorePackDir,
|
|
838
1016
|
installedCoreTarball,
|
|
1017
|
+
instanceDbRole,
|
|
839
1018
|
instanceInitSpec,
|
|
840
1019
|
instanceRepoRemote,
|
|
1020
|
+
instanceStateDir,
|
|
1021
|
+
instanceUser,
|
|
841
1022
|
migrateCmd,
|
|
842
1023
|
onboardMode,
|
|
843
1024
|
parseTargetRef,
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
14
|
-
const { APP_USER, CORE_PKG_DIR, dbName, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
|
|
14
|
+
const { APP_USER, CORE_PKG_DIR, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
|
|
15
15
|
|
|
16
16
|
function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
17
17
|
const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
|
|
18
|
+
const user = instanceUser(inst), stateDir = instanceStateDir(inst);
|
|
18
19
|
const standalone = inst.hosting_shape === 'standalone';
|
|
19
20
|
const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
|
|
20
21
|
const execStart = standalone
|
|
@@ -28,10 +29,21 @@ Requires=postgresql.service
|
|
|
28
29
|
|
|
29
30
|
[Service]
|
|
30
31
|
Type=simple
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
# This instance's OWN unix account, never the shared app user (task 1003369, audit B3).
|
|
33
|
+
# Co-tenant is the default shape, so a shared User= let every instance read every
|
|
34
|
+
# sibling's /proc/<pid>/environ — GITHUB_APP_PRIVATE_KEY, the hub client secret, every
|
|
35
|
+
# OAuth cred in the EnvironmentFile below. A distinct uid is what closes that: /proc
|
|
36
|
+
# environ is readable only by the process's own uid.
|
|
37
|
+
User=${user}
|
|
38
|
+
Group=${user}
|
|
33
39
|
WorkingDirectory=${workDir}
|
|
40
|
+
# Read by PID 1 as root BEFORE the privilege drop, so 0600 root-owned stays correct —
|
|
41
|
+
# the instance account itself never needs read on its own secrets file.
|
|
34
42
|
EnvironmentFile=${envFile}
|
|
43
|
+
# HOME is the state dir, NOT /home: the server writes inside its config home on first
|
|
44
|
+
# boot (secret-box.js provisions builder-secret.key there) and ProtectHome below makes
|
|
45
|
+
# /home read-only. Without this the instance comes up unable to hold a secret.
|
|
46
|
+
Environment=HOME=${stateDir}
|
|
35
47
|
Environment=PGDATABASE=${dbName(inst)}
|
|
36
48
|
Environment=PORT=${inst.port}
|
|
37
49
|
ExecStart=${execStart}
|
|
@@ -40,6 +52,15 @@ RestartSec=3
|
|
|
40
52
|
StandardOutput=journal
|
|
41
53
|
StandardError=journal
|
|
42
54
|
NoNewPrivileges=true
|
|
55
|
+
# The same hardening the sibling backup unit already carries. ReadWritePaths is the
|
|
56
|
+
# whole allow-list under ProtectSystem=strict: the state dir, plus the instance's own
|
|
57
|
+
# config/ (the modules toggle writes config/modules.json at runtime). The checkout the
|
|
58
|
+
# service RUNS is deliberately not writable by it. The leading dash on config/ keeps a
|
|
59
|
+
# shape that has none from failing to mount rather than failing to start.
|
|
60
|
+
ProtectSystem=strict
|
|
61
|
+
ProtectHome=read-only
|
|
62
|
+
PrivateTmp=true
|
|
63
|
+
ReadWritePaths=${stateDir} -${workDir}/config
|
|
43
64
|
|
|
44
65
|
[Install]
|
|
45
66
|
WantedBy=multi-user.target
|
|
@@ -168,7 +189,7 @@ function provisionedEnvPrefix() {
|
|
|
168
189
|
// the hall on this host + OAuth uses the real callback origin — without them the pack
|
|
169
190
|
// stays at neutral localhost and the hall never loads (task 1972). No domain yet
|
|
170
191
|
// (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
|
|
171
|
-
function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null, ownerLogin = null } = {}) {
|
|
192
|
+
function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null, ownerLogin = null, dbPassword = null } = {}) {
|
|
172
193
|
const prefix = provisionedEnvPrefix(), { settingsEnvLines } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
|
|
173
194
|
const origin = inst.domain ? `https://${inst.domain}` : '';
|
|
174
195
|
const originLines = origin
|
|
@@ -239,13 +260,20 @@ GITHUB_APP_ID=${appId}
|
|
|
239
260
|
GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
|
|
240
261
|
`
|
|
241
262
|
: '';
|
|
263
|
+
// The per-instance DB credential (task 1003369, audit B3). Present only once the
|
|
264
|
+
// runner has created the role — a first dry-run and any legacy caller omit it, and the
|
|
265
|
+
// instance then falls back to the peer auth it used before, so this can never brick a
|
|
266
|
+
// box mid-migration. src/bongos/pool.js reads both names explicitly.
|
|
267
|
+
const dbLines = dbPassword
|
|
268
|
+
? `PGUSER=${instanceDbRole(inst)}\nPGPASSWORD=${dbPassword}\n`
|
|
269
|
+
: '';
|
|
242
270
|
return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
|
|
243
271
|
${credComment}
|
|
244
272
|
${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
|
|
245
273
|
${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
|
|
246
274
|
${firstAdminLine}${originLines}${appLines}PORT=${inst.port}
|
|
247
275
|
PGDATABASE=${dbName(inst)}
|
|
248
|
-
${fedLines}${settingsEnvLines(inst, prefix)}`;
|
|
276
|
+
${dbLines}${fedLines}${settingsEnvLines(inst, prefix)}`;
|
|
249
277
|
}
|
|
250
278
|
function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
251
279
|
return path.posix.join(etcBase, inst.slug, 'web.env');
|
package/scripts/gds/provision.js
CHANGED
|
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
|
|
|
50
50
|
const crypto = require('node:crypto');
|
|
51
51
|
|
|
52
52
|
const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
53
|
-
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
53
|
+
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
54
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
55
|
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
|
|
@@ -232,6 +232,9 @@ async function provisionInstance(inst, deps) {
|
|
|
232
232
|
const boxExec = standalone ? controlExec : exec;
|
|
233
233
|
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
234
234
|
const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
|
|
235
|
+
// One fresh per-instance DB password per run (task 1003369, see provision-repo.js): step
|
|
236
|
+
// 2b sets it on the role, step 3 writes it to web.env, so the two cannot disagree.
|
|
237
|
+
const dbPassword = generateDbPassword();
|
|
235
238
|
log(`provision ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
|
|
236
239
|
|
|
237
240
|
// Idempotency: an already-active instance is a no-op success (re-drained intent).
|
|
@@ -374,6 +377,15 @@ async function provisionInstance(inst, deps) {
|
|
|
374
377
|
else log(' [code] would refresh the committed @bongos/core pin if it trails the running core (task 1003050)');
|
|
375
378
|
boxExec(standaloneInstallCmd(), { cwd: root });
|
|
376
379
|
}
|
|
380
|
+
// ── step 1c: the instance's OWN unix account (task 1003369, audit B3) ─────
|
|
381
|
+
// Idempotent; the group-add is what lets a fresh system account READ the checkout it runs
|
|
382
|
+
// from — without it the unit fails at exec with EACCES. Rationale: provision-repo.js.
|
|
383
|
+
if (local) {
|
|
384
|
+
log(` [user] ensure unix account ${instanceUser(inst)} + its state dir`);
|
|
385
|
+
boxExec(ensureInstanceUserCmd(inst, { privileged }));
|
|
386
|
+
boxExec(grantInstanceRepoReadCmd(inst, { privileged }));
|
|
387
|
+
} else { log(' (instance unix account created via cloud-init on the droplet)'); }
|
|
388
|
+
|
|
377
389
|
log(' [db] create + GDS-only migrate');
|
|
378
390
|
if (local) {
|
|
379
391
|
boxExec(dbCreateCmd(inst, { privileged }), { allowFail: true }); // "already exists" is fine
|
|
@@ -395,9 +407,21 @@ async function provisionInstance(inst, deps) {
|
|
|
395
407
|
}
|
|
396
408
|
} else { log(' (db create + migrate + first-version seed run via cloud-init on the droplet)'); }
|
|
397
409
|
|
|
410
|
+
// ── step 2b: the instance's OWN password-authed PG role (task 1003369, audit B3) ──
|
|
411
|
+
// AFTER the migrate: it runs as `postgres`, which therefore OWNS the tables it creates.
|
|
412
|
+
if (local) {
|
|
413
|
+
log(' [db] create/rotate the per-instance role + revoke PUBLIC connect');
|
|
414
|
+
// `shown` is MANDATORY: dbRoleCmd embeds the password, and run() logs the raw command
|
|
415
|
+
// on BOTH the dry-run and success paths — the exact leak this task exists to prevent
|
|
416
|
+
// (makeWriteFile passes `shown` for the web.env body for the same reason, and a `shown`
|
|
417
|
+
// call site also suppresses stderr). The GRANTS carry no secret and log in full.
|
|
418
|
+
boxExec(dbRoleCmd(inst, { password: dbPassword, privileged }), { shown: dbRoleCmdShown(inst) });
|
|
419
|
+
boxExec(dbRoleGrantsCmd(inst, { privileged }));
|
|
420
|
+
} else { log(' (per-instance DB role created via cloud-init on the droplet)'); }
|
|
421
|
+
|
|
398
422
|
// ── step 3: config + per-instance secrets ─────────────────────────────────
|
|
399
423
|
log(' [config] per-instance web.env');
|
|
400
|
-
if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db) }), { mode: 0o600, sudo: true });
|
|
424
|
+
if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db), dbPassword }), { mode: 0o600, sudo: true });
|
|
401
425
|
else log(' (baked into cloud-init)');
|
|
402
426
|
|
|
403
427
|
// ── step 4: systemd unit + enable ─────────────────────────────────────────
|
|
@@ -570,6 +594,10 @@ function dedicatedUserData(inst) {
|
|
|
570
594
|
// enable the timer alongside the web unit. Mirrors provisionInstance's step 4b.
|
|
571
595
|
const svcB64 = Buffer.from(backupService(i), 'utf8').toString('base64');
|
|
572
596
|
const timerB64 = Buffer.from(backupTimer(i), 'utf8').toString('base64');
|
|
597
|
+
// task 1003369: the same role the local leg creates at step 2b, same position around the
|
|
598
|
+
// migrate, but PASSWORD-LESS (peerOnly) — DO retains user-data and serves it back, and a
|
|
599
|
+
// dedicated droplet is single-tenant. Whole argument on dbRoleCmd.
|
|
600
|
+
const roleLines = ` - [ bash, -lc, "${dbRoleCmd(i, { peerOnly: true })}" ]\n - [ bash, -lc, "${dbRoleGrantsCmd(i)}" ]\n`;
|
|
573
601
|
return `#cloud-config
|
|
574
602
|
write_files:
|
|
575
603
|
- path: ${backupServicePath(i)}
|
|
@@ -581,10 +609,12 @@ write_files:
|
|
|
581
609
|
permissions: '0644'
|
|
582
610
|
content: ${timerB64}
|
|
583
611
|
runcmd:
|
|
612
|
+
- [ bash, -lc, "${ensureInstanceUserCmd(i)}" ]
|
|
613
|
+
- [ bash, -lc, "${grantInstanceRepoReadCmd(i)}" ]
|
|
584
614
|
- [ bash, -lc, "${dbCreateCmd(i)} || true" ]
|
|
585
615
|
- [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
|
|
586
616
|
- [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
|
|
587
|
-
- [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
|
|
617
|
+
${roleLines} - [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
|
|
588
618
|
- [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
|
|
589
619
|
`;
|
|
590
620
|
}
|
|
@@ -1441,6 +1471,8 @@ module.exports = {
|
|
|
1441
1471
|
CONFIG, MAX_INTENT_ATTEMPTS, provisionerBotEmail,
|
|
1442
1472
|
// pure step generators (tested directly)
|
|
1443
1473
|
dbName, dbCreateCmd, migrateCmd, seedFirstVersionCmd, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath,
|
|
1474
|
+
// per-instance unix account + password-authed PG role (task 1003369, audit B3)
|
|
1475
|
+
instanceUser, instanceStateDir, instanceDbRole, generateDbPassword, ensureInstanceUserCmd, grantInstanceRepoReadCmd, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd,
|
|
1444
1476
|
backupUnitName, backupServicePath, backupTimerPath, backupScriptPath, backupService, backupTimer,
|
|
1445
1477
|
standaloneRoot, standalonePullCmd, standaloneInstallCmd, standaloneMigrateCmd,
|
|
1446
1478
|
// standalone scaffold (ADR 0108 / 0125, task 2055; consumer-core fix task 1002275)
|
package/src/bongos/pool.js
CHANGED
|
@@ -45,6 +45,20 @@ if (process.env.DATABASE_URL) {
|
|
|
45
45
|
// Peer auth via the local Unix socket on the droplet; PG* env overrides for other environments.
|
|
46
46
|
host: process.env.PGHOST || '/var/run/postgresql',
|
|
47
47
|
database: process.env.PGDATABASE || instanceDbName(),
|
|
48
|
+
// The per-instance DB credential (task 1003369, audit B3). A provisioned instance's
|
|
49
|
+
// web.env carries PGUSER/PGPASSWORD naming a role that owns its OWN database and
|
|
50
|
+
// has CONNECT on no other — so a co-tenant box no longer lets one instance open a
|
|
51
|
+
// pool against a sibling's data, or against the control plane's own builders and
|
|
52
|
+
// builder_sessions, simply by running as the same unix user.
|
|
53
|
+
//
|
|
54
|
+
// Named EXPLICITLY rather than left to pg's env defaults, which would resolve the
|
|
55
|
+
// same two vars invisibly. The whole defect this closes was a connection whose
|
|
56
|
+
// identity nothing in the code stated, so the identity is stated here, and a test
|
|
57
|
+
// can assert it. Absent (self-host, dev, an instance provisioned before this landed)
|
|
58
|
+
// the keys are omitted entirely and pg falls back to exactly the peer auth it used
|
|
59
|
+
// before — this cannot brick a box whose role has not been created yet.
|
|
60
|
+
...(process.env.PGUSER ? { user: process.env.PGUSER } : {}),
|
|
61
|
+
...(process.env.PGPASSWORD ? { password: process.env.PGPASSWORD } : {}),
|
|
48
62
|
});
|
|
49
63
|
}
|
|
50
64
|
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.710'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/provision.mjs
CHANGED
|
@@ -2446,5 +2446,232 @@ await ta('teardownInstance(standalone): no registered key / no token → teardow
|
|
|
2446
2446
|
assert.ok(b.cmds.some((c) => /rm -f \S+\/\.git\/cloudbongos-deploy-key \S+\.pub/.test(c)), 'local key halves removed even with no credential');
|
|
2447
2447
|
});
|
|
2448
2448
|
|
|
2449
|
+
|
|
2450
|
+
console.log('\nper-instance unix account + password-authed PG role (task 1003369, audit B3):');
|
|
2451
|
+
|
|
2452
|
+
t('instanceUser: prefixed, never the bare slug — a project called `postgres` cannot claim that account', () => {
|
|
2453
|
+
assert.equal(P.instanceUser(coTenant), 'bongos-mercury');
|
|
2454
|
+
assert.equal(P.instanceUser({ slug: 'postgres' }), 'bongos-postgres');
|
|
2455
|
+
assert.notEqual(P.instanceUser({ slug: 'lars' }), 'lars');
|
|
2456
|
+
});
|
|
2457
|
+
|
|
2458
|
+
t('instanceUser: a 40-char slug folds to a LEGAL account name — <=32 chars, no trailing/double dash', () => {
|
|
2459
|
+
const slug = 'a'.repeat(39) + 'b'; // the isValidSlug maximum
|
|
2460
|
+
const u = P.instanceUser({ slug });
|
|
2461
|
+
assert.ok(u.length <= 32, `account name must fit utmp's 32 chars, got ${u.length} (${u})`);
|
|
2462
|
+
assert.match(u, /^[a-z][a-z0-9-]*[a-z0-9]$/, 'must satisfy useradd NAME_REGEX');
|
|
2463
|
+
assert.ok(!u.includes('--'), 'no double dash');
|
|
2464
|
+
});
|
|
2465
|
+
|
|
2466
|
+
t('instanceUser: two slugs sharing a long prefix get DIFFERENT accounts — a fold collision would hand each the other\'s identity', () => {
|
|
2467
|
+
const a = 'production-instance-alpha-one'; // 29 chars, identical for 26 of them
|
|
2468
|
+
const b = 'production-instance-alpha-two';
|
|
2469
|
+
assert.notEqual(P.instanceUser({ slug: a }), P.instanceUser({ slug: b }));
|
|
2470
|
+
});
|
|
2471
|
+
|
|
2472
|
+
t('instanceUser: an invalid slug THROWS rather than deriving an account name from it', () => {
|
|
2473
|
+
assert.throws(() => P.instanceUser({ slug: '../../etc/passwd' }), /invalid slug/);
|
|
2474
|
+
assert.throws(() => P.instanceUser({ slug: 'Has Spaces' }), /invalid slug/);
|
|
2475
|
+
assert.throws(() => P.instanceUser({ slug: '' }), /invalid slug/);
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2478
|
+
t('instanceStateDir is NOT under /home — ProtectHome=read-only would make the first-boot secret write EROFS', () => {
|
|
2479
|
+
const dir = P.instanceStateDir(coTenant);
|
|
2480
|
+
assert.equal(dir, '/var/lib/bongos-mercury');
|
|
2481
|
+
assert.ok(!dir.startsWith('/home'), 'the writable state dir must survive ProtectHome=read-only');
|
|
2482
|
+
});
|
|
2483
|
+
|
|
2484
|
+
t('generateDbPassword: base64url only, so it needs no escaping in the env file, the SQL literal or a URL', () => {
|
|
2485
|
+
for (let i = 0; i < 20; i++) {
|
|
2486
|
+
const pw = P.generateDbPassword();
|
|
2487
|
+
assert.match(pw, /^[A-Za-z0-9_-]+$/, `not base64url: ${pw}`);
|
|
2488
|
+
assert.ok(pw.length >= 40, `too short to be 32 bytes: ${pw.length}`);
|
|
2489
|
+
}
|
|
2490
|
+
assert.notEqual(P.generateDbPassword(), P.generateDbPassword(), 'must not be deterministic');
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
t('ensureInstanceUserCmd: idempotent SYSTEM account, nologin, state dir re-asserted every run', () => {
|
|
2494
|
+
const c = P.ensureInstanceUserCmd(coTenant, { privileged: true });
|
|
2495
|
+
assert.match(c, /id -u bongos-mercury >\/dev\/null 2>&1 \|\|/, 'an existing account short-circuits');
|
|
2496
|
+
assert.match(c, /useradd --system/, 'a system account, not a login user');
|
|
2497
|
+
assert.match(c, /--shell \/usr\/sbin\/nologin/, 'nothing should ever log in as an instance');
|
|
2498
|
+
assert.match(c, /install -d -o bongos-mercury -g bongos-mercury -m 0700 \/var\/lib\/bongos-mercury/);
|
|
2499
|
+
assert.ok(!P.ensureInstanceUserCmd(coTenant).includes('sudo '), 'unprivileged renders without sudo');
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
t('grantInstanceRepoReadCmd: adds the instance account to the app-user group so the unit can exec its WorkingDirectory', () => {
|
|
2503
|
+
assert.equal(P.grantInstanceRepoReadCmd(coTenant, { privileged: true }), 'sudo usermod -aG lars bongos-mercury');
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
t('dbRoleCmd: LOGIN + password, upserted — CREATE on a fresh box, ALTER on a re-provision (the rotation path)', () => {
|
|
2507
|
+
const c = P.dbRoleCmd(coTenant, { password: 'pw_ABC-123', privileged: true });
|
|
2508
|
+
assert.match(c, /sudo -u postgres psql -v ON_ERROR_STOP=1 -d postgres/, 'role DDL runs on `postgres`, not the instance DB');
|
|
2509
|
+
assert.match(c, /SELECT 1 FROM pg_roles WHERE rolname = \$r\$bongos-mercury\$r\$/);
|
|
2510
|
+
assert.match(c, /CREATE ROLE %I WITH LOGIN PASSWORD %L/);
|
|
2511
|
+
assert.match(c, /ALTER ROLE %I WITH LOGIN PASSWORD %L/);
|
|
2512
|
+
assert.ok(c.includes('$p$pw_ABC-123$p$'), 'the password rides a dollar-quoted literal');
|
|
2513
|
+
});
|
|
2514
|
+
|
|
2515
|
+
t('dbRoleCmd: the role is NOT a superuser and gets no CREATEDB/CREATEROLE — a compromised instance reaches its own data and no further', () => {
|
|
2516
|
+
const c = P.dbRoleCmd(coTenant, { password: P.generateDbPassword(), privileged: true });
|
|
2517
|
+
assert.ok(!/SUPERUSER/i.test(c), 'never a superuser');
|
|
2518
|
+
assert.ok(!/CREATEDB/i.test(c), 'no CREATEDB');
|
|
2519
|
+
assert.ok(!/CREATEROLE/i.test(c), 'no CREATEROLE');
|
|
2520
|
+
assert.ok(!/BYPASSRLS/i.test(c), 'no BYPASSRLS');
|
|
2521
|
+
});
|
|
2522
|
+
|
|
2523
|
+
t('dbRoleCmd: REVOKEs CONNECT from PUBLIC — the half that isolates, since PG grants it to PUBLIC by default', () => {
|
|
2524
|
+
const c = P.dbRoleCmd(coTenant, { password: 'pw', privileged: true });
|
|
2525
|
+
assert.match(c, /REVOKE CONNECT ON DATABASE %I FROM PUBLIC/, 'without this a sibling peer-auths straight in');
|
|
2526
|
+
assert.match(c, /ALTER DATABASE %I OWNER TO %I/, 'the role owns its own DB');
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
t('dbRoleCmd: refuses a password that is not base64url — no caller can smuggle a quote into the SQL', () => {
|
|
2530
|
+
assert.throws(() => P.dbRoleCmd(coTenant, { password: "x'; DROP DATABASE mercury; --" }), /base64url/);
|
|
2531
|
+
assert.throws(() => P.dbRoleCmd(coTenant, { password: '' }), /base64url/);
|
|
2532
|
+
assert.throws(() => P.dbRoleCmd(coTenant, {}), /base64url/);
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
t('dbRoleGrantsCmd: runs on the INSTANCE db and grants forward too — the migrate runs as postgres, so it owns the tables', () => {
|
|
2536
|
+
const c = P.dbRoleGrantsCmd(coTenant, { privileged: true });
|
|
2537
|
+
assert.match(c, /-d mercury/, 'schema grants are per-database');
|
|
2538
|
+
assert.match(c, /GRANT ALL ON ALL TABLES IN SCHEMA public TO %I/, 'the tables the migrate already created');
|
|
2539
|
+
assert.match(c, /ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO %I/, 'and the ones the NEXT migrate will');
|
|
2540
|
+
assert.match(c, /GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO %I/);
|
|
2541
|
+
});
|
|
2542
|
+
|
|
2543
|
+
t('BOTH role commands render with NO double quote — they have to survive a YAML double-quoted cloud-init scalar', () => {
|
|
2544
|
+
const dq = String.fromCharCode(34);
|
|
2545
|
+
for (const c of [P.dbRoleCmd(coTenant, { password: 'pw_ABC-123' }), P.dbRoleGrantsCmd(coTenant)]) {
|
|
2546
|
+
assert.ok(!c.includes(dq), `a double-quoted identifier silently truncates the cloud-init line: ${c}`);
|
|
2547
|
+
}
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
t('serviceUnit: runs as the instance\'s OWN account, never the shared app user (the /proc/<pid>/environ vector)', () => {
|
|
2551
|
+
const u = P.serviceUnit(coTenant, { etcBase: '/etc' });
|
|
2552
|
+
assert.match(u, /^User=bongos-mercury$/m);
|
|
2553
|
+
assert.match(u, /^Group=bongos-mercury$/m);
|
|
2554
|
+
assert.ok(!/^User=lars$/m.test(u), 'a shared User= is the defect this closes');
|
|
2555
|
+
assert.ok(!/^Group=lars$/m.test(u));
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
t('serviceUnit: two instances on one box get two DIFFERENT accounts — co-tenant is the default shape', () => {
|
|
2559
|
+
const a = P.serviceUnit(coTenant, { etcBase: '/etc' });
|
|
2560
|
+
const b = P.serviceUnit({ ...coTenant, slug: 'venus', db_name: 'venus' }, { etcBase: '/etc' });
|
|
2561
|
+
assert.match(a, /^User=bongos-mercury$/m);
|
|
2562
|
+
assert.match(b, /^User=bongos-venus$/m);
|
|
2563
|
+
});
|
|
2564
|
+
|
|
2565
|
+
t('serviceUnit: carries the hardening block, with HOME + ReadWritePaths pointing at the state dir', () => {
|
|
2566
|
+
const u = P.serviceUnit(coTenant, { etcBase: '/etc' });
|
|
2567
|
+
assert.match(u, /^ProtectSystem=strict$/m);
|
|
2568
|
+
assert.match(u, /^ProtectHome=read-only$/m);
|
|
2569
|
+
assert.match(u, /^PrivateTmp=true$/m);
|
|
2570
|
+
assert.match(u, /^NoNewPrivileges=true$/m);
|
|
2571
|
+
assert.match(u, /^Environment=HOME=\/var\/lib\/bongos-mercury$/m, 'the config-home write must not land under the read-only /home');
|
|
2572
|
+
assert.match(u, /^ReadWritePaths=\/var\/lib\/bongos-mercury /m, 'the state dir is writable');
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2575
|
+
t('serviceUnit: ReadWritePaths does NOT make the checkout the service runs writable by it', () => {
|
|
2576
|
+
const inst = { ...coTenant, hosting_shape: 'standalone' };
|
|
2577
|
+
const u = P.serviceUnit(inst, { etcBase: '/etc' });
|
|
2578
|
+
const root = P.standaloneRoot(inst);
|
|
2579
|
+
const rw = u.split('\n').find((l) => l.startsWith('ReadWritePaths='));
|
|
2580
|
+
assert.ok(rw.includes(`-${root}/config`), 'only config/, and optional so a shape without one still mounts');
|
|
2581
|
+
assert.ok(!rw.split(' ').includes(root), 'the code the service runs stays read-only to it');
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
t('webEnvBody: carries PGUSER + PGPASSWORD for the instance role when the runner minted one', () => {
|
|
2585
|
+
const b = P.webEnvBody(coTenant, { dbPassword: 'pw_ABC-123' });
|
|
2586
|
+
assert.match(b, /^PGUSER=bongos-mercury$/m);
|
|
2587
|
+
assert.match(b, /^PGPASSWORD=pw_ABC-123$/m);
|
|
2588
|
+
assert.match(b, /^PGDATABASE=mercury$/m);
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
t('webEnvBody: NO password ⇒ no PG credential lines — an instance provisioned before this keeps its peer auth', () => {
|
|
2592
|
+
const b = P.webEnvBody(coTenant);
|
|
2593
|
+
assert.doesNotMatch(b, /^PGUSER=/m);
|
|
2594
|
+
assert.doesNotMatch(b, /^PGPASSWORD=/m);
|
|
2595
|
+
assert.match(b, /^PGDATABASE=mercury$/m, 'the DB name is unchanged either way');
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
t('dedicatedUserData: the droplet runs the SAME account + role steps, in the same order around the migrate', () => {
|
|
2599
|
+
const ud = P.dedicatedUserData(coTenant);
|
|
2600
|
+
const at = (needle) => ud.indexOf(needle);
|
|
2601
|
+
assert.ok(at('useradd --system') > -1, 'the droplet creates the account too');
|
|
2602
|
+
assert.ok(at('usermod -aG lars') > -1);
|
|
2603
|
+
assert.ok(at('useradd --system') < at('createdb mercury'), 'account before the DB');
|
|
2604
|
+
assert.ok(at('migrate.sh') < at('CREATE ROLE %I'), 'the role grants must follow the migrate that created the tables');
|
|
2605
|
+
assert.ok(at('CREATE ROLE %I') < at('GRANT ALL ON ALL TABLES'), 'role before its grants');
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
t('dedicatedUserData: NEVER bakes a password into cloud-init — DO retains user-data and serves it back', () => {
|
|
2609
|
+
const ud = P.dedicatedUserData(coTenant);
|
|
2610
|
+
// The dollar-quoted password literal dbRoleCmd uses. Its absence is the assertion.
|
|
2611
|
+
assert.ok(!ud.includes('$p$'), 'a credential in user-data is a permanent copy we cannot lock down');
|
|
2612
|
+
assert.match(ud, /CREATE ROLE %I WITH LOGIN\$f\$/, 'the role is still created, password-less (single-tenant droplet)');
|
|
2613
|
+
assert.match(ud, /REVOKE CONNECT ON DATABASE %I FROM PUBLIC/, 'and PUBLIC still loses CONNECT');
|
|
2614
|
+
});
|
|
2615
|
+
|
|
2616
|
+
t('dbRoleCmd(peerOnly): omits the PASSWORD clause entirely, and REFUSES a password', () => {
|
|
2617
|
+
const c = P.dbRoleCmd(coTenant, { peerOnly: true });
|
|
2618
|
+
assert.ok(!/PASSWORD/.test(c), 'no PASSWORD clause at all — not an empty one');
|
|
2619
|
+
assert.match(c, /CREATE ROLE %I WITH LOGIN\$f\$/);
|
|
2620
|
+
assert.match(c, /ALTER DATABASE %I OWNER TO %I/, 'ownership and the revoke are unchanged');
|
|
2621
|
+
assert.throws(() => P.dbRoleCmd(coTenant, { peerOnly: true, password: 'pw_ABC' }), /peerOnly takes no password/);
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
t('dbRoleCmdShown: the label the runner logs names every effect and carries NO password', () => {
|
|
2625
|
+
const pw = P.generateDbPassword();
|
|
2626
|
+
const real = P.dbRoleCmd(coTenant, { password: pw });
|
|
2627
|
+
const shown = P.dbRoleCmdShown(coTenant);
|
|
2628
|
+
assert.ok(real.includes(pw), 'precondition: the real command does embed the secret');
|
|
2629
|
+
assert.ok(!shown.includes(pw), 'the logged label must not');
|
|
2630
|
+
assert.ok(!shown.includes('$p$'), 'nor the dollar-quoted literal around it');
|
|
2631
|
+
for (const effect of ['bongos-mercury', 'mercury', 'revoke PUBLIC connect']) {
|
|
2632
|
+
assert.ok(shown.includes(effect), `the label must still name: ${effect}`);
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
|
|
2636
|
+
|
|
2637
|
+
await ta('provisionInstance: EVERY secret-bearing command opts into `shown`, so no password reaches the runner log', async () => {
|
|
2638
|
+
// The generator tests prove the label is clean; this proves the CALL SITE uses it.
|
|
2639
|
+
// Stated as an invariant rather than one assertion about one line: any future command
|
|
2640
|
+
// that embeds a dollar-quoted secret is caught by the same test, which is the failure
|
|
2641
|
+
// mode worth pinning — the leak was not a typo, it was a call site that never opted in.
|
|
2642
|
+
const { deps } = recorder();
|
|
2643
|
+
const seen = [];
|
|
2644
|
+
deps.exec = (cmd, opts = {}) => { seen.push({ cmd, opts }); return { ok: true, stdout: '' }; };
|
|
2645
|
+
await P.provisionInstance(coTenant, deps);
|
|
2646
|
+
|
|
2647
|
+
const secretBearing = seen.filter((c) => c.cmd.includes('$p$'));
|
|
2648
|
+
assert.ok(secretBearing.length >= 1, 'precondition: the run really does issue a password-bearing command');
|
|
2649
|
+
for (const { cmd, opts } of secretBearing) {
|
|
2650
|
+
assert.ok(opts.shown, `a command embedding a secret must pass \`shown\`: ${cmd.slice(0, 60)}…`);
|
|
2651
|
+
assert.ok(!opts.shown.includes('$p$'), 'the label must not carry the dollar-quoted secret');
|
|
2652
|
+
// The password itself, pulled back out of the real command, must not be in the label.
|
|
2653
|
+
const pw = (cmd.match(/\$p\$([^$]+)\$p\$/) || [])[1];
|
|
2654
|
+
assert.ok(pw && pw.length > 20, 'precondition: recovered the generated password from the command');
|
|
2655
|
+
assert.ok(!opts.shown.includes(pw), 'the label must not carry the password');
|
|
2656
|
+
}
|
|
2657
|
+
});
|
|
2658
|
+
|
|
2659
|
+
await ta('provisionInstance: the password IS written to web.env, which makeWriteFile logs by path not body', async () => {
|
|
2660
|
+
const { deps } = recorder();
|
|
2661
|
+
const bodies = [];
|
|
2662
|
+
deps.writeFile = (path, body, opts) => { bodies.push({ path, body, opts }); return { ok: true }; };
|
|
2663
|
+
const seen = [];
|
|
2664
|
+
deps.exec = (cmd, opts = {}) => { seen.push({ cmd, opts }); return { ok: true, stdout: '' }; };
|
|
2665
|
+
await P.provisionInstance(coTenant, deps);
|
|
2666
|
+
|
|
2667
|
+
const env = bodies.find((b) => /web\.env$/.test(b.path));
|
|
2668
|
+
assert.ok(env, 'wrote a web.env');
|
|
2669
|
+
assert.equal(env.opts && env.opts.mode, 0o600, 'web.env stays chmod 600');
|
|
2670
|
+
const pw = (seen.map((c) => c.cmd).join('\n').match(/\$p\$([^$]+)\$p\$/) || [])[1];
|
|
2671
|
+
assert.ok(pw, 'recovered the run\'s generated password');
|
|
2672
|
+
assert.ok(env.body.includes(`PGPASSWORD=${pw}`), 'the SAME password the role was set to reaches web.env — one mint per run');
|
|
2673
|
+
assert.ok(env.body.includes('PGUSER=bongos-mercury'));
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2449
2676
|
console.log(`\nprovision: ${passed} passed, ${failed} failed`);
|
|
2450
2677
|
process.exit(failed ? 1 : 0);
|