@caelo-cms/provisioning 0.10.52 → 0.10.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent-db.js +9 -0
- package/dist/aws-task-roles.d.ts +1 -1
- package/dist/aws-task-roles.d.ts.map +1 -1
- package/dist/aws-task-roles.js +0 -1
- package/dist/aws-task-roles.js.map +1 -1
- package/dist/cli-routing.d.ts +20 -0
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +106 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +5 -6
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +4 -3
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +3 -2
- package/dist/compose.js.map +1 -1
- package/dist/gateway-credentials.d.ts +3 -5
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +8 -18
- package/dist/gateway-credentials.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 +8 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/runtime-secrets.d.ts +1 -3
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +2 -6
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +9 -8
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +9 -9
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +7 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +17 -4
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +52 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +36 -36
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +5 -13
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +1 -30
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-memory.test.ts +1 -1
- package/src/agent/command.ts +1 -0
- package/src/aws-task-roles.ts +0 -2
- package/src/caddy-redirects.test.ts +1 -1
- package/src/checkpoint-secrets.test.ts +0 -2
- package/src/cli-routing.test.ts +27 -1
- package/src/cli-routing.ts +131 -0
- package/src/cli.ts +5 -8
- package/src/compose.ts +13 -5
- package/src/gateway-credentials.test.ts +0 -29
- package/src/gateway-credentials.ts +11 -25
- package/src/gateway-env-isolation.test.ts +7 -2
- package/src/index.test.ts +7 -6
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +8 -1
- package/src/runtime-secrets.test.ts +9 -81
- package/src/runtime-secrets.ts +2 -6
- package/src/self-hosted-compose.test.ts +1 -1
- package/src/self-hosted-roles.integration.test.ts +23 -38
- package/src/self-hosted-roles.test.ts +11 -7
- package/src/self-hosted-roles.ts +17 -16
- package/src/self-hosted-upgrade.test.ts +28 -1
- package/src/self-hosted-upgrade.ts +29 -4
- package/src/stack-contract.test.ts +15 -11
- package/src/stack-contract.ts +40 -5
- package/src/stack-converge-secrets.test.ts +2 -0
- package/src/stack-converge.test.ts +2 -0
- package/src/upgrade/converge.ts +5 -19
- package/src/wizards/gcp.ts +1 -31
- package/stacks/aws/README.md +1 -1
- package/stacks/aws/index.ts +1 -6
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +9 -6
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +1 -4
- package/stacks/gcp-firebase/index.ts +1 -4
- package/stacks/self-hosted/index.ts +4 -11
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
4
|
import {
|
|
5
|
-
ensureGatewayRolePassword,
|
|
6
5
|
findSqlInstance,
|
|
7
6
|
gatewayMovesPublicRole,
|
|
8
7
|
planPublicRoleSwitch,
|
|
@@ -52,7 +51,6 @@ const target = { projectId: "acme", env: "production", sqlInstance: "caelo-produ
|
|
|
52
51
|
const SECRETS = {
|
|
53
52
|
"postgres-password": "admin-pw",
|
|
54
53
|
"public-role-password": "public-pw",
|
|
55
|
-
"gateway-role-password": "gateway-pw",
|
|
56
54
|
};
|
|
57
55
|
const secretRef = (secret: string): LiveEnvValue => ({
|
|
58
56
|
kind: "secret",
|
|
@@ -66,7 +64,6 @@ const BEFORE_613 = new Map<string, LiveEnvValue>([
|
|
|
66
64
|
]);
|
|
67
65
|
const AFTER_613 = new Map<string, LiveEnvValue>([
|
|
68
66
|
["PUBLIC_DATABASE_PASSWORD", secretRef("public-role-password")],
|
|
69
|
-
["GATEWAY_DATABASE_PASSWORD", secretRef("gateway-role-password")],
|
|
70
67
|
]);
|
|
71
68
|
|
|
72
69
|
describe("gatewayMovesPublicRole", () => {
|
|
@@ -85,32 +82,6 @@ describe("gatewayMovesPublicRole", () => {
|
|
|
85
82
|
});
|
|
86
83
|
});
|
|
87
84
|
|
|
88
|
-
describe("ensureGatewayRolePassword", () => {
|
|
89
|
-
it("sets gateway_role from its secret through the API, never on argv", async () => {
|
|
90
|
-
const { run, lines } = fakeGcloud(SECRETS);
|
|
91
|
-
const sql = fakeSqlApi();
|
|
92
|
-
const r = await ensureGatewayRolePassword(target, {
|
|
93
|
-
run,
|
|
94
|
-
http: sql.http,
|
|
95
|
-
sleep: async () => {},
|
|
96
|
-
});
|
|
97
|
-
expect(r.status).toBe("applied");
|
|
98
|
-
expect(sql.set).toEqual(["gateway_role gateway-pw"]);
|
|
99
|
-
expect(lines.some((l) => l.includes("gateway-pw"))).toBe(false);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("fails, naming the secret, when the secret can't be read", async () => {
|
|
103
|
-
const { run } = fakeGcloud({});
|
|
104
|
-
const r = await ensureGatewayRolePassword(target, {
|
|
105
|
-
run,
|
|
106
|
-
http: fakeSqlApi().http,
|
|
107
|
-
sleep: async () => {},
|
|
108
|
-
});
|
|
109
|
-
expect(r.status).toBe("failed");
|
|
110
|
-
expect(r.error).toContain("caelo-production-gateway-role-password");
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
85
|
describe("planPublicRoleSwitch", () => {
|
|
115
86
|
it("first upgrade: moves public_role onto its own password before the gateway rolls", async () => {
|
|
116
87
|
const { run } = fakeGcloud(SECRETS);
|
|
@@ -2,28 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Moving an existing install's API gateway off admin_role (issue #613),
|
|
5
|
-
* as `cms-provision upgrade` does it. The env side —
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* roll
|
|
9
|
-
* the contract release drops the role, because the previous release's
|
|
10
|
-
* gateway logs in with it until it is rolled. The database side is ordered around
|
|
11
|
-
* that roll so /api/* keeps working:
|
|
5
|
+
* as `cms-provision upgrade` does it. The env side — public_role's own
|
|
6
|
+
* password secret in, every cms_admin credential out (#613, #635) — rides
|
|
7
|
+
* the gateway's roll (stack-contract.ts). The database side is ordered
|
|
8
|
+
* around that roll so /api/* keeps working:
|
|
12
9
|
*
|
|
13
|
-
* 1.
|
|
14
|
-
* `gateway_role` gets its password. Nothing uses the role yet, so this
|
|
15
|
-
* is safe at any point before the gateway rolls.
|
|
16
|
-
* 2. right before the gateway rolls: `public_role` moves off admin_role's
|
|
10
|
+
* 1. right before the gateway rolls: `public_role` moves off admin_role's
|
|
17
11
|
* password onto its own. Only the gateway logs in as public_role, so
|
|
18
12
|
* the window in which the old revision cannot open NEW public
|
|
19
13
|
* connections is the roll itself (open connections stay up).
|
|
20
|
-
*
|
|
14
|
+
* 2. if the gateway's roll fails and it is rolled back: `public_role`
|
|
21
15
|
* goes back to the password the prior revision reads, so the rollback
|
|
22
16
|
* leaves a working gateway, not a locked-out one.
|
|
23
17
|
*
|
|
24
18
|
* On every later upgrade the gateway already reads public_role's own
|
|
25
|
-
* secret: step
|
|
26
|
-
* step
|
|
19
|
+
* secret: step 1 then re-applies the same value (a no-op for clients) and
|
|
20
|
+
* step 2 has nothing to undo.
|
|
27
21
|
*/
|
|
28
22
|
|
|
29
23
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
@@ -60,22 +54,14 @@ export interface GatewayCredentialDeps {
|
|
|
60
54
|
readonly http?: HttpFetch;
|
|
61
55
|
}
|
|
62
56
|
|
|
63
|
-
/**
|
|
64
|
-
export function ensureGatewayRolePassword(
|
|
65
|
-
target: DatabaseRoleTarget,
|
|
66
|
-
deps: GatewayCredentialDeps = {},
|
|
67
|
-
): Promise<EnsureOutcome> {
|
|
68
|
-
return ensureDatabaseRolePassword(target, "gateway_role", deps);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Steps 2 and 3 for one roll of the gateway. */
|
|
57
|
+
/** Steps 1 and 2 for one roll of the gateway. */
|
|
72
58
|
export interface PublicRoleSwitch {
|
|
73
59
|
/** Whether this roll moves public_role off admin_role's password. */
|
|
74
60
|
readonly moves: boolean;
|
|
75
|
-
/** Step
|
|
61
|
+
/** Step 1 — run right before the gateway's `services update`. */
|
|
76
62
|
beforeGatewayRoll(): Promise<EnsureOutcome>;
|
|
77
63
|
/**
|
|
78
|
-
* Step
|
|
64
|
+
* Step 2 — run when the gateway's roll failed and it goes back to its
|
|
79
65
|
* prior revision. Returns what happened, for the operator.
|
|
80
66
|
*/
|
|
81
67
|
afterGatewayRollFailed(): Promise<string>;
|
|
@@ -126,7 +126,7 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
|
|
|
126
126
|
const out = generateDockerCompose({
|
|
127
127
|
domain: "example.com",
|
|
128
128
|
postgresPassword: "superpw",
|
|
129
|
-
rolePasswords: { admin: "adminpw", public: "publicpw"
|
|
129
|
+
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
130
130
|
caeloSecretKek: "0".repeat(64),
|
|
131
131
|
diskSize: "20Gi",
|
|
132
132
|
images: {
|
|
@@ -148,7 +148,12 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
|
|
|
148
148
|
expect(gateway).not.toMatch(/^ {4}(secrets|env_file|volumes):/m);
|
|
149
149
|
const env = gateway.slice(gateway.indexOf(" environment:"));
|
|
150
150
|
const keys = [...env.matchAll(/^ {6}([A-Z_][A-Z0-9_]*):/gm)].map((m) => m[1]);
|
|
151
|
-
expect(keys.sort()).toEqual([
|
|
151
|
+
expect(keys.sort()).toEqual([
|
|
152
|
+
"CAELO_TRUSTED_PROXY_HOPS",
|
|
153
|
+
"GATEWAY_PORT",
|
|
154
|
+
"NODE_ENV",
|
|
155
|
+
"PUBLIC_DATABASE_URL",
|
|
156
|
+
]);
|
|
152
157
|
expect(out).not.toMatch(/^secrets:/m);
|
|
153
158
|
});
|
|
154
159
|
});
|
package/src/index.test.ts
CHANGED
|
@@ -115,7 +115,7 @@ describe("docker-compose generator", () => {
|
|
|
115
115
|
const out = generateDockerCompose({
|
|
116
116
|
domain: "example.com",
|
|
117
117
|
postgresPassword: "supersecret",
|
|
118
|
-
rolePasswords: { admin: "adminpw", public: "publicpw"
|
|
118
|
+
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
119
119
|
caeloSecretKek: "0".repeat(64),
|
|
120
120
|
anthropicApiKey: "sk-ant-…",
|
|
121
121
|
diskSize: "20Gi",
|
|
@@ -136,7 +136,7 @@ describe("docker-compose generator", () => {
|
|
|
136
136
|
const out = generateDockerCompose({
|
|
137
137
|
domain: "example.com",
|
|
138
138
|
postgresPassword: "supersecret",
|
|
139
|
-
rolePasswords: { admin: "adminpw", public: "publicpw"
|
|
139
|
+
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
140
140
|
caeloSecretKek: "0".repeat(64),
|
|
141
141
|
diskSize: "20Gi",
|
|
142
142
|
images: RELEASE_IMAGES,
|
|
@@ -150,16 +150,17 @@ describe("docker-compose generator", () => {
|
|
|
150
150
|
expect(gateway).not.toMatch(
|
|
151
151
|
/ADMIN_DATABASE|GATEWAY_DATABASE|cms_admin|admin_role|gateway_role|adminpw|gatewaypw|supersecret|CAELO_SECRET_KEK/,
|
|
152
152
|
);
|
|
153
|
-
// bootstrap.sh
|
|
154
|
-
//
|
|
155
|
-
expect(out).toContain('
|
|
153
|
+
// bootstrap.sh creates the two application roles from these (#635: no
|
|
154
|
+
// gateway_role).
|
|
155
|
+
expect(out).toContain('PUBLIC_ROLE_PASSWORD: "publicpw"');
|
|
156
|
+
expect(out).not.toContain("GATEWAY_ROLE_PASSWORD");
|
|
156
157
|
});
|
|
157
158
|
|
|
158
159
|
it("escapes secrets containing quotes safely", () => {
|
|
159
160
|
const out = generateDockerCompose({
|
|
160
161
|
domain: "example.com",
|
|
161
162
|
postgresPassword: 'pa"ss',
|
|
162
|
-
rolePasswords: { admin: "a", public: "b"
|
|
163
|
+
rolePasswords: { admin: "a", public: "b" },
|
|
163
164
|
caeloSecretKek: "0".repeat(64),
|
|
164
165
|
diskSize: "1Gi",
|
|
165
166
|
images: RELEASE_IMAGES,
|
|
@@ -20,12 +20,13 @@ describe("infrastructure contract steps", () => {
|
|
|
20
20
|
);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("is at
|
|
24
|
-
expect(INFRA_CONTRACT_VERSION).toBe(
|
|
23
|
+
it("is at 7: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7)", () => {
|
|
24
|
+
expect(INFRA_CONTRACT_VERSION).toBe(7);
|
|
25
25
|
expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
|
|
26
26
|
expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
|
|
27
27
|
expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
|
|
28
28
|
expect(INFRA_CONTRACT_STEPS[6]).toContain("#799");
|
|
29
|
+
expect(INFRA_CONTRACT_STEPS[7]).toContain("#845");
|
|
29
30
|
});
|
|
30
31
|
|
|
31
32
|
it("lists the pending steps oldest first", () => {
|
|
@@ -36,6 +37,6 @@ describe("infrastructure contract steps", () => {
|
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
it("names a version a newer release added by number", () => {
|
|
39
|
-
expect(infraStepsText(
|
|
40
|
+
expect(infraStepsText(7, 8)).toBe("infrastructure step 8 (see that release's notes)");
|
|
40
41
|
});
|
|
41
42
|
});
|
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 = 7;
|
|
26
26
|
|
|
27
27
|
// History (why each bump happened — an install below a number needs the
|
|
28
28
|
// converge that introduced it):
|
|
@@ -45,6 +45,12 @@ export const INFRA_CONTRACT_VERSION = 6;
|
|
|
45
45
|
// into the bucket lists it first; without the binding the in-cloud
|
|
46
46
|
// upgrader fails at its state lock, so it must not run before the
|
|
47
47
|
// converge grants it.
|
|
48
|
+
// 7 — #845: the gateway refuses to boot without CAELO_TRUSTED_PROXY_HOPS
|
|
49
|
+
// (which X-Forwarded-For entry is the visitor). A plain env var, but
|
|
50
|
+
// the in-cloud upgrader rolls with the env contract of the release it
|
|
51
|
+
// runs from (the installed one), which does not set it — so the new
|
|
52
|
+
// gateway would not start. The operator-identity upgrade runs the new
|
|
53
|
+
// release's contract and sets it.
|
|
48
54
|
|
|
49
55
|
/**
|
|
50
56
|
* What each contract version adds, in operator terms — the upgrader's
|
|
@@ -58,6 +64,7 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
|
|
|
58
64
|
4: "the admin's permission to serve redirects from the load balancer (#587)",
|
|
59
65
|
5: "the gateway's cms_admin login removed from its env and IAM (#635)",
|
|
60
66
|
6: "the upgrader's permission to list its ops bucket (#799)",
|
|
67
|
+
7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
|
|
61
68
|
};
|
|
62
69
|
|
|
63
70
|
/**
|
|
@@ -115,13 +115,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
115
115
|
{ ...secretsInstall, replication: regional },
|
|
116
116
|
{ run },
|
|
117
117
|
);
|
|
118
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
119
|
-
"present",
|
|
120
|
-
"present",
|
|
121
|
-
"present",
|
|
122
|
-
"present",
|
|
123
|
-
"present",
|
|
124
|
-
]);
|
|
118
|
+
expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
|
|
125
119
|
expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
|
|
126
120
|
});
|
|
127
121
|
|
|
@@ -132,13 +126,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
132
126
|
{ ...secretsInstall, replication: regional },
|
|
133
127
|
{ run, generate: () => `generated-value-${++n}` },
|
|
134
128
|
);
|
|
135
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
136
|
-
"applied",
|
|
137
|
-
"applied",
|
|
138
|
-
"applied",
|
|
139
|
-
"applied",
|
|
140
|
-
"applied",
|
|
141
|
-
]);
|
|
129
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
|
|
142
130
|
const creates = calls.filter((c) => c.line.startsWith("secrets create"));
|
|
143
131
|
expect(creates).toEqual([
|
|
144
132
|
{
|
|
@@ -149,19 +137,15 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
149
137
|
line: "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
150
138
|
stdin: "generated-value-2",
|
|
151
139
|
},
|
|
152
|
-
// #613 — the gateway's role
|
|
140
|
+
// #613 — the gateway's role password: a fresh value, never admin_role's.
|
|
153
141
|
{
|
|
154
142
|
line: "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
155
143
|
stdin: "generated-value-3",
|
|
156
144
|
},
|
|
157
|
-
{
|
|
158
|
-
line: "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
159
|
-
stdin: "generated-value-4",
|
|
160
|
-
},
|
|
161
145
|
// #650 — the staging access gate's key.
|
|
162
146
|
{
|
|
163
147
|
line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
164
|
-
stdin: "generated-value-
|
|
148
|
+
stdin: "generated-value-4",
|
|
165
149
|
},
|
|
166
150
|
]);
|
|
167
151
|
for (const c of calls) expect(c.line).not.toContain("generated-value");
|
|
@@ -186,7 +170,6 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
186
170
|
"generated",
|
|
187
171
|
"generated",
|
|
188
172
|
"generated",
|
|
189
|
-
"generated",
|
|
190
173
|
]);
|
|
191
174
|
for (const c of calls) expect(c.line).not.toContain("operator-set");
|
|
192
175
|
});
|
|
@@ -197,20 +180,13 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
197
180
|
"secrets versions list caelo-production-internal-secret": [ok("")],
|
|
198
181
|
"secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
|
|
199
182
|
"secrets versions list caelo-production-public-role-password": [ok("v/1")],
|
|
200
|
-
"secrets versions list caelo-production-gateway-role-password": [ok("v/1")],
|
|
201
183
|
"secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
|
|
202
184
|
});
|
|
203
185
|
const outcomes = await ensureGeneratedSecrets(
|
|
204
186
|
{ ...secretsInstall, replication: { kind: "automatic" } },
|
|
205
187
|
{ run, generate: () => "v" },
|
|
206
188
|
);
|
|
207
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
208
|
-
"applied",
|
|
209
|
-
"present",
|
|
210
|
-
"present",
|
|
211
|
-
"present",
|
|
212
|
-
"present",
|
|
213
|
-
]);
|
|
189
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
|
|
214
190
|
expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
|
|
215
191
|
{
|
|
216
192
|
line: "secrets versions add caelo-production-internal-secret --project=acme --data-file=-",
|
|
@@ -244,13 +220,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
244
220
|
{ ...secretsInstall, replication: { kind: "automatic" } },
|
|
245
221
|
{ run, generate: () => "v" },
|
|
246
222
|
);
|
|
247
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
248
|
-
"applied",
|
|
249
|
-
"applied",
|
|
250
|
-
"applied",
|
|
251
|
-
"applied",
|
|
252
|
-
"applied",
|
|
253
|
-
]);
|
|
223
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
|
|
254
224
|
expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
|
|
255
225
|
const creates = lines().filter((l) => l.startsWith("secrets create"));
|
|
256
226
|
// One refused automatic attempt; then every secret — the refused one
|
|
@@ -260,7 +230,6 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
260
230
|
"secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
261
231
|
"secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
262
232
|
"secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
263
|
-
"secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
264
233
|
// #652 — the staging gate's key follows the same path.
|
|
265
234
|
"secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
266
235
|
]);
|
|
@@ -290,7 +259,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
290
259
|
{ run, generate: () => "v" },
|
|
291
260
|
);
|
|
292
261
|
expect(outcomes.every((o) => o.status === "failed")).toBe(true);
|
|
293
|
-
expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(
|
|
262
|
+
expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(4);
|
|
294
263
|
});
|
|
295
264
|
});
|
|
296
265
|
|
|
@@ -299,7 +268,6 @@ describe("rotationRefusal", () => {
|
|
|
299
268
|
for (const s of [
|
|
300
269
|
"postgres-password",
|
|
301
270
|
"public-role-password",
|
|
302
|
-
"gateway-role-password",
|
|
303
271
|
"internal-secret",
|
|
304
272
|
"tool-approval-secret",
|
|
305
273
|
"staging-gate-secret",
|
|
@@ -311,6 +279,8 @@ describe("rotationRefusal", () => {
|
|
|
311
279
|
it("refuses the KEK (re-encryption needed) and secrets nothing reads", () => {
|
|
312
280
|
expect(rotationRefusal("secret-kek")).toContain("re-encryption");
|
|
313
281
|
expect(rotationRefusal("csrf-secret")).toContain("Rotatable");
|
|
282
|
+
// #635 — the former gateway_role is gone; there is nothing to rotate.
|
|
283
|
+
expect(rotationRefusal("gateway-role-password")).toContain("Rotatable");
|
|
314
284
|
});
|
|
315
285
|
});
|
|
316
286
|
|
|
@@ -411,28 +381,6 @@ describe("rotateRuntimeSecret", () => {
|
|
|
411
381
|
).toBe(true);
|
|
412
382
|
});
|
|
413
383
|
|
|
414
|
-
it("gateway-role-password: changes gateway_role only and rolls no service (#613, #635)", async () => {
|
|
415
|
-
const { run, lines } = fakeGcloud({
|
|
416
|
-
...converged(),
|
|
417
|
-
"secrets versions access": [ok("old-pw")],
|
|
418
|
-
});
|
|
419
|
-
const sql = fakeSqlApi();
|
|
420
|
-
const report = await rotateRuntimeSecret(target, "gateway-role-password", {
|
|
421
|
-
run,
|
|
422
|
-
http: sql.http,
|
|
423
|
-
sleep: async () => {},
|
|
424
|
-
generate: () => "new-pw",
|
|
425
|
-
});
|
|
426
|
-
expect(report.ok).toBe(true);
|
|
427
|
-
expect(sql.passwords()).toEqual(["gateway_role new-pw"]);
|
|
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([]);
|
|
431
|
-
expect(lines()).toContain(
|
|
432
|
-
"secrets versions access latest --secret=caelo-production-gateway-role-password --project=acme",
|
|
433
|
-
);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
384
|
it("public-role-password: stores nothing and rolls nothing when public_role can't be changed", async () => {
|
|
437
385
|
const { run, lines } = fakeGcloud({
|
|
438
386
|
...converged(),
|
|
@@ -474,26 +422,6 @@ describe("rotateRuntimeSecret", () => {
|
|
|
474
422
|
expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
|
|
475
423
|
});
|
|
476
424
|
|
|
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
|
-
|
|
497
425
|
it("refuses before changing anything on an install upgrade hasn't moved over", async () => {
|
|
498
426
|
const { run, lines } = fakeGcloud({
|
|
499
427
|
"run services describe adm-svc": [
|
package/src/runtime-secrets.ts
CHANGED
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
* Secret Manager on stdin — never on argv, never to disk.
|
|
14
14
|
* - {@link ensureDatabaseRolePassword}: a database role's password set
|
|
15
15
|
* from its secret (`DATABASE_ROLE_SECRET`), through the Cloud SQL Admin
|
|
16
|
-
* API — how `
|
|
17
|
-
*
|
|
18
|
-
* get theirs.
|
|
16
|
+
* API — how `public_role` (moved off admin_role's password, #613) gets
|
|
17
|
+
* its own.
|
|
19
18
|
* - {@link rotateRuntimeSecret}: `cms-provision rotate-secret`.
|
|
20
19
|
*
|
|
21
20
|
* Every function reports instead of throwing on a gcloud failure.
|
|
@@ -368,7 +367,6 @@ async function addVersion(
|
|
|
368
367
|
export const ROTATABLE_SECRETS = [
|
|
369
368
|
"postgres-password",
|
|
370
369
|
"public-role-password",
|
|
371
|
-
"gateway-role-password",
|
|
372
370
|
"internal-secret",
|
|
373
371
|
"tool-approval-secret",
|
|
374
372
|
// #650 — rotating it signs every staging session out and replaces the
|
|
@@ -726,8 +724,6 @@ export async function readSecretValue(
|
|
|
726
724
|
* the role already has is a no-op for every client, so upgrade runs this on
|
|
727
725
|
* every roll and an install converges whatever state it was left in.
|
|
728
726
|
*
|
|
729
|
-
* The role must exist: `gateway_role` is created by migration 0248, so this
|
|
730
|
-
* runs after migrations.
|
|
731
727
|
*/
|
|
732
728
|
export async function ensureDatabaseRolePassword(
|
|
733
729
|
target: DatabaseRoleTarget,
|
|
@@ -19,7 +19,7 @@ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
|
|
|
19
19
|
const base: Omit<ComposeSpec, "images"> = {
|
|
20
20
|
domain: "example.com",
|
|
21
21
|
postgresPassword: "pgpw",
|
|
22
|
-
rolePasswords: { admin: "adminpw", public: "publicpw"
|
|
22
|
+
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
23
23
|
caeloSecretKek: "0".repeat(64),
|
|
24
24
|
anthropicApiKey: "sk-ant-x",
|
|
25
25
|
diskSize: "20Gi",
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* #613 review — `cms-provision up` on a self-hosted volume
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* #613 review, #635 — `cms-provision up` on a self-hosted volume from
|
|
5
|
+
* before: `public_role` on another password, and the gateway's former
|
|
6
|
+
* `gateway_role` login still there with its grants. The convergence
|
|
7
|
+
* script, run as the superuser exactly as `up` runs it (psql, script on
|
|
8
|
+
* stdin), must leave the roles on the configured passwords and the former
|
|
9
|
+
* gateway_role gone — and be safe to re-run.
|
|
9
10
|
*
|
|
10
11
|
* Runs against the test database with the passwords the environment already
|
|
11
12
|
* uses, so the other suites' connection URLs keep working afterwards.
|
|
@@ -25,9 +26,8 @@ const required = [
|
|
|
25
26
|
"POSTGRES_PORT",
|
|
26
27
|
"ADMIN_ROLE_PASSWORD",
|
|
27
28
|
"PUBLIC_ROLE_PASSWORD",
|
|
28
|
-
"GATEWAY_ROLE_PASSWORD",
|
|
29
29
|
"ADMIN_DATABASE_URL",
|
|
30
|
-
"
|
|
30
|
+
"PUBLIC_DATABASE_URL",
|
|
31
31
|
] as const;
|
|
32
32
|
const missing = required.filter((name) => !env[name]);
|
|
33
33
|
if (missing.length > 0) throw new Error(`${missing.join(", ")} required (see .env.example)`);
|
|
@@ -63,19 +63,20 @@ const psql: SuperuserPsql = async (script) => {
|
|
|
63
63
|
const passwords = {
|
|
64
64
|
admin: env.ADMIN_ROLE_PASSWORD as string,
|
|
65
65
|
public: env.PUBLIC_ROLE_PASSWORD as string,
|
|
66
|
-
gateway: env.GATEWAY_ROLE_PASSWORD as string,
|
|
67
66
|
};
|
|
68
67
|
|
|
69
|
-
describe("self-hosted role convergence against Postgres (#613 review)", () => {
|
|
70
|
-
it("brings
|
|
71
|
-
// The
|
|
68
|
+
describe("self-hosted role convergence against Postgres (#613 review, #635)", () => {
|
|
69
|
+
it("brings an old volume to the configured roles and passwords, without gateway_role — twice", async () => {
|
|
70
|
+
// The old volume: the former gateway_role with a cms_admin grant, and
|
|
71
|
+
// public_role on another password.
|
|
72
72
|
const reset = await psql(
|
|
73
73
|
[
|
|
74
74
|
"\\set ON_ERROR_STOP on",
|
|
75
|
+
"SELECT 'CREATE ROLE gateway_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') \\gexec",
|
|
76
|
+
"GRANT CONNECT ON DATABASE cms_admin TO gateway_role;",
|
|
75
77
|
"\\connect cms_admin",
|
|
76
|
-
"
|
|
78
|
+
"GRANT SELECT (gateway_cookie_secret) ON site_settings TO gateway_role;",
|
|
77
79
|
"\\connect postgres",
|
|
78
|
-
"DROP ROLE gateway_role;",
|
|
79
80
|
"ALTER ROLE public_role WITH PASSWORD 'the-old-shared-password';",
|
|
80
81
|
"",
|
|
81
82
|
].join("\n"),
|
|
@@ -87,36 +88,20 @@ describe("self-hosted role convergence against Postgres (#613 review)", () => {
|
|
|
87
88
|
expect(r).toEqual({ ok: true });
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
//
|
|
91
|
-
const
|
|
91
|
+
// public_role logs in with its configured password again.
|
|
92
|
+
const pub = new SQL(env.PUBLIC_DATABASE_URL as string);
|
|
92
93
|
try {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
return tx`SELECT current_user::text AS u, length(gateway_cookie_secret) > 0 AS s FROM site_settings`;
|
|
96
|
-
})) as { u: string; s: boolean }[];
|
|
97
|
-
expect(rows[0]).toEqual({ u: "gateway_role", s: true });
|
|
94
|
+
const [who] = (await pub`SELECT current_user::text AS u`) as { u: string }[];
|
|
95
|
+
expect(who?.u).toBe("public_role");
|
|
98
96
|
} finally {
|
|
99
|
-
await
|
|
97
|
+
await pub.end();
|
|
100
98
|
}
|
|
99
|
+
// The former gateway_role is gone, with its grants (#635).
|
|
101
100
|
const admin = new SQL(env.ADMIN_DATABASE_URL as string);
|
|
102
101
|
try {
|
|
103
|
-
const
|
|
104
|
-
SELECT
|
|
105
|
-
|
|
106
|
-
rolinherit: boolean;
|
|
107
|
-
}[];
|
|
108
|
-
expect(attrs).toEqual({ rolcanlogin: true, rolinherit: false });
|
|
109
|
-
const policies = (await admin`
|
|
110
|
-
SELECT policyname::text AS p FROM pg_policies WHERE 'gateway_role' = ANY (roles) ORDER BY 1`) as {
|
|
111
|
-
p: string;
|
|
112
|
-
}[];
|
|
113
|
-
expect(policies.map((p) => p.p)).toEqual([
|
|
114
|
-
"audit_events_gateway_plugin_only",
|
|
115
|
-
// #634 (migration 0252): the visitor paths' row limits. The A/B
|
|
116
|
-
// ones went with the experiment tables (#676, migration 0259).
|
|
117
|
-
"comment_archive_gateway_approved_only",
|
|
118
|
-
"rate_limit_buckets_gateway_keys",
|
|
119
|
-
]);
|
|
102
|
+
const roles =
|
|
103
|
+
(await admin`SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role'`) as unknown[];
|
|
104
|
+
expect(roles).toEqual([]);
|
|
120
105
|
} finally {
|
|
121
106
|
await admin.end();
|
|
122
107
|
}
|
|
@@ -8,26 +8,30 @@ import {
|
|
|
8
8
|
selfHostedBootstrapScript,
|
|
9
9
|
} from "./self-hosted-roles.js";
|
|
10
10
|
|
|
11
|
-
const PASSWORDS = { admin: "a'pw", public: "p-pw"
|
|
11
|
+
const PASSWORDS = { admin: "a'pw", public: "p-pw" };
|
|
12
12
|
|
|
13
13
|
describe("roleConvergenceScript (#613 review)", () => {
|
|
14
14
|
const script = roleConvergenceScript(PASSWORDS);
|
|
15
15
|
|
|
16
16
|
it("creates missing roles and (re)sets every role's password, quoted", () => {
|
|
17
|
-
for (const role of ["admin_role", "public_role"
|
|
17
|
+
for (const role of ["admin_role", "public_role"]) {
|
|
18
18
|
expect(script).toContain(
|
|
19
19
|
`WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}')`,
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
expect(script).toContain("ALTER ROLE admin_role WITH LOGIN PASSWORD 'a''pw';");
|
|
23
|
-
expect(script).toContain("
|
|
23
|
+
expect(script).not.toContain("CREATE ROLE gateway_role");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
it("
|
|
26
|
+
it("drops the former gateway_role with what it holds in both databases, and stops on the first error (#635)", () => {
|
|
27
27
|
expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
);
|
|
28
|
+
const publicDrop = script.indexOf("\\connect cms_public");
|
|
29
|
+
const adminDrop = script.indexOf("\\connect cms_admin");
|
|
30
|
+
expect(publicDrop).toBeGreaterThan(-1);
|
|
31
|
+
expect(adminDrop).toBeGreaterThan(publicDrop);
|
|
32
|
+
expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(2);
|
|
33
|
+
expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
|
|
34
|
+
expect(script).not.toContain("caelo_grant_gateway_role");
|
|
31
35
|
});
|
|
32
36
|
});
|
|
33
37
|
|