@caelo-cms/provisioning 0.10.50 → 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 (74) hide show
  1. package/dist/adapter.d.ts +4 -6
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/admin-internal.d.ts +41 -0
  4. package/dist/admin-internal.d.ts.map +1 -0
  5. package/dist/admin-internal.js +169 -0
  6. package/dist/admin-internal.js.map +1 -0
  7. package/dist/agent-db.js +89 -20
  8. package/dist/aws-task-roles.d.ts +27 -0
  9. package/dist/aws-task-roles.d.ts.map +1 -0
  10. package/dist/aws-task-roles.js +35 -0
  11. package/dist/aws-task-roles.js.map +1 -0
  12. package/dist/cli.js +34 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.js +2 -2
  15. package/dist/compose.js.map +1 -1
  16. package/dist/deployed-release.d.ts +3 -2
  17. package/dist/deployed-release.d.ts.map +1 -1
  18. package/dist/deployed-release.js +12 -5
  19. package/dist/deployed-release.js.map +1 -1
  20. package/dist/gateway-credentials.d.ts.map +1 -1
  21. package/dist/gateway-credentials.js +6 -3
  22. package/dist/gateway-credentials.js.map +1 -1
  23. package/dist/gcloud.d.ts +2 -2
  24. package/dist/gcloud.d.ts.map +1 -1
  25. package/dist/gcloud.js +3 -3
  26. package/dist/gcloud.js.map +1 -1
  27. package/dist/infra-contract.d.ts +1 -1
  28. package/dist/infra-contract.d.ts.map +1 -1
  29. package/dist/infra-contract.js +6 -1
  30. package/dist/infra-contract.js.map +1 -1
  31. package/dist/owner-reset.d.ts +37 -0
  32. package/dist/owner-reset.d.ts.map +1 -0
  33. package/dist/owner-reset.js +80 -0
  34. package/dist/owner-reset.js.map +1 -0
  35. package/dist/stack-contract.d.ts +9 -10
  36. package/dist/stack-contract.d.ts.map +1 -1
  37. package/dist/stack-contract.js +21 -10
  38. package/dist/stack-contract.js.map +1 -1
  39. package/dist/staging-admin.d.ts +12 -20
  40. package/dist/staging-admin.d.ts.map +1 -1
  41. package/dist/staging-admin.js +7 -159
  42. package/dist/staging-admin.js.map +1 -1
  43. package/dist/wizards/gcp-cost.js +1 -1
  44. package/package.json +2 -2
  45. package/src/adapter.ts +4 -6
  46. package/src/admin-internal.ts +212 -0
  47. package/src/aws-task-roles.ts +58 -0
  48. package/src/checkpoint-secrets.test.ts +0 -1
  49. package/src/cli.ts +41 -1
  50. package/src/compose.ts +2 -2
  51. package/src/deployed-release.test.ts +22 -4
  52. package/src/deployed-release.ts +14 -7
  53. package/src/gateway-credentials.ts +6 -3
  54. package/src/gateway-env-isolation.test.ts +212 -0
  55. package/src/gcloud.ts +5 -3
  56. package/src/index.test.ts +6 -6
  57. package/src/infra-contract.test.ts +4 -3
  58. package/src/infra-contract.ts +6 -1
  59. package/src/owner-reset.test.ts +165 -0
  60. package/src/owner-reset.ts +106 -0
  61. package/src/runtime-secrets.test.ts +25 -8
  62. package/src/stack-contract.test.ts +9 -18
  63. package/src/stack-contract.ts +25 -16
  64. package/src/stack-converge-secrets.test.ts +45 -10
  65. package/src/stack-converge.test.ts +19 -10
  66. package/src/staging-admin.ts +18 -181
  67. package/src/wizards/gcp-cost.ts +1 -1
  68. package/stacks/aws/README.md +1 -1
  69. package/stacks/aws/index.ts +55 -43
  70. package/stacks/azure/README.md +1 -1
  71. package/stacks/azure/index.ts +8 -14
  72. package/stacks/gcp/README.md +1 -1
  73. package/stacks/gcp/index.ts +2 -5
  74. package/stacks/gcp-firebase/index.ts +0 -3
@@ -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,11 @@ 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 5: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(5);
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");
27
28
  });
28
29
 
29
30
  it("lists the pending steps oldest first", () => {
@@ -34,6 +35,6 @@ describe("infrastructure contract steps", () => {
34
35
  });
35
36
 
36
37
  it("names a version a newer release added by number", () => {
37
- expect(infraStepsText(4, 5)).toBe("infrastructure step 5 (see that release's notes)");
38
+ expect(infraStepsText(5, 6)).toBe("infrastructure step 6 (see that release's notes)");
38
39
  });
39
40
  });
@@ -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 = 5;
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,10 @@ 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).
39
43
 
40
44
  /**
41
45
  * What each contract version adds, in operator terms — the upgrader's
@@ -47,6 +51,7 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
47
51
  2: "the in-cloud upgrader job and its scoped permissions",
48
52
  3: "public_role out of the Cloud SQL superuser group (#633)",
49
53
  4: "the admin's permission to serve redirects from the load balancer (#587)",
54
+ 5: "the gateway's cms_admin login removed from its env and IAM (#635)",
50
55
  };
51
56
 
52
57
  /**
@@ -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
+ }
@@ -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": [