@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
package/src/cli.ts
CHANGED
|
@@ -1120,6 +1120,7 @@ const handlers: Record<string, () => Promise<void>> = {
|
|
|
1120
1120
|
truncate: lifecycleTruncate,
|
|
1121
1121
|
"admin-domain": adminDomain,
|
|
1122
1122
|
"staging-domain": stagingDomain,
|
|
1123
|
+
"owner-reset": ownerReset,
|
|
1123
1124
|
// #696 — Pulumi state in the install's own project.
|
|
1124
1125
|
state: async () => (await import("./state-command.js")).stateCommand(process.argv),
|
|
1125
1126
|
attach: async () => (await import("./state-command.js")).attachCommand(process.argv),
|
|
@@ -1196,6 +1197,45 @@ async function lifecycleTruncate(): Promise<void> {
|
|
|
1196
1197
|
await truncateCommand();
|
|
1197
1198
|
}
|
|
1198
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* #769 `owner-reset [--install <id>] [--email <addr>]` — print a one-time
|
|
1202
|
+
* reset link for a locked-out Owner, for installs whose admin cannot email
|
|
1203
|
+
* one (see owner-reset.ts for why the operator's cloud identity may).
|
|
1204
|
+
*/
|
|
1205
|
+
async function ownerReset(): Promise<void> {
|
|
1206
|
+
const { installFlag, listInstalls, selectInstall } = await import("./install-state.js");
|
|
1207
|
+
const selected = selectInstall(listInstalls(), installFlag(process.argv));
|
|
1208
|
+
if (!selected.ok) {
|
|
1209
|
+
console.error(selected.message);
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
const meta = selected.meta;
|
|
1213
|
+
if (meta.provider !== "gcp-firebase") {
|
|
1214
|
+
console.error(
|
|
1215
|
+
`owner-reset works on gcp-firebase installs so far; this one is ${meta.provider} (issue #770). ` +
|
|
1216
|
+
"Until then, another Owner can set a new password under Settings -> Users, or set up email under Settings -> Email so /forgot can send the link.",
|
|
1217
|
+
);
|
|
1218
|
+
process.exit(1);
|
|
1219
|
+
}
|
|
1220
|
+
if (!meta.projectId || !meta.region) {
|
|
1221
|
+
console.error(
|
|
1222
|
+
`install ${meta.installId} has no project or region recorded in install.json; re-run the wizard to record it.`,
|
|
1223
|
+
);
|
|
1224
|
+
process.exit(1);
|
|
1225
|
+
}
|
|
1226
|
+
const email = arg("email");
|
|
1227
|
+
const { describeOwnerReset, requestOwnerReset } = await import("./owner-reset.js");
|
|
1228
|
+
const r = await requestOwnerReset(
|
|
1229
|
+
{ projectId: meta.projectId, region: meta.region },
|
|
1230
|
+
email ? { email } : {},
|
|
1231
|
+
);
|
|
1232
|
+
if (!r.ok) {
|
|
1233
|
+
console.error(`owner-reset: ${r.error}`);
|
|
1234
|
+
process.exit(1);
|
|
1235
|
+
}
|
|
1236
|
+
for (const line of describeOwnerReset(r.value)) console.log(line);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1199
1239
|
/**
|
|
1200
1240
|
* `admin-domain enable` — bind admin.<domain> to a gcp-firebase admin
|
|
1201
1241
|
* (see admin-domain.ts for why this runs as the operator, not Pulumi).
|
|
@@ -1495,7 +1535,7 @@ if (route.kind === "handler" || route.kind === "wizard") {
|
|
|
1495
1535
|
}
|
|
1496
1536
|
} else {
|
|
1497
1537
|
console.log(
|
|
1498
|
-
"Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|state migrate|attach|pulumi-output-sync|version> [options]\n" +
|
|
1538
|
+
"Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|owner-reset|state migrate|attach|pulumi-output-sync|version> [options]\n" +
|
|
1499
1539
|
"With several installs on this machine, pick one with --install <install-id or domain> (status, upgrade and backup also take --install all); in a terminal you are asked otherwise.\n" +
|
|
1500
1540
|
"Pass --no-wizard with no sub-command to print this usage instead of the wizard.",
|
|
1501
1541
|
);
|
package/src/compose.ts
CHANGED
|
@@ -388,8 +388,8 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
|
|
|
388
388
|
depends_on:
|
|
389
389
|
postgres: { condition: service_healthy }
|
|
390
390
|
environment:
|
|
391
|
-
#
|
|
392
|
-
|
|
391
|
+
# public_role on cms_public is its only database login (#613, #635,
|
|
392
|
+
# CLAUDE.md §2: the public side has no path to the authoring database).
|
|
393
393
|
PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
|
|
394
394
|
GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
|
|
395
395
|
NODE_ENV: production`;
|
|
@@ -93,6 +93,21 @@ describe("checkDeployedRuntimeEnv", () => {
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
it("passes once both services read the password from Secret Manager", async () => {
|
|
96
|
+
const { run } = fakeGcloud({
|
|
97
|
+
...lists(),
|
|
98
|
+
"describe caelo-production-admin-abc": [
|
|
99
|
+
svc([secret("ADMIN_DATABASE_PASSWORD"), secret("PUBLIC_ADMIN_DATABASE_PASSWORD")]),
|
|
100
|
+
],
|
|
101
|
+
"describe caelo-production-gateway-def": [
|
|
102
|
+
svc([secret("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
expect(await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run })).toEqual({
|
|
106
|
+
ok: true,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("stops a wizard re-run on a gateway from before #635 (it needs its gateway_role pool)", async () => {
|
|
96
111
|
const { run } = fakeGcloud({
|
|
97
112
|
...lists(),
|
|
98
113
|
"describe caelo-production-admin-abc": [
|
|
@@ -105,9 +120,12 @@ describe("checkDeployedRuntimeEnv", () => {
|
|
|
105
120
|
]),
|
|
106
121
|
],
|
|
107
122
|
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
|
|
124
|
+
expect(r.ok).toBe(false);
|
|
125
|
+
if (!r.ok) {
|
|
126
|
+
expect(r.error).toContain("GATEWAY_DATABASE_PASSWORD");
|
|
127
|
+
expect(r.error).toContain("Run `upgrade` first");
|
|
128
|
+
}
|
|
111
129
|
});
|
|
112
130
|
|
|
113
131
|
it("stops a wizard re-run on a gateway from before #613 (it needs the admin_role pool)", async () => {
|
|
@@ -123,7 +141,7 @@ describe("checkDeployedRuntimeEnv", () => {
|
|
|
123
141
|
const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
|
|
124
142
|
expect(r.ok).toBe(false);
|
|
125
143
|
if (!r.ok) {
|
|
126
|
-
expect(r.error).toContain("
|
|
144
|
+
expect(r.error).toContain("ADMIN_DATABASE_PASSWORD");
|
|
127
145
|
expect(r.error).toContain("Run `upgrade` first");
|
|
128
146
|
}
|
|
129
147
|
});
|
package/src/deployed-release.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
|
15
15
|
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
16
16
|
import { GCP_STACK_ENV, gcpNamePrefix } from "./gcp-names.js";
|
|
17
17
|
import type { ImageDigests } from "./install-state.js";
|
|
18
|
-
import { type CloudRunSlug, SERVICE_SECRET_ENV } from "./stack-contract.js";
|
|
18
|
+
import { type CloudRunSlug, RETIRED_SERVICE_ENV, SERVICE_SECRET_ENV } from "./stack-contract.js";
|
|
19
19
|
import { liveContainerEnv } from "./stack-converge.js";
|
|
20
20
|
|
|
21
21
|
/** The `sha256:…` digest an image reference pins, or null for a tag reference. */
|
|
@@ -61,8 +61,9 @@ async function findDeployedService(
|
|
|
61
61
|
* reads the password from the URL). The wizard keeps the release an install
|
|
62
62
|
* runs, so re-running it on an install `upgrade` hasn't moved over yet would
|
|
63
63
|
* pair the old release with env it can't connect with. The same holds for a
|
|
64
|
-
* gateway from before #613: its release needs
|
|
65
|
-
* current contract no longer gives it
|
|
64
|
+
* gateway from before #613 or #635: its release needs a cms_admin pool
|
|
65
|
+
* (admin_role, then gateway_role) the current contract no longer gives it —
|
|
66
|
+
* it still carries the retired database vars.
|
|
66
67
|
* Such an install has to go through `upgrade` first, which moves the image
|
|
67
68
|
* and the env in one revision.
|
|
68
69
|
*/
|
|
@@ -88,13 +89,19 @@ export async function checkDeployedRuntimeEnv(opts: {
|
|
|
88
89
|
return { ok: false, error: `describe ${found.name}: ${describe.stderr.trim()}` };
|
|
89
90
|
}
|
|
90
91
|
const env = liveContainerEnv(describe.stdout);
|
|
91
|
-
const missing =
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
const missing = [
|
|
93
|
+
...Object.keys(SERVICE_SECRET_ENV[slug]).filter(
|
|
94
|
+
(name) => name.endsWith("_PASSWORD") && env.get(name)?.kind !== "secret",
|
|
95
|
+
),
|
|
96
|
+
// A database var the contract retired (the gateway's ADMIN_* before
|
|
97
|
+
// #613, its GATEWAY_* before #635) means the running release still
|
|
98
|
+
// needs that login.
|
|
99
|
+
...RETIRED_SERVICE_ENV[slug].filter((name) => /DATABASE/.test(name) && env.has(name)),
|
|
100
|
+
];
|
|
94
101
|
if (missing.length > 0) {
|
|
95
102
|
return {
|
|
96
103
|
ok: false,
|
|
97
|
-
error: `${found.name} does not
|
|
104
|
+
error: `${found.name} does not match the current database env (${missing.join(", ")}) — its release predates it (Secret Manager runtime secrets; for the gateway, its own database login, #613/#635). Run \`upgrade\` first: it moves the release and its env over together. Re-running the installer now would give the running release a database env it can't connect with.`,
|
|
98
105
|
};
|
|
99
106
|
}
|
|
100
107
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #802 — every provider answers an unknown URL with the site's own
|
|
5
|
+
* `/404.html` (written by the static generator from the designated 404
|
|
6
|
+
* page) and status 404. Firebase Hosting does this by convention for a
|
|
7
|
+
* root `/404.html`; the other stacks must wire it. Source-level checks:
|
|
8
|
+
* the Pulumi programs cannot run in a unit test.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
const stack = (provider: string): string =>
|
|
16
|
+
readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
|
|
17
|
+
|
|
18
|
+
describe("#802 site 404 page wiring per provider", () => {
|
|
19
|
+
it("gcp: the static bucket's website serves 404.html for unknown objects", () => {
|
|
20
|
+
expect(stack("gcp")).toContain('notFoundPage: "404.html"');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("aws: CloudFront maps both 404 and the OAI origin's 403 to /404.html with status 404", () => {
|
|
24
|
+
const src = stack("aws");
|
|
25
|
+
expect(src).toContain(
|
|
26
|
+
'{ errorCode: 404, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 }',
|
|
27
|
+
);
|
|
28
|
+
// S3 behind an OAI without s3:ListBucket answers a missing key with
|
|
29
|
+
// 403 — without this entry visitors get S3's AccessDenied XML.
|
|
30
|
+
expect(src).toContain(
|
|
31
|
+
'{ errorCode: 403, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 }',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("azure: the static website's error document is 404.html", () => {
|
|
36
|
+
expect(stack("azure")).toContain('error404Document: "404.html"');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -2,9 +2,12 @@
|
|
|
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
|
-
* gateway
|
|
5
|
+
* as `cms-provision upgrade` does it. The env side — the per-role password
|
|
6
|
+
* secrets in, ADMIN_DATABASE_* out (and, since #635, GATEWAY_DATABASE_* out
|
|
7
|
+
* too: the gateway has no cms_admin login at all) — rides the gateway's
|
|
8
|
+
* roll (stack-contract.ts). gateway_role keeps getting its password until
|
|
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
|
|
8
11
|
* that roll so /api/* keeps working:
|
|
9
12
|
*
|
|
10
13
|
* 1. after migrations (which create `gateway_role` without a password):
|
|
@@ -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,12 @@ describe("infrastructure contract steps", () => {
|
|
|
20
20
|
);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("is at
|
|
24
|
-
expect(INFRA_CONTRACT_VERSION).toBe(
|
|
23
|
+
it("is at 6: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6)", () => {
|
|
24
|
+
expect(INFRA_CONTRACT_VERSION).toBe(6);
|
|
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");
|
|
28
|
+
expect(INFRA_CONTRACT_STEPS[6]).toContain("#799");
|
|
27
29
|
});
|
|
28
30
|
|
|
29
31
|
it("lists the pending steps oldest first", () => {
|
|
@@ -34,6 +36,6 @@ describe("infrastructure contract steps", () => {
|
|
|
34
36
|
});
|
|
35
37
|
|
|
36
38
|
it("names a version a newer release added by number", () => {
|
|
37
|
-
expect(infraStepsText(
|
|
39
|
+
expect(infraStepsText(6, 7)).toBe("infrastructure step 7 (see that release's notes)");
|
|
38
40
|
});
|
|
39
41
|
});
|
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 = 6;
|
|
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,15 @@ 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).
|
|
43
|
+
// 6 — #799: the upgrader's custom role `caeloUpgraderOpsList`
|
|
44
|
+
// (`storage.objects.list` on the ops bucket). Every `gcloud storage cp`
|
|
45
|
+
// into the bucket lists it first; without the binding the in-cloud
|
|
46
|
+
// upgrader fails at its state lock, so it must not run before the
|
|
47
|
+
// converge grants it.
|
|
39
48
|
|
|
40
49
|
/**
|
|
41
50
|
* What each contract version adds, in operator terms — the upgrader's
|
|
@@ -47,6 +56,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
|
|
|
47
56
|
2: "the in-cloud upgrader job and its scoped permissions",
|
|
48
57
|
3: "public_role out of the Cloud SQL superuser group (#633)",
|
|
49
58
|
4: "the admin's permission to serve redirects from the load balancer (#587)",
|
|
59
|
+
5: "the gateway's cms_admin login removed from its env and IAM (#635)",
|
|
60
|
+
6: "the upgrader's permission to list its ops bucket (#799)",
|
|
50
61
|
};
|
|
51
62
|
|
|
52
63
|
/**
|
package/src/ops-backend.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* anything. The bucket's convenience bindings for project viewers and
|
|
21
21
|
* editors are removed; public principals are refused outright. Project
|
|
22
22
|
* Owners keep access through their project role (that cannot be taken
|
|
23
|
-
* away at the bucket);
|
|
23
|
+
* away at the bucket); the GCP install docs say so.
|
|
24
24
|
*
|
|
25
25
|
* Every step reads before it writes, so a re-run is a no-op and an operator
|
|
26
26
|
* without IAM-write permission can still run against an ops backend someone
|