@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.
- package/dist/adapter.d.ts +4 -6
- package/dist/adapter.d.ts.map +1 -1
- package/dist/admin-internal.d.ts +41 -0
- package/dist/admin-internal.d.ts.map +1 -0
- package/dist/admin-internal.js +169 -0
- package/dist/admin-internal.js.map +1 -0
- package/dist/agent-db.js +89 -20
- package/dist/aws-task-roles.d.ts +27 -0
- package/dist/aws-task-roles.d.ts.map +1 -0
- package/dist/aws-task-roles.js +35 -0
- package/dist/aws-task-roles.js.map +1 -0
- package/dist/cli.js +34 -1
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +2 -2
- package/dist/compose.js.map +1 -1
- package/dist/deployed-release.d.ts +3 -2
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +12 -5
- package/dist/deployed-release.js.map +1 -1
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +6 -3
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +2 -2
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +3 -3
- package/dist/gcloud.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +6 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/owner-reset.d.ts +37 -0
- package/dist/owner-reset.d.ts.map +1 -0
- package/dist/owner-reset.js +80 -0
- package/dist/owner-reset.js.map +1 -0
- package/dist/stack-contract.d.ts +9 -10
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +21 -10
- package/dist/stack-contract.js.map +1 -1
- package/dist/staging-admin.d.ts +12 -20
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js +7 -159
- package/dist/staging-admin.js.map +1 -1
- package/dist/wizards/gcp-cost.js +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +4 -6
- package/src/admin-internal.ts +212 -0
- package/src/aws-task-roles.ts +58 -0
- package/src/checkpoint-secrets.test.ts +0 -1
- package/src/cli.ts +41 -1
- package/src/compose.ts +2 -2
- package/src/deployed-release.test.ts +22 -4
- package/src/deployed-release.ts +14 -7
- package/src/gateway-credentials.ts +6 -3
- package/src/gateway-env-isolation.test.ts +212 -0
- package/src/gcloud.ts +5 -3
- package/src/index.test.ts +6 -6
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +6 -1
- package/src/owner-reset.test.ts +165 -0
- package/src/owner-reset.ts +106 -0
- package/src/runtime-secrets.test.ts +25 -8
- package/src/stack-contract.test.ts +9 -18
- package/src/stack-contract.ts +25 -16
- package/src/stack-converge-secrets.test.ts +45 -10
- package/src/stack-converge.test.ts +19 -10
- package/src/staging-admin.ts +18 -181
- package/src/wizards/gcp-cost.ts +1 -1
- package/stacks/aws/README.md +1 -1
- package/stacks/aws/index.ts +55 -43
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +8 -14
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +2 -5
- package/stacks/gcp-firebase/index.ts +0 -3
|
@@ -49,12 +49,11 @@ const ADMIN_SECRETS = {
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
describe("databaseUrls", () => {
|
|
52
|
-
it("builds password-less URLs for the
|
|
52
|
+
it("builds password-less URLs for the three role/database pairs", () => {
|
|
53
53
|
expect(databaseUrls("10.20.0.3")).toEqual({
|
|
54
54
|
admin: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
55
55
|
publicAdmin: "postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
56
56
|
public: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
57
|
-
gateway: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
58
57
|
});
|
|
59
58
|
});
|
|
60
59
|
});
|
|
@@ -154,15 +153,13 @@ describe.each([
|
|
|
154
153
|
describe("gatewayEnvContract", () => {
|
|
155
154
|
const gateway = gatewayEnvContract({ ...base, provider: "gcp" });
|
|
156
155
|
|
|
157
|
-
it("carries its
|
|
156
|
+
it("carries its one own login and none of the admin's database or app secrets (#635)", () => {
|
|
158
157
|
expect(asRecord(gateway)).toEqual({
|
|
159
158
|
CAELO_PROVIDER: "gcp",
|
|
160
159
|
CAELO_ENV: "production",
|
|
161
160
|
MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
|
|
162
161
|
PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
163
|
-
GATEWAY_DATABASE_URL: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
164
162
|
PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
|
|
165
|
-
GATEWAY_DATABASE_PASSWORD: "secret:caelo-production-gateway-role-password:latest",
|
|
166
163
|
CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
|
|
167
164
|
});
|
|
168
165
|
});
|
|
@@ -188,11 +185,7 @@ describe("gatewayEnvContract", () => {
|
|
|
188
185
|
});
|
|
189
186
|
|
|
190
187
|
it("the gateway's run SA reads no admin-only secret (admin password, KEK, internal, tool approval)", () => {
|
|
191
|
-
expect(serviceSecrets("gateway")).toEqual([
|
|
192
|
-
"gateway-role-password",
|
|
193
|
-
"public-role-password",
|
|
194
|
-
"staging-gate-secret",
|
|
195
|
-
]);
|
|
188
|
+
expect(serviceSecrets("gateway")).toEqual(["public-role-password", "staging-gate-secret"]);
|
|
196
189
|
for (const v of gateway) {
|
|
197
190
|
expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
|
|
198
191
|
}
|
|
@@ -280,11 +273,6 @@ describe("runtime secrets", () => {
|
|
|
280
273
|
secret: "staging-gate-secret",
|
|
281
274
|
stackResource: "staging-gate-secret-binding",
|
|
282
275
|
},
|
|
283
|
-
{
|
|
284
|
-
service: "gateway",
|
|
285
|
-
secret: "gateway-role-password",
|
|
286
|
-
stackResource: "gateway-gateway-role-password-binding",
|
|
287
|
-
},
|
|
288
276
|
{
|
|
289
277
|
service: "gateway",
|
|
290
278
|
secret: "public-role-password",
|
|
@@ -299,7 +287,11 @@ describe("runtime secrets", () => {
|
|
|
299
287
|
});
|
|
300
288
|
|
|
301
289
|
it("every CLI-generated secret is read by some service", () => {
|
|
302
|
-
|
|
290
|
+
// gateway-role-password is read by no service since #635: it only keeps
|
|
291
|
+
// the legacy gateway_role's password set until the contract release
|
|
292
|
+
// drops the role (the previous release's gateway logs in with it while
|
|
293
|
+
// an upgrade rolls).
|
|
294
|
+
for (const s of CLI_GENERATED_SECRETS.filter((x) => x !== "gateway-role-password")) {
|
|
303
295
|
expect([...serviceSecrets("admin"), ...serviceSecrets("gateway")]).toContain(s);
|
|
304
296
|
}
|
|
305
297
|
});
|
|
@@ -351,13 +343,12 @@ describe("stackIamInvariants", () => {
|
|
|
351
343
|
}
|
|
352
344
|
});
|
|
353
345
|
|
|
354
|
-
it("grants the gateway SA only its
|
|
346
|
+
it("grants the gateway SA only its own role password, the staging gate key + telemetry roles", () => {
|
|
355
347
|
for (const provider of ["gcp", "gcp-firebase"] as const) {
|
|
356
348
|
const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
|
|
357
349
|
expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
|
|
358
350
|
'roles/logging.logWriter {"kind":"project"}',
|
|
359
351
|
'roles/monitoring.metricWriter {"kind":"project"}',
|
|
360
|
-
'roles/secretmanager.secretAccessor {"kind":"secret","name":"gateway-role-password"}',
|
|
361
352
|
'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
|
|
362
353
|
'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
|
|
363
354
|
]);
|
package/src/stack-contract.ts
CHANGED
|
@@ -86,14 +86,12 @@ export interface DatabaseUrls<V = string> {
|
|
|
86
86
|
readonly admin: V;
|
|
87
87
|
/** admin_role on cms_public — the admin's second pool (DDL, migrations). */
|
|
88
88
|
readonly publicAdmin: V;
|
|
89
|
-
/** public_role on cms_public — the gateway's visitor-write pool. */
|
|
90
|
-
readonly public: V;
|
|
91
89
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
90
|
+
* public_role on cms_public — the gateway's only pool. The gateway has no
|
|
91
|
+
* cms_admin login (#635, CLAUDE.md §2): what it needs from authoring
|
|
92
|
+
* state the admin publishes into cms_public.
|
|
95
93
|
*/
|
|
96
|
-
readonly
|
|
94
|
+
readonly public: V;
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
/** Port Cloud SQL Postgres listens on (private IP). */
|
|
@@ -111,7 +109,6 @@ export function databaseUrls(host: string): DatabaseUrls {
|
|
|
111
109
|
admin: url("admin_role", "cms_admin"),
|
|
112
110
|
publicAdmin: url("admin_role", "cms_public"),
|
|
113
111
|
public: url("public_role", "cms_public"),
|
|
114
|
-
gateway: url("gateway_role", "cms_admin"),
|
|
115
112
|
};
|
|
116
113
|
}
|
|
117
114
|
|
|
@@ -251,8 +248,8 @@ export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
|
|
|
251
248
|
|
|
252
249
|
/**
|
|
253
250
|
* Each database role's password secret — one per role (#613), so the
|
|
254
|
-
* gateway's SA, which reads only the public_role
|
|
255
|
-
*
|
|
251
|
+
* gateway's SA, which reads only the public_role password, can never read
|
|
252
|
+
* admin_role's (or, since #635, gateway_role's). `postgres-password` keeps its
|
|
256
253
|
* name: it has always been admin_role's (and, before #613, shared with
|
|
257
254
|
* public_role). `rotate-secret` changes exactly the role(s) listed here.
|
|
258
255
|
*/
|
|
@@ -306,9 +303,8 @@ export const SERVICE_SECRET_ENV: Readonly<
|
|
|
306
303
|
CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
|
|
307
304
|
},
|
|
308
305
|
gateway: {
|
|
309
|
-
// CLAUDE.md §2:
|
|
310
|
-
//
|
|
311
|
-
GATEWAY_DATABASE_PASSWORD: "gateway-role-password",
|
|
306
|
+
// CLAUDE.md §2: no cms_admin credential of any role (#613, #635).
|
|
307
|
+
// public_role on cms_public is the gateway's only login.
|
|
312
308
|
PUBLIC_DATABASE_PASSWORD: "public-role-password",
|
|
313
309
|
// #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
|
|
314
310
|
CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
|
|
@@ -323,12 +319,20 @@ export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
|
|
|
323
319
|
/**
|
|
324
320
|
* Env vars an older stack set that the contract no longer gives a service;
|
|
325
321
|
* upgrade removes them. The gateway never used the KEK, and its run SA
|
|
326
|
-
* cannot read it. Its admin_role pool went with #613
|
|
327
|
-
*
|
|
322
|
+
* cannot read it. Its admin_role pool went with #613 and its gateway_role
|
|
323
|
+
* pool with #635 — the gateway refuses to boot while any cms_admin
|
|
324
|
+
* credential is in its env (apps/api-gateway `adminDatabaseAccessInEnv`).
|
|
328
325
|
*/
|
|
329
326
|
export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[]>> = {
|
|
330
327
|
admin: [],
|
|
331
|
-
gateway: [
|
|
328
|
+
gateway: [
|
|
329
|
+
"CAELO_SECRET_KEK",
|
|
330
|
+
"ADMIN_DATABASE_URL",
|
|
331
|
+
"ADMIN_DATABASE_PASSWORD",
|
|
332
|
+
// #635 — its gateway_role login on cms_admin is gone too.
|
|
333
|
+
"GATEWAY_DATABASE_URL",
|
|
334
|
+
"GATEWAY_DATABASE_PASSWORD",
|
|
335
|
+
],
|
|
332
336
|
};
|
|
333
337
|
|
|
334
338
|
function secretEnv(service: CloudRunSlug, env: string): CloudRunEnvVar[] {
|
|
@@ -362,7 +366,6 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
|
|
|
362
366
|
return [
|
|
363
367
|
...commonEnv(inputs),
|
|
364
368
|
{ name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
|
|
365
|
-
{ name: "GATEWAY_DATABASE_URL", value: inputs.databaseUrls.gateway },
|
|
366
369
|
...secretEnv("gateway", inputs.env),
|
|
367
370
|
];
|
|
368
371
|
}
|
|
@@ -679,6 +682,12 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
|
|
|
679
682
|
target: { kind: "secret", name: "postgres-password" },
|
|
680
683
|
why: "the gateway no longer logs in as admin_role and must not be able to read its password (CLAUDE.md §2)",
|
|
681
684
|
},
|
|
685
|
+
{
|
|
686
|
+
role: SECRET_ACCESSOR,
|
|
687
|
+
member: "gateway-sa",
|
|
688
|
+
target: { kind: "secret", name: "gateway-role-password" },
|
|
689
|
+
why: "the gateway has no cms_admin login at all and must not be able to read gateway_role's password (CLAUDE.md §2, #635)",
|
|
690
|
+
},
|
|
682
691
|
];
|
|
683
692
|
|
|
684
693
|
/** Every IAM binding upgrade ensures on an install of `provider`. */
|
|
@@ -38,12 +38,14 @@ const SECRET_VALUES: Record<string, string> = {
|
|
|
38
38
|
"caelo-production-gateway-role-password": "g".repeat(64),
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
/** Env vars that would hand the gateway
|
|
41
|
+
/** Env vars that would hand the gateway a cms_admin credential (a subset of what apps/api-gateway adminDatabaseAccessInEnv refuses). */
|
|
42
42
|
const ADMIN_CREDENTIAL_ENV = [
|
|
43
43
|
"ADMIN_DATABASE_URL",
|
|
44
44
|
"ADMIN_DATABASE_PASSWORD",
|
|
45
45
|
"PUBLIC_ADMIN_DATABASE_URL",
|
|
46
46
|
"PUBLIC_ADMIN_DATABASE_PASSWORD",
|
|
47
|
+
"GATEWAY_DATABASE_URL",
|
|
48
|
+
"GATEWAY_DATABASE_PASSWORD",
|
|
47
49
|
];
|
|
48
50
|
|
|
49
51
|
/** Split a gcloud list flag value, honouring the `^<delim>^` escape. */
|
|
@@ -122,16 +124,15 @@ function credentials(url: string | undefined): string {
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
/**
|
|
125
|
-
* The gateway's process env after the upgrade:
|
|
126
|
-
*
|
|
127
|
-
*
|
|
127
|
+
* The gateway's process env after the upgrade: public_role on cms_public
|
|
128
|
+
* with its own secret, and no cms_admin credential of any role at all
|
|
129
|
+
* (#613, #635 — the gateway refuses to boot with one).
|
|
128
130
|
*/
|
|
129
131
|
function expectGatewayOnItsOwnLogins(env: Record<string, string>): void {
|
|
130
132
|
for (const name of ADMIN_CREDENTIAL_ENV) expect(Object.keys(env)).not.toContain(name);
|
|
131
133
|
expect(Object.values(env).join("\n")).not.toContain(PW);
|
|
132
|
-
expect(
|
|
133
|
-
|
|
134
|
-
);
|
|
134
|
+
expect(Object.values(env).join("\n")).not.toContain("g".repeat(64));
|
|
135
|
+
expect(Object.values(env).join("\n")).not.toContain("cms_admin");
|
|
135
136
|
expect(credentials(databaseUrlFromEnv(["PUBLIC_DATABASE_URL"], env))).toBe(
|
|
136
137
|
`public_role:${"p".repeat(64)}@${HOST}:5432/cms_public?sslmode=require`,
|
|
137
138
|
);
|
|
@@ -331,7 +332,7 @@ function v01035Install() {
|
|
|
331
332
|
}
|
|
332
333
|
|
|
333
334
|
describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", () => {
|
|
334
|
-
it("drops the admin_role credential and adds the gateway's own
|
|
335
|
+
it("drops the admin_role credential and adds the gateway's own login in one revision", () => {
|
|
335
336
|
const services = v01035Install();
|
|
336
337
|
const plan = upgrade(services);
|
|
337
338
|
expect(services.gateway.revisions).toHaveLength(2);
|
|
@@ -343,8 +344,6 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
|
|
|
343
344
|
"ADMIN_DATABASE_URL",
|
|
344
345
|
// #650 — the staging gate key arrives in the same roll.
|
|
345
346
|
"CAELO_STAGING_GATE_SECRET",
|
|
346
|
-
"GATEWAY_DATABASE_PASSWORD",
|
|
347
|
-
"GATEWAY_DATABASE_URL",
|
|
348
347
|
"PUBLIC_DATABASE_PASSWORD",
|
|
349
348
|
]);
|
|
350
349
|
});
|
|
@@ -374,6 +373,42 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
|
|
|
374
373
|
});
|
|
375
374
|
});
|
|
376
375
|
|
|
376
|
+
/**
|
|
377
|
+
* A v0.10.50 install (#613, before #635): the gateway on gateway_role's
|
|
378
|
+
* cms_admin pool next to public_role's.
|
|
379
|
+
*/
|
|
380
|
+
function v01050Install() {
|
|
381
|
+
const services = v01035Install();
|
|
382
|
+
upgrade(services);
|
|
383
|
+
const env = new Map(services.gateway.env);
|
|
384
|
+
env.set("GATEWAY_DATABASE_URL", {
|
|
385
|
+
kind: "value",
|
|
386
|
+
value: `postgresql://gateway_role@${HOST}:5432/cms_admin?sslmode=require`,
|
|
387
|
+
});
|
|
388
|
+
env.set("GATEWAY_DATABASE_PASSWORD", {
|
|
389
|
+
kind: "secret",
|
|
390
|
+
secret: "caelo-production-gateway-role-password",
|
|
391
|
+
version: "latest",
|
|
392
|
+
});
|
|
393
|
+
services.gateway = new FakeService(env, SERVICE_ACCOUNTS.gateway);
|
|
394
|
+
return services;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
describe("upgrading a v0.10.50 install: the gateway leaves cms_admin entirely (#635)", () => {
|
|
398
|
+
it("removes the gateway_role login in one revision and keeps the public pool", () => {
|
|
399
|
+
const services = v01050Install();
|
|
400
|
+
const plan = upgrade(services);
|
|
401
|
+
expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
|
|
402
|
+
"GATEWAY_DATABASE_PASSWORD",
|
|
403
|
+
"GATEWAY_DATABASE_URL",
|
|
404
|
+
]);
|
|
405
|
+
expectGatewayOnItsOwnLogins(containerEnv(services.gateway.env));
|
|
406
|
+
for (const revision of services.gateway.revisions) {
|
|
407
|
+
expect(revision.has("PUBLIC_DATABASE_URL")).toBe(true);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
377
412
|
describe("plain → Secret Manager migration of an existing var", () => {
|
|
378
413
|
it("never leaves a revision without the var", () => {
|
|
379
414
|
// An operator set CAELO_INTERNAL_SECRET by hand as a plain value.
|
|
@@ -268,12 +268,7 @@ const CONVERGED_GATEWAY_DB = [
|
|
|
268
268
|
name: "PUBLIC_DATABASE_URL",
|
|
269
269
|
value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
270
270
|
},
|
|
271
|
-
{
|
|
272
|
-
name: "GATEWAY_DATABASE_URL",
|
|
273
|
-
value: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
274
|
-
},
|
|
275
271
|
secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
|
|
276
|
-
secretRef("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
|
|
277
272
|
];
|
|
278
273
|
|
|
279
274
|
describe("planContractEnv", () => {
|
|
@@ -776,10 +771,10 @@ describe("ensureStackInvariants", () => {
|
|
|
776
771
|
const gatewayGrants = calls.filter(
|
|
777
772
|
(c) => c.startsWith("secrets add-iam-policy-binding") && c.includes(GW_SA),
|
|
778
773
|
);
|
|
779
|
-
// #613 — never admin_role's postgres-password
|
|
780
|
-
// gate key is the one secret the
|
|
774
|
+
// #613 — never admin_role's postgres-password; #635 — nor
|
|
775
|
+
// gateway_role's. #650 — the staging gate key is the one secret the
|
|
776
|
+
// gateway shares with the admin.
|
|
781
777
|
expect(gatewayGrants).toEqual([
|
|
782
|
-
`secrets add-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
783
778
|
`secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
784
779
|
`secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
785
780
|
]);
|
|
@@ -802,7 +797,8 @@ describe("removeRetiredIamBindings (#613)", () => {
|
|
|
802
797
|
],
|
|
803
798
|
});
|
|
804
799
|
const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
|
|
805
|
-
|
|
800
|
+
// postgres-password removed; gateway-role-password (#635) already clean.
|
|
801
|
+
expect(outcomes.map((o) => o.status)).toEqual(["removed", "absent"]);
|
|
806
802
|
expect(calls).toContain(REMOVE);
|
|
807
803
|
// The admin's own binding on the same secret is never touched.
|
|
808
804
|
expect(calls.some((c) => c.includes("remove") && c.includes(RUN_SA))).toBe(false);
|
|
@@ -815,10 +811,23 @@ describe("removeRetiredIamBindings (#613)", () => {
|
|
|
815
811
|
],
|
|
816
812
|
});
|
|
817
813
|
const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
|
|
818
|
-
expect(outcomes.map((o) => o.status)).toEqual(["absent"]);
|
|
814
|
+
expect(outcomes.map((o) => o.status)).toEqual(["absent", "absent"]);
|
|
819
815
|
expect(calls.filter((c) => c.includes("remove-iam-policy-binding"))).toEqual([]);
|
|
820
816
|
});
|
|
821
817
|
|
|
818
|
+
it("takes the gateway SA off gateway_role's password (#635)", async () => {
|
|
819
|
+
const { run, calls } = fakeGcloud({
|
|
820
|
+
"secrets get-iam-policy caelo-production-gateway-role-password": [
|
|
821
|
+
policy([{ role: "roles/secretmanager.secretAccessor", members: [GW_SA] }]),
|
|
822
|
+
],
|
|
823
|
+
});
|
|
824
|
+
const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
|
|
825
|
+
expect(outcomes.map((o) => o.status)).toEqual(["absent", "removed"]);
|
|
826
|
+
expect(calls).toContain(
|
|
827
|
+
`secrets remove-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
828
|
+
);
|
|
829
|
+
});
|
|
830
|
+
|
|
822
831
|
it("reports a failed removal instead of throwing", async () => {
|
|
823
832
|
const { run } = fakeGcloud({
|
|
824
833
|
"secrets get-iam-policy caelo-production-postgres-password": [
|
package/src/staging-admin.ts
CHANGED
|
@@ -3,51 +3,25 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* #678 — `staging-domain enable` tells the admin itself what the operator
|
|
5
5
|
* decided, instead of printing "now set the same host in the admin".
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* through Cloud Logging, which would put the password into the project's
|
|
11
|
-
* logs. The admin endpoint (`POST /api/internal/staging/provision`) answers
|
|
12
|
-
* over TLS straight to this process, and the password is never written
|
|
13
|
-
* anywhere (CLAUDE.md §7, #659).
|
|
14
|
-
*
|
|
15
|
-
* Getting there, as the operator's own gcloud identity — the same path the
|
|
16
|
-
* Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
|
|
17
|
-
*
|
|
18
|
-
* 1. the admin Cloud Run service and its URL;
|
|
19
|
-
* 2. the install's internal secret from Secret Manager (in memory only),
|
|
20
|
-
* which signs a 5-minute token for scope `staging.provision`;
|
|
21
|
-
* 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
|
|
22
|
-
* API — the admin's IAP lets that service account in, and every
|
|
23
|
-
* IAP-allowlisted operator may sign as it (`cms-provision upgrade`
|
|
24
|
-
* grants it) — so the request passes IAP first, then Caelo's token.
|
|
6
|
+
* It calls `POST /api/internal/staging/provision` through admin-internal.ts
|
|
7
|
+
* (IAP as caelo-mcp + the internal token, scope `staging.provision`), because
|
|
8
|
+
* the answer carries the generated basic-auth password back to the CLI, which
|
|
9
|
+
* prints it once and never stores it.
|
|
25
10
|
*/
|
|
26
11
|
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
12
|
+
import {
|
|
13
|
+
type AdminInternalDeps,
|
|
14
|
+
type AdminInternalResult,
|
|
15
|
+
type AdminInternalTarget,
|
|
16
|
+
callAdminInternal,
|
|
17
|
+
} from "./admin-internal.js";
|
|
33
18
|
|
|
34
19
|
/** Token scope the admin endpoint checks (apps/admin …/api/internal/staging/provision). */
|
|
35
20
|
export const STAGING_PROVISION_SCOPE = "staging.provision";
|
|
36
21
|
const ENDPOINT = "/api/internal/staging/provision";
|
|
37
|
-
/** Signed JWTs live at most 1 hour; one call needs a few seconds. */
|
|
38
|
-
const IAP_JWT_LIFETIME_S = 600;
|
|
39
22
|
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
readonly region: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface StagingAdminDeps {
|
|
46
|
-
readonly run?: Run;
|
|
47
|
-
readonly fetch?: typeof fetch;
|
|
48
|
-
/** Seconds since epoch (IAP JWT); tests pin it. */
|
|
49
|
-
readonly now?: () => number;
|
|
50
|
-
}
|
|
23
|
+
export type StagingAdminTarget = AdminInternalTarget;
|
|
24
|
+
export type StagingAdminDeps = AdminInternalDeps;
|
|
51
25
|
|
|
52
26
|
/** Main's draft pages, as the admin reports them. */
|
|
53
27
|
export interface DraftPages {
|
|
@@ -80,7 +54,7 @@ export interface StagingApply {
|
|
|
80
54
|
readonly draftPages: DraftPages;
|
|
81
55
|
}
|
|
82
56
|
|
|
83
|
-
export type StagingAdminResult<T> =
|
|
57
|
+
export type StagingAdminResult<T> = AdminInternalResult<T>;
|
|
84
58
|
|
|
85
59
|
export interface ApplyRequest {
|
|
86
60
|
readonly host?: string;
|
|
@@ -110,149 +84,12 @@ async function callStagingAdmin<T>(
|
|
|
110
84
|
body: Record<string, unknown>,
|
|
111
85
|
deps: StagingAdminDeps,
|
|
112
86
|
): Promise<StagingAdminResult<T>> {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const found = await findService(run, scope, "admin");
|
|
118
|
-
if (!found.ok) return { ok: false, error: found.error };
|
|
119
|
-
const url = await run([
|
|
120
|
-
"run",
|
|
121
|
-
"services",
|
|
122
|
-
"describe",
|
|
123
|
-
found.service,
|
|
124
|
-
...scope,
|
|
125
|
-
"--format=value(status.url)",
|
|
126
|
-
]);
|
|
127
|
-
const adminUrl = url.stdout.trim();
|
|
128
|
-
if (!url.ok || !adminUrl) {
|
|
129
|
-
return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
|
|
133
|
-
const secret = await run([
|
|
134
|
-
"secrets",
|
|
135
|
-
"versions",
|
|
136
|
-
"access",
|
|
137
|
-
"latest",
|
|
138
|
-
`--secret=${secretId}`,
|
|
139
|
-
`--project=${target.projectId}`,
|
|
140
|
-
]);
|
|
141
|
-
const internalSecret = secret.stdout.trim();
|
|
142
|
-
if (!secret.ok || internalSecret.length < 32) {
|
|
143
|
-
return {
|
|
144
|
-
ok: false,
|
|
145
|
-
error: `read the install's internal secret (${secretId}) from Secret Manager: ${secret.stderr.trim() || "missing or too short"}. Your gcloud account needs secretmanager.versions.access on the project.`,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Installs from before #678 may not have the IAM Credentials API on;
|
|
150
|
-
// enabling an enabled API is a no-op (codex review on #681).
|
|
151
|
-
const api = await run([
|
|
152
|
-
"services",
|
|
153
|
-
"enable",
|
|
154
|
-
"iamcredentials.googleapis.com",
|
|
155
|
-
`--project=${target.projectId}`,
|
|
156
|
-
]);
|
|
157
|
-
if (!api.ok) {
|
|
158
|
-
return {
|
|
159
|
-
ok: false,
|
|
160
|
-
error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
const iap = await signIapJwt(run, doFetch, target.projectId, adminUrl, deps.now);
|
|
164
|
-
if (!iap.ok) return iap;
|
|
165
|
-
|
|
166
|
-
const token = await mintInternalToken(internalSecret, STAGING_PROVISION_SCOPE);
|
|
167
|
-
let res: Response;
|
|
168
|
-
try {
|
|
169
|
-
res = await doFetch(`${adminUrl}${ENDPOINT}`, {
|
|
170
|
-
method: "POST",
|
|
171
|
-
headers: {
|
|
172
|
-
authorization: `Bearer ${iap.value}`,
|
|
173
|
-
"x-caelo-internal-token": token,
|
|
174
|
-
"content-type": "application/json",
|
|
175
|
-
},
|
|
176
|
-
body: JSON.stringify(body),
|
|
177
|
-
});
|
|
178
|
-
} catch (e) {
|
|
179
|
-
return { ok: false, error: `reach the admin at ${adminUrl}: ${String(e)}` };
|
|
180
|
-
}
|
|
181
|
-
const text = await res.text();
|
|
182
|
-
if (res.headers.get("x-goog-iap-generated-response") === "true") {
|
|
183
|
-
return {
|
|
184
|
-
ok: false,
|
|
185
|
-
error: `Google IAP refused the request (HTTP ${res.status}: ${text.trim().slice(0, 200)}) — ${mcpIapServiceAccountEmail(target.projectId)} is not allowed on the admin's IAP yet. Run \`cms-provision upgrade\` (it allowlists that service account), then run this command again.`,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
if (res.status === 404) {
|
|
189
|
-
return {
|
|
190
|
-
ok: false,
|
|
191
|
-
error: `the admin has no ${ENDPOINT} yet (it runs a release from before #678). Run \`cms-provision upgrade\`, then run this command again.`,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
let parsed: { ok?: boolean; error?: string } & Record<string, unknown>;
|
|
195
|
-
try {
|
|
196
|
-
parsed = JSON.parse(text) as typeof parsed;
|
|
197
|
-
} catch {
|
|
198
|
-
return {
|
|
199
|
-
ok: false,
|
|
200
|
-
error: `the admin answered HTTP ${res.status} with a non-JSON body: ${text.slice(0, 200)}`,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
if (!res.ok || parsed.ok !== true) {
|
|
204
|
-
return {
|
|
205
|
-
ok: false,
|
|
206
|
-
error: `the admin refused (HTTP ${res.status}): ${parsed.error ?? text.slice(0, 300)}`,
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
return { ok: true, value: parsed as unknown as T };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** A JWT signed as the install's MCP service account, for the admin's IAP. */
|
|
213
|
-
async function signIapJwt(
|
|
214
|
-
run: Run,
|
|
215
|
-
doFetch: typeof fetch,
|
|
216
|
-
projectId: string,
|
|
217
|
-
adminUrl: string,
|
|
218
|
-
now: (() => number) | undefined,
|
|
219
|
-
): Promise<StagingAdminResult<string>> {
|
|
220
|
-
const access = await run(["auth", "print-access-token"]);
|
|
221
|
-
const accessToken = access.stdout.trim();
|
|
222
|
-
if (!access.ok || !accessToken) {
|
|
223
|
-
return {
|
|
224
|
-
ok: false,
|
|
225
|
-
error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
const serviceAccount = mcpIapServiceAccountEmail(projectId);
|
|
229
|
-
const iat = now ? now() : Math.floor(Date.now() / 1000);
|
|
230
|
-
const res = await doFetch(
|
|
231
|
-
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
|
|
232
|
-
{
|
|
233
|
-
method: "POST",
|
|
234
|
-
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
235
|
-
body: JSON.stringify({
|
|
236
|
-
payload: JSON.stringify({
|
|
237
|
-
iss: serviceAccount,
|
|
238
|
-
sub: serviceAccount,
|
|
239
|
-
aud: `${new URL(adminUrl).origin}/*`,
|
|
240
|
-
iat,
|
|
241
|
-
exp: iat + IAP_JWT_LIFETIME_S,
|
|
242
|
-
}),
|
|
243
|
-
}),
|
|
244
|
-
},
|
|
87
|
+
const r = await callAdminInternal<T>(
|
|
88
|
+
target,
|
|
89
|
+
{ endpoint: ENDPOINT, scope: STAGING_PROVISION_SCOPE, body, introducedBy: "#678" },
|
|
90
|
+
deps,
|
|
245
91
|
);
|
|
246
|
-
|
|
247
|
-
const detail = (await res.text().catch(() => "")).slice(0, 300);
|
|
248
|
-
return {
|
|
249
|
-
ok: false,
|
|
250
|
-
error: `sign an IAP token as ${serviceAccount} (HTTP ${res.status}): ${detail} — your account needs roles/iam.serviceAccountTokenCreator on it; \`cms-provision upgrade\` grants it to everyone the admin's IAP lets in.`,
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
const { signedJwt } = (await res.json()) as { signedJwt?: string };
|
|
254
|
-
if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
|
|
255
|
-
return { ok: true, value: signedJwt };
|
|
92
|
+
return r.ok ? { ok: true, value: r.value.answer } : r;
|
|
256
93
|
}
|
|
257
94
|
|
|
258
95
|
/**
|
package/src/wizards/gcp-cost.ts
CHANGED
|
@@ -104,7 +104,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
|
|
|
104
104
|
name: "Quality checks (Lighthouse on the admin)",
|
|
105
105
|
monthlyUsd: 1,
|
|
106
106
|
notes:
|
|
107
|
-
"~
|
|
107
|
+
"~2-4 min of admin CPU per audited Stage (up to 5 pages x 5 runs); ~$1/mo at a few Stages a day",
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
name: "Cloud Run gateway",
|
package/stacks/aws/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `
|
|
|
10
10
|
| Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
|
|
11
11
|
| CDN | CloudFront distribution with two origins (S3 static + S3 media); static files are served as files, no edge compute on the request path |
|
|
12
12
|
| Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
|
|
13
|
-
| Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `
|
|
13
|
+
| Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `publicDatabaseUrlOut` only, never a cms_admin URL of any role (#613, #635). |
|
|
14
14
|
| DNS + cert | ACM cert (us-east-1, DNS-validated, supports `*.<domain>`) |
|
|
15
15
|
| Edge-log sink | CloudFront access logs delivered to the CloudWatch log group `/caelo/<env>/cloudfront` in us-east-1 (queryable by P12A analytics plugin) |
|
|
16
16
|
|