@caelo-cms/provisioning 0.10.53 → 0.10.57
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-db.js +272 -128
- package/dist/audit-job-setup.d.ts +69 -0
- package/dist/audit-job-setup.d.ts.map +1 -0
- package/dist/audit-job-setup.js +317 -0
- package/dist/audit-job-setup.js.map +1 -0
- package/dist/caddy.d.ts +9 -0
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +38 -0
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +36 -9
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +66 -0
- package/dist/converge-checkpoint.d.ts.map +1 -0
- package/dist/converge-checkpoint.js +61 -0
- package/dist/converge-checkpoint.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +46 -0
- package/dist/converge-job-gcp.d.ts.map +1 -0
- package/dist/converge-job-gcp.js +368 -0
- package/dist/converge-job-gcp.js.map +1 -0
- package/dist/converge-job-setup.d.ts +62 -0
- package/dist/converge-job-setup.d.ts.map +1 -0
- package/dist/converge-job-setup.js +228 -0
- package/dist/converge-job-setup.js.map +1 -0
- package/dist/converge-job.d.ts +141 -0
- package/dist/converge-job.d.ts.map +1 -0
- package/dist/converge-job.js +325 -0
- package/dist/converge-job.js.map +1 -0
- package/dist/gcp-names.d.ts +25 -1
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +36 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.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 +28 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +11 -3
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +22 -12
- package/dist/install-job.js.map +1 -1
- package/dist/lifecycle.d.ts +13 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +115 -58
- package/dist/lifecycle.js.map +1 -1
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +13 -3
- package/dist/operator-access.js.map +1 -1
- package/dist/private-file.d.ts +26 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +49 -0
- package/dist/private-file.js.map +1 -0
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +8 -2
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp-edge.d.ts +114 -0
- package/dist/remote-mcp-edge.d.ts.map +1 -0
- package/dist/remote-mcp-edge.js +116 -0
- package/dist/remote-mcp-edge.js.map +1 -0
- package/dist/remote-mcp.d.ts +117 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +379 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/runtime-secrets.d.ts +12 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +32 -4
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/seal-key-rotation.d.ts +38 -0
- package/dist/seal-key-rotation.d.ts.map +1 -0
- package/dist/seal-key-rotation.js +206 -0
- package/dist/seal-key-rotation.js.map +1 -0
- package/dist/stack-contract.d.ts +175 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +183 -3
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +38 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +74 -4
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-lock-lease.d.ts +142 -0
- package/dist/state-lock-lease.d.ts.map +1 -0
- package/dist/state-lock-lease.js +347 -0
- package/dist/state-lock-lease.js.map +1 -0
- package/dist/state-store.d.ts +66 -9
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +249 -30
- package/dist/state-store.js.map +1 -1
- package/dist/tar-entries.d.ts.map +1 -1
- package/dist/tar-entries.js +84 -9
- package/dist/tar-entries.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +88 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts +38 -0
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
- package/dist/upgrade/audit-job-roll.js +43 -0
- package/dist/upgrade/audit-job-roll.js.map +1 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +141 -8
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +27 -10
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +46 -7
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/record-rolled.d.ts +40 -0
- package/dist/upgrade/record-rolled.d.ts.map +1 -0
- package/dist/upgrade/record-rolled.js +46 -0
- package/dist/upgrade/record-rolled.js.map +1 -0
- package/dist/upgrade/upgrade.d.ts +45 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +60 -17
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +15 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +36 -6
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +15 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +42 -7
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +123 -0
- package/dist/upgrader-iam-preflight.d.ts.map +1 -0
- package/dist/upgrader-iam-preflight.js +414 -0
- package/dist/upgrader-iam-preflight.js.map +1 -0
- package/dist/upgrader-job.d.ts +33 -9
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +169 -27
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +58 -0
- package/dist/upgrader-records.d.ts.map +1 -0
- package/dist/upgrader-records.js +87 -0
- package/dist/upgrader-records.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +3 -2
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +10 -2
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/upgrader-setup.d.ts +25 -3
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +132 -14
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +14 -6
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +7 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +130 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/run.test.ts +15 -1
- package/src/audit-job-setup.test.ts +234 -0
- package/src/audit-job-setup.ts +360 -0
- package/src/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +40 -8
- package/src/compose.ts +5 -0
- package/src/converge-checkpoint.ts +75 -0
- package/src/converge-entrypoint.test.ts +213 -0
- package/src/converge-job-gcp.test.ts +222 -0
- package/src/converge-job-gcp.ts +443 -0
- package/src/converge-job-setup.test.ts +152 -0
- package/src/converge-job-setup.ts +268 -0
- package/src/converge-job.test.ts +486 -0
- package/src/converge-job.ts +542 -0
- package/src/gateway-env-isolation.test.ts +8 -1
- package/src/gcp-names.ts +43 -1
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +7 -4
- package/src/infra-contract.ts +28 -1
- package/src/install-job.test.ts +3 -1
- package/src/install-job.ts +31 -14
- package/src/lifecycle.ts +137 -71
- package/src/operator-access.test.ts +14 -0
- package/src/operator-access.ts +13 -3
- package/src/private-file.test.ts +80 -0
- package/src/private-file.ts +50 -0
- package/src/release-contract.ts +8 -2
- package/src/release-manifest.test.ts +42 -0
- package/src/release-manifest.ts +2 -0
- package/src/remote-mcp-edge.ts +137 -0
- package/src/remote-mcp.test.ts +517 -0
- package/src/remote-mcp.ts +460 -0
- package/src/runtime-secrets.test.ts +31 -6
- package/src/runtime-secrets.ts +41 -3
- package/src/seal-key-rotation.test.ts +131 -0
- package/src/seal-key-rotation.ts +258 -0
- package/src/stack-contract-parity.test.ts +20 -3
- package/src/stack-contract.test.ts +34 -2
- package/src/stack-contract.ts +305 -3
- package/src/stack-converge-secrets.test.ts +3 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +101 -7
- package/src/state-attach.test.ts +7 -1
- package/src/state-lock-lease.test.ts +302 -0
- package/src/state-lock-lease.ts +423 -0
- package/src/state-lock.test.ts +568 -0
- package/src/state-store.ts +347 -27
- package/src/tar-entries.test.ts +149 -7
- package/src/tar-entries.ts +90 -9
- package/src/upgrade/app-roll.ts +135 -21
- package/src/upgrade/audit-job-roll.ts +68 -0
- package/src/upgrade/converge.ts +170 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -0
- package/src/upgrade/gcp-lookup.ts +65 -8
- package/src/upgrade/record-rolled.test.ts +105 -0
- package/src/upgrade/record-rolled.ts +91 -0
- package/src/upgrade/upgrade.test.ts +279 -5
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +93 -3
- package/src/upgrader-assess.ts +58 -9
- package/src/upgrader-gcp.ts +47 -8
- package/src/upgrader-iam-preflight.test.ts +393 -0
- package/src/upgrader-iam-preflight.ts +510 -0
- package/src/upgrader-job.test.ts +587 -15
- package/src/upgrader-job.ts +237 -53
- package/src/upgrader-records.ts +151 -0
- package/src/upgrader-reporter.test.ts +6 -0
- package/src/upgrader-reporter.ts +7 -2
- package/src/upgrader-setup.test.ts +4 -3
- package/src/upgrader-setup.ts +137 -13
- package/src/wizards/gcp-cost.ts +15 -6
- package/src/wizards/gcp-pulumi.ts +7 -0
- package/src/wizards/gcp.ts +162 -4
- package/stacks/aws/index.ts +18 -0
- package/stacks/azure/index.ts +18 -0
- package/stacks/gcp/index.ts +59 -7
- package/stacks/gcp-firebase/index.ts +50 -6
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The upgrader's IAM self-check (#818): which permissions it asks for, how
|
|
5
|
+
* it groups and judges IAM's answers, the exact REST calls it makes, and
|
|
6
|
+
* that it never asks for a permission upgrader-setup.ts does not grant (a
|
|
7
|
+
* check the setup cannot pass would stop every update).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import type { HttpFetch } from "./runtime-secrets.js";
|
|
12
|
+
import {
|
|
13
|
+
type IamResource,
|
|
14
|
+
type IamTester,
|
|
15
|
+
objectProbeCall,
|
|
16
|
+
PREFLIGHT_PROBE_OBJECT,
|
|
17
|
+
preflightMissingReason,
|
|
18
|
+
resourceName,
|
|
19
|
+
restIamTester,
|
|
20
|
+
runIamPreflight,
|
|
21
|
+
testIamCall,
|
|
22
|
+
upgraderAccessChecks,
|
|
23
|
+
} from "./upgrader-iam-preflight.js";
|
|
24
|
+
import {
|
|
25
|
+
UPGRADER_FORBIDDEN_ROLES,
|
|
26
|
+
UPGRADER_OPS_LIST_ROLE,
|
|
27
|
+
UPGRADER_PROJECT_ROLES,
|
|
28
|
+
UPGRADER_RUN_ROLE,
|
|
29
|
+
UPGRADER_SQL_BACKUP_ROLE,
|
|
30
|
+
} from "./upgrader-setup.js";
|
|
31
|
+
|
|
32
|
+
const TARGET = { projectId: "acme", region: "europe-west1", env: "production" };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The permissions of the predefined roles upgrader-setup.ts grants, as far
|
|
36
|
+
* as the self-check needs them (cloud.google.com/run/docs/reference/iam/roles,
|
|
37
|
+
* cloud.google.com/iam/docs/understanding-roles). Only the ones asked for
|
|
38
|
+
* are listed; a new check must find its permission here or in a custom role.
|
|
39
|
+
*/
|
|
40
|
+
const PREDEFINED: Record<string, readonly string[]> = {
|
|
41
|
+
"roles/cloudsql.instanceUser": ["cloudsql.instances.get", "cloudsql.instances.login"],
|
|
42
|
+
"roles/logging.logWriter": ["logging.logEntries.create"],
|
|
43
|
+
"roles/iam.serviceAccountUser": ["iam.serviceAccounts.actAs"],
|
|
44
|
+
"roles/iam.serviceAccountTokenCreator": ["iam.serviceAccounts.signJwt"],
|
|
45
|
+
"roles/storage.objectUser": [
|
|
46
|
+
"storage.objects.get",
|
|
47
|
+
"storage.objects.create",
|
|
48
|
+
"storage.objects.delete",
|
|
49
|
+
"storage.objects.list",
|
|
50
|
+
],
|
|
51
|
+
// The public release mirror grants allUsers roles/artifactregistry.reader.
|
|
52
|
+
"roles/artifactregistry.reader": ["artifactregistry.repositories.downloadArtifacts"],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** What upgrader-setup.ts grants the upgrader's account, per resource name. */
|
|
56
|
+
function setupGrants(): Map<string, Set<string>> {
|
|
57
|
+
const p = TARGET.projectId;
|
|
58
|
+
const of = (...roles: string[]) => new Set(roles.flatMap((r) => PREDEFINED[r] ?? []));
|
|
59
|
+
const project = of(...UPGRADER_PROJECT_ROLES);
|
|
60
|
+
for (const perm of UPGRADER_SQL_BACKUP_ROLE.permissions) project.add(perm);
|
|
61
|
+
// #883 — the Cloud Run rights come from the custom caeloUpgraderRun role
|
|
62
|
+
// (no run.developer: it would hold runWithOverrides on the provisioner's jobs).
|
|
63
|
+
for (const perm of UPGRADER_RUN_ROLE.permissions) project.add(perm);
|
|
64
|
+
const bucketList = new Set(UPGRADER_OPS_LIST_ROLE.permissions);
|
|
65
|
+
return new Map([
|
|
66
|
+
[`projects/${p}`, project],
|
|
67
|
+
[
|
|
68
|
+
`projects/${p}/serviceAccounts/caelo-production-run-sa@${p}.iam.gserviceaccount.com`,
|
|
69
|
+
of("roles/iam.serviceAccountUser"),
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
`projects/${p}/serviceAccounts/caelo-production-gateway-sa@${p}.iam.gserviceaccount.com`,
|
|
73
|
+
of("roles/iam.serviceAccountUser"),
|
|
74
|
+
],
|
|
75
|
+
[
|
|
76
|
+
`projects/${p}/serviceAccounts/caelo-production-upgrader@${p}.iam.gserviceaccount.com`,
|
|
77
|
+
of("roles/iam.serviceAccountUser"),
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
`projects/${p}/serviceAccounts/caelo-mcp@${p}.iam.gserviceaccount.com`,
|
|
81
|
+
of("roles/iam.serviceAccountTokenCreator"),
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
`projects/${p}/serviceAccounts/caelo-production-audit@${p}.iam.gserviceaccount.com`,
|
|
85
|
+
of("roles/iam.serviceAccountUser"),
|
|
86
|
+
],
|
|
87
|
+
// The project-level caeloUpgraderRun reaches the job too.
|
|
88
|
+
[`projects/${p}/locations/europe-west1/jobs/caelo-production-upgrader`, project],
|
|
89
|
+
[`gs://${p}-caelo-ops`, bucketList],
|
|
90
|
+
// The install/-conditioned roles/storage.objectUser grant.
|
|
91
|
+
[`gs://${p}-caelo-ops/install/`, of("roles/storage.objectUser")],
|
|
92
|
+
[
|
|
93
|
+
"projects/caelo-website/locations/europe-west1/repositories/caelo-cms-images",
|
|
94
|
+
of("roles/artifactregistry.reader"),
|
|
95
|
+
],
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A tester answering from `grants`, recording every question. */
|
|
100
|
+
function tester(grants: Map<string, Set<string>>, asked: string[] = []): IamTester {
|
|
101
|
+
return {
|
|
102
|
+
granted: async (resource, permissions) => {
|
|
103
|
+
asked.push(`${resourceName(resource)} ${permissions.join(",")}`);
|
|
104
|
+
const held = grants.get(resourceName(resource)) ?? new Set<string>();
|
|
105
|
+
return { ok: true, granted: permissions.filter((x) => held.has(x)) };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("upgraderAccessChecks", () => {
|
|
111
|
+
it("asks only for what upgrader-setup.ts grants: a fresh setup passes the self-check", async () => {
|
|
112
|
+
const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(setupGrants()));
|
|
113
|
+
expect(r).toEqual({ kind: "ok" });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("#879 review: the audit job's account is checked, since every app-roll moves that job (#875)", async () => {
|
|
117
|
+
const grants = setupGrants();
|
|
118
|
+
grants.delete(
|
|
119
|
+
"projects/acme/serviceAccounts/caelo-production-audit@acme.iam.gserviceaccount.com",
|
|
120
|
+
);
|
|
121
|
+
const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(grants));
|
|
122
|
+
expect(r.kind).toBe("missing");
|
|
123
|
+
if (r.kind !== "missing") return;
|
|
124
|
+
expect(r.missing.map((m) => [resourceName(m.check.resource), m.missing])).toEqual([
|
|
125
|
+
[
|
|
126
|
+
"projects/acme/serviceAccounts/caelo-production-audit@acme.iam.gserviceaccount.com",
|
|
127
|
+
["iam.serviceAccounts.actAs"],
|
|
128
|
+
],
|
|
129
|
+
]);
|
|
130
|
+
expect(preflightMissingReason(r.missing)).toContain("quality-audit job");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("never needs a permission only a forbidden role would give (no compute, no IAM admin)", () => {
|
|
134
|
+
const all = upgraderAccessChecks(TARGET).flatMap((c) => c.permissions);
|
|
135
|
+
for (const p of all) {
|
|
136
|
+
expect(p.startsWith("compute.")).toBe(false);
|
|
137
|
+
expect(p.endsWith(".setIamPolicy")).toBe(false);
|
|
138
|
+
}
|
|
139
|
+
// Sanity: the forbidden list still bans compute (the Direct VPC decision).
|
|
140
|
+
expect(UPGRADER_FORBIDDEN_ROLES).toContain("roles/compute.");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("covers every step the run takes after its decision", () => {
|
|
144
|
+
const all = new Set(upgraderAccessChecks(TARGET).flatMap((c) => c.permissions));
|
|
145
|
+
for (const p of [
|
|
146
|
+
"run.services.update", // the roll + traffic
|
|
147
|
+
"run.jobs.create", // the migration job
|
|
148
|
+
"run.jobs.run",
|
|
149
|
+
"run.executions.get", // execute --wait, the lock lease
|
|
150
|
+
"run.jobs.update", // the self-update
|
|
151
|
+
"cloudsql.backupRuns.create", // the backup
|
|
152
|
+
"iam.serviceAccounts.actAs",
|
|
153
|
+
"iam.serviceAccounts.signJwt", // the IAP health check
|
|
154
|
+
"storage.objects.list", // gcloud storage cp lists first (#799)
|
|
155
|
+
"storage.objects.create",
|
|
156
|
+
"storage.objects.delete", // overwrite + lock release
|
|
157
|
+
"artifactregistry.repositories.downloadArtifacts",
|
|
158
|
+
]) {
|
|
159
|
+
expect(all.has(p)).toBe(true);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("runIamPreflight", () => {
|
|
165
|
+
it("asks once per resource, with every permission the checks on it need", async () => {
|
|
166
|
+
const asked: string[] = [];
|
|
167
|
+
await runIamPreflight(upgraderAccessChecks(TARGET), tester(setupGrants(), asked));
|
|
168
|
+
const names = asked.map((a) => a.split(" ")[0]);
|
|
169
|
+
expect(new Set(names).size).toBe(names.length);
|
|
170
|
+
const project = asked.find((a) => a.startsWith("projects/acme "));
|
|
171
|
+
expect(project).toContain("run.services.update");
|
|
172
|
+
expect(project).toContain("cloudsql.backupRuns.create");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("names every missing permission, per need, in one outcome", async () => {
|
|
176
|
+
const grants = setupGrants();
|
|
177
|
+
grants.get("projects/acme")?.delete("run.jobs.run");
|
|
178
|
+
grants.get("projects/acme")?.delete("cloudsql.backupRuns.create");
|
|
179
|
+
grants.get("gs://acme-caelo-ops")?.delete("storage.objects.list");
|
|
180
|
+
const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(grants));
|
|
181
|
+
expect(r.kind).toBe("missing");
|
|
182
|
+
if (r.kind !== "missing") return;
|
|
183
|
+
expect(r.missing.flatMap((m) => m.missing).sort()).toEqual([
|
|
184
|
+
"cloudsql.backupRuns.create",
|
|
185
|
+
"run.jobs.run",
|
|
186
|
+
"storage.objects.list",
|
|
187
|
+
]);
|
|
188
|
+
const reason = preflightMissingReason(r.missing);
|
|
189
|
+
expect(reason).toContain("run the database migrations");
|
|
190
|
+
expect(reason).toContain("missing storage.objects.list on gs://acme-caelo-ops");
|
|
191
|
+
expect(reason).toContain("Run the upgrade once with Cloud Shell");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("probes objects.get only once objects.list is confirmed; otherwise names it unchecked, not missing", async () => {
|
|
195
|
+
const withList: string[] = [];
|
|
196
|
+
await runIamPreflight(upgraderAccessChecks(TARGET), tester(setupGrants(), withList));
|
|
197
|
+
expect(withList.find((a) => a.startsWith("gs://acme-caelo-ops/install/"))).toContain(
|
|
198
|
+
"storage.objects.get",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const grants = setupGrants();
|
|
202
|
+
grants.get("gs://acme-caelo-ops")?.delete("storage.objects.list");
|
|
203
|
+
const asked: string[] = [];
|
|
204
|
+
const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(grants, asked));
|
|
205
|
+
const objects = asked.find((a) => a.startsWith("gs://acme-caelo-ops/install/"));
|
|
206
|
+
expect(objects).toBeDefined();
|
|
207
|
+
expect(objects).not.toContain("storage.objects.get");
|
|
208
|
+
expect(r.kind).toBe("missing");
|
|
209
|
+
if (r.kind !== "missing") return;
|
|
210
|
+
expect(r.missing.flatMap((m) => m.missing)).not.toContain("storage.objects.get");
|
|
211
|
+
expect(r.missing.flatMap((m) => m.unchecked ?? [])).toEqual(["storage.objects.get"]);
|
|
212
|
+
expect(preflightMissingReason(r.missing)).toContain(
|
|
213
|
+
"storage.objects.get on gs://acme-caelo-ops/install/ could not be checked",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("an unanswerable resource is an error, never 'nothing missing'", async () => {
|
|
218
|
+
const failing: IamTester = {
|
|
219
|
+
granted: async (resource) =>
|
|
220
|
+
resource.kind === "artifact-repository"
|
|
221
|
+
? { ok: false, error: "HTTP 403 caller has no access" }
|
|
222
|
+
: { ok: true, granted: [] },
|
|
223
|
+
};
|
|
224
|
+
const r = await runIamPreflight(upgraderAccessChecks(TARGET), failing);
|
|
225
|
+
expect(r.kind).toBe("error");
|
|
226
|
+
if (r.kind === "error") expect(r.errors[0]).toContain("HTTP 403");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("a tester that throws is an error too", async () => {
|
|
230
|
+
const throwing: IamTester = {
|
|
231
|
+
granted: async () => {
|
|
232
|
+
throw new Error("socket hang up");
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
const r = await runIamPreflight(
|
|
236
|
+
[{ need: "x", resource: { kind: "project", projectId: "p" }, permissions: ["a.b.c"] }],
|
|
237
|
+
throwing,
|
|
238
|
+
);
|
|
239
|
+
expect(r).toEqual({ kind: "error", errors: ["projects/p: socket hang up"] });
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
type Call = { url: string; method: string; headers: Record<string, string>; body?: string };
|
|
244
|
+
|
|
245
|
+
/** An HTTP client answering by method + URL prefix. */
|
|
246
|
+
function http(calls: Call[], answer: (c: Call) => { status: number; body: string }): HttpFetch {
|
|
247
|
+
return async (url, init) => {
|
|
248
|
+
const call = { url, ...init };
|
|
249
|
+
calls.push(call);
|
|
250
|
+
const a = answer(call);
|
|
251
|
+
return { ok: a.status >= 200 && a.status < 300, status: a.status, text: async () => a.body };
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
describe("restIamTester", () => {
|
|
256
|
+
it("calls each API's testIamPermissions with the job's token", async () => {
|
|
257
|
+
const calls: Call[] = [];
|
|
258
|
+
const t = restIamTester({
|
|
259
|
+
http: http(calls, () => ({ status: 200, body: JSON.stringify({ permissions: ["a.b.c"] }) })),
|
|
260
|
+
token: async () => "tok",
|
|
261
|
+
});
|
|
262
|
+
const resources: IamResource[] = [
|
|
263
|
+
{ kind: "project", projectId: "acme" },
|
|
264
|
+
{ kind: "service-account", projectId: "acme", email: "x@acme.iam.gserviceaccount.com" },
|
|
265
|
+
{
|
|
266
|
+
kind: "run-job",
|
|
267
|
+
projectId: "acme",
|
|
268
|
+
region: "europe-west1",
|
|
269
|
+
name: "caelo-production-upgrader",
|
|
270
|
+
},
|
|
271
|
+
{ kind: "bucket", bucket: "acme-caelo-ops" },
|
|
272
|
+
{
|
|
273
|
+
kind: "artifact-repository",
|
|
274
|
+
projectId: "caelo-website",
|
|
275
|
+
location: "europe-west1",
|
|
276
|
+
repository: "caelo-cms-images",
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
for (const res of resources) {
|
|
280
|
+
expect(await t.granted(res, ["a.b.c", "d.e.f"])).toEqual({ ok: true, granted: ["a.b.c"] });
|
|
281
|
+
}
|
|
282
|
+
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
|
|
283
|
+
"POST https://cloudresourcemanager.googleapis.com/v1/projects/acme:testIamPermissions",
|
|
284
|
+
"POST https://iam.googleapis.com/v1/projects/acme/serviceAccounts/x%40acme.iam.gserviceaccount.com:testIamPermissions",
|
|
285
|
+
"POST https://run.googleapis.com/v2/projects/acme/locations/europe-west1/jobs/caelo-production-upgrader:testIamPermissions",
|
|
286
|
+
"GET https://storage.googleapis.com/storage/v1/b/acme-caelo-ops/iam/testPermissions?permissions=a.b.c&permissions=d.e.f",
|
|
287
|
+
"POST https://artifactregistry.googleapis.com/v1/projects/caelo-website/locations/europe-west1/repositories/caelo-cms-images:testIamPermissions",
|
|
288
|
+
]);
|
|
289
|
+
for (const c of calls) expect(c.headers.Authorization).toBe("Bearer tok");
|
|
290
|
+
expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ permissions: ["a.b.c", "d.e.f"] });
|
|
291
|
+
expect(calls[3]?.body).toBeUndefined();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("an answer without `permissions` means none is held", async () => {
|
|
295
|
+
const t = restIamTester({
|
|
296
|
+
http: http([], () => ({ status: 200, body: "{}" })),
|
|
297
|
+
token: async () => "tok",
|
|
298
|
+
});
|
|
299
|
+
expect(await t.granted({ kind: "project", projectId: "acme" }, ["a.b.c"])).toEqual({
|
|
300
|
+
ok: true,
|
|
301
|
+
granted: [],
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("an HTTP error or non-JSON answer is an error, not 'missing'", async () => {
|
|
306
|
+
const denied = restIamTester({
|
|
307
|
+
http: http([], () => ({ status: 403, body: "PERMISSION_DENIED" })),
|
|
308
|
+
token: async () => "tok",
|
|
309
|
+
});
|
|
310
|
+
const r = await denied.granted({ kind: "project", projectId: "acme" }, ["a.b.c"]);
|
|
311
|
+
expect(r).toEqual({ ok: false, error: "HTTP 403 PERMISSION_DENIED" });
|
|
312
|
+
const garbled = restIamTester({
|
|
313
|
+
http: http([], () => ({ status: 200, body: "<html>" })),
|
|
314
|
+
token: async () => "tok",
|
|
315
|
+
});
|
|
316
|
+
expect((await garbled.granted({ kind: "project", projectId: "acme" }, ["a"])).ok).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("an empty token is an error before any call", async () => {
|
|
320
|
+
const calls: Call[] = [];
|
|
321
|
+
const t = restIamTester({
|
|
322
|
+
http: http(calls, () => ({ status: 200, body: "{}" })),
|
|
323
|
+
token: async () => "",
|
|
324
|
+
});
|
|
325
|
+
expect((await t.granted({ kind: "project", projectId: "acme" }, ["a"])).ok).toBe(false);
|
|
326
|
+
expect(calls).toEqual([]);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("install/ object probes", () => {
|
|
331
|
+
const objects: Extract<IamResource, { kind: "bucket-objects" }> = {
|
|
332
|
+
kind: "bucket-objects",
|
|
333
|
+
bucket: "acme-caelo-ops",
|
|
334
|
+
prefix: "install",
|
|
335
|
+
};
|
|
336
|
+
const PERMS = ["storage.objects.get", "storage.objects.create", "storage.objects.delete"];
|
|
337
|
+
|
|
338
|
+
it("every write probe is preconditioned on generation 1, so it can never change anything", () => {
|
|
339
|
+
const create = objectProbeCall(objects, "storage.objects.create");
|
|
340
|
+
const del = objectProbeCall(objects, "storage.objects.delete");
|
|
341
|
+
expect(create?.url).toContain("ifGenerationMatch=1");
|
|
342
|
+
expect(del?.url).toContain("ifGenerationMatch=1");
|
|
343
|
+
for (const c of [create, del, objectProbeCall(objects, "storage.objects.get")]) {
|
|
344
|
+
expect(c?.url).toContain(encodeURIComponent(`install/${PREFLIGHT_PROBE_OBJECT}`));
|
|
345
|
+
}
|
|
346
|
+
expect(objectProbeCall(objects, "storage.objects.list")).toBeNull();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("404/412 is held, 403 is missing", async () => {
|
|
350
|
+
const calls: Call[] = [];
|
|
351
|
+
const t = restIamTester({
|
|
352
|
+
http: http(calls, (c) =>
|
|
353
|
+
c.method === "GET"
|
|
354
|
+
? { status: 404, body: "No such object" }
|
|
355
|
+
: c.method === "POST"
|
|
356
|
+
? { status: 412, body: "conditionNotMet" }
|
|
357
|
+
: { status: 403, body: "does not have storage.objects.delete access" },
|
|
358
|
+
),
|
|
359
|
+
token: async () => "tok",
|
|
360
|
+
});
|
|
361
|
+
expect(await t.granted(objects, PERMS)).toEqual({
|
|
362
|
+
ok: true,
|
|
363
|
+
granted: ["storage.objects.get", "storage.objects.create"],
|
|
364
|
+
});
|
|
365
|
+
expect(calls.map((c) => c.method)).toEqual(["GET", "POST", "DELETE"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("an upload that succeeded (never expected) is an error, loudly", async () => {
|
|
369
|
+
const t = restIamTester({
|
|
370
|
+
http: http([], (c) =>
|
|
371
|
+
c.method === "POST" ? { status: 200, body: "{}" } : { status: 404, body: "" },
|
|
372
|
+
),
|
|
373
|
+
token: async () => "tok",
|
|
374
|
+
});
|
|
375
|
+
const r = await t.granted(objects, PERMS);
|
|
376
|
+
expect(r.ok).toBe(false);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("a permission without a probe is an error", async () => {
|
|
380
|
+
const t = restIamTester({
|
|
381
|
+
http: http([], () => ({ status: 404, body: "" })),
|
|
382
|
+
token: async () => "tok",
|
|
383
|
+
});
|
|
384
|
+
expect((await t.granted(objects, ["storage.objects.update"])).ok).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("testIamCall", () => {
|
|
389
|
+
it("URL-encodes path parts", () => {
|
|
390
|
+
const c = testIamCall({ kind: "bucket", bucket: "a b" }, ["x"]);
|
|
391
|
+
expect(c.url).toContain("/b/a%20b/");
|
|
392
|
+
});
|
|
393
|
+
});
|