@caelo-cms/provisioning 0.10.49 → 0.10.51

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 (191) hide show
  1. package/caddy/Caddyfile.production +4 -0
  2. package/caddy/Caddyfile.staging +4 -0
  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-db.js +93 -20
  10. package/dist/aws-task-roles.d.ts +27 -0
  11. package/dist/aws-task-roles.d.ts.map +1 -0
  12. package/dist/aws-task-roles.js +35 -0
  13. package/dist/aws-task-roles.js.map +1 -0
  14. package/dist/caddy-redirects.d.ts +15 -0
  15. package/dist/caddy-redirects.d.ts.map +1 -0
  16. package/dist/caddy-redirects.js +65 -0
  17. package/dist/caddy-redirects.js.map +1 -0
  18. package/dist/caddy.d.ts +22 -2
  19. package/dist/caddy.d.ts.map +1 -1
  20. package/dist/caddy.js +66 -17
  21. package/dist/caddy.js.map +1 -1
  22. package/dist/cli-routing.d.ts.map +1 -1
  23. package/dist/cli-routing.js +7 -0
  24. package/dist/cli-routing.js.map +1 -1
  25. package/dist/cli.js +282 -255
  26. package/dist/cli.js.map +1 -1
  27. package/dist/compose.d.ts +54 -2
  28. package/dist/compose.d.ts.map +1 -1
  29. package/dist/compose.js +88 -34
  30. package/dist/compose.js.map +1 -1
  31. package/dist/deployed-release.d.ts +3 -2
  32. package/dist/deployed-release.d.ts.map +1 -1
  33. package/dist/deployed-release.js +12 -5
  34. package/dist/deployed-release.js.map +1 -1
  35. package/dist/gateway-credentials.d.ts.map +1 -1
  36. package/dist/gateway-credentials.js +6 -3
  37. package/dist/gateway-credentials.js.map +1 -1
  38. package/dist/gcloud.d.ts +2 -2
  39. package/dist/gcloud.d.ts.map +1 -1
  40. package/dist/gcloud.js +3 -3
  41. package/dist/gcloud.js.map +1 -1
  42. package/dist/gcp-names.d.ts +12 -0
  43. package/dist/gcp-names.d.ts.map +1 -1
  44. package/dist/gcp-names.js +21 -0
  45. package/dist/gcp-names.js.map +1 -1
  46. package/dist/gcp-url-map.d.ts +27 -0
  47. package/dist/gcp-url-map.d.ts.map +1 -0
  48. package/dist/gcp-url-map.js +62 -0
  49. package/dist/gcp-url-map.js.map +1 -0
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +0 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/infra-contract.d.ts +20 -1
  55. package/dist/infra-contract.d.ts.map +1 -1
  56. package/dist/infra-contract.js +44 -1
  57. package/dist/infra-contract.js.map +1 -1
  58. package/dist/owner-reset.d.ts +37 -0
  59. package/dist/owner-reset.d.ts.map +1 -0
  60. package/dist/owner-reset.js +80 -0
  61. package/dist/owner-reset.js.map +1 -0
  62. package/dist/public-role-isolation.d.ts +19 -0
  63. package/dist/public-role-isolation.d.ts.map +1 -0
  64. package/dist/public-role-isolation.js +69 -0
  65. package/dist/public-role-isolation.js.map +1 -0
  66. package/dist/release-contract.d.ts.map +1 -1
  67. package/dist/release-contract.js +3 -0
  68. package/dist/release-contract.js.map +1 -1
  69. package/dist/release-manifest.d.ts.map +1 -1
  70. package/dist/release-manifest.js +2 -1
  71. package/dist/release-manifest.js.map +1 -1
  72. package/dist/self-hosted-backup.d.ts +86 -0
  73. package/dist/self-hosted-backup.d.ts.map +1 -0
  74. package/dist/self-hosted-backup.js +350 -0
  75. package/dist/self-hosted-backup.js.map +1 -0
  76. package/dist/self-hosted-release.d.ts +12 -3
  77. package/dist/self-hosted-release.d.ts.map +1 -1
  78. package/dist/self-hosted-release.js +6 -4
  79. package/dist/self-hosted-release.js.map +1 -1
  80. package/dist/self-hosted-start.d.ts +50 -0
  81. package/dist/self-hosted-start.d.ts.map +1 -0
  82. package/dist/self-hosted-start.js +59 -0
  83. package/dist/self-hosted-start.js.map +1 -0
  84. package/dist/self-hosted-upgrade.d.ts +30 -0
  85. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  86. package/dist/self-hosted-upgrade.js +29 -7
  87. package/dist/self-hosted-upgrade.js.map +1 -1
  88. package/dist/stack-contract.d.ts +9 -10
  89. package/dist/stack-contract.d.ts.map +1 -1
  90. package/dist/stack-contract.js +21 -10
  91. package/dist/stack-contract.js.map +1 -1
  92. package/dist/staging-admin.d.ts +12 -20
  93. package/dist/staging-admin.d.ts.map +1 -1
  94. package/dist/staging-admin.js +7 -159
  95. package/dist/staging-admin.js.map +1 -1
  96. package/dist/tar-entries.d.ts +14 -0
  97. package/dist/tar-entries.d.ts.map +1 -0
  98. package/dist/tar-entries.js +104 -0
  99. package/dist/tar-entries.js.map +1 -0
  100. package/dist/upgrade/converge.d.ts +2 -2
  101. package/dist/upgrade/converge.d.ts.map +1 -1
  102. package/dist/upgrade/converge.js +44 -2
  103. package/dist/upgrade/converge.js.map +1 -1
  104. package/dist/upgrader-assess.d.ts.map +1 -1
  105. package/dist/upgrader-assess.js +2 -1
  106. package/dist/upgrader-assess.js.map +1 -1
  107. package/dist/url-map-redirects-access.d.ts +15 -0
  108. package/dist/url-map-redirects-access.d.ts.map +1 -0
  109. package/dist/url-map-redirects-access.js +81 -0
  110. package/dist/url-map-redirects-access.js.map +1 -0
  111. package/dist/wizard.d.ts.map +1 -1
  112. package/dist/wizard.js +3 -1
  113. package/dist/wizard.js.map +1 -1
  114. package/dist/wizards/gcp-cost.js +1 -1
  115. package/dist/wizards/gcp.d.ts.map +1 -1
  116. package/dist/wizards/gcp.js +45 -0
  117. package/dist/wizards/gcp.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/adapter.ts +4 -6
  120. package/src/admin-internal.ts +212 -0
  121. package/src/admin-memory.test.ts +2 -2
  122. package/src/aws-task-roles.ts +58 -0
  123. package/src/caddy-redirects.docker.test.ts +255 -0
  124. package/src/caddy-redirects.test.ts +115 -0
  125. package/src/caddy-redirects.ts +68 -0
  126. package/src/caddy-validate.test.ts +119 -0
  127. package/src/caddy.test.ts +108 -0
  128. package/src/caddy.ts +89 -20
  129. package/src/checkpoint-secrets.test.ts +0 -1
  130. package/src/cli-routing.test.ts +12 -0
  131. package/src/cli-routing.ts +7 -0
  132. package/src/cli.ts +340 -288
  133. package/src/compose.ts +108 -36
  134. package/src/deployed-release.test.ts +22 -4
  135. package/src/deployed-release.ts +14 -7
  136. package/src/gateway-credentials.ts +6 -3
  137. package/src/gateway-env-isolation.test.ts +212 -0
  138. package/src/gcloud.ts +5 -3
  139. package/src/gcp-names.ts +23 -0
  140. package/src/gcp-url-map.test.ts +150 -0
  141. package/src/gcp-url-map.ts +81 -0
  142. package/src/index.test.ts +24 -152
  143. package/src/index.ts +0 -9
  144. package/src/infra-contract.test.ts +40 -0
  145. package/src/infra-contract.ts +47 -1
  146. package/src/owner-reset.test.ts +165 -0
  147. package/src/owner-reset.ts +106 -0
  148. package/src/public-role-isolation.test.ts +89 -0
  149. package/src/public-role-isolation.ts +83 -0
  150. package/src/release-contract.ts +3 -0
  151. package/src/release-manifest.test.ts +12 -0
  152. package/src/release-manifest.ts +2 -1
  153. package/src/runtime-secrets.test.ts +25 -8
  154. package/src/self-hosted-backup.test.ts +344 -0
  155. package/src/self-hosted-backup.ts +480 -0
  156. package/src/self-hosted-compose.test.ts +88 -4
  157. package/src/self-hosted-release.ts +22 -7
  158. package/src/self-hosted-start.test.ts +68 -0
  159. package/src/self-hosted-start.ts +123 -0
  160. package/src/self-hosted-upgrade.ts +35 -8
  161. package/src/stack-contract.test.ts +11 -20
  162. package/src/stack-contract.ts +25 -16
  163. package/src/stack-converge-secrets.test.ts +45 -10
  164. package/src/stack-converge.test.ts +19 -10
  165. package/src/staging-admin.ts +18 -181
  166. package/src/tar-entries.test.ts +52 -0
  167. package/src/tar-entries.ts +116 -0
  168. package/src/upgrade/converge.ts +51 -4
  169. package/src/upgrade/upgrade.test.ts +78 -1
  170. package/src/upgrader-assess.test.ts +22 -0
  171. package/src/upgrader-assess.ts +2 -1
  172. package/src/url-map-redirects-access.test.ts +88 -0
  173. package/src/url-map-redirects-access.ts +89 -0
  174. package/src/wizard.ts +3 -1
  175. package/src/wizards/gcp-cost.ts +1 -1
  176. package/src/wizards/gcp.ts +48 -0
  177. package/stacks/aws/README.md +1 -1
  178. package/stacks/aws/index.ts +89 -45
  179. package/stacks/azure/README.md +1 -1
  180. package/stacks/azure/index.ts +21 -14
  181. package/stacks/gcp/README.md +1 -1
  182. package/stacks/gcp/index.ts +26 -22
  183. package/stacks/gcp-firebase/index.ts +7 -3
  184. package/stacks/self-hosted/Pulumi.yaml +2 -2
  185. package/stacks/self-hosted/README.md +3 -3
  186. package/stacks/self-hosted/index.ts +45 -22
  187. package/dist/redirects-emit.d.ts +0 -52
  188. package/dist/redirects-emit.d.ts.map +0 -1
  189. package/dist/redirects-emit.js +0 -111
  190. package/dist/redirects-emit.js.map +0 -1
  191. package/src/redirects-emit.ts +0 -175
@@ -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
+ }
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { GcloudResult } from "./gcloud.js";
5
+ import type { GcloudRunner } from "./gcloud-retry.js";
6
+ import {
7
+ revokePublicRoleMemberships,
8
+ revokePublicRoleMembershipsArgs,
9
+ } from "./public-role-isolation.js";
10
+
11
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
12
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
13
+ const noSleep = async () => {};
14
+
15
+ function fake(answers: (line: string) => GcloudResult) {
16
+ const calls: string[] = [];
17
+ const run: GcloudRunner = async (args) => {
18
+ const line = args.join(" ");
19
+ calls.push(line);
20
+ return answers(line);
21
+ };
22
+ return { run, calls };
23
+ }
24
+
25
+ describe("revokePublicRoleMembershipsArgs (#633)", () => {
26
+ it("revokes every database role of the built-in public_role user", () => {
27
+ expect(
28
+ revokePublicRoleMembershipsArgs({ projectId: "acme", sqlInstance: "caelo-production-pg-1" }),
29
+ ).toEqual([
30
+ "sql",
31
+ "users",
32
+ "assign-roles",
33
+ "public_role",
34
+ "--instance=caelo-production-pg-1",
35
+ "--type=BUILT_IN",
36
+ "--revoke-existing-roles",
37
+ "--project=acme",
38
+ "--quiet",
39
+ ]);
40
+ });
41
+
42
+ it("grants nothing in exchange (no --database-roles)", () => {
43
+ const args = revokePublicRoleMembershipsArgs({ projectId: "p", sqlInstance: "i" });
44
+ expect(args.some((a) => a.startsWith("--database-roles"))).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("revokePublicRoleMemberships", () => {
49
+ const install = { projectId: "acme", env: "production" };
50
+
51
+ it("finds the instance, then revokes", async () => {
52
+ const { run, calls } = fake((l) => (l.startsWith("sql instances list") ? ok("pg-1\n") : ok()));
53
+ const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
54
+ expect(r).toEqual({
55
+ id: "public_role database role memberships",
56
+ status: "applied",
57
+ detail: "no database roles left on pg-1",
58
+ });
59
+ expect(calls[1]).toBe(
60
+ "sql users assign-roles public_role --instance=pg-1 --type=BUILT_IN --revoke-existing-roles --project=acme --quiet",
61
+ );
62
+ });
63
+
64
+ it("is idempotent: a second run issues the same revoke and succeeds", async () => {
65
+ const { run, calls } = fake((l) => (l.startsWith("sql instances list") ? ok("pg-1\n") : ok()));
66
+ await revokePublicRoleMemberships(install, { run, sleep: noSleep });
67
+ const again = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
68
+ expect(again.status).toBe("applied");
69
+ expect(calls[1]).toBe(calls[3] as string);
70
+ });
71
+
72
+ it("fails without an instance and never calls the revoke", async () => {
73
+ const { run, calls } = fake(() => ok(""));
74
+ const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
75
+ expect(r.status).toBe("failed");
76
+ expect(r.error).toBe("the Cloud SQL instance was not found");
77
+ expect(calls).toHaveLength(1);
78
+ });
79
+
80
+ it("reports a refused revoke with the permission it needs", async () => {
81
+ const { run } = fake((l) =>
82
+ l.startsWith("sql instances list") ? ok("pg-1\n") : fail("PERMISSION_DENIED: nope"),
83
+ );
84
+ const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
85
+ expect(r.status).toBe("failed");
86
+ expect(r.error).toContain("PERMISSION_DENIED: nope");
87
+ expect(r.error).toContain("cloudsql.users.update");
88
+ });
89
+ });
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Take `public_role` out of `cloudsqlsuperuser` (issue #633, CLAUDE.md §2).
5
+ *
6
+ * The stacks declare `public_role` as a Cloud SQL Admin API user
7
+ * (`gcp.sql.User`), and Cloud SQL makes every such user a member of
8
+ * `cloudsqlsuperuser` — the group that owns cms_admin and cms_public. That
9
+ * gave the API gateway's visitor-facing login the owner group's rights.
10
+ *
11
+ * Why the Admin API and not SQL: on PostgreSQL 16+ a role membership can
12
+ * only be revoked by the role that granted it (or a superuser), and here
13
+ * that is Cloud SQL's own administrator, not `admin_role` — a `REVOKE` from
14
+ * the migration job would leave the membership in place with a warning.
15
+ * `users.update` with `revokeExistingRoles` runs as that administrator. It
16
+ * is also the one path that works the same for a fresh install and for the
17
+ * installs out there, with no change to the Pulumi resource (re-creating the
18
+ * user in SQL would mean deleting a user the stacks' state owns, with
19
+ * dependent grants in both databases, while the gateway serves on it).
20
+ *
21
+ * Revoking every role leaves `public_role` with none, which is exactly its
22
+ * contract: its rights are object grants in cms_public, nothing else.
23
+ *
24
+ * The CLI cannot reach the private-IP database to check the result, so the
25
+ * check runs where SQL runs: the migration job's role audit
26
+ * (packages/migrations/src/role-audit.ts), which fails the migration — and
27
+ * with it the install or upgrade, before any traffic moves — while
28
+ * `public_role` is still a member of any role. This step therefore runs
29
+ * BEFORE migrations, on every install pass and every `upgrade`. Idempotent:
30
+ * revoking from a role that has none changes nothing.
31
+ */
32
+
33
+ import { findSqlInstance } from "./gateway-credentials.js";
34
+ import { gcloud as defaultGcloud } from "./gcloud.js";
35
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
36
+ import type { EnsureOutcome } from "./runtime-secrets.js";
37
+
38
+ /** The gcloud call that leaves `public_role` with no database roles. */
39
+ export function revokePublicRoleMembershipsArgs(target: {
40
+ readonly projectId: string;
41
+ readonly sqlInstance: string;
42
+ }): string[] {
43
+ return [
44
+ "sql",
45
+ "users",
46
+ "assign-roles",
47
+ "public_role",
48
+ `--instance=${target.sqlInstance}`,
49
+ "--type=BUILT_IN",
50
+ "--revoke-existing-roles",
51
+ `--project=${target.projectId}`,
52
+ "--quiet",
53
+ ];
54
+ }
55
+
56
+ /**
57
+ * Revoke every database role the Admin API gave `public_role`. Reports
58
+ * instead of throwing; `failed` names the next step.
59
+ */
60
+ export async function revokePublicRoleMemberships(
61
+ install: { readonly projectId: string; readonly env: string },
62
+ deps: { readonly run?: GcloudRunner; readonly sleep?: Sleep } = {},
63
+ ): Promise<EnsureOutcome> {
64
+ const run = deps.run ?? defaultGcloud;
65
+ const id = "public_role database role memberships";
66
+ const sqlInstance = await findSqlInstance(install, { run });
67
+ if (!sqlInstance) {
68
+ return { id, status: "failed", error: "the Cloud SQL instance was not found" };
69
+ }
70
+ const r = await runWithRetry(
71
+ run,
72
+ deps.sleep ?? realSleep,
73
+ revokePublicRoleMembershipsArgs({ projectId: install.projectId, sqlInstance }),
74
+ );
75
+ if (!r.ok) {
76
+ return {
77
+ id,
78
+ status: "failed",
79
+ error: `gcloud sql users assign-roles public_role --revoke-existing-roles on ${sqlInstance}: ${r.stderr.trim()}. The account running this needs cloudsql.users.update on the project; re-run once it has.`,
80
+ };
81
+ }
82
+ return { id, status: "applied", detail: `no database roles left on ${sqlInstance}` };
83
+ }
@@ -43,6 +43,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
43
43
  "packages/provisioning/src/upgrade/converge.ts",
44
44
  "packages/provisioning/src/mcp-iap.ts",
45
45
  "packages/provisioning/src/default-firewall.ts",
46
+ // #587 — the admin's URL-map redirects role and the URL map's shape.
47
+ "packages/provisioning/src/url-map-redirects-access.ts",
48
+ "packages/provisioning/src/gcp-url-map.ts",
46
49
  ];
47
50
 
48
51
  /**
@@ -253,6 +253,18 @@ describe("assessAutoInstall", () => {
253
253
  );
254
254
  expect(a.mode).toBe("manual");
255
255
  expect(a.reasons[0]).toContain("1 → 3");
256
+ expect(a.reasons[0]).toContain("public_role out of the Cloud SQL superuser group (#633)");
257
+ });
258
+
259
+ it("names both pending infrastructure steps for a 2 → 4 bump (#633, #587)", () => {
260
+ const a = assessAutoInstall(
261
+ { ...MANIFEST, infraContractVersion: 4 },
262
+ { ...installed, infraContractVersion: 2 },
263
+ );
264
+ expect(a.mode).toBe("manual");
265
+ expect(a.reasons[0]).toContain(
266
+ "2 → 4: public_role out of the Cloud SQL superuser group (#633); the admin's permission to serve redirects from the load balancer (#587)",
267
+ );
256
268
  });
257
269
 
258
270
  it("refuses downgrades, re-installs and versions below minFromVersion", () => {
@@ -34,6 +34,7 @@ import {
34
34
  type ReleaseImage,
35
35
  type ReleaseManifest,
36
36
  } from "@caelo-cms/shared";
37
+ import { infraStepsText } from "./infra-contract.js";
37
38
  import { RELEASE_OIDC_ISSUER, RELEASE_WORKFLOWS_URL } from "./upgrade/release-images.js";
38
39
 
39
40
  // The manifest's shape and location live in @caelo-cms/shared (the admin's
@@ -292,7 +293,7 @@ export function assessAutoInstall(
292
293
  const manual: string[] = [];
293
294
  if (manifest.infraContractVersion > installed.infraContractVersion) {
294
295
  manual.push(
295
- `the release changes the infrastructure contract (${installed.infraContractVersion} → ${manifest.infraContractVersion}); it must run with the operator's own identity`,
296
+ `the release changes the infrastructure contract (${installed.infraContractVersion} → ${manifest.infraContractVersion}: ${infraStepsText(installed.infraContractVersion, manifest.infraContractVersion)}); it must run with the operator's own identity`,
296
297
  );
297
298
  }
298
299
  const lastContract = manifest.migrations.lastContractVersion;
@@ -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": [