@caelo-cms/provisioning 0.10.57 → 0.10.58

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 (68) hide show
  1. package/dist/agent-db.js +21 -5
  2. package/dist/compose.js +1 -1
  3. package/dist/infra-contract.d.ts +1 -1
  4. package/dist/infra-contract.d.ts.map +1 -1
  5. package/dist/infra-contract.js +12 -1
  6. package/dist/infra-contract.js.map +1 -1
  7. package/dist/operator-access.d.ts +4 -0
  8. package/dist/operator-access.d.ts.map +1 -1
  9. package/dist/operator-access.js +37 -11
  10. package/dist/operator-access.js.map +1 -1
  11. package/dist/remote-mcp.d.ts +22 -0
  12. package/dist/remote-mcp.d.ts.map +1 -1
  13. package/dist/remote-mcp.js +59 -4
  14. package/dist/remote-mcp.js.map +1 -1
  15. package/dist/runtime-secrets.js +2 -2
  16. package/dist/runtime-secrets.js.map +1 -1
  17. package/dist/stack-contract.d.ts +54 -15
  18. package/dist/stack-contract.d.ts.map +1 -1
  19. package/dist/stack-contract.js +80 -33
  20. package/dist/stack-contract.js.map +1 -1
  21. package/dist/stack-converge.d.ts +17 -2
  22. package/dist/stack-converge.d.ts.map +1 -1
  23. package/dist/stack-converge.js +52 -2
  24. package/dist/stack-converge.js.map +1 -1
  25. package/dist/upgrade/app-roll.d.ts.map +1 -1
  26. package/dist/upgrade/app-roll.js +3 -3
  27. package/dist/upgrade/app-roll.js.map +1 -1
  28. package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
  29. package/dist/upgrade/audit-job-roll.js +3 -1
  30. package/dist/upgrade/audit-job-roll.js.map +1 -1
  31. package/dist/upgrade/converge.d.ts.map +1 -1
  32. package/dist/upgrade/converge.js +16 -5
  33. package/dist/upgrade/converge.js.map +1 -1
  34. package/dist/wizards/gcp-cost.js +1 -1
  35. package/dist/wizards/gcp-cost.js.map +1 -1
  36. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  37. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  38. package/dist/wizards/gcp-pulumi.js +14 -2
  39. package/dist/wizards/gcp-pulumi.js.map +1 -1
  40. package/dist/wizards/gcp.js +9 -2
  41. package/dist/wizards/gcp.js.map +1 -1
  42. package/package.json +2 -2
  43. package/src/audit-job-setup.test.ts +7 -1
  44. package/src/compose.ts +1 -1
  45. package/src/gateway-env-isolation.test.ts +1 -1
  46. package/src/infra-contract.test.ts +4 -3
  47. package/src/infra-contract.ts +12 -1
  48. package/src/operator-access.test.ts +11 -2
  49. package/src/operator-access.ts +38 -10
  50. package/src/remote-mcp.test.ts +196 -23
  51. package/src/remote-mcp.ts +68 -4
  52. package/src/runtime-secrets.test.ts +3 -3
  53. package/src/runtime-secrets.ts +2 -2
  54. package/src/stack-contract.test.ts +10 -8
  55. package/src/stack-contract.ts +110 -39
  56. package/src/stack-converge-secrets.test.ts +3 -2
  57. package/src/stack-converge.test.ts +4 -4
  58. package/src/stack-converge.ts +61 -0
  59. package/src/upgrade/app-roll.ts +8 -2
  60. package/src/upgrade/audit-job-roll.ts +3 -1
  61. package/src/upgrade/converge.ts +20 -4
  62. package/src/upgrade/upgrade.test.ts +1 -1
  63. package/src/wizards/gcp-cost.ts +1 -1
  64. package/src/wizards/gcp-pulumi.test.ts +15 -0
  65. package/src/wizards/gcp-pulumi.ts +18 -2
  66. package/src/wizards/gcp.ts +11 -2
  67. package/stacks/gcp/index.ts +9 -8
  68. package/stacks/gcp-firebase/index.ts +9 -8
@@ -6,10 +6,11 @@
6
6
  * `cms-provision upgrade`, both as the operator's own gcloud identity.
7
7
  *
8
8
  * Trust boundary. Who may pass the admin's IAP gate and sign as `caelo-mcp`
9
- * follows the Caelo user list; which OAuth client may call the admin
10
- * programmatically (IAP `programmatic_clients`, remote MCP #806) follows the
11
- * client the Owner configured under Settings → MCP → Remote. The admin does NOT write that: its runtime
12
- * service account holds no IAM-policy rights. A Cloud Run job with its own
9
+ * follows the Caelo user list; which OAuth client IAP signs people in with
10
+ * and accepts programmatically (IAP `oauthSettings`: browser client, its
11
+ * secret and `programmaticClients`, #914) follows the Google sign-in client
12
+ * the Owner configured under Settings → Google sign-in. The admin does NOT
13
+ * write that: its runtime service account holds no IAM-policy rights. A Cloud Run job with its own
13
14
  * service account (admin-core security/operator-access/sync-job.ts) is the
14
15
  * only principal that may change those two bindings, and it only ever grants
15
16
  * individual `user:` emails read from the database. The admin may only start
@@ -22,10 +23,12 @@
22
23
  * What this ensures, every step idempotent:
23
24
  * 1. the Cloud Scheduler API;
24
25
  * 2. the custom role `caeloOperatorAccess` (get/setIamPolicy, and
25
- * get/updateSettings for `programmatic_clients`);
26
+ * get/updateSettings for `oauthSettings`);
26
27
  * 3. the job's service account, bound to that role on exactly the admin's
27
- * IAP resource and the `caelo-mcp` service account, plus
28
- * `cloudsql.instanceUser` + `logging.logWriter`;
28
+ * IAP resource and the `caelo-mcp` service account, `secretAccessor`
29
+ * on exactly the Google sign-in client's secret (#914: IAP needs the
30
+ * client secret too), plus `cloudsql.instanceUser` +
31
+ * `logging.logWriter`;
29
32
  * 4. removal of the admin run SA's operator-access grants an earlier
30
33
  * revision of this feature gave it;
31
34
  * 5. Cloud SQL IAM database authentication (flag + the job's IAM database
@@ -45,6 +48,7 @@ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
45
48
  import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
46
49
  import {
47
50
  gcpNamePrefix,
51
+ gcpSecretId,
48
52
  mcpIapServiceAccountEmail,
49
53
  operatorAccessJobName,
50
54
  operatorAccessScheduleName,
@@ -53,6 +57,7 @@ import {
53
57
  runServiceAccountEmail,
54
58
  } from "./gcp-names.js";
55
59
  import { type IapResource, iapResourceArgs } from "./mcp-iap.js";
60
+ import { operatorOAuthClientSecretResource } from "./stack-contract.js";
56
61
 
57
62
  type Run = (args: string[]) => Promise<GcloudResult>;
58
63
 
@@ -69,12 +74,12 @@ export const OPERATOR_ACCESS_ROLE: CustomRoleSpec = {
69
74
  roleId: "caeloOperatorAccess",
70
75
  title: "Caelo operator access",
71
76
  description:
72
- "Lets the Caelo operator-access sync job set who passes the admin's IAP gate, which OAuth client may call it programmatically (remote MCP), and who may sign as caelo-mcp. Bound only on those two resources, only to the job's service account.",
77
+ "Lets the Caelo operator-access sync job set who passes the admin's IAP gate, which OAuth client IAP signs people in with and accepts programmatically (Google sign-in, remote MCP), and who may sign as caelo-mcp. Bound only on those two resources, only to the job's service account.",
73
78
  permissions: [
74
79
  "iap.webServices.getIamPolicy",
75
80
  "iap.webServices.setIamPolicy",
76
- // #806 — the admin IAP resource's `programmatic_clients` (the remote-MCP
77
- // OAuth client). IAP settings permissions per resource level:
81
+ // #914 — the admin IAP resource's `oauthSettings` (the install's Google
82
+ // sign-in client). IAP settings permissions per resource level:
78
83
  // cloud.google.com/iap/docs/customizing ("Web Services": getSettings /
79
84
  // updateSettings); the role is bound on exactly the admin's IAP web
80
85
  // service, so it reaches no other resource's settings.
@@ -196,6 +201,12 @@ export interface OperatorAccessSyncTarget {
196
201
  /** Env var of the sync job listing {@link OperatorAccessSyncTarget.mcpSigners}. */
197
202
  export const MCP_SIGNERS_ENV = "CAELO_OPERATOR_ACCESS_MCP_SIGNERS";
198
203
 
204
+ /** #914 — env var of the sync job naming the Google sign-in client's secret. */
205
+ export const OPERATOR_OAUTH_CLIENT_SECRET_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_SECRET";
206
+
207
+ /** The role the job holds on exactly the Google sign-in client's secret (#914). */
208
+ export const OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE = "roles/secretmanager.secretAccessor";
209
+
199
210
  export type OperatorAccessSyncResult =
200
211
  | { readonly ok: true; readonly done: string[] }
201
212
  | { readonly ok: false; readonly done: string[]; readonly error: string };
@@ -286,6 +297,22 @@ export async function ensureOperatorAccessSync(
286
297
  if (!r.ok) return fail(`grant the job ${g.what}`, r);
287
298
  done.push(`job: ${g.what}`);
288
299
  }
300
+ // #914 — the job hands the Google sign-in client to IAP (`oauthSettings`
301
+ // needs the secret too), so it reads that one secret; no other.
302
+ const clientSecret = gcpSecretId(env, "operator-oauth-client");
303
+ const accessor = await retry([
304
+ "secrets",
305
+ "add-iam-policy-binding",
306
+ clientSecret,
307
+ `--member=${jobMember}`,
308
+ `--role=${OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE}`,
309
+ "--condition=None",
310
+ project,
311
+ "--quiet",
312
+ "--format=none",
313
+ ]);
314
+ if (!accessor.ok) return fail(`grant the job read access to ${clientSecret}`, accessor);
315
+ done.push(`job: ${OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE} on ${clientSecret}`);
289
316
  for (const role of ["roles/cloudsql.instanceUser", "roles/logging.logWriter"]) {
290
317
  const r = await retry([
291
318
  "projects",
@@ -364,6 +391,7 @@ export async function ensureOperatorAccessSync(
364
391
  `CAELO_MCP_IAP_SERVICE_ACCOUNT=${mcpSa}`,
365
392
  `CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=${installIapAllowlist(target.ownerEmail).join(",")}`,
366
393
  `CAELO_OPERATOR_ACCESS_DB_HOST=${target.databaseHost}`,
394
+ `${OPERATOR_OAUTH_CLIENT_SECRET_ENV}=${operatorOAuthClientSecretResource(projectId, env)}`,
367
395
  ...(target.mcpSigners && target.mcpSigners.length > 0
368
396
  ? [`${MCP_SIGNERS_ENV}=${target.mcpSigners.join(",")}`]
369
397
  : []),
@@ -16,6 +16,7 @@ import {
16
16
  type LiveUrlMap,
17
17
  planUrlMapPaths,
18
18
  type RemoteMcpTarget,
19
+ resolveGoogleOrgDomain,
19
20
  resolveRemoteMcpIdentity,
20
21
  } from "./remote-mcp.js";
21
22
  import {
@@ -33,15 +34,18 @@ import {
33
34
  GATEWAY_TIMEOUT_SECONDS,
34
35
  GATEWAY_TRUSTED_PROXY_HOPS,
35
36
  GENERATED_SECRET_FORMAT,
36
- MCP_OAUTH_CLIENT_FILE,
37
+ OPERATOR_OAUTH_CLIENT_FILE,
37
38
  REMOTE_MCP_ENV,
39
+ RETIRED_RUNTIME_SECRETS,
40
+ RETIRED_SECRET_FILES,
41
+ RETIRED_SERVICE_ENV,
38
42
  remoteMcpIdentity,
39
43
  remoteMcpIdentityEnv,
40
44
  SERVICE_SECRET_FILES,
41
45
  secretFileVolumesTemplate,
42
46
  stackIamInvariants,
43
47
  } from "./stack-contract.js";
44
- import { planRequestTimeout, planSecretFiles } from "./stack-converge.js";
48
+ import { deleteRetiredSecrets, planRequestTimeout, planSecretFiles } from "./stack-converge.js";
45
49
 
46
50
  const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "" }) as GcloudResult;
47
51
  const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr }) as GcloudResult;
@@ -122,11 +126,13 @@ describe("remote-MCP identity (contract)", () => {
122
126
  expect(new URL(firebase.mcpPublicUrl).hostname.endsWith(".run.app")).toBe(true);
123
127
  });
124
128
 
125
- it("gives the admin the audience + public URL and the gateway the admin + public URL", () => {
129
+ it("gives the admin the audience, public URL and its own URL, and the gateway the admin + public URL", () => {
126
130
  const env = remoteMcpIdentityEnv({ adminUrl: "A", mcpPublicUrl: "P", iapAudience: "AUD" });
127
131
  expect(env.admin).toEqual([
128
132
  { name: REMOTE_MCP_ENV.iapAudience, value: "AUD" },
129
133
  { name: REMOTE_MCP_ENV.mcpPublicUrl, value: "P" },
134
+ // #914 — Google sign-in's self-check opens the admin signed out.
135
+ { name: REMOTE_MCP_ENV.adminUrl, value: "A" },
130
136
  ]);
131
137
  expect(env.gateway).toEqual([
132
138
  { name: REMOTE_MCP_ENV.adminUrl, value: "A" },
@@ -136,8 +142,22 @@ describe("remote-MCP identity (contract)", () => {
136
142
  adminUrl: "CAELO_ADMIN_URL",
137
143
  mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
138
144
  iapAudience: "CAELO_IAP_AUDIENCE",
145
+ googleOrgDomain: "CAELO_GOOGLE_ORG_DOMAIN",
139
146
  });
140
147
  });
148
+
149
+ it("#914 — gives the admin the organisation domain only when it was read", () => {
150
+ const known = remoteMcpIdentityEnv({
151
+ adminUrl: "A",
152
+ mcpPublicUrl: "P",
153
+ iapAudience: "AUD",
154
+ googleOrgDomain: "acme.com",
155
+ });
156
+ expect(known.admin).toContainEqual({ name: "CAELO_GOOGLE_ORG_DOMAIN", value: "acme.com" });
157
+ expect(known.gateway.map((e) => e.name)).not.toContain("CAELO_GOOGLE_ORG_DOMAIN");
158
+ const unknown = remoteMcpIdentityEnv({ adminUrl: "A", mcpPublicUrl: "P", iapAudience: "AUD" });
159
+ expect(unknown.admin.map((e) => e.name)).not.toContain("CAELO_GOOGLE_ORG_DOMAIN");
160
+ });
141
161
  });
142
162
 
143
163
  describe("remote-MCP secrets + IAM (contract)", () => {
@@ -146,7 +166,7 @@ describe("remote-MCP secrets + IAM (contract)", () => {
146
166
  });
147
167
 
148
168
  it("starts the OAuth client as the {} placeholder and the seal key as 32 random bytes", () => {
149
- expect(initialSecretValue("mcp-oauth-client")).toBe("{}");
169
+ expect(initialSecretValue("operator-oauth-client")).toBe("{}");
150
170
  const a = initialSecretValue("mcp-oauth-seal-key");
151
171
  expect(Buffer.from(a, "base64")).toHaveLength(32);
152
172
  expect(initialSecretValue("mcp-oauth-seal-key")).not.toBe(a);
@@ -160,9 +180,9 @@ describe("remote-MCP secrets + IAM (contract)", () => {
160
180
  );
161
181
  expect(adders).toEqual([
162
182
  expect.objectContaining({
163
- stackResource: "admin-mcp-oauth-client-version-adder",
183
+ stackResource: "admin-operator-oauth-client-version-adder",
164
184
  member: "run-sa",
165
- target: { kind: "secret", name: "mcp-oauth-client" },
185
+ target: { kind: "secret", name: "operator-oauth-client" },
166
186
  onFailure: "warn",
167
187
  }),
168
188
  ]);
@@ -171,7 +191,7 @@ describe("remote-MCP secrets + IAM (contract)", () => {
171
191
  i.member === "run-sa" &&
172
192
  i.role === "roles/secretmanager.secretAccessor" &&
173
193
  i.target.kind === "secret" &&
174
- i.target.name.startsWith("mcp-oauth"),
194
+ (i.target.name.startsWith("mcp-oauth") || i.target.name === "operator-oauth-client"),
175
195
  );
176
196
  expect(adminReads).toEqual([]);
177
197
  }
@@ -179,18 +199,20 @@ describe("remote-MCP secrets + IAM (contract)", () => {
179
199
 
180
200
  it("mounts the OAuth client on the gateway only, at latest, as the file its env names", () => {
181
201
  expect(SERVICE_SECRET_FILES.admin).toEqual([]);
182
- expect(SERVICE_SECRET_FILES.gateway).toEqual([MCP_OAUTH_CLIENT_FILE]);
202
+ expect(SERVICE_SECRET_FILES.gateway).toEqual([OPERATOR_OAUTH_CLIENT_FILE]);
183
203
  expect(secretFileVolumesTemplate("gateway", "production")).toEqual({
184
204
  volumes: [
185
205
  {
186
- name: "mcp-oauth-client",
206
+ name: "operator-oauth-client",
187
207
  secret: {
188
- secret: "caelo-production-mcp-oauth-client",
208
+ secret: "caelo-production-operator-oauth-client",
189
209
  items: [{ version: "latest", path: "client.json" }],
190
210
  },
191
211
  },
192
212
  ],
193
- volumeMounts: [{ name: "mcp-oauth-client", mountPath: "/secrets/mcp-oauth-client" }],
213
+ volumeMounts: [
214
+ { name: "operator-oauth-client", mountPath: "/secrets/operator-oauth-client" },
215
+ ],
194
216
  });
195
217
  expect(secretFileVolumesTemplate("admin", "production")).toEqual({
196
218
  volumes: [],
@@ -203,33 +225,33 @@ describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
203
225
  const empty = { executionEnvironment: null, volumes: [], mounts: [] };
204
226
 
205
227
  it("mounts a missing secret file with the documented --update-secrets path form", () => {
206
- expect(planSecretFiles(empty, [MCP_OAUTH_CLIENT_FILE], "production")).toEqual({
228
+ expect(planSecretFiles(empty, [OPERATOR_OAUTH_CLIENT_FILE], "production")).toEqual({
207
229
  ok: true,
208
230
  flags: [
209
- "--update-secrets=/secrets/mcp-oauth-client/client.json=caelo-production-mcp-oauth-client:latest",
231
+ "--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
210
232
  ],
211
233
  changes: [
212
- "secret file /secrets/mcp-oauth-client/client.json: (none) → caelo-production-mcp-oauth-client:latest",
234
+ "secret file /secrets/operator-oauth-client/client.json: (none) → caelo-production-operator-oauth-client:latest",
213
235
  ],
214
236
  });
215
237
  });
216
238
 
217
239
  it("leaves the file the stack (or an earlier upgrade) mounted alone, whatever its volume is called", () => {
218
- for (const name of ["mcp-oauth-client", "secret-1a2b3c"]) {
240
+ for (const name of ["operator-oauth-client", "secret-1a2b3c"]) {
219
241
  const live = {
220
242
  executionEnvironment: null,
221
243
  volumes: [
222
244
  {
223
245
  name,
224
246
  secret: {
225
- secretName: "projects/42/secrets/caelo-production-mcp-oauth-client",
247
+ secretName: "projects/42/secrets/caelo-production-operator-oauth-client",
226
248
  items: [{ key: "latest", path: "client.json" }],
227
249
  },
228
250
  },
229
251
  ],
230
- mounts: [{ name, mountPath: "/secrets/mcp-oauth-client" }],
252
+ mounts: [{ name, mountPath: "/secrets/operator-oauth-client" }],
231
253
  };
232
- expect(planSecretFiles(live, [MCP_OAUTH_CLIENT_FILE], "production")).toEqual({
254
+ expect(planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production")).toEqual({
233
255
  ok: true,
234
256
  flags: [],
235
257
  changes: [],
@@ -237,6 +259,101 @@ describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
237
259
  }
238
260
  });
239
261
 
262
+ it("#914 — moves the gateway from the retired mcp-oauth-client mount to the renamed secret in one roll", () => {
263
+ const live = {
264
+ executionEnvironment: null,
265
+ volumes: [
266
+ {
267
+ name: "mcp-oauth-client",
268
+ secret: {
269
+ secretName: "caelo-production-mcp-oauth-client",
270
+ items: [{ key: "latest", path: "client.json" }],
271
+ },
272
+ },
273
+ ],
274
+ mounts: [{ name: "mcp-oauth-client", mountPath: "/secrets/mcp-oauth-client" }],
275
+ };
276
+ expect(RETIRED_SECRET_FILES.gateway.map((f) => f.mountDir)).toEqual([
277
+ "/secrets/mcp-oauth-client",
278
+ ]);
279
+ const plan = planSecretFiles(
280
+ live,
281
+ [OPERATOR_OAUTH_CLIENT_FILE],
282
+ "production",
283
+ RETIRED_SECRET_FILES.gateway,
284
+ );
285
+ expect(plan.ok && plan.flags).toEqual([
286
+ "--remove-secrets=/secrets/mcp-oauth-client/client.json",
287
+ "--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
288
+ ]);
289
+ // Converged: nothing left to remove or mount.
290
+ const after = {
291
+ executionEnvironment: null,
292
+ volumes: [
293
+ {
294
+ name: "operator-oauth-client",
295
+ secret: {
296
+ secretName: "caelo-production-operator-oauth-client",
297
+ items: [{ key: "latest", path: "client.json" }],
298
+ },
299
+ },
300
+ ],
301
+ mounts: [{ name: "operator-oauth-client", mountPath: "/secrets/operator-oauth-client" }],
302
+ };
303
+ expect(
304
+ planSecretFiles(
305
+ after,
306
+ [OPERATOR_OAUTH_CLIENT_FILE],
307
+ "production",
308
+ RETIRED_SECRET_FILES.gateway,
309
+ ),
310
+ ).toEqual({ ok: true, flags: [], changes: [] });
311
+ });
312
+
313
+ it("#914 — deletes the retired secret after the roll; an absent one is fine", async () => {
314
+ const install = {
315
+ provider: "gcp-firebase" as const,
316
+ projectId: "acme",
317
+ region: "europe-west1",
318
+ env: "production",
319
+ };
320
+ const present = fakeGcloud({
321
+ "secrets describe caelo-production-mcp-oauth-client": ok("projects/1/secrets/x\n"),
322
+ "secrets delete caelo-production-mcp-oauth-client": ok(),
323
+ });
324
+ expect(await deleteRetiredSecrets(install, { run: present.run })).toEqual([
325
+ {
326
+ id: "secret caelo-production-mcp-oauth-client",
327
+ status: "removed",
328
+ why: "the Google sign-in client moved to operator-oauth-client (#914)",
329
+ },
330
+ ]);
331
+ expect(present.calls).toContain(
332
+ "secrets delete caelo-production-mcp-oauth-client --project=acme --quiet",
333
+ );
334
+ const absent = fakeGcloud({
335
+ "secrets describe caelo-production-mcp-oauth-client": fail("NOT_FOUND: Secret not found"),
336
+ });
337
+ const outcomes = await deleteRetiredSecrets(install, { run: absent.run });
338
+ expect(outcomes.map((o) => o.status)).toEqual(["absent"]);
339
+ expect(absent.calls.some((c) => c.startsWith("secrets delete"))).toBe(false);
340
+ const denied = fakeGcloud({
341
+ "secrets describe caelo-production-mcp-oauth-client": ok("x"),
342
+ "secrets delete caelo-production-mcp-oauth-client": fail("PERMISSION_DENIED"),
343
+ });
344
+ expect((await deleteRetiredSecrets(install, { run: denied.run }))[0]).toMatchObject({
345
+ status: "failed",
346
+ error: "PERMISSION_DENIED",
347
+ });
348
+ });
349
+
350
+ it("#914 — retires the old secret and the old env names", () => {
351
+ expect(RETIRED_RUNTIME_SECRETS.map((s) => s.name)).toEqual(["mcp-oauth-client"]);
352
+ expect(RETIRED_SERVICE_ENV.gateway).toContain("CAELO_MCP_OAUTH_CLIENT_FILE");
353
+ expect(RETIRED_SERVICE_ENV.admin).toContain("CAELO_MCP_OAUTH_CLIENT_STORE");
354
+ expect(CLI_GENERATED_SECRETS).not.toContain("mcp-oauth-client" as never);
355
+ });
356
+
240
357
  it("refuses something else mounted where the client belongs (a pinned version included)", () => {
241
358
  const live = {
242
359
  executionEnvironment: null,
@@ -244,16 +361,17 @@ describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
244
361
  {
245
362
  name: "v",
246
363
  secret: {
247
- secretName: "caelo-production-mcp-oauth-client",
364
+ secretName: "caelo-production-operator-oauth-client",
248
365
  items: [{ key: "3", path: "client.json" }],
249
366
  },
250
367
  },
251
368
  ],
252
- mounts: [{ name: "v", mountPath: "/secrets/mcp-oauth-client" }],
369
+ mounts: [{ name: "v", mountPath: "/secrets/operator-oauth-client" }],
253
370
  };
254
- const plan = planSecretFiles(live, [MCP_OAUTH_CLIENT_FILE], "production");
371
+ const plan = planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production");
255
372
  expect(plan.ok).toBe(false);
256
- if (!plan.ok) expect(plan.error).toContain("--remove-volume-mount=/secrets/mcp-oauth-client");
373
+ if (!plan.ok)
374
+ expect(plan.error).toContain("--remove-volume-mount=/secrets/operator-oauth-client");
257
375
  });
258
376
 
259
377
  it("raises the gateway's request timeout to the contract's", () => {
@@ -277,10 +395,20 @@ const firebase: RemoteMcpTarget = {
277
395
  services: { admin: "caelo-production-admin-aaa", gateway: "caelo-production-gateway-bbb" },
278
396
  };
279
397
 
398
+ const ANCESTORS_IN_ORG = ok(
399
+ JSON.stringify([
400
+ { id: "acme", type: "project" },
401
+ { id: "555", type: "folder" },
402
+ { id: "123456", type: "organization" },
403
+ ]),
404
+ );
405
+
280
406
  describe("resolveRemoteMcpIdentity", () => {
281
- it("gcp-firebase: reads the project number and both services' URLs", async () => {
407
+ it("gcp-firebase: reads the project number, both services' URLs and the organisation", async () => {
282
408
  const { run } = fakeGcloud({
283
409
  "projects describe acme": ok("42\n"),
410
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
411
+ "organizations describe 123456": ok("Acme.com\n"),
284
412
  "run services describe caelo-production-admin-aaa": ok(
285
413
  "https://caelo-production-admin-aaa-xyz.a.run.app\n",
286
414
  ),
@@ -295,10 +423,26 @@ describe("resolveRemoteMcpIdentity", () => {
295
423
  adminUrl: "https://caelo-production-admin-aaa-xyz.a.run.app",
296
424
  mcpPublicUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
297
425
  iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
426
+ googleOrgDomain: "acme.com",
298
427
  },
299
428
  });
300
429
  });
301
430
 
431
+ it("#914 — an unreadable organisation is reported, not fatal, and sets no domain", async () => {
432
+ const { run } = fakeGcloud({
433
+ "projects describe acme": ok("42\n"),
434
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
435
+ "organizations describe 123456": fail("PERMISSION_DENIED: organizations.get"),
436
+ "run services describe caelo-production-admin-aaa": ok("https://a-xyz.a.run.app\n"),
437
+ "run services describe caelo-production-gateway-bbb": ok("https://g-xyz.a.run.app\n"),
438
+ });
439
+ const r = await resolveRemoteMcpIdentity(firebase, { run });
440
+ expect(r.ok).toBe(true);
441
+ if (!r.ok) return;
442
+ expect(r.identity.googleOrgDomain).toBeUndefined();
443
+ expect(r.orgDomainError).toContain("Organization Viewer");
444
+ });
445
+
302
446
  it("gcp: reads the IAP-enabled admin backend service's numeric id", async () => {
303
447
  const { run, calls } = fakeGcloud({
304
448
  "projects describe acme": ok("42"),
@@ -323,6 +467,34 @@ describe("resolveRemoteMcpIdentity", () => {
323
467
  });
324
468
  });
325
469
 
470
+ describe("resolveGoogleOrgDomain (#914)", () => {
471
+ it("reads the organisation's domain through the project's ancestors", async () => {
472
+ const { run, calls } = fakeGcloud({
473
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
474
+ "organizations describe 123456": ok("acme.com\n"),
475
+ });
476
+ expect(await resolveGoogleOrgDomain("acme", { run })).toEqual({ ok: true, domain: "acme.com" });
477
+ expect(calls).toContain("organizations describe 123456 --format=value(displayName)");
478
+ });
479
+
480
+ it("answers `none` for a project in no organisation", async () => {
481
+ const { run } = fakeGcloud({
482
+ "projects get-ancestors acme": ok(JSON.stringify([{ id: "acme", type: "project" }])),
483
+ });
484
+ expect(await resolveGoogleOrgDomain("acme", { run })).toEqual({ ok: true, domain: "none" });
485
+ });
486
+
487
+ it("fails naming the missing role when the organisation cannot be read", async () => {
488
+ const { run } = fakeGcloud({
489
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
490
+ "organizations describe 123456": ok("not a domain\n"),
491
+ });
492
+ const r = await resolveGoogleOrgDomain("acme", { run });
493
+ expect(r.ok).toBe(false);
494
+ if (!r.ok) expect(r.error).toContain("Organization Viewer");
495
+ });
496
+ });
497
+
326
498
  describe("applyRemoteMcpIdentityEnv", () => {
327
499
  const identity = {
328
500
  adminUrl: "https://a.run.app",
@@ -337,6 +509,7 @@ describe("applyRemoteMcpIdentityEnv", () => {
337
509
  "run services describe caelo-production-admin-aaa": serviceJson([
338
510
  { name: "CAELO_IAP_AUDIENCE", value: identity.iapAudience },
339
511
  { name: "CAELO_MCP_PUBLIC_URL", value: identity.mcpPublicUrl },
512
+ { name: "CAELO_ADMIN_URL", value: identity.adminUrl },
340
513
  ]),
341
514
  "run services describe caelo-production-gateway-bbb": serviceJson([]),
342
515
  "run services update caelo-production-gateway-bbb": ok(),
package/src/remote-mcp.ts CHANGED
@@ -20,9 +20,13 @@
20
20
  * come through the shared contract like everything else
21
21
  * (runtime-secrets.ts, stack-contract.ts, upgrade/app-roll.ts).
22
22
  *
23
- * Nothing here enables remote MCP: that is the Owner's opt-in under
24
- * Settings → MCP → Remote. Until then the gateway answers 503
25
- * `remote_mcp_not_configured`.
23
+ * The same identity env carries what the admin's Google sign-in needs
24
+ * (#914): its own URL and the project's Google organisation domain
25
+ * ({@link resolveGoogleOrgDomain}).
26
+ *
27
+ * Nothing here enables remote MCP: that happens when the Owner sets up the
28
+ * install's Google sign-in client (Settings → Google sign-in). Until then
29
+ * the gateway answers 503 `remote_mcp_not_configured`.
26
30
  */
27
31
 
28
32
  import { GCP_PUBLIC_BASE_PATHS } from "@caelo-cms/shared";
@@ -81,14 +85,68 @@ function lines(stdout: string): string[] {
81
85
  // Identity env
82
86
  // ===========================================================================
83
87
 
88
+ /**
89
+ * #914 — the project's Google organisation domain (the organisation's
90
+ * `displayName`, which Cloud Resource Manager sets to its primary domain),
91
+ * or `none` when the project sits in no organisation. Read as the
92
+ * operator's identity: `organizations describe` needs
93
+ * `resourcemanager.organizations.get` on the organisation, which project
94
+ * Owners often have and the admin's service account never does.
95
+ */
96
+ export async function resolveGoogleOrgDomain(
97
+ projectId: string,
98
+ deps: { run?: GcloudRunner } = {},
99
+ ): Promise<RemoteMcpResult<{ readonly domain: string }>> {
100
+ const run = deps.run ?? defaultGcloud;
101
+ const ancestors = await run(["projects", "get-ancestors", projectId, "--format=json"]);
102
+ if (!ancestors.ok) {
103
+ return { ok: false, error: `read the ancestors of ${projectId}: ${ancestors.stderr.trim()}` };
104
+ }
105
+ const list = parseJson(ancestors.stdout || "[]");
106
+ if (!Array.isArray(list)) return { ok: false, error: "get-ancestors: output is not a JSON list" };
107
+ const org = (list as { id?: string; type?: string }[]).find((a) => a.type === "organization");
108
+ if (!org?.id) return { ok: true, domain: NO_GOOGLE_ORGANISATION };
109
+ const describe = await run(["organizations", "describe", org.id, "--format=value(displayName)"]);
110
+ const domain = describe.ok ? describe.stdout.trim().toLowerCase() : "";
111
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) {
112
+ return {
113
+ ok: false,
114
+ error: `read organization ${org.id}: ${describe.stderr.trim() || `unexpected name "${domain}"`} (needs the Organization Viewer role)`,
115
+ };
116
+ }
117
+ return { ok: true, domain };
118
+ }
119
+
120
+ /** `CAELO_GOOGLE_ORG_DOMAIN` value for a project outside any organisation (admin-core identity-gate.ts). */
121
+ export const NO_GOOGLE_ORGANISATION = "none";
122
+
84
123
  /**
85
124
  * Read the live identities {@link remoteMcpIdentity} derives from: the
86
125
  * project number, and the admin's backend service id (`gcp`) or both
87
126
  * services' URLs (`gcp-firebase`). Fails naming what it could not read.
127
+ *
128
+ * The organisation domain (#914) is best-effort: when it cannot be read,
129
+ * `orgDomainError` says why and the identity carries none, so the admin's
130
+ * var keeps its value — the admin then reports the organisation as unknown
131
+ * and refuses user creation loudly until the Owner sets up Google sign-in.
88
132
  */
89
133
  export async function resolveRemoteMcpIdentity(
90
134
  target: RemoteMcpTarget,
91
135
  deps: { run?: GcloudRunner } = {},
136
+ ): Promise<
137
+ RemoteMcpResult<{ readonly identity: RemoteMcpIdentity; readonly orgDomainError?: string }>
138
+ > {
139
+ const resolved = await resolveServiceIdentity(target, deps);
140
+ if (!resolved.ok) return resolved;
141
+ const org = await resolveGoogleOrgDomain(target.projectId, deps);
142
+ return org.ok
143
+ ? { ok: true, identity: { ...resolved.identity, googleOrgDomain: org.domain } }
144
+ : { ok: true, identity: resolved.identity, orgDomainError: org.error };
145
+ }
146
+
147
+ async function resolveServiceIdentity(
148
+ target: RemoteMcpTarget,
149
+ deps: { run?: GcloudRunner } = {},
92
150
  ): Promise<RemoteMcpResult<{ readonly identity: RemoteMcpIdentity }>> {
93
151
  const run = deps.run ?? defaultGcloud;
94
152
  const project = `--project=${target.projectId}`;
@@ -223,13 +281,19 @@ export async function ensureRemoteMcpIdentityEnv(
223
281
  RemoteMcpResult<{
224
282
  readonly identity: RemoteMcpIdentity;
225
283
  readonly changes: Record<CloudRunSlug, EnvChange[]>;
284
+ readonly orgDomainError?: string;
226
285
  }>
227
286
  > {
228
287
  const resolved = await resolveRemoteMcpIdentity(target, deps);
229
288
  if (!resolved.ok) return resolved;
230
289
  const applied = await applyRemoteMcpIdentityEnv(target, resolved.identity, deps);
231
290
  if (!applied.ok) return applied;
232
- return { ok: true, identity: resolved.identity, changes: applied.changes };
291
+ return {
292
+ ok: true,
293
+ identity: resolved.identity,
294
+ changes: applied.changes,
295
+ ...(resolved.orgDomainError ? { orgDomainError: resolved.orgDomainError } : {}),
296
+ };
233
297
  }
234
298
 
235
299
  // ===========================================================================
@@ -153,7 +153,7 @@ describe("ensureGeneratedSecrets", () => {
153
153
  // #806 — remote MCP's OAuth client starts as the "not configured"
154
154
  // placeholder; the Owner writes the real one from Settings → MCP.
155
155
  {
156
- line: "secrets create caelo-production-mcp-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
156
+ line: "secrets create caelo-production-operator-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
157
157
  stdin: "{}",
158
158
  },
159
159
  {
@@ -195,7 +195,7 @@ describe("ensureGeneratedSecrets", () => {
195
195
  "secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
196
196
  "secrets versions list caelo-production-public-role-password": [ok("v/1")],
197
197
  "secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
198
- "secrets versions list caelo-production-mcp-oauth-client": [ok("v/1")],
198
+ "secrets versions list caelo-production-operator-oauth-client": [ok("v/1")],
199
199
  "secrets versions list caelo-production-mcp-oauth-seal-key": [ok("v/1")],
200
200
  });
201
201
  const outcomes = await ensureGeneratedSecrets(
@@ -255,7 +255,7 @@ describe("ensureGeneratedSecrets", () => {
255
255
  "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
256
256
  // #652 — the staging gate's key follows the same path.
257
257
  "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
258
- "secrets create caelo-production-mcp-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
258
+ "secrets create caelo-production-operator-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
259
259
  "secrets create caelo-production-mcp-oauth-seal-key --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
260
260
  ]);
261
261
  });
@@ -419,8 +419,8 @@ export function rotationRefusal(name: string): string | null {
419
419
  if ((ROTATABLE_SECRETS as readonly string[]).includes(name)) return null;
420
420
  // #806 — two-step rotation with an overlap window (seal-key-rotation.ts).
421
421
  if (name === SEAL_KEY_SECRET) return null;
422
- if (name === "mcp-oauth-client") {
423
- return "mcp-oauth-client is the Google OAuth client of remote MCP; the Owner replaces it under Settings → MCP → Remote (create a new client secret in the Google console first).";
422
+ if (name === "operator-oauth-client") {
423
+ return "operator-oauth-client is the install's Google sign-in client (IAP sign-in and remote MCP); the Owner replaces it under Settings → Google sign-in (create a new client secret in the Google console first).";
424
424
  }
425
425
  if (name === "secret-kek") {
426
426
  return "secret-kek encrypts the AI provider keys and other at-rest secrets in cms_admin; a new KEK would make them unreadable. Rotating it needs a re-encryption step that does not exist yet.";