@caelo-cms/provisioning 0.10.50 → 0.10.52
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/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- 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/units.js +1 -1
- package/dist/agent/units.js.map +1 -1
- package/dist/agent-db.js +169 -65
- 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 +12 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/ops-backend.js +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/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- 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/state-store.d.ts +8 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +10 -2
- package/dist/state-store.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +7 -1
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-setup.d.ts +5 -0
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +40 -6
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.js +1 -1
- package/dist/wizards/gcp.js +1 -1
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +7 -5
- package/src/adapter.ts +4 -6
- package/src/admin-internal.ts +212 -0
- package/src/agent/units.ts +1 -1
- package/src/aws-task-roles.ts +58 -0
- package/src/caddy-redirects.docker.test.ts +8 -1
- 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/error-pages-stacks.test.ts +38 -0
- 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 +5 -3
- package/src/infra-contract.ts +12 -1
- package/src/ops-backend.ts +1 -1
- package/src/owner-reset.test.ts +165 -0
- package/src/owner-reset.ts +106 -0
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +1 -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/state-store.ts +11 -2
- package/src/upgrader-job.test.ts +17 -1
- package/src/upgrader-job.ts +13 -3
- package/src/upgrader-setup.test.ts +12 -1
- package/src/upgrader-setup.ts +41 -5
- package/src/welcome-page.test.ts +40 -0
- package/src/wizards/gcp-cost.ts +1 -1
- package/src/wizards/gcp.ts +1 -1
- package/stacks/aws/README.md +3 -1
- package/stacks/aws/index.ts +62 -43
- package/stacks/azure/README.md +3 -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
- package/static/welcome.html +93 -111
|
@@ -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
|
+
}
|
package/src/release-contract.ts
CHANGED
|
@@ -46,6 +46,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
|
|
|
46
46
|
// #587 — the admin's URL-map redirects role and the URL map's shape.
|
|
47
47
|
"packages/provisioning/src/url-map-redirects-access.ts",
|
|
48
48
|
"packages/provisioning/src/gcp-url-map.ts",
|
|
49
|
+
// The in-cloud upgrader job's service account, custom roles and bucket
|
|
50
|
+
// grants (#799 added one without the checks noticing).
|
|
51
|
+
"packages/provisioning/src/upgrader-setup.ts",
|
|
49
52
|
];
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -296,6 +296,7 @@ describe("release-contract", () => {
|
|
|
296
296
|
expect(isInfraContractPath("packages/provisioning/stacks/gcp/index.ts")).toBe(true);
|
|
297
297
|
expect(isInfraContractPath("packages/provisioning/src/stack-converge.ts")).toBe(true);
|
|
298
298
|
expect(isInfraContractPath("packages/provisioning/src/upgrade/converge.ts")).toBe(true);
|
|
299
|
+
expect(isInfraContractPath("packages/provisioning/src/upgrader-setup.ts")).toBe(true);
|
|
299
300
|
expect(isInfraContractPath("packages/provisioning/src/stack-converge.test.ts")).toBe(false);
|
|
300
301
|
expect(isInfraContractPath("packages/provisioning/stacks/gcp/README.md")).toBe(false);
|
|
301
302
|
expect(isInfraContractPath("packages/provisioning/src/cli.ts")).toBe(false);
|
|
@@ -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": [
|
|
@@ -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.
|