@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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #635 guardrail (c): on EVERY adapter, the API gateway's service
|
|
5
|
+
* env holds no cms_admin credential of any role, AND its identity cannot
|
|
6
|
+
* read a secret that unlocks one (CLAUDE.md §2: the public side has no path
|
|
7
|
+
* to cms_admin). Env strings alone are not enough: a role that may read
|
|
8
|
+
* admin_role's password is a path even when no env var names it.
|
|
9
|
+
*
|
|
10
|
+
* gcp, gcp-firebase the env contract (stack-contract.ts), every IAM
|
|
11
|
+
* binding the gateway SA gets (contract + stack source)
|
|
12
|
+
* aws the gateway's own ECS task role and the secrets its
|
|
13
|
+
* policy covers (aws-task-roles.ts + stack source)
|
|
14
|
+
* azure the gateway container app's env, and no identity or
|
|
15
|
+
* Key Vault access for any container app
|
|
16
|
+
* self-hosted the generated compose gateway service: env, secrets,
|
|
17
|
+
* mounts
|
|
18
|
+
*
|
|
19
|
+
* The gateway itself refuses to boot on such an env
|
|
20
|
+
* (apps/api-gateway `adminDatabaseAccessInEnv`); this catches it at review
|
|
21
|
+
* time, before a release can ship it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, expect, it } from "bun:test";
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { resolve } from "node:path";
|
|
27
|
+
import { AWS_SERVICE_SECRETS, secretsReadPolicy } from "./aws-task-roles.js";
|
|
28
|
+
import { generateDockerCompose } from "./compose.js";
|
|
29
|
+
import {
|
|
30
|
+
databaseUrls,
|
|
31
|
+
gatewayEnvContract,
|
|
32
|
+
RETIRED_IAM_BINDINGS,
|
|
33
|
+
RETIRED_SERVICE_ENV,
|
|
34
|
+
runtimeSecretBindings,
|
|
35
|
+
SERVICE_SECRET_ENV,
|
|
36
|
+
stackIamInvariants,
|
|
37
|
+
} from "./stack-contract.js";
|
|
38
|
+
|
|
39
|
+
/** Anything in a gateway env value or name that reaches the authoring database. */
|
|
40
|
+
const ADMIN_ACCESS = /cms_admin|admin_role|gateway_role|ADMIN_DATABASE|GATEWAY_DATABASE/;
|
|
41
|
+
|
|
42
|
+
/** Secrets that unlock a cms_admin login. */
|
|
43
|
+
const ADMIN_SECRETS = ["postgres-password", "gateway-role-password"];
|
|
44
|
+
|
|
45
|
+
/** The secrets the gateway reads on GCP: its own login and the staging gate key. */
|
|
46
|
+
const GCP_GATEWAY_SECRETS = ["public-role-password", "staging-gate-secret"];
|
|
47
|
+
|
|
48
|
+
/** The source of a stack program. */
|
|
49
|
+
function stack(provider: "aws" | "azure" | "gcp" | "gcp-firebase"): string {
|
|
50
|
+
return readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
|
|
54
|
+
const env = gatewayEnvContract({
|
|
55
|
+
provider: "gcp",
|
|
56
|
+
projectId: "acme",
|
|
57
|
+
env: "production",
|
|
58
|
+
domain: "example.com",
|
|
59
|
+
region: "europe-west1",
|
|
60
|
+
stagingHost: "staging.example.com",
|
|
61
|
+
databaseUrls: databaseUrls("10.20.0.3"),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("carries no cms_admin URL, role or credential name", () => {
|
|
65
|
+
for (const v of env) {
|
|
66
|
+
expect(v.name).not.toMatch(ADMIN_ACCESS);
|
|
67
|
+
if ("value" in v && typeof v.value === "string") expect(v.value).not.toMatch(ADMIN_ACCESS);
|
|
68
|
+
}
|
|
69
|
+
expect(env.find((v) => v.name === "PUBLIC_DATABASE_URL")).toEqual({
|
|
70
|
+
name: "PUBLIC_DATABASE_URL",
|
|
71
|
+
value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("references no secret that unlocks a cms_admin login", () => {
|
|
76
|
+
for (const secret of Object.values(SERVICE_SECRET_ENV.gateway)) {
|
|
77
|
+
expect(ADMIN_SECRETS).not.toContain(secret);
|
|
78
|
+
}
|
|
79
|
+
const gatewayBindings = runtimeSecretBindings().filter((b) => b.service === "gateway");
|
|
80
|
+
for (const b of gatewayBindings) expect(ADMIN_SECRETS).not.toContain(b.secret);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
for (const provider of ["gcp", "gcp-firebase"] as const) {
|
|
84
|
+
it(`${provider}: the gateway SA's IAM bindings reach no cms_admin secret (review finding)`, () => {
|
|
85
|
+
const gateway = stackIamInvariants(provider).filter((b) => b.member === "gateway-sa");
|
|
86
|
+
for (const b of gateway) {
|
|
87
|
+
if (b.role === "roles/secretmanager.secretAccessor") {
|
|
88
|
+
// Per secret, never project-wide (that would cover every secret).
|
|
89
|
+
expect(b.target.kind).toBe("secret");
|
|
90
|
+
if (b.target.kind === "secret") expect(GCP_GATEWAY_SECRETS).toContain(b.target.name);
|
|
91
|
+
} else {
|
|
92
|
+
expect(["roles/logging.logWriter", "roles/monitoring.metricWriter"]).toContain(b.role);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// The stack declares no other binding for the gateway SA: the secret
|
|
96
|
+
// bindings go through runtimeSecretBindings, the two telemetry ones
|
|
97
|
+
// name it directly.
|
|
98
|
+
const direct = stack(provider).match(/serviceAccount:\$\{gatewaySa\.email\}/g) ?? [];
|
|
99
|
+
expect(direct).toHaveLength(2);
|
|
100
|
+
expect(stack(provider)).toContain("for (const binding of runtimeSecretBindings())");
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
it("upgrade strips the old credentials and the gateway SA's access to them", () => {
|
|
105
|
+
for (const name of [
|
|
106
|
+
"ADMIN_DATABASE_URL",
|
|
107
|
+
"ADMIN_DATABASE_PASSWORD",
|
|
108
|
+
"GATEWAY_DATABASE_URL",
|
|
109
|
+
"GATEWAY_DATABASE_PASSWORD",
|
|
110
|
+
]) {
|
|
111
|
+
expect(RETIRED_SERVICE_ENV.gateway).toContain(name);
|
|
112
|
+
}
|
|
113
|
+
for (const secret of ADMIN_SECRETS) {
|
|
114
|
+
expect(
|
|
115
|
+
RETIRED_IAM_BINDINGS.some(
|
|
116
|
+
(b) =>
|
|
117
|
+
b.member === "gateway-sa" && b.target.kind === "secret" && b.target.name === secret,
|
|
118
|
+
),
|
|
119
|
+
).toBe(true);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("self-hosted: the generated compose gateway service (#635)", () => {
|
|
125
|
+
it("holds public_role on cms_public and nothing else", () => {
|
|
126
|
+
const out = generateDockerCompose({
|
|
127
|
+
domain: "example.com",
|
|
128
|
+
postgresPassword: "superpw",
|
|
129
|
+
rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
|
|
130
|
+
caeloSecretKek: "0".repeat(64),
|
|
131
|
+
diskSize: "20Gi",
|
|
132
|
+
images: {
|
|
133
|
+
mode: "release" as const,
|
|
134
|
+
admin: `ghcr.io/caelo-cms/admin@sha256:${"a".repeat(64)}`,
|
|
135
|
+
gateway: `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`,
|
|
136
|
+
},
|
|
137
|
+
ports: { http: 80, https: 443 },
|
|
138
|
+
project: "caelo-example-com",
|
|
139
|
+
});
|
|
140
|
+
const start = out.indexOf(" caelo-gateway:");
|
|
141
|
+
const end = out.indexOf("\n caelo-", start + 1);
|
|
142
|
+
const gateway = out.slice(start, end === -1 ? undefined : end);
|
|
143
|
+
expect(gateway).toContain("PUBLIC_DATABASE_URL:");
|
|
144
|
+
expect(gateway).not.toMatch(ADMIN_ACCESS);
|
|
145
|
+
expect(gateway).not.toMatch(/superpw|adminpw|gatewaypw/);
|
|
146
|
+
// No other way in: no Compose secrets, no env file, no mounts (a
|
|
147
|
+
// mounted file is read by no env check), and exactly its own env.
|
|
148
|
+
expect(gateway).not.toMatch(/^ {4}(secrets|env_file|volumes):/m);
|
|
149
|
+
const env = gateway.slice(gateway.indexOf(" environment:"));
|
|
150
|
+
const keys = [...env.matchAll(/^ {6}([A-Z_][A-Z0-9_]*):/gm)].map((m) => m[1]);
|
|
151
|
+
expect(keys.sort()).toEqual(["GATEWAY_PORT", "NODE_ENV", "PUBLIC_DATABASE_URL"]);
|
|
152
|
+
expect(out).not.toMatch(/^secrets:/m);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("azure: the gateway container app (#635)", () => {
|
|
157
|
+
it("gets PUBLIC_DATABASE_URL only", () => {
|
|
158
|
+
const src = stack("azure");
|
|
159
|
+
const start = src.indexOf('serviceName: "gateway"');
|
|
160
|
+
expect(start).toBeGreaterThan(-1);
|
|
161
|
+
const block = src.slice(start, src.indexOf("});", start));
|
|
162
|
+
expect(block).toContain('{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }');
|
|
163
|
+
expect(block).not.toMatch(/adminDatabase|gatewayDatabase|ADMIN_DATABASE|GATEWAY_DATABASE/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("builds no cms_admin URL for any role but the admin's", () => {
|
|
167
|
+
expect(stack("azure")).not.toMatch(/gateway_role:\$\{/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("gives no container app an identity or Key Vault access (review finding)", () => {
|
|
171
|
+
// The vault uses RBAC authorisation: without a role assignment or a
|
|
172
|
+
// managed identity no container app can read any vault secret, so the
|
|
173
|
+
// gateway's only credential is the public_role URL in its env.
|
|
174
|
+
const src = stack("azure");
|
|
175
|
+
expect(src).toContain("enableRbacAuthorization: true");
|
|
176
|
+
expect(src).not.toMatch(/RoleAssignment|accessPolicies|identity\s*:|keyVaultUrl|secretRef/);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("aws: the stack (#635)", () => {
|
|
181
|
+
it("builds no gateway_role URL and exports no gateway cms_admin DSN", () => {
|
|
182
|
+
const src = stack("aws");
|
|
183
|
+
expect(src).not.toMatch(/gateway_role:\$\{/);
|
|
184
|
+
expect(src).not.toMatch(/gatewayDatabaseUrl/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("the gateway's task role reads only the public_role password (review finding)", () => {
|
|
188
|
+
expect(AWS_SERVICE_SECRETS.gateway).toEqual(["public-role-password"]);
|
|
189
|
+
for (const secret of ADMIN_SECRETS) expect(AWS_SERVICE_SECRETS.gateway).not.toContain(secret);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("each service gets its own task role whose policy is built from that table", () => {
|
|
193
|
+
const src = stack("aws");
|
|
194
|
+
expect(src).toContain('gateway: taskRole("gateway")');
|
|
195
|
+
expect(src).toContain('admin: taskRole("admin")');
|
|
196
|
+
// One RolePolicy, inside taskRole, scoped by AWS_SERVICE_SECRETS[service]:
|
|
197
|
+
// no policy anywhere lists secret ARNs by hand.
|
|
198
|
+
expect(src.match(/new aws\.iam\.RolePolicy\(/g)).toHaveLength(1);
|
|
199
|
+
expect(src).toContain(".all(AWS_SERVICE_SECRETS[service].map((name) => secretArns[name]))");
|
|
200
|
+
expect(src).not.toMatch(/GetSecretValue/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("secretsReadPolicy grants GetSecretValue on exactly the given ARNs", () => {
|
|
204
|
+
expect(JSON.parse(secretsReadPolicy(["arn:a", "arn:b"]))).toEqual({
|
|
205
|
+
Version: "2012-10-17",
|
|
206
|
+
Statement: [
|
|
207
|
+
{ Effect: "Allow", Action: "secretsmanager:GetSecretValue", Resource: ["arn:a", "arn:b"] },
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
expect(() => secretsReadPolicy([])).toThrow();
|
|
211
|
+
});
|
|
212
|
+
});
|
package/src/gcloud.ts
CHANGED
|
@@ -75,10 +75,12 @@ export async function gcloud(
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Active gcloud account. Returns `null` when no account is logged in
|
|
78
|
-
* — caller prompts `gcloud auth login`.
|
|
78
|
+
* — caller prompts `gcloud auth login`. `run` is injectable for tests.
|
|
79
79
|
*/
|
|
80
|
-
export async function activeAccount(
|
|
81
|
-
|
|
80
|
+
export async function activeAccount(
|
|
81
|
+
run: (args: string[]) => Promise<GcloudResult> = gcloud,
|
|
82
|
+
): Promise<string | null> {
|
|
83
|
+
const r = await run(["auth", "list", "--format=value(account)", "--filter=status:ACTIVE"]);
|
|
82
84
|
if (!r.ok) return null;
|
|
83
85
|
const value = r.stdout.trim();
|
|
84
86
|
return value.length > 0 ? value : null;
|
package/src/index.test.ts
CHANGED
|
@@ -132,7 +132,7 @@ describe("docker-compose generator", () => {
|
|
|
132
132
|
expect(out).toContain("CAELO_SECRET_KEK");
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
it("gives the gateway
|
|
135
|
+
it("gives the gateway public_role only and no cms_admin credential (#613, #635)", () => {
|
|
136
136
|
const out = generateDockerCompose({
|
|
137
137
|
domain: "example.com",
|
|
138
138
|
postgresPassword: "supersecret",
|
|
@@ -144,14 +144,14 @@ describe("docker-compose generator", () => {
|
|
|
144
144
|
project: "caelo-example-com",
|
|
145
145
|
});
|
|
146
146
|
const gateway = out.slice(out.indexOf("caelo-gateway:"), out.indexOf("\nvolumes:"));
|
|
147
|
-
expect(gateway).toContain(
|
|
148
|
-
'GATEWAY_DATABASE_URL: "postgres://gateway_role:gatewaypw@postgres:5432/cms_admin"',
|
|
149
|
-
);
|
|
150
147
|
expect(gateway).toContain(
|
|
151
148
|
'PUBLIC_DATABASE_URL: "postgres://public_role:publicpw@postgres:5432/cms_public"',
|
|
152
149
|
);
|
|
153
|
-
expect(gateway).not.toMatch(
|
|
154
|
-
|
|
150
|
+
expect(gateway).not.toMatch(
|
|
151
|
+
/ADMIN_DATABASE|GATEWAY_DATABASE|cms_admin|admin_role|gateway_role|adminpw|gatewaypw|supersecret|CAELO_SECRET_KEK/,
|
|
152
|
+
);
|
|
153
|
+
// bootstrap.sh still creates the three roles from these (gateway_role
|
|
154
|
+
// until the contract release drops it).
|
|
155
155
|
expect(out).toContain('GATEWAY_ROLE_PASSWORD: "gatewaypw"');
|
|
156
156
|
});
|
|
157
157
|
|
|
@@ -20,10 +20,11 @@ describe("infrastructure contract steps", () => {
|
|
|
20
20
|
);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("is at
|
|
24
|
-
expect(INFRA_CONTRACT_VERSION).toBe(
|
|
23
|
+
it("is at 5: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5)", () => {
|
|
24
|
+
expect(INFRA_CONTRACT_VERSION).toBe(5);
|
|
25
25
|
expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
|
|
26
26
|
expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
|
|
27
|
+
expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
it("lists the pending steps oldest first", () => {
|
|
@@ -34,6 +35,6 @@ describe("infrastructure contract steps", () => {
|
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
it("names a version a newer release added by number", () => {
|
|
37
|
-
expect(infraStepsText(
|
|
38
|
+
expect(infraStepsText(5, 6)).toBe("infrastructure step 6 (see that release's notes)");
|
|
38
39
|
});
|
|
39
40
|
});
|
package/src/infra-contract.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* whose app-roll references infrastructure an install does not have yet fails
|
|
23
23
|
* at roll time, after migrations ran.
|
|
24
24
|
*/
|
|
25
|
-
export const INFRA_CONTRACT_VERSION =
|
|
25
|
+
export const INFRA_CONTRACT_VERSION = 5;
|
|
26
26
|
|
|
27
27
|
// History (why each bump happened — an install below a number needs the
|
|
28
28
|
// converge that introduced it):
|
|
@@ -36,6 +36,10 @@ export const INFRA_CONTRACT_VERSION = 4;
|
|
|
36
36
|
// first.
|
|
37
37
|
// 4 — #587: on `gcp`, the admin's custom role for writing the redirects
|
|
38
38
|
// table into the URL map (url-map-redirects-access.ts).
|
|
39
|
+
// 5 — #635: the gateway has no cms_admin login. Its GATEWAY_DATABASE_URL /
|
|
40
|
+
// GATEWAY_DATABASE_PASSWORD go, and its SA loses access to the
|
|
41
|
+
// gateway-role-password secret (a retired IAM binding, removed after
|
|
42
|
+
// the roll — the in-cloud upgrader cannot change IAM).
|
|
39
43
|
|
|
40
44
|
/**
|
|
41
45
|
* What each contract version adds, in operator terms — the upgrader's
|
|
@@ -47,6 +51,7 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
|
|
|
47
51
|
2: "the in-cloud upgrader job and its scoped permissions",
|
|
48
52
|
3: "public_role out of the Cloud SQL superuser group (#633)",
|
|
49
53
|
4: "the admin's permission to serve redirects from the load balancer (#587)",
|
|
54
|
+
5: "the gateway's cms_admin login removed from its env and IAM (#635)",
|
|
50
55
|
};
|
|
51
56
|
|
|
52
57
|
/**
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #769 — `owner-reset` asks the admin for a reset link through IAP (as
|
|
5
|
+
* caelo-mcp) with the internal token for scope `auth.owner_reset`, and builds
|
|
6
|
+
* the link on the admin URL the operator can open.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
11
|
+
import { describeOwnerReset, OWNER_RESET_SCOPE, requestOwnerReset } from "./owner-reset.js";
|
|
12
|
+
|
|
13
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
14
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
15
|
+
const ADMIN = "https://caelo-production-admin-abc-ew.a.run.app";
|
|
16
|
+
const target = { projectId: "proj", region: "europe-west1" };
|
|
17
|
+
|
|
18
|
+
function fakeGcloud(
|
|
19
|
+
overrides: Record<string, GcloudResult> = {},
|
|
20
|
+
): (args: string[]) => Promise<GcloudResult> {
|
|
21
|
+
const answers: Record<string, GcloudResult> = {
|
|
22
|
+
"auth list": ok("operator@example.com\n"),
|
|
23
|
+
"run services list": ok("caelo-production-admin-abc\n"),
|
|
24
|
+
"run services describe": ok(`${ADMIN}\n`),
|
|
25
|
+
"secrets versions access": ok("s".repeat(40)),
|
|
26
|
+
"auth print-access-token": ok("ya29.operator\n"),
|
|
27
|
+
"services enable iamcredentials.googleapis.com": ok(),
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
return async (args) => {
|
|
31
|
+
const key = Object.keys(answers).find((k) => args.join(" ").startsWith(k));
|
|
32
|
+
return (key && answers[key]) || fail(`unexpected gcloud ${args.join(" ")}`);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Seen {
|
|
37
|
+
url: string;
|
|
38
|
+
headers: Record<string, string>;
|
|
39
|
+
body: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fakeFetch(admin: () => Response) {
|
|
43
|
+
const seen: Seen[] = [];
|
|
44
|
+
const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
45
|
+
const url = String(input);
|
|
46
|
+
seen.push({
|
|
47
|
+
url,
|
|
48
|
+
headers: Object.fromEntries(new Headers(init?.headers).entries()),
|
|
49
|
+
body: init?.body ? JSON.parse(String(init.body)) : null,
|
|
50
|
+
});
|
|
51
|
+
if (url.startsWith("https://iamcredentials.googleapis.com/")) {
|
|
52
|
+
return Response.json({ signedJwt: "iap.jwt.signed" });
|
|
53
|
+
}
|
|
54
|
+
return admin();
|
|
55
|
+
}) as typeof fetch;
|
|
56
|
+
return { doFetch, seen };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ISSUED = {
|
|
60
|
+
ok: true,
|
|
61
|
+
email: "owner@example.com",
|
|
62
|
+
token: "tok/with+chars",
|
|
63
|
+
expiresAt: "2026-10-15T12:52:56.000Z",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe("requestOwnerReset", () => {
|
|
67
|
+
it("calls the endpoint with the owner_reset scope and builds the link on the admin URL", async () => {
|
|
68
|
+
const { doFetch, seen } = fakeFetch(() => Response.json(ISSUED));
|
|
69
|
+
const r = await requestOwnerReset(target, {}, { run: fakeGcloud(), fetch: doFetch });
|
|
70
|
+
expect(r).toEqual({
|
|
71
|
+
ok: true,
|
|
72
|
+
value: {
|
|
73
|
+
email: "owner@example.com",
|
|
74
|
+
resetUrl: `${ADMIN}/reset?token=tok%2Fwith%2Bchars`,
|
|
75
|
+
expiresAt: "2026-10-15T12:52:56.000Z",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const call = seen[1];
|
|
79
|
+
expect(call?.url).toBe(`${ADMIN}/api/internal/owner-reset`);
|
|
80
|
+
expect(call?.headers.authorization).toBe("Bearer iap.jwt.signed");
|
|
81
|
+
expect(call?.body).toEqual({ requestedBy: "operator@example.com" });
|
|
82
|
+
const [message] = (call?.headers["x-caelo-internal-token"] ?? "").split(".");
|
|
83
|
+
expect(Buffer.from(message ?? "", "base64url").toString()).toEndWith(`:${OWNER_RESET_SCOPE}`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("forwards --email so the operator can pick among several Owners", async () => {
|
|
87
|
+
const { doFetch, seen } = fakeFetch(() => Response.json(ISSUED));
|
|
88
|
+
await requestOwnerReset(
|
|
89
|
+
target,
|
|
90
|
+
{ email: "owner@example.com" },
|
|
91
|
+
{ run: fakeGcloud(), fetch: doFetch },
|
|
92
|
+
);
|
|
93
|
+
expect(seen[1]?.body).toEqual({
|
|
94
|
+
email: "owner@example.com",
|
|
95
|
+
requestedBy: "operator@example.com",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("refuses to run without an active gcloud account to name in the audit row", async () => {
|
|
100
|
+
const { doFetch, seen } = fakeFetch(() => Response.json(ISSUED));
|
|
101
|
+
for (const answer of [
|
|
102
|
+
ok(""),
|
|
103
|
+
fail("ERROR: no credentialed accounts"),
|
|
104
|
+
ok("a@x.com\nb@x.com"),
|
|
105
|
+
]) {
|
|
106
|
+
const r = await requestOwnerReset(
|
|
107
|
+
target,
|
|
108
|
+
{},
|
|
109
|
+
{ run: fakeGcloud({ "auth list": answer }), fetch: doFetch },
|
|
110
|
+
);
|
|
111
|
+
expect(r.ok ? "" : r.error).toContain("gcloud auth login");
|
|
112
|
+
}
|
|
113
|
+
expect(seen).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("passes the admin's refusal through and names the upgrade for an older admin", async () => {
|
|
117
|
+
const refused = await requestOwnerReset(
|
|
118
|
+
target,
|
|
119
|
+
{},
|
|
120
|
+
{
|
|
121
|
+
run: fakeGcloud(),
|
|
122
|
+
fetch: fakeFetch(() =>
|
|
123
|
+
Response.json(
|
|
124
|
+
{ ok: false, error: "This install has 2 Owners (a@x.com, b@x.com). Name the one" },
|
|
125
|
+
{ status: 422 },
|
|
126
|
+
),
|
|
127
|
+
).doFetch,
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
expect(refused.ok ? "" : refused.error).toContain("2 Owners (a@x.com, b@x.com)");
|
|
131
|
+
|
|
132
|
+
const old = await requestOwnerReset(
|
|
133
|
+
target,
|
|
134
|
+
{},
|
|
135
|
+
{ run: fakeGcloud(), fetch: fakeFetch(() => new Response("nf", { status: 404 })).doFetch },
|
|
136
|
+
);
|
|
137
|
+
expect(old.ok ? "" : old.error).toContain("before #769");
|
|
138
|
+
expect(old.ok ? "" : old.error).toContain("cms-provision upgrade");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("refuses an answer without a token instead of printing a broken link", async () => {
|
|
142
|
+
const r = await requestOwnerReset(
|
|
143
|
+
target,
|
|
144
|
+
{},
|
|
145
|
+
{
|
|
146
|
+
run: fakeGcloud(),
|
|
147
|
+
fetch: fakeFetch(() => Response.json({ ok: true, email: "owner@example.com" })).doFetch,
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
expect(r.ok).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("describeOwnerReset", () => {
|
|
155
|
+
it("prints the account, the link and when it stops working", () => {
|
|
156
|
+
const text = describeOwnerReset({
|
|
157
|
+
email: "owner@example.com",
|
|
158
|
+
resetUrl: `${ADMIN}/reset?token=abc`,
|
|
159
|
+
expiresAt: "2026-10-15T12:52:56.000Z",
|
|
160
|
+
}).join("\n");
|
|
161
|
+
expect(text).toContain("owner@example.com");
|
|
162
|
+
expect(text).toContain(` ${ADMIN}/reset?token=abc`);
|
|
163
|
+
expect(text).toContain("2026-10-15T12:52:56.000Z");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #769 — `cms-provision owner-reset` — get an Owner back into a locked-out admin
|
|
5
|
+
* when the install cannot email a reset link (no email transport set up).
|
|
6
|
+
*
|
|
7
|
+
* The root of trust is the operator's cloud identity: whoever may read the
|
|
8
|
+
* install's internal secret and pass the admin's IAP already controls its
|
|
9
|
+
* database and deploys, so issuing them a reset link grants nothing new. The
|
|
10
|
+
* admin issues the one-hour, single-use token (`auth.issue_owner_reset`,
|
|
11
|
+
* audited) and hands it back over the admin-internal.ts channel; the CLI
|
|
12
|
+
* prints the link once. It is never logged, so it does not land in the
|
|
13
|
+
* project's Cloud Logging the way a "log the link" fallback would.
|
|
14
|
+
*
|
|
15
|
+
* The request carries the operator's gcloud account so the admin's audit row
|
|
16
|
+
* names who asked. That account is self-asserted (the admin sees the shared
|
|
17
|
+
* `caelo-mcp` identity and the shared internal secret), and labelled so; the
|
|
18
|
+
* authoritative record is Google's Data Access audit log for the IAM
|
|
19
|
+
* Credentials and Secret Manager calls this command makes as the operator.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
type AdminInternalDeps,
|
|
24
|
+
type AdminInternalResult,
|
|
25
|
+
type AdminInternalTarget,
|
|
26
|
+
callAdminInternal,
|
|
27
|
+
} from "./admin-internal.js";
|
|
28
|
+
import { activeAccount, gcloud as defaultGcloud } from "./gcloud.js";
|
|
29
|
+
|
|
30
|
+
/** Token scope the admin endpoint checks (apps/admin …/api/internal/owner-reset). */
|
|
31
|
+
export const OWNER_RESET_SCOPE = "auth.owner_reset";
|
|
32
|
+
const ENDPOINT = "/api/internal/owner-reset";
|
|
33
|
+
|
|
34
|
+
/** What the admin issued: whose account, the link to open, and until when it works. */
|
|
35
|
+
export interface OwnerReset {
|
|
36
|
+
readonly email: string;
|
|
37
|
+
readonly resetUrl: string;
|
|
38
|
+
readonly expiresAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ask the admin for a reset link for the install's Owner (or the Owner named
|
|
43
|
+
* by `email` when there are several).
|
|
44
|
+
*/
|
|
45
|
+
export async function requestOwnerReset(
|
|
46
|
+
target: AdminInternalTarget,
|
|
47
|
+
request: { readonly email?: string },
|
|
48
|
+
deps: AdminInternalDeps = {},
|
|
49
|
+
): Promise<AdminInternalResult<OwnerReset>> {
|
|
50
|
+
const account = await readGcloudAccount(deps.run ?? defaultGcloud);
|
|
51
|
+
if (!account.ok) return account;
|
|
52
|
+
const r = await callAdminInternal<{ email: string; token: string; expiresAt: string }>(
|
|
53
|
+
target,
|
|
54
|
+
{
|
|
55
|
+
endpoint: ENDPOINT,
|
|
56
|
+
scope: OWNER_RESET_SCOPE,
|
|
57
|
+
body: {
|
|
58
|
+
...(request.email !== undefined ? { email: request.email } : {}),
|
|
59
|
+
requestedBy: account.value,
|
|
60
|
+
},
|
|
61
|
+
introducedBy: "#769",
|
|
62
|
+
},
|
|
63
|
+
deps,
|
|
64
|
+
);
|
|
65
|
+
if (!r.ok) return r;
|
|
66
|
+
const { answer, adminUrl } = r.value;
|
|
67
|
+
const { email, token, expiresAt } = answer;
|
|
68
|
+
if (!email || !token || !expiresAt) {
|
|
69
|
+
return { ok: false, error: "the admin answered without an email, token or expiry" };
|
|
70
|
+
}
|
|
71
|
+
const resetUrl = new URL(`/reset?token=${encodeURIComponent(token)}`, adminUrl).toString();
|
|
72
|
+
return { ok: true, value: { email, resetUrl, expiresAt } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The gcloud account this command runs as, for the admin's audit row. Without
|
|
77
|
+
* one there is nobody to name, and the IAP step after this would fail anyway.
|
|
78
|
+
*/
|
|
79
|
+
async function readGcloudAccount(
|
|
80
|
+
run: NonNullable<AdminInternalDeps["run"]>,
|
|
81
|
+
): Promise<AdminInternalResult<string>> {
|
|
82
|
+
// `auth list --filter=status:ACTIVE`, not `config get-value account`: the
|
|
83
|
+
// latter prints the sentinel `(unset)` and exits 0 when no account is set.
|
|
84
|
+
const account = await activeAccount(run);
|
|
85
|
+
if (!account || /\s/.test(account)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error:
|
|
89
|
+
"read the active gcloud account: none is signed in. Run `gcloud auth login` with the account you provisioned with.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, value: account };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The lines the command prints. Pure — the CLI prints them and stores nothing. */
|
|
96
|
+
export function describeOwnerReset(r: OwnerReset): string[] {
|
|
97
|
+
return [
|
|
98
|
+
`Reset link for ${r.email} — open it to set a new password:`,
|
|
99
|
+
"",
|
|
100
|
+
` ${r.resetUrl}`,
|
|
101
|
+
"",
|
|
102
|
+
`It works once and expires at ${r.expiresAt}. Setting the password signs out every`,
|
|
103
|
+
"other session of this account. The link is not saved anywhere; run the command",
|
|
104
|
+
"again for a new one.",
|
|
105
|
+
];
|
|
106
|
+
}
|
|
@@ -340,10 +340,7 @@ describe("rotateRuntimeSecret", () => {
|
|
|
340
340
|
]),
|
|
341
341
|
],
|
|
342
342
|
"run services describe gw-svc": [
|
|
343
|
-
service([
|
|
344
|
-
ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
|
|
345
|
-
ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
|
|
346
|
-
]),
|
|
343
|
+
service([ref("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
|
|
347
344
|
],
|
|
348
345
|
"auth print-access-token": [ok("tok\n")],
|
|
349
346
|
});
|
|
@@ -414,7 +411,7 @@ describe("rotateRuntimeSecret", () => {
|
|
|
414
411
|
).toBe(true);
|
|
415
412
|
});
|
|
416
413
|
|
|
417
|
-
it("gateway-role-password: changes gateway_role only and rolls
|
|
414
|
+
it("gateway-role-password: changes gateway_role only and rolls no service (#613, #635)", async () => {
|
|
418
415
|
const { run, lines } = fakeGcloud({
|
|
419
416
|
...converged(),
|
|
420
417
|
"secrets versions access": [ok("old-pw")],
|
|
@@ -428,9 +425,9 @@ describe("rotateRuntimeSecret", () => {
|
|
|
428
425
|
});
|
|
429
426
|
expect(report.ok).toBe(true);
|
|
430
427
|
expect(sql.passwords()).toEqual(["gateway_role new-pw"]);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
]);
|
|
428
|
+
// No service logs in as gateway_role any more: the gateway reads
|
|
429
|
+
// cms_public only (#635).
|
|
430
|
+
expect(lines().filter((l) => l.startsWith("run services update "))).toEqual([]);
|
|
434
431
|
expect(lines()).toContain(
|
|
435
432
|
"secrets versions access latest --secret=caelo-production-gateway-role-password --project=acme",
|
|
436
433
|
);
|
|
@@ -477,6 +474,26 @@ describe("rotateRuntimeSecret", () => {
|
|
|
477
474
|
expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
|
|
478
475
|
});
|
|
479
476
|
|
|
477
|
+
it("refuses gateway-role-password while the gateway still logs in with it (before #635)", async () => {
|
|
478
|
+
const answers = converged();
|
|
479
|
+
answers["run services describe gw-svc"] = [
|
|
480
|
+
service([
|
|
481
|
+
ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
|
|
482
|
+
ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
|
|
483
|
+
]),
|
|
484
|
+
];
|
|
485
|
+
const { run } = fakeGcloud(answers);
|
|
486
|
+
const sql = fakeSqlApi();
|
|
487
|
+
const report = await rotateRuntimeSecret(target, "gateway-role-password", {
|
|
488
|
+
run,
|
|
489
|
+
http: sql.http,
|
|
490
|
+
generate: () => "new-pw",
|
|
491
|
+
});
|
|
492
|
+
expect(report.ok).toBe(false);
|
|
493
|
+
expect(report.error).toContain("GATEWAY_DATABASE_PASSWORD");
|
|
494
|
+
expect(sql.requests).toHaveLength(0);
|
|
495
|
+
});
|
|
496
|
+
|
|
480
497
|
it("refuses before changing anything on an install upgrade hasn't moved over", async () => {
|
|
481
498
|
const { run, lines } = fakeGcloud({
|
|
482
499
|
"run services describe adm-svc": [
|