@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
@@ -0,0 +1,165 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #769 — `owner-reset` asks the admin for a reset link through IAP (as
5
+ * caelo-mcp) with the internal token for scope `auth.owner_reset`, and builds
6
+ * the link on the admin URL the operator can open.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import type { GcloudResult } from "./gcloud.js";
11
+ import { describeOwnerReset, OWNER_RESET_SCOPE, requestOwnerReset } from "./owner-reset.js";
12
+
13
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
14
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
15
+ const ADMIN = "https://caelo-production-admin-abc-ew.a.run.app";
16
+ const target = { projectId: "proj", region: "europe-west1" };
17
+
18
+ function fakeGcloud(
19
+ overrides: Record<string, GcloudResult> = {},
20
+ ): (args: string[]) => Promise<GcloudResult> {
21
+ const answers: Record<string, GcloudResult> = {
22
+ "auth list": ok("operator@example.com\n"),
23
+ "run services list": ok("caelo-production-admin-abc\n"),
24
+ "run services describe": ok(`${ADMIN}\n`),
25
+ "secrets versions access": ok("s".repeat(40)),
26
+ "auth print-access-token": ok("ya29.operator\n"),
27
+ "services enable iamcredentials.googleapis.com": ok(),
28
+ ...overrides,
29
+ };
30
+ return async (args) => {
31
+ const key = Object.keys(answers).find((k) => args.join(" ").startsWith(k));
32
+ return (key && answers[key]) || fail(`unexpected gcloud ${args.join(" ")}`);
33
+ };
34
+ }
35
+
36
+ interface Seen {
37
+ url: string;
38
+ headers: Record<string, string>;
39
+ body: unknown;
40
+ }
41
+
42
+ function fakeFetch(admin: () => Response) {
43
+ const seen: Seen[] = [];
44
+ const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
45
+ const url = String(input);
46
+ seen.push({
47
+ url,
48
+ headers: Object.fromEntries(new Headers(init?.headers).entries()),
49
+ body: init?.body ? JSON.parse(String(init.body)) : null,
50
+ });
51
+ if (url.startsWith("https://iamcredentials.googleapis.com/")) {
52
+ return Response.json({ signedJwt: "iap.jwt.signed" });
53
+ }
54
+ return admin();
55
+ }) as typeof fetch;
56
+ return { doFetch, seen };
57
+ }
58
+
59
+ const ISSUED = {
60
+ ok: true,
61
+ email: "owner@example.com",
62
+ token: "tok/with+chars",
63
+ expiresAt: "2026-10-15T12:52:56.000Z",
64
+ };
65
+
66
+ describe("requestOwnerReset", () => {
67
+ it("calls the endpoint with the owner_reset scope and builds the link on the admin URL", async () => {
68
+ const { doFetch, seen } = fakeFetch(() => Response.json(ISSUED));
69
+ const r = await requestOwnerReset(target, {}, { run: fakeGcloud(), fetch: doFetch });
70
+ expect(r).toEqual({
71
+ ok: true,
72
+ value: {
73
+ email: "owner@example.com",
74
+ resetUrl: `${ADMIN}/reset?token=tok%2Fwith%2Bchars`,
75
+ expiresAt: "2026-10-15T12:52:56.000Z",
76
+ },
77
+ });
78
+ const call = seen[1];
79
+ expect(call?.url).toBe(`${ADMIN}/api/internal/owner-reset`);
80
+ expect(call?.headers.authorization).toBe("Bearer iap.jwt.signed");
81
+ expect(call?.body).toEqual({ requestedBy: "operator@example.com" });
82
+ const [message] = (call?.headers["x-caelo-internal-token"] ?? "").split(".");
83
+ expect(Buffer.from(message ?? "", "base64url").toString()).toEndWith(`:${OWNER_RESET_SCOPE}`);
84
+ });
85
+
86
+ it("forwards --email so the operator can pick among several Owners", async () => {
87
+ const { doFetch, seen } = fakeFetch(() => Response.json(ISSUED));
88
+ await requestOwnerReset(
89
+ target,
90
+ { email: "owner@example.com" },
91
+ { run: fakeGcloud(), fetch: doFetch },
92
+ );
93
+ expect(seen[1]?.body).toEqual({
94
+ email: "owner@example.com",
95
+ requestedBy: "operator@example.com",
96
+ });
97
+ });
98
+
99
+ it("refuses to run without an active gcloud account to name in the audit row", async () => {
100
+ const { doFetch, seen } = fakeFetch(() => Response.json(ISSUED));
101
+ for (const answer of [
102
+ ok(""),
103
+ fail("ERROR: no credentialed accounts"),
104
+ ok("a@x.com\nb@x.com"),
105
+ ]) {
106
+ const r = await requestOwnerReset(
107
+ target,
108
+ {},
109
+ { run: fakeGcloud({ "auth list": answer }), fetch: doFetch },
110
+ );
111
+ expect(r.ok ? "" : r.error).toContain("gcloud auth login");
112
+ }
113
+ expect(seen).toHaveLength(0);
114
+ });
115
+
116
+ it("passes the admin's refusal through and names the upgrade for an older admin", async () => {
117
+ const refused = await requestOwnerReset(
118
+ target,
119
+ {},
120
+ {
121
+ run: fakeGcloud(),
122
+ fetch: fakeFetch(() =>
123
+ Response.json(
124
+ { ok: false, error: "This install has 2 Owners (a@x.com, b@x.com). Name the one" },
125
+ { status: 422 },
126
+ ),
127
+ ).doFetch,
128
+ },
129
+ );
130
+ expect(refused.ok ? "" : refused.error).toContain("2 Owners (a@x.com, b@x.com)");
131
+
132
+ const old = await requestOwnerReset(
133
+ target,
134
+ {},
135
+ { run: fakeGcloud(), fetch: fakeFetch(() => new Response("nf", { status: 404 })).doFetch },
136
+ );
137
+ expect(old.ok ? "" : old.error).toContain("before #769");
138
+ expect(old.ok ? "" : old.error).toContain("cms-provision upgrade");
139
+ });
140
+
141
+ it("refuses an answer without a token instead of printing a broken link", async () => {
142
+ const r = await requestOwnerReset(
143
+ target,
144
+ {},
145
+ {
146
+ run: fakeGcloud(),
147
+ fetch: fakeFetch(() => Response.json({ ok: true, email: "owner@example.com" })).doFetch,
148
+ },
149
+ );
150
+ expect(r.ok).toBe(false);
151
+ });
152
+ });
153
+
154
+ describe("describeOwnerReset", () => {
155
+ it("prints the account, the link and when it stops working", () => {
156
+ const text = describeOwnerReset({
157
+ email: "owner@example.com",
158
+ resetUrl: `${ADMIN}/reset?token=abc`,
159
+ expiresAt: "2026-10-15T12:52:56.000Z",
160
+ }).join("\n");
161
+ expect(text).toContain("owner@example.com");
162
+ expect(text).toContain(` ${ADMIN}/reset?token=abc`);
163
+ expect(text).toContain("2026-10-15T12:52:56.000Z");
164
+ });
165
+ });
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #769 — `cms-provision owner-reset` — get an Owner back into a locked-out admin
5
+ * when the install cannot email a reset link (no email transport set up).
6
+ *
7
+ * The root of trust is the operator's cloud identity: whoever may read the
8
+ * install's internal secret and pass the admin's IAP already controls its
9
+ * database and deploys, so issuing them a reset link grants nothing new. The
10
+ * admin issues the one-hour, single-use token (`auth.issue_owner_reset`,
11
+ * audited) and hands it back over the admin-internal.ts channel; the CLI
12
+ * prints the link once. It is never logged, so it does not land in the
13
+ * project's Cloud Logging the way a "log the link" fallback would.
14
+ *
15
+ * The request carries the operator's gcloud account so the admin's audit row
16
+ * names who asked. That account is self-asserted (the admin sees the shared
17
+ * `caelo-mcp` identity and the shared internal secret), and labelled so; the
18
+ * authoritative record is Google's Data Access audit log for the IAM
19
+ * Credentials and Secret Manager calls this command makes as the operator.
20
+ */
21
+
22
+ import {
23
+ type AdminInternalDeps,
24
+ type AdminInternalResult,
25
+ type AdminInternalTarget,
26
+ callAdminInternal,
27
+ } from "./admin-internal.js";
28
+ import { activeAccount, gcloud as defaultGcloud } from "./gcloud.js";
29
+
30
+ /** Token scope the admin endpoint checks (apps/admin …/api/internal/owner-reset). */
31
+ export const OWNER_RESET_SCOPE = "auth.owner_reset";
32
+ const ENDPOINT = "/api/internal/owner-reset";
33
+
34
+ /** What the admin issued: whose account, the link to open, and until when it works. */
35
+ export interface OwnerReset {
36
+ readonly email: string;
37
+ readonly resetUrl: string;
38
+ readonly expiresAt: string;
39
+ }
40
+
41
+ /**
42
+ * Ask the admin for a reset link for the install's Owner (or the Owner named
43
+ * by `email` when there are several).
44
+ */
45
+ export async function requestOwnerReset(
46
+ target: AdminInternalTarget,
47
+ request: { readonly email?: string },
48
+ deps: AdminInternalDeps = {},
49
+ ): Promise<AdminInternalResult<OwnerReset>> {
50
+ const account = await readGcloudAccount(deps.run ?? defaultGcloud);
51
+ if (!account.ok) return account;
52
+ const r = await callAdminInternal<{ email: string; token: string; expiresAt: string }>(
53
+ target,
54
+ {
55
+ endpoint: ENDPOINT,
56
+ scope: OWNER_RESET_SCOPE,
57
+ body: {
58
+ ...(request.email !== undefined ? { email: request.email } : {}),
59
+ requestedBy: account.value,
60
+ },
61
+ introducedBy: "#769",
62
+ },
63
+ deps,
64
+ );
65
+ if (!r.ok) return r;
66
+ const { answer, adminUrl } = r.value;
67
+ const { email, token, expiresAt } = answer;
68
+ if (!email || !token || !expiresAt) {
69
+ return { ok: false, error: "the admin answered without an email, token or expiry" };
70
+ }
71
+ const resetUrl = new URL(`/reset?token=${encodeURIComponent(token)}`, adminUrl).toString();
72
+ return { ok: true, value: { email, resetUrl, expiresAt } };
73
+ }
74
+
75
+ /**
76
+ * The gcloud account this command runs as, for the admin's audit row. Without
77
+ * one there is nobody to name, and the IAP step after this would fail anyway.
78
+ */
79
+ async function readGcloudAccount(
80
+ run: NonNullable<AdminInternalDeps["run"]>,
81
+ ): Promise<AdminInternalResult<string>> {
82
+ // `auth list --filter=status:ACTIVE`, not `config get-value account`: the
83
+ // latter prints the sentinel `(unset)` and exits 0 when no account is set.
84
+ const account = await activeAccount(run);
85
+ if (!account || /\s/.test(account)) {
86
+ return {
87
+ ok: false,
88
+ error:
89
+ "read the active gcloud account: none is signed in. Run `gcloud auth login` with the account you provisioned with.",
90
+ };
91
+ }
92
+ return { ok: true, value: account };
93
+ }
94
+
95
+ /** The lines the command prints. Pure — the CLI prints them and stores nothing. */
96
+ export function describeOwnerReset(r: OwnerReset): string[] {
97
+ return [
98
+ `Reset link for ${r.email} — open it to set a new password:`,
99
+ "",
100
+ ` ${r.resetUrl}`,
101
+ "",
102
+ `It works once and expires at ${r.expiresAt}. Setting the password signs out every`,
103
+ "other session of this account. The link is not saved anywhere; run the command",
104
+ "again for a new one.",
105
+ ];
106
+ }
@@ -46,6 +46,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
46
46
  // #587 — the admin's URL-map redirects role and the URL map's shape.
47
47
  "packages/provisioning/src/url-map-redirects-access.ts",
48
48
  "packages/provisioning/src/gcp-url-map.ts",
49
+ // The in-cloud upgrader job's service account, custom roles and bucket
50
+ // grants (#799 added one without the checks noticing).
51
+ "packages/provisioning/src/upgrader-setup.ts",
49
52
  ];
50
53
 
51
54
  /**
@@ -296,6 +296,7 @@ describe("release-contract", () => {
296
296
  expect(isInfraContractPath("packages/provisioning/stacks/gcp/index.ts")).toBe(true);
297
297
  expect(isInfraContractPath("packages/provisioning/src/stack-converge.ts")).toBe(true);
298
298
  expect(isInfraContractPath("packages/provisioning/src/upgrade/converge.ts")).toBe(true);
299
+ expect(isInfraContractPath("packages/provisioning/src/upgrader-setup.ts")).toBe(true);
299
300
  expect(isInfraContractPath("packages/provisioning/src/stack-converge.test.ts")).toBe(false);
300
301
  expect(isInfraContractPath("packages/provisioning/stacks/gcp/README.md")).toBe(false);
301
302
  expect(isInfraContractPath("packages/provisioning/src/cli.ts")).toBe(false);
@@ -340,10 +340,7 @@ describe("rotateRuntimeSecret", () => {
340
340
  ]),
341
341
  ],
342
342
  "run services describe gw-svc": [
343
- service([
344
- ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
345
- ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
346
- ]),
343
+ service([ref("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
347
344
  ],
348
345
  "auth print-access-token": [ok("tok\n")],
349
346
  });
@@ -414,7 +411,7 @@ describe("rotateRuntimeSecret", () => {
414
411
  ).toBe(true);
415
412
  });
416
413
 
417
- it("gateway-role-password: changes gateway_role only and rolls the gateway only (#613)", async () => {
414
+ it("gateway-role-password: changes gateway_role only and rolls no service (#613, #635)", async () => {
418
415
  const { run, lines } = fakeGcloud({
419
416
  ...converged(),
420
417
  "secrets versions access": [ok("old-pw")],
@@ -428,9 +425,9 @@ describe("rotateRuntimeSecret", () => {
428
425
  });
429
426
  expect(report.ok).toBe(true);
430
427
  expect(sql.passwords()).toEqual(["gateway_role new-pw"]);
431
- expect(lines().filter((l) => l.startsWith("run services update "))).toEqual([
432
- expect.stringContaining("run services update gw-svc"),
433
- ]);
428
+ // No service logs in as gateway_role any more: the gateway reads
429
+ // cms_public only (#635).
430
+ expect(lines().filter((l) => l.startsWith("run services update "))).toEqual([]);
434
431
  expect(lines()).toContain(
435
432
  "secrets versions access latest --secret=caelo-production-gateway-role-password --project=acme",
436
433
  );
@@ -477,6 +474,26 @@ describe("rotateRuntimeSecret", () => {
477
474
  expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
478
475
  });
479
476
 
477
+ it("refuses gateway-role-password while the gateway still logs in with it (before #635)", async () => {
478
+ const answers = converged();
479
+ answers["run services describe gw-svc"] = [
480
+ service([
481
+ ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
482
+ ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
483
+ ]),
484
+ ];
485
+ const { run } = fakeGcloud(answers);
486
+ const sql = fakeSqlApi();
487
+ const report = await rotateRuntimeSecret(target, "gateway-role-password", {
488
+ run,
489
+ http: sql.http,
490
+ generate: () => "new-pw",
491
+ });
492
+ expect(report.ok).toBe(false);
493
+ expect(report.error).toContain("GATEWAY_DATABASE_PASSWORD");
494
+ expect(sql.requests).toHaveLength(0);
495
+ });
496
+
480
497
  it("refuses before changing anything on an install upgrade hasn't moved over", async () => {
481
498
  const { run, lines } = fakeGcloud({
482
499
  "run services describe adm-svc": [
@@ -49,12 +49,11 @@ const ADMIN_SECRETS = {
49
49
  };
50
50
 
51
51
  describe("databaseUrls", () => {
52
- it("builds password-less URLs for the four role/database pairs", () => {
52
+ it("builds password-less URLs for the three role/database pairs", () => {
53
53
  expect(databaseUrls("10.20.0.3")).toEqual({
54
54
  admin: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
55
55
  publicAdmin: "postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
56
56
  public: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
57
- gateway: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
58
57
  });
59
58
  });
60
59
  });
@@ -154,15 +153,13 @@ describe.each([
154
153
  describe("gatewayEnvContract", () => {
155
154
  const gateway = gatewayEnvContract({ ...base, provider: "gcp" });
156
155
 
157
- it("carries its two own logins and none of the admin's database or app secrets", () => {
156
+ it("carries its one own login and none of the admin's database or app secrets (#635)", () => {
158
157
  expect(asRecord(gateway)).toEqual({
159
158
  CAELO_PROVIDER: "gcp",
160
159
  CAELO_ENV: "production",
161
160
  MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
162
161
  PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
163
- GATEWAY_DATABASE_URL: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
164
162
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
165
- GATEWAY_DATABASE_PASSWORD: "secret:caelo-production-gateway-role-password:latest",
166
163
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
167
164
  });
168
165
  });
@@ -188,11 +185,7 @@ describe("gatewayEnvContract", () => {
188
185
  });
189
186
 
190
187
  it("the gateway's run SA reads no admin-only secret (admin password, KEK, internal, tool approval)", () => {
191
- expect(serviceSecrets("gateway")).toEqual([
192
- "gateway-role-password",
193
- "public-role-password",
194
- "staging-gate-secret",
195
- ]);
188
+ expect(serviceSecrets("gateway")).toEqual(["public-role-password", "staging-gate-secret"]);
196
189
  for (const v of gateway) {
197
190
  expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
198
191
  }
@@ -280,11 +273,6 @@ describe("runtime secrets", () => {
280
273
  secret: "staging-gate-secret",
281
274
  stackResource: "staging-gate-secret-binding",
282
275
  },
283
- {
284
- service: "gateway",
285
- secret: "gateway-role-password",
286
- stackResource: "gateway-gateway-role-password-binding",
287
- },
288
276
  {
289
277
  service: "gateway",
290
278
  secret: "public-role-password",
@@ -299,7 +287,11 @@ describe("runtime secrets", () => {
299
287
  });
300
288
 
301
289
  it("every CLI-generated secret is read by some service", () => {
302
- for (const s of CLI_GENERATED_SECRETS) {
290
+ // gateway-role-password is read by no service since #635: it only keeps
291
+ // the legacy gateway_role's password set until the contract release
292
+ // drops the role (the previous release's gateway logs in with it while
293
+ // an upgrade rolls).
294
+ for (const s of CLI_GENERATED_SECRETS.filter((x) => x !== "gateway-role-password")) {
303
295
  expect([...serviceSecrets("admin"), ...serviceSecrets("gateway")]).toContain(s);
304
296
  }
305
297
  });
@@ -351,13 +343,12 @@ describe("stackIamInvariants", () => {
351
343
  }
352
344
  });
353
345
 
354
- it("grants the gateway SA only its two role passwords, the staging gate key + telemetry roles", () => {
346
+ it("grants the gateway SA only its own role password, the staging gate key + telemetry roles", () => {
355
347
  for (const provider of ["gcp", "gcp-firebase"] as const) {
356
348
  const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
357
349
  expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
358
350
  'roles/logging.logWriter {"kind":"project"}',
359
351
  'roles/monitoring.metricWriter {"kind":"project"}',
360
- 'roles/secretmanager.secretAccessor {"kind":"secret","name":"gateway-role-password"}',
361
352
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
362
353
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
363
354
  ]);
@@ -86,14 +86,12 @@ export interface DatabaseUrls<V = string> {
86
86
  readonly admin: V;
87
87
  /** admin_role on cms_public — the admin's second pool (DDL, migrations). */
88
88
  readonly publicAdmin: V;
89
- /** public_role on cms_public — the gateway's visitor-write pool. */
90
- readonly public: V;
91
89
  /**
92
- * gateway_role on cms_admin — the gateway's narrow pool (#613): its
93
- * settings, rate limits, captcha, request log, plugin op audit and the
94
- * plugin registry, read-mostly (migration 0248).
90
+ * public_role on cms_public — the gateway's only pool. The gateway has no
91
+ * cms_admin login (#635, CLAUDE.md §2): what it needs from authoring
92
+ * state the admin publishes into cms_public.
95
93
  */
96
- readonly gateway: V;
94
+ readonly public: V;
97
95
  }
98
96
 
99
97
  /** Port Cloud SQL Postgres listens on (private IP). */
@@ -111,7 +109,6 @@ export function databaseUrls(host: string): DatabaseUrls {
111
109
  admin: url("admin_role", "cms_admin"),
112
110
  publicAdmin: url("admin_role", "cms_public"),
113
111
  public: url("public_role", "cms_public"),
114
- gateway: url("gateway_role", "cms_admin"),
115
112
  };
116
113
  }
117
114
 
@@ -251,8 +248,8 @@ export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
251
248
 
252
249
  /**
253
250
  * Each database role's password secret — one per role (#613), so the
254
- * gateway's SA, which reads only the public_role and gateway_role
255
- * passwords, can never read admin_role's. `postgres-password` keeps its
251
+ * gateway's SA, which reads only the public_role password, can never read
252
+ * admin_role's (or, since #635, gateway_role's). `postgres-password` keeps its
256
253
  * name: it has always been admin_role's (and, before #613, shared with
257
254
  * public_role). `rotate-secret` changes exactly the role(s) listed here.
258
255
  */
@@ -306,9 +303,8 @@ export const SERVICE_SECRET_ENV: Readonly<
306
303
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
307
304
  },
308
305
  gateway: {
309
- // CLAUDE.md §2: never an admin_role credential (#613). gateway_role on
310
- // cms_admin + public_role on cms_public, each with its own password.
311
- GATEWAY_DATABASE_PASSWORD: "gateway-role-password",
306
+ // CLAUDE.md §2: no cms_admin credential of any role (#613, #635).
307
+ // public_role on cms_public is the gateway's only login.
312
308
  PUBLIC_DATABASE_PASSWORD: "public-role-password",
313
309
  // #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
314
310
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
@@ -323,12 +319,20 @@ export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
323
319
  /**
324
320
  * Env vars an older stack set that the contract no longer gives a service;
325
321
  * upgrade removes them. The gateway never used the KEK, and its run SA
326
- * cannot read it. Its admin_role pool went with #613 — the gateway refuses
327
- * to boot while either var is set (apps/api-gateway `gatewayDatabaseUrls`).
322
+ * cannot read it. Its admin_role pool went with #613 and its gateway_role
323
+ * pool with #635 — the gateway refuses to boot while any cms_admin
324
+ * credential is in its env (apps/api-gateway `adminDatabaseAccessInEnv`).
328
325
  */
329
326
  export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[]>> = {
330
327
  admin: [],
331
- gateway: ["CAELO_SECRET_KEK", "ADMIN_DATABASE_URL", "ADMIN_DATABASE_PASSWORD"],
328
+ gateway: [
329
+ "CAELO_SECRET_KEK",
330
+ "ADMIN_DATABASE_URL",
331
+ "ADMIN_DATABASE_PASSWORD",
332
+ // #635 — its gateway_role login on cms_admin is gone too.
333
+ "GATEWAY_DATABASE_URL",
334
+ "GATEWAY_DATABASE_PASSWORD",
335
+ ],
332
336
  };
333
337
 
334
338
  function secretEnv(service: CloudRunSlug, env: string): CloudRunEnvVar[] {
@@ -362,7 +366,6 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
362
366
  return [
363
367
  ...commonEnv(inputs),
364
368
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
365
- { name: "GATEWAY_DATABASE_URL", value: inputs.databaseUrls.gateway },
366
369
  ...secretEnv("gateway", inputs.env),
367
370
  ];
368
371
  }
@@ -679,6 +682,12 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
679
682
  target: { kind: "secret", name: "postgres-password" },
680
683
  why: "the gateway no longer logs in as admin_role and must not be able to read its password (CLAUDE.md §2)",
681
684
  },
685
+ {
686
+ role: SECRET_ACCESSOR,
687
+ member: "gateway-sa",
688
+ target: { kind: "secret", name: "gateway-role-password" },
689
+ why: "the gateway has no cms_admin login at all and must not be able to read gateway_role's password (CLAUDE.md §2, #635)",
690
+ },
682
691
  ];
683
692
 
684
693
  /** Every IAM binding upgrade ensures on an install of `provider`. */
@@ -38,12 +38,14 @@ const SECRET_VALUES: Record<string, string> = {
38
38
  "caelo-production-gateway-role-password": "g".repeat(64),
39
39
  };
40
40
 
41
- /** Env vars that would hand the gateway an admin_role credential (apps/api-gateway ADMIN_CREDENTIAL_ENV). */
41
+ /** Env vars that would hand the gateway a cms_admin credential (a subset of what apps/api-gateway adminDatabaseAccessInEnv refuses). */
42
42
  const ADMIN_CREDENTIAL_ENV = [
43
43
  "ADMIN_DATABASE_URL",
44
44
  "ADMIN_DATABASE_PASSWORD",
45
45
  "PUBLIC_ADMIN_DATABASE_URL",
46
46
  "PUBLIC_ADMIN_DATABASE_PASSWORD",
47
+ "GATEWAY_DATABASE_URL",
48
+ "GATEWAY_DATABASE_PASSWORD",
47
49
  ];
48
50
 
49
51
  /** Split a gcloud list flag value, honouring the `^<delim>^` escape. */
@@ -122,16 +124,15 @@ function credentials(url: string | undefined): string {
122
124
  }
123
125
 
124
126
  /**
125
- * The gateway's process env after the upgrade: gateway_role on cms_admin and
126
- * public_role on cms_public, each with its own secret, and no admin_role
127
- * credential at all (the gateway refuses to boot with one).
127
+ * The gateway's process env after the upgrade: public_role on cms_public
128
+ * with its own secret, and no cms_admin credential of any role at all
129
+ * (#613, #635 — the gateway refuses to boot with one).
128
130
  */
129
131
  function expectGatewayOnItsOwnLogins(env: Record<string, string>): void {
130
132
  for (const name of ADMIN_CREDENTIAL_ENV) expect(Object.keys(env)).not.toContain(name);
131
133
  expect(Object.values(env).join("\n")).not.toContain(PW);
132
- expect(credentials(databaseUrlFromEnv(["GATEWAY_DATABASE_URL"], env))).toBe(
133
- `gateway_role:${"g".repeat(64)}@${HOST}:5432/cms_admin?sslmode=require`,
134
- );
134
+ expect(Object.values(env).join("\n")).not.toContain("g".repeat(64));
135
+ expect(Object.values(env).join("\n")).not.toContain("cms_admin");
135
136
  expect(credentials(databaseUrlFromEnv(["PUBLIC_DATABASE_URL"], env))).toBe(
136
137
  `public_role:${"p".repeat(64)}@${HOST}:5432/cms_public?sslmode=require`,
137
138
  );
@@ -331,7 +332,7 @@ function v01035Install() {
331
332
  }
332
333
 
333
334
  describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", () => {
334
- it("drops the admin_role credential and adds the gateway's own logins in one revision", () => {
335
+ it("drops the admin_role credential and adds the gateway's own login in one revision", () => {
335
336
  const services = v01035Install();
336
337
  const plan = upgrade(services);
337
338
  expect(services.gateway.revisions).toHaveLength(2);
@@ -343,8 +344,6 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
343
344
  "ADMIN_DATABASE_URL",
344
345
  // #650 — the staging gate key arrives in the same roll.
345
346
  "CAELO_STAGING_GATE_SECRET",
346
- "GATEWAY_DATABASE_PASSWORD",
347
- "GATEWAY_DATABASE_URL",
348
347
  "PUBLIC_DATABASE_PASSWORD",
349
348
  ]);
350
349
  });
@@ -374,6 +373,42 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
374
373
  });
375
374
  });
376
375
 
376
+ /**
377
+ * A v0.10.50 install (#613, before #635): the gateway on gateway_role's
378
+ * cms_admin pool next to public_role's.
379
+ */
380
+ function v01050Install() {
381
+ const services = v01035Install();
382
+ upgrade(services);
383
+ const env = new Map(services.gateway.env);
384
+ env.set("GATEWAY_DATABASE_URL", {
385
+ kind: "value",
386
+ value: `postgresql://gateway_role@${HOST}:5432/cms_admin?sslmode=require`,
387
+ });
388
+ env.set("GATEWAY_DATABASE_PASSWORD", {
389
+ kind: "secret",
390
+ secret: "caelo-production-gateway-role-password",
391
+ version: "latest",
392
+ });
393
+ services.gateway = new FakeService(env, SERVICE_ACCOUNTS.gateway);
394
+ return services;
395
+ }
396
+
397
+ describe("upgrading a v0.10.50 install: the gateway leaves cms_admin entirely (#635)", () => {
398
+ it("removes the gateway_role login in one revision and keeps the public pool", () => {
399
+ const services = v01050Install();
400
+ const plan = upgrade(services);
401
+ expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
402
+ "GATEWAY_DATABASE_PASSWORD",
403
+ "GATEWAY_DATABASE_URL",
404
+ ]);
405
+ expectGatewayOnItsOwnLogins(containerEnv(services.gateway.env));
406
+ for (const revision of services.gateway.revisions) {
407
+ expect(revision.has("PUBLIC_DATABASE_URL")).toBe(true);
408
+ }
409
+ });
410
+ });
411
+
377
412
  describe("plain → Secret Manager migration of an existing var", () => {
378
413
  it("never leaves a revision without the var", () => {
379
414
  // An operator set CAELO_INTERNAL_SECRET by hand as a plain value.