@caelo-cms/provisioning 0.10.50 → 0.10.52

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.
Files changed (109) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/adapter.d.ts +4 -6
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/admin-internal.d.ts +41 -0
  6. package/dist/admin-internal.d.ts.map +1 -0
  7. package/dist/admin-internal.js +169 -0
  8. package/dist/admin-internal.js.map +1 -0
  9. package/dist/agent/units.js +1 -1
  10. package/dist/agent/units.js.map +1 -1
  11. package/dist/agent-db.js +169 -65
  12. package/dist/aws-task-roles.d.ts +27 -0
  13. package/dist/aws-task-roles.d.ts.map +1 -0
  14. package/dist/aws-task-roles.js +35 -0
  15. package/dist/aws-task-roles.js.map +1 -0
  16. package/dist/cli.js +34 -1
  17. package/dist/cli.js.map +1 -1
  18. package/dist/compose.js +2 -2
  19. package/dist/compose.js.map +1 -1
  20. package/dist/deployed-release.d.ts +3 -2
  21. package/dist/deployed-release.d.ts.map +1 -1
  22. package/dist/deployed-release.js +12 -5
  23. package/dist/deployed-release.js.map +1 -1
  24. package/dist/gateway-credentials.d.ts.map +1 -1
  25. package/dist/gateway-credentials.js +6 -3
  26. package/dist/gateway-credentials.js.map +1 -1
  27. package/dist/gcloud.d.ts +2 -2
  28. package/dist/gcloud.d.ts.map +1 -1
  29. package/dist/gcloud.js +3 -3
  30. package/dist/gcloud.js.map +1 -1
  31. package/dist/infra-contract.d.ts +1 -1
  32. package/dist/infra-contract.d.ts.map +1 -1
  33. package/dist/infra-contract.js +12 -1
  34. package/dist/infra-contract.js.map +1 -1
  35. package/dist/ops-backend.js +1 -1
  36. package/dist/owner-reset.d.ts +37 -0
  37. package/dist/owner-reset.d.ts.map +1 -0
  38. package/dist/owner-reset.js +80 -0
  39. package/dist/owner-reset.js.map +1 -0
  40. package/dist/release-contract.d.ts.map +1 -1
  41. package/dist/release-contract.js +3 -0
  42. package/dist/release-contract.js.map +1 -1
  43. package/dist/stack-contract.d.ts +9 -10
  44. package/dist/stack-contract.d.ts.map +1 -1
  45. package/dist/stack-contract.js +21 -10
  46. package/dist/stack-contract.js.map +1 -1
  47. package/dist/staging-admin.d.ts +12 -20
  48. package/dist/staging-admin.d.ts.map +1 -1
  49. package/dist/staging-admin.js +7 -159
  50. package/dist/staging-admin.js.map +1 -1
  51. package/dist/state-store.d.ts +8 -0
  52. package/dist/state-store.d.ts.map +1 -1
  53. package/dist/state-store.js +10 -2
  54. package/dist/state-store.js.map +1 -1
  55. package/dist/upgrader-job.d.ts.map +1 -1
  56. package/dist/upgrader-job.js +7 -1
  57. package/dist/upgrader-job.js.map +1 -1
  58. package/dist/upgrader-setup.d.ts +5 -0
  59. package/dist/upgrader-setup.d.ts.map +1 -1
  60. package/dist/upgrader-setup.js +40 -6
  61. package/dist/upgrader-setup.js.map +1 -1
  62. package/dist/wizards/gcp-cost.js +1 -1
  63. package/dist/wizards/gcp.js +1 -1
  64. package/dist/wizards/gcp.js.map +1 -1
  65. package/package.json +7 -5
  66. package/src/adapter.ts +4 -6
  67. package/src/admin-internal.ts +212 -0
  68. package/src/agent/units.ts +1 -1
  69. package/src/aws-task-roles.ts +58 -0
  70. package/src/caddy-redirects.docker.test.ts +8 -1
  71. package/src/checkpoint-secrets.test.ts +0 -1
  72. package/src/cli.ts +41 -1
  73. package/src/compose.ts +2 -2
  74. package/src/deployed-release.test.ts +22 -4
  75. package/src/deployed-release.ts +14 -7
  76. package/src/error-pages-stacks.test.ts +38 -0
  77. package/src/gateway-credentials.ts +6 -3
  78. package/src/gateway-env-isolation.test.ts +212 -0
  79. package/src/gcloud.ts +5 -3
  80. package/src/index.test.ts +6 -6
  81. package/src/infra-contract.test.ts +5 -3
  82. package/src/infra-contract.ts +12 -1
  83. package/src/ops-backend.ts +1 -1
  84. package/src/owner-reset.test.ts +165 -0
  85. package/src/owner-reset.ts +106 -0
  86. package/src/release-contract.ts +3 -0
  87. package/src/release-manifest.test.ts +1 -0
  88. package/src/runtime-secrets.test.ts +25 -8
  89. package/src/stack-contract.test.ts +9 -18
  90. package/src/stack-contract.ts +25 -16
  91. package/src/stack-converge-secrets.test.ts +45 -10
  92. package/src/stack-converge.test.ts +19 -10
  93. package/src/staging-admin.ts +18 -181
  94. package/src/state-store.ts +11 -2
  95. package/src/upgrader-job.test.ts +17 -1
  96. package/src/upgrader-job.ts +13 -3
  97. package/src/upgrader-setup.test.ts +12 -1
  98. package/src/upgrader-setup.ts +41 -5
  99. package/src/welcome-page.test.ts +40 -0
  100. package/src/wizards/gcp-cost.ts +1 -1
  101. package/src/wizards/gcp.ts +1 -1
  102. package/stacks/aws/README.md +3 -1
  103. package/stacks/aws/index.ts +62 -43
  104. package/stacks/azure/README.md +3 -1
  105. package/stacks/azure/index.ts +8 -14
  106. package/stacks/gcp/README.md +1 -1
  107. package/stacks/gcp/index.ts +2 -5
  108. package/stacks/gcp-firebase/index.ts +0 -3
  109. package/static/welcome.html +93 -111
package/src/cli.ts CHANGED
@@ -1120,6 +1120,7 @@ const handlers: Record<string, () => Promise<void>> = {
1120
1120
  truncate: lifecycleTruncate,
1121
1121
  "admin-domain": adminDomain,
1122
1122
  "staging-domain": stagingDomain,
1123
+ "owner-reset": ownerReset,
1123
1124
  // #696 — Pulumi state in the install's own project.
1124
1125
  state: async () => (await import("./state-command.js")).stateCommand(process.argv),
1125
1126
  attach: async () => (await import("./state-command.js")).attachCommand(process.argv),
@@ -1196,6 +1197,45 @@ async function lifecycleTruncate(): Promise<void> {
1196
1197
  await truncateCommand();
1197
1198
  }
1198
1199
 
1200
+ /**
1201
+ * #769 `owner-reset [--install <id>] [--email <addr>]` — print a one-time
1202
+ * reset link for a locked-out Owner, for installs whose admin cannot email
1203
+ * one (see owner-reset.ts for why the operator's cloud identity may).
1204
+ */
1205
+ async function ownerReset(): Promise<void> {
1206
+ const { installFlag, listInstalls, selectInstall } = await import("./install-state.js");
1207
+ const selected = selectInstall(listInstalls(), installFlag(process.argv));
1208
+ if (!selected.ok) {
1209
+ console.error(selected.message);
1210
+ process.exit(1);
1211
+ }
1212
+ const meta = selected.meta;
1213
+ if (meta.provider !== "gcp-firebase") {
1214
+ console.error(
1215
+ `owner-reset works on gcp-firebase installs so far; this one is ${meta.provider} (issue #770). ` +
1216
+ "Until then, another Owner can set a new password under Settings -> Users, or set up email under Settings -> Email so /forgot can send the link.",
1217
+ );
1218
+ process.exit(1);
1219
+ }
1220
+ if (!meta.projectId || !meta.region) {
1221
+ console.error(
1222
+ `install ${meta.installId} has no project or region recorded in install.json; re-run the wizard to record it.`,
1223
+ );
1224
+ process.exit(1);
1225
+ }
1226
+ const email = arg("email");
1227
+ const { describeOwnerReset, requestOwnerReset } = await import("./owner-reset.js");
1228
+ const r = await requestOwnerReset(
1229
+ { projectId: meta.projectId, region: meta.region },
1230
+ email ? { email } : {},
1231
+ );
1232
+ if (!r.ok) {
1233
+ console.error(`owner-reset: ${r.error}`);
1234
+ process.exit(1);
1235
+ }
1236
+ for (const line of describeOwnerReset(r.value)) console.log(line);
1237
+ }
1238
+
1199
1239
  /**
1200
1240
  * `admin-domain enable` — bind admin.<domain> to a gcp-firebase admin
1201
1241
  * (see admin-domain.ts for why this runs as the operator, not Pulumi).
@@ -1495,7 +1535,7 @@ if (route.kind === "handler" || route.kind === "wizard") {
1495
1535
  }
1496
1536
  } else {
1497
1537
  console.log(
1498
- "Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|state migrate|attach|pulumi-output-sync|version> [options]\n" +
1538
+ "Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|owner-reset|state migrate|attach|pulumi-output-sync|version> [options]\n" +
1499
1539
  "With several installs on this machine, pick one with --install <install-id or domain> (status, upgrade and backup also take --install all); in a terminal you are asked otherwise.\n" +
1500
1540
  "Pass --no-wizard with no sub-command to print this usage instead of the wizard.",
1501
1541
  );
package/src/compose.ts CHANGED
@@ -388,8 +388,8 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
388
388
  depends_on:
389
389
  postgres: { condition: service_healthy }
390
390
  environment:
391
- # Its own two logins only — no authoring credential (#613, CLAUDE.md §2).
392
- GATEWAY_DATABASE_URL: "${dbUrl("gateway_role", spec.rolePasswords.gateway, "cms_admin")}"
391
+ # public_role on cms_public is its only database login (#613, #635,
392
+ # CLAUDE.md §2: the public side has no path to the authoring database).
393
393
  PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
394
394
  GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
395
395
  NODE_ENV: production`;
@@ -93,6 +93,21 @@ describe("checkDeployedRuntimeEnv", () => {
93
93
  });
94
94
 
95
95
  it("passes once both services read the password from Secret Manager", async () => {
96
+ const { run } = fakeGcloud({
97
+ ...lists(),
98
+ "describe caelo-production-admin-abc": [
99
+ svc([secret("ADMIN_DATABASE_PASSWORD"), secret("PUBLIC_ADMIN_DATABASE_PASSWORD")]),
100
+ ],
101
+ "describe caelo-production-gateway-def": [
102
+ svc([secret("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
103
+ ],
104
+ });
105
+ expect(await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run })).toEqual({
106
+ ok: true,
107
+ });
108
+ });
109
+
110
+ it("stops a wizard re-run on a gateway from before #635 (it needs its gateway_role pool)", async () => {
96
111
  const { run } = fakeGcloud({
97
112
  ...lists(),
98
113
  "describe caelo-production-admin-abc": [
@@ -105,9 +120,12 @@ describe("checkDeployedRuntimeEnv", () => {
105
120
  ]),
106
121
  ],
107
122
  });
108
- expect(await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run })).toEqual({
109
- ok: true,
110
- });
123
+ const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
124
+ expect(r.ok).toBe(false);
125
+ if (!r.ok) {
126
+ expect(r.error).toContain("GATEWAY_DATABASE_PASSWORD");
127
+ expect(r.error).toContain("Run `upgrade` first");
128
+ }
111
129
  });
112
130
 
113
131
  it("stops a wizard re-run on a gateway from before #613 (it needs the admin_role pool)", async () => {
@@ -123,7 +141,7 @@ describe("checkDeployedRuntimeEnv", () => {
123
141
  const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
124
142
  expect(r.ok).toBe(false);
125
143
  if (!r.ok) {
126
- expect(r.error).toContain("GATEWAY_DATABASE_PASSWORD");
144
+ expect(r.error).toContain("ADMIN_DATABASE_PASSWORD");
127
145
  expect(r.error).toContain("Run `upgrade` first");
128
146
  }
129
147
  });
@@ -15,7 +15,7 @@ import { gcloud as defaultGcloud } from "./gcloud.js";
15
15
  import type { GcloudRunner } from "./gcloud-retry.js";
16
16
  import { GCP_STACK_ENV, gcpNamePrefix } from "./gcp-names.js";
17
17
  import type { ImageDigests } from "./install-state.js";
18
- import { type CloudRunSlug, SERVICE_SECRET_ENV } from "./stack-contract.js";
18
+ import { type CloudRunSlug, RETIRED_SERVICE_ENV, SERVICE_SECRET_ENV } from "./stack-contract.js";
19
19
  import { liveContainerEnv } from "./stack-converge.js";
20
20
 
21
21
  /** The `sha256:…` digest an image reference pins, or null for a tag reference. */
@@ -61,8 +61,9 @@ async function findDeployedService(
61
61
  * reads the password from the URL). The wizard keeps the release an install
62
62
  * runs, so re-running it on an install `upgrade` hasn't moved over yet would
63
63
  * pair the old release with env it can't connect with. The same holds for a
64
- * gateway from before #613: its release needs the admin_role pool the
65
- * current contract no longer gives it (no GATEWAY_DATABASE_PASSWORD yet).
64
+ * gateway from before #613 or #635: its release needs a cms_admin pool
65
+ * (admin_role, then gateway_role) the current contract no longer gives it —
66
+ * it still carries the retired database vars.
66
67
  * Such an install has to go through `upgrade` first, which moves the image
67
68
  * and the env in one revision.
68
69
  */
@@ -88,13 +89,19 @@ export async function checkDeployedRuntimeEnv(opts: {
88
89
  return { ok: false, error: `describe ${found.name}: ${describe.stderr.trim()}` };
89
90
  }
90
91
  const env = liveContainerEnv(describe.stdout);
91
- const missing = Object.keys(SERVICE_SECRET_ENV[slug]).filter(
92
- (name) => name.endsWith("_PASSWORD") && env.get(name)?.kind !== "secret",
93
- );
92
+ const missing = [
93
+ ...Object.keys(SERVICE_SECRET_ENV[slug]).filter(
94
+ (name) => name.endsWith("_PASSWORD") && env.get(name)?.kind !== "secret",
95
+ ),
96
+ // A database var the contract retired (the gateway's ADMIN_* before
97
+ // #613, its GATEWAY_* before #635) means the running release still
98
+ // needs that login.
99
+ ...RETIRED_SERVICE_ENV[slug].filter((name) => /DATABASE/.test(name) && env.has(name)),
100
+ ];
94
101
  if (missing.length > 0) {
95
102
  return {
96
103
  ok: false,
97
- error: `${found.name} does not read ${missing.join(", ")} from Secret Manager — its release predates the current database env (Secret Manager runtime secrets; for the gateway, its own database logins, #613). Run \`upgrade\` first: it moves the release and its env over together. Re-running the installer now would give the running release a database env it can't connect with.`,
104
+ error: `${found.name} does not match the current database env (${missing.join(", ")}) — its release predates it (Secret Manager runtime secrets; for the gateway, its own database login, #613/#635). Run \`upgrade\` first: it moves the release and its env over together. Re-running the installer now would give the running release a database env it can't connect with.`,
98
105
  };
99
106
  }
100
107
  }
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #802 — every provider answers an unknown URL with the site's own
5
+ * `/404.html` (written by the static generator from the designated 404
6
+ * page) and status 404. Firebase Hosting does this by convention for a
7
+ * root `/404.html`; the other stacks must wire it. Source-level checks:
8
+ * the Pulumi programs cannot run in a unit test.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import { readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+
15
+ const stack = (provider: string): string =>
16
+ readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
17
+
18
+ describe("#802 site 404 page wiring per provider", () => {
19
+ it("gcp: the static bucket's website serves 404.html for unknown objects", () => {
20
+ expect(stack("gcp")).toContain('notFoundPage: "404.html"');
21
+ });
22
+
23
+ it("aws: CloudFront maps both 404 and the OAI origin's 403 to /404.html with status 404", () => {
24
+ const src = stack("aws");
25
+ expect(src).toContain(
26
+ '{ errorCode: 404, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 }',
27
+ );
28
+ // S3 behind an OAI without s3:ListBucket answers a missing key with
29
+ // 403 — without this entry visitors get S3's AccessDenied XML.
30
+ expect(src).toContain(
31
+ '{ errorCode: 403, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 }',
32
+ );
33
+ });
34
+
35
+ it("azure: the static website's error document is 404.html", () => {
36
+ expect(stack("azure")).toContain('error404Document: "404.html"');
37
+ });
38
+ });
@@ -2,9 +2,12 @@
2
2
 
3
3
  /**
4
4
  * Moving an existing install's API gateway off admin_role (issue #613),
5
- * as `cms-provision upgrade` does it. The env side — GATEWAY_DATABASE_URL
6
- * and the per-role password secrets in, ADMIN_DATABASE_* out — rides the
7
- * gateway's roll (stack-contract.ts). The database side is ordered around
5
+ * as `cms-provision upgrade` does it. The env side — the per-role password
6
+ * secrets in, ADMIN_DATABASE_* out (and, since #635, GATEWAY_DATABASE_* out
7
+ * too: the gateway has no cms_admin login at all) — rides the gateway's
8
+ * roll (stack-contract.ts). gateway_role keeps getting its password until
9
+ * the contract release drops the role, because the previous release's
10
+ * gateway logs in with it until it is rolled. The database side is ordered around
8
11
  * that roll so /api/* keeps working:
9
12
  *
10
13
  * 1. after migrations (which create `gateway_role` without a password):
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #635 guardrail (c): on EVERY adapter, the API gateway's service
5
+ * env holds no cms_admin credential of any role, AND its identity cannot
6
+ * read a secret that unlocks one (CLAUDE.md §2: the public side has no path
7
+ * to cms_admin). Env strings alone are not enough: a role that may read
8
+ * admin_role's password is a path even when no env var names it.
9
+ *
10
+ * gcp, gcp-firebase the env contract (stack-contract.ts), every IAM
11
+ * binding the gateway SA gets (contract + stack source)
12
+ * aws the gateway's own ECS task role and the secrets its
13
+ * policy covers (aws-task-roles.ts + stack source)
14
+ * azure the gateway container app's env, and no identity or
15
+ * Key Vault access for any container app
16
+ * self-hosted the generated compose gateway service: env, secrets,
17
+ * mounts
18
+ *
19
+ * The gateway itself refuses to boot on such an env
20
+ * (apps/api-gateway `adminDatabaseAccessInEnv`); this catches it at review
21
+ * time, before a release can ship it.
22
+ */
23
+
24
+ import { describe, expect, it } from "bun:test";
25
+ import { readFileSync } from "node:fs";
26
+ import { resolve } from "node:path";
27
+ import { AWS_SERVICE_SECRETS, secretsReadPolicy } from "./aws-task-roles.js";
28
+ import { generateDockerCompose } from "./compose.js";
29
+ import {
30
+ databaseUrls,
31
+ gatewayEnvContract,
32
+ RETIRED_IAM_BINDINGS,
33
+ RETIRED_SERVICE_ENV,
34
+ runtimeSecretBindings,
35
+ SERVICE_SECRET_ENV,
36
+ stackIamInvariants,
37
+ } from "./stack-contract.js";
38
+
39
+ /** Anything in a gateway env value or name that reaches the authoring database. */
40
+ const ADMIN_ACCESS = /cms_admin|admin_role|gateway_role|ADMIN_DATABASE|GATEWAY_DATABASE/;
41
+
42
+ /** Secrets that unlock a cms_admin login. */
43
+ const ADMIN_SECRETS = ["postgres-password", "gateway-role-password"];
44
+
45
+ /** The secrets the gateway reads on GCP: its own login and the staging gate key. */
46
+ const GCP_GATEWAY_SECRETS = ["public-role-password", "staging-gate-secret"];
47
+
48
+ /** The source of a stack program. */
49
+ function stack(provider: "aws" | "azure" | "gcp" | "gcp-firebase"): string {
50
+ return readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
51
+ }
52
+
53
+ describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
54
+ const env = gatewayEnvContract({
55
+ provider: "gcp",
56
+ projectId: "acme",
57
+ env: "production",
58
+ domain: "example.com",
59
+ region: "europe-west1",
60
+ stagingHost: "staging.example.com",
61
+ databaseUrls: databaseUrls("10.20.0.3"),
62
+ });
63
+
64
+ it("carries no cms_admin URL, role or credential name", () => {
65
+ for (const v of env) {
66
+ expect(v.name).not.toMatch(ADMIN_ACCESS);
67
+ if ("value" in v && typeof v.value === "string") expect(v.value).not.toMatch(ADMIN_ACCESS);
68
+ }
69
+ expect(env.find((v) => v.name === "PUBLIC_DATABASE_URL")).toEqual({
70
+ name: "PUBLIC_DATABASE_URL",
71
+ value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
72
+ });
73
+ });
74
+
75
+ it("references no secret that unlocks a cms_admin login", () => {
76
+ for (const secret of Object.values(SERVICE_SECRET_ENV.gateway)) {
77
+ expect(ADMIN_SECRETS).not.toContain(secret);
78
+ }
79
+ const gatewayBindings = runtimeSecretBindings().filter((b) => b.service === "gateway");
80
+ for (const b of gatewayBindings) expect(ADMIN_SECRETS).not.toContain(b.secret);
81
+ });
82
+
83
+ for (const provider of ["gcp", "gcp-firebase"] as const) {
84
+ it(`${provider}: the gateway SA's IAM bindings reach no cms_admin secret (review finding)`, () => {
85
+ const gateway = stackIamInvariants(provider).filter((b) => b.member === "gateway-sa");
86
+ for (const b of gateway) {
87
+ if (b.role === "roles/secretmanager.secretAccessor") {
88
+ // Per secret, never project-wide (that would cover every secret).
89
+ expect(b.target.kind).toBe("secret");
90
+ if (b.target.kind === "secret") expect(GCP_GATEWAY_SECRETS).toContain(b.target.name);
91
+ } else {
92
+ expect(["roles/logging.logWriter", "roles/monitoring.metricWriter"]).toContain(b.role);
93
+ }
94
+ }
95
+ // The stack declares no other binding for the gateway SA: the secret
96
+ // bindings go through runtimeSecretBindings, the two telemetry ones
97
+ // name it directly.
98
+ const direct = stack(provider).match(/serviceAccount:\$\{gatewaySa\.email\}/g) ?? [];
99
+ expect(direct).toHaveLength(2);
100
+ expect(stack(provider)).toContain("for (const binding of runtimeSecretBindings())");
101
+ });
102
+ }
103
+
104
+ it("upgrade strips the old credentials and the gateway SA's access to them", () => {
105
+ for (const name of [
106
+ "ADMIN_DATABASE_URL",
107
+ "ADMIN_DATABASE_PASSWORD",
108
+ "GATEWAY_DATABASE_URL",
109
+ "GATEWAY_DATABASE_PASSWORD",
110
+ ]) {
111
+ expect(RETIRED_SERVICE_ENV.gateway).toContain(name);
112
+ }
113
+ for (const secret of ADMIN_SECRETS) {
114
+ expect(
115
+ RETIRED_IAM_BINDINGS.some(
116
+ (b) =>
117
+ b.member === "gateway-sa" && b.target.kind === "secret" && b.target.name === secret,
118
+ ),
119
+ ).toBe(true);
120
+ }
121
+ });
122
+ });
123
+
124
+ describe("self-hosted: the generated compose gateway service (#635)", () => {
125
+ it("holds public_role on cms_public and nothing else", () => {
126
+ const out = generateDockerCompose({
127
+ domain: "example.com",
128
+ postgresPassword: "superpw",
129
+ rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
130
+ caeloSecretKek: "0".repeat(64),
131
+ diskSize: "20Gi",
132
+ images: {
133
+ mode: "release" as const,
134
+ admin: `ghcr.io/caelo-cms/admin@sha256:${"a".repeat(64)}`,
135
+ gateway: `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`,
136
+ },
137
+ ports: { http: 80, https: 443 },
138
+ project: "caelo-example-com",
139
+ });
140
+ const start = out.indexOf(" caelo-gateway:");
141
+ const end = out.indexOf("\n caelo-", start + 1);
142
+ const gateway = out.slice(start, end === -1 ? undefined : end);
143
+ expect(gateway).toContain("PUBLIC_DATABASE_URL:");
144
+ expect(gateway).not.toMatch(ADMIN_ACCESS);
145
+ expect(gateway).not.toMatch(/superpw|adminpw|gatewaypw/);
146
+ // No other way in: no Compose secrets, no env file, no mounts (a
147
+ // mounted file is read by no env check), and exactly its own env.
148
+ expect(gateway).not.toMatch(/^ {4}(secrets|env_file|volumes):/m);
149
+ const env = gateway.slice(gateway.indexOf(" environment:"));
150
+ const keys = [...env.matchAll(/^ {6}([A-Z_][A-Z0-9_]*):/gm)].map((m) => m[1]);
151
+ expect(keys.sort()).toEqual(["GATEWAY_PORT", "NODE_ENV", "PUBLIC_DATABASE_URL"]);
152
+ expect(out).not.toMatch(/^secrets:/m);
153
+ });
154
+ });
155
+
156
+ describe("azure: the gateway container app (#635)", () => {
157
+ it("gets PUBLIC_DATABASE_URL only", () => {
158
+ const src = stack("azure");
159
+ const start = src.indexOf('serviceName: "gateway"');
160
+ expect(start).toBeGreaterThan(-1);
161
+ const block = src.slice(start, src.indexOf("});", start));
162
+ expect(block).toContain('{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }');
163
+ expect(block).not.toMatch(/adminDatabase|gatewayDatabase|ADMIN_DATABASE|GATEWAY_DATABASE/);
164
+ });
165
+
166
+ it("builds no cms_admin URL for any role but the admin's", () => {
167
+ expect(stack("azure")).not.toMatch(/gateway_role:\$\{/);
168
+ });
169
+
170
+ it("gives no container app an identity or Key Vault access (review finding)", () => {
171
+ // The vault uses RBAC authorisation: without a role assignment or a
172
+ // managed identity no container app can read any vault secret, so the
173
+ // gateway's only credential is the public_role URL in its env.
174
+ const src = stack("azure");
175
+ expect(src).toContain("enableRbacAuthorization: true");
176
+ expect(src).not.toMatch(/RoleAssignment|accessPolicies|identity\s*:|keyVaultUrl|secretRef/);
177
+ });
178
+ });
179
+
180
+ describe("aws: the stack (#635)", () => {
181
+ it("builds no gateway_role URL and exports no gateway cms_admin DSN", () => {
182
+ const src = stack("aws");
183
+ expect(src).not.toMatch(/gateway_role:\$\{/);
184
+ expect(src).not.toMatch(/gatewayDatabaseUrl/);
185
+ });
186
+
187
+ it("the gateway's task role reads only the public_role password (review finding)", () => {
188
+ expect(AWS_SERVICE_SECRETS.gateway).toEqual(["public-role-password"]);
189
+ for (const secret of ADMIN_SECRETS) expect(AWS_SERVICE_SECRETS.gateway).not.toContain(secret);
190
+ });
191
+
192
+ it("each service gets its own task role whose policy is built from that table", () => {
193
+ const src = stack("aws");
194
+ expect(src).toContain('gateway: taskRole("gateway")');
195
+ expect(src).toContain('admin: taskRole("admin")');
196
+ // One RolePolicy, inside taskRole, scoped by AWS_SERVICE_SECRETS[service]:
197
+ // no policy anywhere lists secret ARNs by hand.
198
+ expect(src.match(/new aws\.iam\.RolePolicy\(/g)).toHaveLength(1);
199
+ expect(src).toContain(".all(AWS_SERVICE_SECRETS[service].map((name) => secretArns[name]))");
200
+ expect(src).not.toMatch(/GetSecretValue/);
201
+ });
202
+
203
+ it("secretsReadPolicy grants GetSecretValue on exactly the given ARNs", () => {
204
+ expect(JSON.parse(secretsReadPolicy(["arn:a", "arn:b"]))).toEqual({
205
+ Version: "2012-10-17",
206
+ Statement: [
207
+ { Effect: "Allow", Action: "secretsmanager:GetSecretValue", Resource: ["arn:a", "arn:b"] },
208
+ ],
209
+ });
210
+ expect(() => secretsReadPolicy([])).toThrow();
211
+ });
212
+ });
package/src/gcloud.ts CHANGED
@@ -75,10 +75,12 @@ export async function gcloud(
75
75
 
76
76
  /**
77
77
  * Active gcloud account. Returns `null` when no account is logged in
78
- * — caller prompts `gcloud auth login`.
78
+ * — caller prompts `gcloud auth login`. `run` is injectable for tests.
79
79
  */
80
- export async function activeAccount(): Promise<string | null> {
81
- const r = await gcloud(["auth", "list", "--format=value(account)", "--filter=status:ACTIVE"]);
80
+ export async function activeAccount(
81
+ run: (args: string[]) => Promise<GcloudResult> = gcloud,
82
+ ): Promise<string | null> {
83
+ const r = await run(["auth", "list", "--format=value(account)", "--filter=status:ACTIVE"]);
82
84
  if (!r.ok) return null;
83
85
  const value = r.stdout.trim();
84
86
  return value.length > 0 ? value : null;
package/src/index.test.ts CHANGED
@@ -132,7 +132,7 @@ describe("docker-compose generator", () => {
132
132
  expect(out).toContain("CAELO_SECRET_KEK");
133
133
  });
134
134
 
135
- it("gives the gateway gateway_role + public_role and no admin credential (#613)", () => {
135
+ it("gives the gateway public_role only and no cms_admin credential (#613, #635)", () => {
136
136
  const out = generateDockerCompose({
137
137
  domain: "example.com",
138
138
  postgresPassword: "supersecret",
@@ -144,14 +144,14 @@ describe("docker-compose generator", () => {
144
144
  project: "caelo-example-com",
145
145
  });
146
146
  const gateway = out.slice(out.indexOf("caelo-gateway:"), out.indexOf("\nvolumes:"));
147
- expect(gateway).toContain(
148
- 'GATEWAY_DATABASE_URL: "postgres://gateway_role:gatewaypw@postgres:5432/cms_admin"',
149
- );
150
147
  expect(gateway).toContain(
151
148
  'PUBLIC_DATABASE_URL: "postgres://public_role:publicpw@postgres:5432/cms_public"',
152
149
  );
153
- expect(gateway).not.toMatch(/ADMIN_DATABASE|admin_role|adminpw|supersecret|CAELO_SECRET_KEK/);
154
- // bootstrap.sh creates the three roles from these.
150
+ expect(gateway).not.toMatch(
151
+ /ADMIN_DATABASE|GATEWAY_DATABASE|cms_admin|admin_role|gateway_role|adminpw|gatewaypw|supersecret|CAELO_SECRET_KEK/,
152
+ );
153
+ // bootstrap.sh still creates the three roles from these (gateway_role
154
+ // until the contract release drops it).
155
155
  expect(out).toContain('GATEWAY_ROLE_PASSWORD: "gatewaypw"');
156
156
  });
157
157
 
@@ -20,10 +20,12 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 4: the public_role revoke (3) and the URL-map redirects role (4) are separate", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(4);
23
+ it("is at 6: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(6);
25
25
  expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
26
  expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
+ expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
28
+ expect(INFRA_CONTRACT_STEPS[6]).toContain("#799");
27
29
  });
28
30
 
29
31
  it("lists the pending steps oldest first", () => {
@@ -34,6 +36,6 @@ describe("infrastructure contract steps", () => {
34
36
  });
35
37
 
36
38
  it("names a version a newer release added by number", () => {
37
- expect(infraStepsText(4, 5)).toBe("infrastructure step 5 (see that release's notes)");
39
+ expect(infraStepsText(6, 7)).toBe("infrastructure step 7 (see that release's notes)");
38
40
  });
39
41
  });
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 4;
25
+ export const INFRA_CONTRACT_VERSION = 6;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -36,6 +36,15 @@ export const INFRA_CONTRACT_VERSION = 4;
36
36
  // first.
37
37
  // 4 — #587: on `gcp`, the admin's custom role for writing the redirects
38
38
  // table into the URL map (url-map-redirects-access.ts).
39
+ // 5 — #635: the gateway has no cms_admin login. Its GATEWAY_DATABASE_URL /
40
+ // GATEWAY_DATABASE_PASSWORD go, and its SA loses access to the
41
+ // gateway-role-password secret (a retired IAM binding, removed after
42
+ // the roll — the in-cloud upgrader cannot change IAM).
43
+ // 6 — #799: the upgrader's custom role `caeloUpgraderOpsList`
44
+ // (`storage.objects.list` on the ops bucket). Every `gcloud storage cp`
45
+ // into the bucket lists it first; without the binding the in-cloud
46
+ // upgrader fails at its state lock, so it must not run before the
47
+ // converge grants it.
39
48
 
40
49
  /**
41
50
  * What each contract version adds, in operator terms — the upgrader's
@@ -47,6 +56,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
47
56
  2: "the in-cloud upgrader job and its scoped permissions",
48
57
  3: "public_role out of the Cloud SQL superuser group (#633)",
49
58
  4: "the admin's permission to serve redirects from the load balancer (#587)",
59
+ 5: "the gateway's cms_admin login removed from its env and IAM (#635)",
60
+ 6: "the upgrader's permission to list its ops bucket (#799)",
50
61
  };
51
62
 
52
63
  /**
@@ -20,7 +20,7 @@
20
20
  * anything. The bucket's convenience bindings for project viewers and
21
21
  * editors are removed; public principals are refused outright. Project
22
22
  * Owners keep access through their project role (that cannot be taken
23
- * away at the bucket); docs-site/pages/install-gcp.md says so.
23
+ * away at the bucket); the GCP install docs say so.
24
24
  *
25
25
  * Every step reads before it writes, so a re-run is a no-op and an operator
26
26
  * without IAM-write permission can still run against an ops backend someone