@caelo-cms/provisioning 0.10.57 → 0.10.59
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.
- package/dist/agent-db.js +76 -5
- package/dist/compose.js +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +12 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/operator-access.d.ts +4 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +37 -11
- package/dist/operator-access.js.map +1 -1
- package/dist/remote-mcp.d.ts +22 -0
- package/dist/remote-mcp.d.ts.map +1 -1
- package/dist/remote-mcp.js +59 -4
- package/dist/remote-mcp.js.map +1 -1
- package/dist/runtime-secrets.js +2 -2
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/stack-contract.d.ts +54 -15
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +80 -33
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +17 -2
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +52 -2
- package/dist/stack-converge.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +3 -3
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
- package/dist/upgrade/audit-job-roll.js +3 -1
- package/dist/upgrade/audit-job-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +16 -5
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/wizards/gcp-cost.js +1 -1
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +14 -2
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.js +9 -2
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/audit-job-setup.test.ts +7 -1
- package/src/compose.ts +1 -1
- package/src/gateway-env-isolation.test.ts +1 -1
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +12 -1
- package/src/operator-access.test.ts +11 -2
- package/src/operator-access.ts +38 -10
- package/src/remote-mcp.test.ts +196 -23
- package/src/remote-mcp.ts +68 -4
- package/src/runtime-secrets.test.ts +3 -3
- package/src/runtime-secrets.ts +2 -2
- package/src/stack-contract.test.ts +10 -8
- package/src/stack-contract.ts +110 -39
- package/src/stack-converge-secrets.test.ts +3 -2
- package/src/stack-converge.test.ts +4 -4
- package/src/stack-converge.ts +61 -0
- package/src/upgrade/app-roll.ts +8 -2
- package/src/upgrade/audit-job-roll.ts +3 -1
- package/src/upgrade/converge.ts +20 -4
- package/src/upgrade/upgrade.test.ts +1 -1
- package/src/wizards/gcp-cost.ts +1 -1
- package/src/wizards/gcp-pulumi.test.ts +15 -0
- package/src/wizards/gcp-pulumi.ts +18 -2
- package/src/wizards/gcp.ts +11 -2
- package/stacks/gcp/index.ts +9 -8
- package/stacks/gcp-firebase/index.ts +9 -8
package/src/operator-access.ts
CHANGED
|
@@ -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
|
|
10
|
-
* programmatically (IAP `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 `
|
|
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,
|
|
28
|
-
*
|
|
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
|
|
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
|
-
// #
|
|
77
|
-
//
|
|
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
|
: []),
|
package/src/remote-mcp.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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("
|
|
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-
|
|
183
|
+
stackResource: "admin-operator-oauth-client-version-adder",
|
|
164
184
|
member: "run-sa",
|
|
165
|
-
target: { kind: "secret", name: "
|
|
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([
|
|
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: "
|
|
206
|
+
name: "operator-oauth-client",
|
|
187
207
|
secret: {
|
|
188
|
-
secret: "caelo-production-
|
|
208
|
+
secret: "caelo-production-operator-oauth-client",
|
|
189
209
|
items: [{ version: "latest", path: "client.json" }],
|
|
190
210
|
},
|
|
191
211
|
},
|
|
192
212
|
],
|
|
193
|
-
volumeMounts: [
|
|
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, [
|
|
228
|
+
expect(planSecretFiles(empty, [OPERATOR_OAUTH_CLIENT_FILE], "production")).toEqual({
|
|
207
229
|
ok: true,
|
|
208
230
|
flags: [
|
|
209
|
-
"--update-secrets=/secrets/
|
|
231
|
+
"--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
|
|
210
232
|
],
|
|
211
233
|
changes: [
|
|
212
|
-
"secret file /secrets/
|
|
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 ["
|
|
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-
|
|
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/
|
|
252
|
+
mounts: [{ name, mountPath: "/secrets/operator-oauth-client" }],
|
|
231
253
|
};
|
|
232
|
-
expect(planSecretFiles(live, [
|
|
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-
|
|
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/
|
|
369
|
+
mounts: [{ name: "v", mountPath: "/secrets/operator-oauth-client" }],
|
|
253
370
|
};
|
|
254
|
-
const plan = planSecretFiles(live, [
|
|
371
|
+
const plan = planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production");
|
|
255
372
|
expect(plan.ok).toBe(false);
|
|
256
|
-
if (!plan.ok)
|
|
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
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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 {
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
});
|
package/src/runtime-secrets.ts
CHANGED
|
@@ -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 === "
|
|
423
|
-
return "
|
|
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.";
|