@caelo-cms/provisioning 0.10.61 → 0.10.62
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 +7 -0
- package/caddy/Caddyfile.staging +7 -0
- package/dist/admin-internal-answer.d.ts +12 -0
- package/dist/admin-internal-answer.d.ts.map +1 -0
- package/dist/admin-internal-answer.js +32 -0
- package/dist/admin-internal-answer.js.map +1 -0
- package/dist/admin-internal-google.d.ts +4 -0
- package/dist/admin-internal-google.d.ts.map +1 -0
- package/dist/admin-internal-google.js +161 -0
- package/dist/admin-internal-google.js.map +1 -0
- package/dist/admin-internal-self-hosted.d.ts +30 -0
- package/dist/admin-internal-self-hosted.d.ts.map +1 -0
- package/dist/admin-internal-self-hosted.js +92 -0
- package/dist/admin-internal-self-hosted.js.map +1 -0
- package/dist/admin-internal.d.ts +33 -12
- package/dist/admin-internal.d.ts.map +1 -1
- package/dist/admin-internal.js +27 -159
- package/dist/admin-internal.js.map +1 -1
- package/dist/agent/command.d.ts +7 -0
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/lock.d.ts +13 -1
- package/dist/agent/lock.d.ts.map +1 -1
- package/dist/agent/lock.js +62 -5
- package/dist/agent/lock.js.map +1 -1
- package/dist/agent/run.d.ts +5 -1
- package/dist/agent/run.d.ts.map +1 -1
- package/dist/agent/run.js +8 -1
- package/dist/agent/run.js.map +1 -1
- package/dist/agent-db.js +192 -90
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +11 -1
- 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 +95 -38
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +10 -7
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts +21 -0
- package/dist/converge-job-preflight.d.ts.map +1 -0
- package/dist/converge-job-preflight.js +111 -0
- package/dist/converge-job-preflight.js.map +1 -0
- package/dist/converge-job.d.ts +8 -2
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +14 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/gateway-credentials.d.ts +5 -3
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +5 -8
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +29 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +66 -0
- 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 +10 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +3 -1
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +10 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +12 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +12 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +113 -28
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-iap.d.ts +2 -0
- package/dist/mcp-iap.d.ts.map +1 -1
- package/dist/mcp-iap.js +50 -17
- package/dist/mcp-iap.js.map +1 -1
- package/dist/owner-reset.d.ts +16 -19
- package/dist/owner-reset.d.ts.map +1 -1
- package/dist/owner-reset.js +92 -19
- package/dist/owner-reset.js.map +1 -1
- package/dist/preview-plan.d.ts +71 -0
- package/dist/preview-plan.d.ts.map +1 -0
- package/dist/preview-plan.js +168 -0
- package/dist/preview-plan.js.map +1 -0
- package/dist/public-role-isolation.d.ts +2 -0
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +9 -3
- package/dist/public-role-isolation.js.map +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +4 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/self-hosted-internal-secret.d.ts +27 -0
- package/dist/self-hosted-internal-secret.d.ts.map +1 -0
- package/dist/self-hosted-internal-secret.js +69 -0
- package/dist/self-hosted-internal-secret.js.map +1 -0
- package/dist/self-hosted-release.d.ts +3 -1
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -2
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +13 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +26 -2
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-converge.d.ts +5 -2
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +73 -8
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-admin.d.ts +4 -1
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js.map +1 -1
- package/dist/state-backend.d.ts +3 -2
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +3 -2
- package/dist/state-backend.js.map +1 -1
- package/dist/state-command.d.ts +17 -0
- package/dist/state-command.d.ts.map +1 -1
- package/dist/state-command.js +34 -5
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +14 -3
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +31 -7
- package/dist/state-migrate.js.map +1 -1
- package/dist/state-pulumi.d.ts +0 -2
- package/dist/state-pulumi.d.ts.map +1 -1
- package/dist/state-pulumi.js +19 -20
- package/dist/state-pulumi.js.map +1 -1
- package/dist/static-security-headers.d.ts +86 -0
- package/dist/static-security-headers.d.ts.map +1 -0
- package/dist/static-security-headers.js +126 -0
- package/dist/static-security-headers.js.map +1 -0
- package/dist/static-svg-gcp.d.ts +28 -0
- package/dist/static-svg-gcp.d.ts.map +1 -0
- package/dist/static-svg-gcp.js +106 -0
- package/dist/static-svg-gcp.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +3 -0
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +12 -5
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/backup.d.ts +2 -0
- package/dist/upgrade/backup.d.ts.map +1 -1
- package/dist/upgrade/backup.js +5 -4
- package/dist/upgrade/backup.js.map +1 -1
- package/dist/upgrade/converge.d.ts +7 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +19 -7
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +38 -3
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +31 -9
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/health.d.ts +31 -5
- package/dist/upgrade/health.d.ts.map +1 -1
- package/dist/upgrade/health.js +114 -25
- package/dist/upgrade/health.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +17 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +23 -12
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/reporter.d.ts +2 -0
- package/dist/upgrade/reporter.d.ts.map +1 -1
- package/dist/upgrade/reporter.js +1 -0
- package/dist/upgrade/reporter.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +11 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +5 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +12 -2
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +22 -13
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +16 -3
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +9 -4
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +10 -8
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +1 -0
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +70 -23
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +118 -39
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +67 -11
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-internal-answer.ts +43 -0
- package/src/admin-internal-google.ts +187 -0
- package/src/admin-internal-self-hosted.test.ts +70 -0
- package/src/admin-internal-self-hosted.ts +128 -0
- package/src/admin-internal.ts +59 -174
- package/src/admin-memory.test.ts +1 -0
- package/src/agent/command.ts +3 -0
- package/src/agent/lock-config.test.ts +57 -2
- package/src/agent/lock.ts +68 -5
- package/src/agent/run.test.ts +18 -1
- package/src/agent/run.ts +17 -6
- package/src/caddy-redirects.test.ts +1 -0
- package/src/caddy.ts +12 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +102 -44
- package/src/compose.ts +12 -0
- package/src/converge-job-gcp.ts +16 -11
- package/src/converge-job-preflight.test.ts +108 -0
- package/src/converge-job-preflight.ts +124 -0
- package/src/converge-job.test.ts +50 -0
- package/src/converge-job.ts +38 -2
- package/src/gateway-credentials.test.ts +4 -3
- package/src/gateway-credentials.ts +6 -10
- package/src/gateway-env-isolation.test.ts +1 -0
- package/src/gcloud.test.ts +93 -1
- package/src/gcloud.ts +82 -0
- package/src/index.test.ts +38 -1
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +10 -1
- package/src/install-job.ts +5 -2
- package/src/install-state.ts +13 -0
- package/src/lifecycle.ts +134 -28
- package/src/mcp-iap.test.ts +66 -5
- package/src/mcp-iap.ts +55 -17
- package/src/owner-reset-providers.test.ts +370 -0
- package/src/owner-reset.test.ts +2 -2
- package/src/owner-reset.ts +108 -19
- package/src/preview-plan.test.ts +233 -0
- package/src/preview-plan.ts +226 -0
- package/src/public-role-isolation.test.ts +13 -2
- package/src/public-role-isolation.ts +14 -4
- package/src/release-contract.ts +4 -0
- package/src/self-hosted-compose.test.ts +25 -0
- package/src/self-hosted-internal-secret.test.ts +61 -0
- package/src/self-hosted-internal-secret.ts +80 -0
- package/src/self-hosted-release.ts +10 -3
- package/src/self-hosted-upgrade.test.ts +35 -1
- package/src/self-hosted-upgrade.ts +40 -3
- package/src/stack-contract.test.ts +1 -0
- package/src/stack-converge.test.ts +89 -5
- package/src/stack-converge.ts +81 -8
- package/src/staging-admin.test.ts +1 -1
- package/src/staging-admin.ts +2 -1
- package/src/state-backend.ts +3 -2
- package/src/state-command.test.ts +31 -0
- package/src/state-command.ts +35 -8
- package/src/state-migrate.test.ts +58 -7
- package/src/state-migrate.ts +48 -10
- package/src/state-pulumi.ts +20 -21
- package/src/static-security-headers.test.ts +124 -0
- package/src/static-security-headers.ts +154 -0
- package/src/static-svg-gcp.test.ts +133 -0
- package/src/static-svg-gcp.ts +140 -0
- package/src/upgrade/app-roll.ts +18 -7
- package/src/upgrade/backup.ts +14 -5
- package/src/upgrade/converge.ts +25 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -1
- package/src/upgrade/gcp-lookup.ts +61 -11
- package/src/upgrade/health.test.ts +132 -8
- package/src/upgrade/health.ts +169 -31
- package/src/upgrade/release-images.test.ts +17 -0
- package/src/upgrade/release-images.ts +26 -14
- package/src/upgrade/reporter.ts +3 -0
- package/src/upgrade/upgrade.test.ts +137 -8
- package/src/upgrade/upgrade.ts +14 -2
- package/src/upgrader-gcp.ts +28 -13
- package/src/upgrader-iam-preflight.test.ts +6 -3
- package/src/upgrader-iam-preflight.ts +26 -5
- package/src/upgrader-job.ts +14 -6
- package/src/upgrader-reporter.ts +1 -0
- package/src/wizards/gcp-pulumi.test.ts +1 -1
- package/src/wizards/gcp-pulumi.ts +147 -38
- package/src/wizards/gcp.ts +89 -9
- package/src/wizards/stack-work-dir.test.ts +175 -0
- package/stacks/aws/index.ts +40 -0
- package/stacks/azure/index.ts +22 -1
- package/stacks/gcp/README.md +4 -0
- package/stacks/gcp/index.ts +6 -2
- package/stacks/gcp/Pulumi.production.yaml +0 -16
package/src/mcp-iap.test.ts
CHANGED
|
@@ -62,11 +62,31 @@ describe("iapOperators", () => {
|
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
/** The IAP policy before the MCP account existed: operators only. */
|
|
66
|
+
const OPERATORS_ONLY = JSON.stringify({
|
|
67
|
+
bindings: [
|
|
68
|
+
{
|
|
69
|
+
role: "roles/iap.httpsResourceAccessor",
|
|
70
|
+
members: ["user:owner@example.com", "group:editors@example.com"],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** The MCP account's own policy once every operator may sign as it. */
|
|
76
|
+
const SIGNERS = JSON.stringify({
|
|
77
|
+
bindings: [
|
|
78
|
+
{
|
|
79
|
+
role: "roles/iam.serviceAccountTokenCreator",
|
|
80
|
+
members: ["user:owner@example.com", "group:editors@example.com"],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
65
85
|
describe("ensureMcpIapAccess", () => {
|
|
66
86
|
it("creates the SA, allowlists it on IAP and lets every IAP operator sign as it", async () => {
|
|
67
87
|
const { run, calls } = fakeGcloud({
|
|
68
88
|
"iam service-accounts describe": [fail("NOT_FOUND")],
|
|
69
|
-
"iap web get-iam-policy": [ok(
|
|
89
|
+
"iap web get-iam-policy": [ok(OPERATORS_ONLY)],
|
|
70
90
|
});
|
|
71
91
|
const r = await ensureMcpIapAccess({
|
|
72
92
|
projectId: "p",
|
|
@@ -79,6 +99,11 @@ describe("ensureMcpIapAccess", () => {
|
|
|
79
99
|
ok: true,
|
|
80
100
|
serviceAccount: mcpIapServiceAccountEmail("p"),
|
|
81
101
|
operators: ["user:owner@example.com", "group:editors@example.com"],
|
|
102
|
+
granted: [
|
|
103
|
+
"roles/iap.httpsResourceAccessor → serviceAccount:caelo-mcp@p.iam.gserviceaccount.com",
|
|
104
|
+
"roles/iam.serviceAccountTokenCreator → user:owner@example.com",
|
|
105
|
+
"roles/iam.serviceAccountTokenCreator → group:editors@example.com",
|
|
106
|
+
],
|
|
82
107
|
});
|
|
83
108
|
const lines = calls.map((c) => c.join(" "));
|
|
84
109
|
expect(lines.some((l) => l.startsWith("iam service-accounts create caelo-mcp"))).toBe(true);
|
|
@@ -92,11 +117,47 @@ describe("ensureMcpIapAccess", () => {
|
|
|
92
117
|
}
|
|
93
118
|
});
|
|
94
119
|
|
|
95
|
-
it("is a no-op re-run when
|
|
96
|
-
const { run, calls } = fakeGcloud({
|
|
120
|
+
it("is a no-op re-run when everything is in place: no create, no binding written, nothing granted", async () => {
|
|
121
|
+
const { run, calls } = fakeGcloud({
|
|
122
|
+
"iap web get-iam-policy": [ok(POLICY)],
|
|
123
|
+
"iam service-accounts get-iam-policy": [ok(SIGNERS)],
|
|
124
|
+
});
|
|
97
125
|
const r = await ensureMcpIapAccess({ projectId: "p", resource: cloudRun, run });
|
|
98
|
-
expect(r.ok).
|
|
126
|
+
expect(r.ok && r.granted).toEqual([]);
|
|
99
127
|
expect(calls.some((c) => c.join(" ").startsWith("iam service-accounts create"))).toBe(false);
|
|
128
|
+
expect(calls.some((c) => c.includes("add-iam-policy-binding"))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("#734 — names only the grant this run added (a new operator on the allowlist)", async () => {
|
|
132
|
+
const { run, calls } = fakeGcloud({
|
|
133
|
+
"iap web get-iam-policy": [ok(POLICY)],
|
|
134
|
+
"iam service-accounts get-iam-policy": [
|
|
135
|
+
ok(
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
bindings: [
|
|
138
|
+
{
|
|
139
|
+
role: "roles/iam.serviceAccountTokenCreator",
|
|
140
|
+
members: ["user:owner@example.com"],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
const r = await ensureMcpIapAccess({ projectId: "p", resource: cloudRun, run });
|
|
148
|
+
expect(r.ok && r.granted).toEqual([
|
|
149
|
+
"roles/iam.serviceAccountTokenCreator → group:editors@example.com",
|
|
150
|
+
]);
|
|
151
|
+
expect(calls.filter((c) => c.includes("add-iam-policy-binding"))).toHaveLength(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("writes every binding when the account's policy can't be read (and counts them as granted)", async () => {
|
|
155
|
+
const { run } = fakeGcloud({
|
|
156
|
+
"iap web get-iam-policy": [ok(POLICY)],
|
|
157
|
+
"iam service-accounts get-iam-policy": [fail("PERMISSION_DENIED")],
|
|
158
|
+
});
|
|
159
|
+
const r = await ensureMcpIapAccess({ projectId: "p", resource: cloudRun, run });
|
|
160
|
+
expect(r.ok && r.granted).toHaveLength(2);
|
|
100
161
|
});
|
|
101
162
|
|
|
102
163
|
it("addresses the LB backend service on the gcp provider", async () => {
|
|
@@ -117,7 +178,7 @@ describe("ensureMcpIapAccess", () => {
|
|
|
117
178
|
it("retries bindings while a new SA propagates", async () => {
|
|
118
179
|
const { run, calls } = fakeGcloud({
|
|
119
180
|
"iam service-accounts describe": [fail("NOT_FOUND")],
|
|
120
|
-
"iap web get-iam-policy": [ok(
|
|
181
|
+
"iap web get-iam-policy": [ok(OPERATORS_ONLY)],
|
|
121
182
|
"iap web add-iam-policy-binding": [fail("Service account caelo-mcp@p does not exist.")],
|
|
122
183
|
});
|
|
123
184
|
const slept: number[] = [];
|
package/src/mcp-iap.ts
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* open the admin in a browser can also drive it over MCP
|
|
18
18
|
*
|
|
19
19
|
* Every step is an additive IAM binding or a create-if-missing, so re-running
|
|
20
|
-
* is a no-op.
|
|
20
|
+
* is a no-op. Bindings already in place are not written again, and the
|
|
21
|
+
* result names the ones this run added: IAM applies a new binding only
|
|
22
|
+
* after a while (up to 7 minutes), so the upgrade's health check waits for
|
|
23
|
+
* a refusal to clear only when this run just granted something (#734). The caller puts the returned email into the admin's
|
|
21
24
|
* `CAELO_MCP_IAP_SERVICE_ACCOUNT` env var, which /settings/mcp shows in the
|
|
22
25
|
* `claude mcp add` command.
|
|
23
26
|
*/
|
|
@@ -25,6 +28,7 @@
|
|
|
25
28
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
26
29
|
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
27
30
|
import { MCP_IAP_SERVICE_ACCOUNT_ID, mcpIapServiceAccountEmail } from "./gcp-names.js";
|
|
31
|
+
import { policyGrants } from "./stack-converge.js";
|
|
28
32
|
|
|
29
33
|
/** The admin's IAP resource, as `gcloud iap web` addresses it. */
|
|
30
34
|
export type IapResource =
|
|
@@ -49,13 +53,33 @@ export function iapOperators(policyJson: string): string[] {
|
|
|
49
53
|
return [...new Set(members.filter((m) => m.startsWith("user:") || m.startsWith("group:")))];
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Whether a policy read (JSON) grants `role` to `member`; false when the
|
|
58
|
+
* policy could not be read or parsed, so the binding is written.
|
|
59
|
+
*/
|
|
60
|
+
function readGrants(policyJson: string | null, role: string, member: string): boolean {
|
|
61
|
+
if (policyJson === null) return false;
|
|
62
|
+
try {
|
|
63
|
+
return policyGrants(policyJson, role, member);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
export async function ensureMcpIapAccess(opts: {
|
|
53
70
|
projectId: string;
|
|
54
71
|
resource: IapResource;
|
|
55
72
|
run?: GcloudRunner;
|
|
56
73
|
sleep?: Sleep;
|
|
57
74
|
}): Promise<
|
|
58
|
-
|
|
75
|
+
| {
|
|
76
|
+
ok: true;
|
|
77
|
+
serviceAccount: string;
|
|
78
|
+
operators: string[];
|
|
79
|
+
/** The bindings this run added (empty: everything was in place). */
|
|
80
|
+
granted: string[];
|
|
81
|
+
}
|
|
82
|
+
| { ok: false; error: string }
|
|
59
83
|
> {
|
|
60
84
|
const run = opts.run ?? defaultGcloud;
|
|
61
85
|
const sleep = opts.sleep ?? realSleep;
|
|
@@ -73,7 +97,8 @@ export async function ensureMcpIapAccess(opts: {
|
|
|
73
97
|
project,
|
|
74
98
|
"--format=value(email)",
|
|
75
99
|
]);
|
|
76
|
-
|
|
100
|
+
const created = !describe.ok;
|
|
101
|
+
if (created) {
|
|
77
102
|
const create = await run([
|
|
78
103
|
"iam",
|
|
79
104
|
"service-accounts",
|
|
@@ -98,34 +123,47 @@ export async function ensureMcpIapAccess(opts: {
|
|
|
98
123
|
]);
|
|
99
124
|
if (!policy.ok) return { ok: false, error: `read IAP policy: ${policy.stderr.trim()}` };
|
|
100
125
|
const operators = iapOperators(policy.stdout);
|
|
126
|
+
const granted: string[] = [];
|
|
101
127
|
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
const ACCESSOR = "roles/iap.httpsResourceAccessor";
|
|
129
|
+
if (!readGrants(policy.stdout, ACCESSOR, `serviceAccount:${sa}`)) {
|
|
130
|
+
const allow = await withRetry([
|
|
131
|
+
"iap",
|
|
132
|
+
"web",
|
|
133
|
+
"add-iam-policy-binding",
|
|
134
|
+
...iapResourceArgs(opts.resource),
|
|
135
|
+
`--member=serviceAccount:${sa}`,
|
|
136
|
+
`--role=${ACCESSOR}`,
|
|
137
|
+
"--condition=None",
|
|
138
|
+
project,
|
|
139
|
+
"--quiet",
|
|
140
|
+
]);
|
|
141
|
+
if (!allow.ok) return { ok: false, error: `allow ${sa} on IAP: ${allow.stderr.trim()}` };
|
|
142
|
+
granted.push(`${ACCESSOR} → serviceAccount:${sa}`);
|
|
143
|
+
}
|
|
114
144
|
|
|
145
|
+
const TOKEN_CREATOR = "roles/iam.serviceAccountTokenCreator";
|
|
146
|
+
// A just-created account has no policy yet; an unreadable one is rewritten.
|
|
147
|
+
const saPolicy = created
|
|
148
|
+
? null
|
|
149
|
+
: await run(["iam", "service-accounts", "get-iam-policy", sa, project, "--format=json"]);
|
|
150
|
+
const saPolicyJson = saPolicy?.ok ? saPolicy.stdout : null;
|
|
115
151
|
for (const member of operators) {
|
|
152
|
+
if (readGrants(saPolicyJson, TOKEN_CREATOR, member)) continue;
|
|
116
153
|
const grant = await withRetry([
|
|
117
154
|
"iam",
|
|
118
155
|
"service-accounts",
|
|
119
156
|
"add-iam-policy-binding",
|
|
120
157
|
sa,
|
|
121
158
|
`--member=${member}`,
|
|
122
|
-
|
|
159
|
+
`--role=${TOKEN_CREATOR}`,
|
|
123
160
|
"--condition=None",
|
|
124
161
|
project,
|
|
125
162
|
"--quiet",
|
|
126
163
|
]);
|
|
127
164
|
if (!grant.ok)
|
|
128
165
|
return { ok: false, error: `let ${member} sign as ${sa}: ${grant.stderr.trim()}` };
|
|
166
|
+
granted.push(`${TOKEN_CREATOR} → ${member}`);
|
|
129
167
|
}
|
|
130
|
-
return { ok: true, serviceAccount: sa, operators };
|
|
168
|
+
return { ok: true, serviceAccount: sa, operators, granted };
|
|
131
169
|
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #770 — `owner-reset` beyond gcp-firebase: plain gcp reaches the admin on
|
|
5
|
+
* `admin.<domain>` behind the load balancer's IAP, self-hosted reaches it
|
|
6
|
+
* through `docker compose exec` with nothing secret in the process
|
|
7
|
+
* arguments, aws / azure refuse naming #952 — and on no provider does the
|
|
8
|
+
* reset link (or the token in it) reach a log line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
16
|
+
import {
|
|
17
|
+
describeOwnerReset,
|
|
18
|
+
OWNER_RESET_SCOPE,
|
|
19
|
+
ownerResetTarget,
|
|
20
|
+
requestOwnerReset,
|
|
21
|
+
} from "./owner-reset.js";
|
|
22
|
+
import { writeInternalSecret } from "./self-hosted-internal-secret.js";
|
|
23
|
+
import type { CommandResult, HostRunner, HostRunOptions } from "./self-hosted-release.js";
|
|
24
|
+
|
|
25
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
26
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
27
|
+
|
|
28
|
+
const TOKEN = "tok/with+chars";
|
|
29
|
+
const ISSUED = {
|
|
30
|
+
ok: true,
|
|
31
|
+
email: "owner@example.com",
|
|
32
|
+
token: TOKEN,
|
|
33
|
+
expiresAt: "2026-10-15T12:52:56.000Z",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** gcloud for a gcp install: no Cloud Run lookup is expected (the LB host is the URL). */
|
|
37
|
+
function fakeGcloud(seen: string[][]): (args: string[]) => Promise<GcloudResult> {
|
|
38
|
+
const answers: Record<string, GcloudResult> = {
|
|
39
|
+
"auth list": ok("operator@example.com\n"),
|
|
40
|
+
"secrets versions access": ok("s".repeat(40)),
|
|
41
|
+
"auth print-access-token": ok("ya29.operator\n"),
|
|
42
|
+
"services enable iamcredentials.googleapis.com": ok(),
|
|
43
|
+
};
|
|
44
|
+
return async (args) => {
|
|
45
|
+
seen.push(args);
|
|
46
|
+
const key = Object.keys(answers).find((k) => args.join(" ").startsWith(k));
|
|
47
|
+
return (key && answers[key]) || fail(`unexpected gcloud ${args.join(" ")}`);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Seen {
|
|
52
|
+
url: string;
|
|
53
|
+
headers: Record<string, string>;
|
|
54
|
+
body: Record<string, unknown> | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fakeFetch(admin: () => Response) {
|
|
58
|
+
const seen: Seen[] = [];
|
|
59
|
+
const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
60
|
+
const url = String(input);
|
|
61
|
+
seen.push({
|
|
62
|
+
url,
|
|
63
|
+
headers: Object.fromEntries(new Headers(init?.headers).entries()),
|
|
64
|
+
body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : null,
|
|
65
|
+
});
|
|
66
|
+
if (url.startsWith("https://iamcredentials.googleapis.com/")) {
|
|
67
|
+
return Response.json({ signedJwt: "iap.jwt.signed" });
|
|
68
|
+
}
|
|
69
|
+
return admin();
|
|
70
|
+
}) as typeof fetch;
|
|
71
|
+
return { doFetch, seen };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A host whose `docker compose exec` answers `reply` and records every call. */
|
|
75
|
+
function fakeHost(reply: CommandResult) {
|
|
76
|
+
const calls: { argv: readonly string[]; opts?: HostRunOptions }[] = [];
|
|
77
|
+
const host: HostRunner = {
|
|
78
|
+
run: async (argv, opts) => {
|
|
79
|
+
calls.push({ argv, ...(opts ? { opts } : {}) });
|
|
80
|
+
return reply;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return { host, calls };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const answered = (status: number, body: unknown): CommandResult => ({
|
|
87
|
+
exitCode: 0,
|
|
88
|
+
stdout: JSON.stringify({ status, text: JSON.stringify(body) }),
|
|
89
|
+
stderr: "",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("owner-reset on gcp (IAP on the load balancer)", () => {
|
|
93
|
+
it("calls admin.<domain> with a JWT for that origin and builds the link there", async () => {
|
|
94
|
+
const gcloudCalls: string[][] = [];
|
|
95
|
+
const { doFetch, seen } = fakeFetch(() => Response.json(ISSUED));
|
|
96
|
+
const r = await requestOwnerReset(
|
|
97
|
+
{ provider: "gcp", projectId: "proj", domain: "example.com" },
|
|
98
|
+
{},
|
|
99
|
+
{ run: fakeGcloud(gcloudCalls), fetch: doFetch, now: () => 1_000 },
|
|
100
|
+
);
|
|
101
|
+
expect(r).toEqual({
|
|
102
|
+
ok: true,
|
|
103
|
+
value: {
|
|
104
|
+
email: "owner@example.com",
|
|
105
|
+
resetUrl: "https://admin.example.com/reset?token=tok%2Fwith%2Bchars",
|
|
106
|
+
expiresAt: "2026-10-15T12:52:56.000Z",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const sign = seen[0];
|
|
110
|
+
expect(sign?.url).toContain("caelo-mcp%40proj.iam.gserviceaccount.com:signJwt");
|
|
111
|
+
expect(JSON.parse(String(sign?.body?.payload))).toMatchObject({
|
|
112
|
+
aud: "https://admin.example.com/*",
|
|
113
|
+
iat: 1_000,
|
|
114
|
+
});
|
|
115
|
+
const call = seen[1];
|
|
116
|
+
expect(call?.url).toBe("https://admin.example.com/api/internal/owner-reset");
|
|
117
|
+
expect(call?.headers.authorization).toBe("Bearer iap.jwt.signed");
|
|
118
|
+
expect(call?.body).toEqual({ requestedBy: "operator@example.com" });
|
|
119
|
+
// The run.app URL is not reachable from outside the LB; nothing looks it up.
|
|
120
|
+
expect(gcloudCalls.some((a) => a[0] === "run")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("points an unreachable admin host at its DNS record and certificate", async () => {
|
|
124
|
+
const r = await requestOwnerReset(
|
|
125
|
+
{ provider: "gcp", projectId: "proj", domain: "example.com" },
|
|
126
|
+
{},
|
|
127
|
+
{
|
|
128
|
+
run: fakeGcloud([]),
|
|
129
|
+
fetch: (async (input: RequestInfo | URL) => {
|
|
130
|
+
if (String(input).startsWith("https://iamcredentials")) {
|
|
131
|
+
return Response.json({ signedJwt: "j" });
|
|
132
|
+
}
|
|
133
|
+
throw new TypeError("getaddrinfo ENOTFOUND admin.example.com");
|
|
134
|
+
}) as typeof fetch,
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
expect(r.ok ? "" : r.error).toContain("dig +short admin.example.com");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("owner-reset on self-hosted (docker compose exec)", () => {
|
|
142
|
+
let dir: string;
|
|
143
|
+
const SECRET = "x".repeat(96);
|
|
144
|
+
const target = () => ({
|
|
145
|
+
provider: "self-hosted" as const,
|
|
146
|
+
caeloDir: dir,
|
|
147
|
+
adminUrl: "https://admin.example.com",
|
|
148
|
+
});
|
|
149
|
+
const localUser = () => ({ user: "michael", host: "box.lan" });
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
dir = mkdtempSync(join(tmpdir(), "caelo-owner-reset-"));
|
|
153
|
+
writeInternalSecret(dir, SECRET);
|
|
154
|
+
});
|
|
155
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
156
|
+
|
|
157
|
+
it("runs one exec into caelo-admin with the token and body on stdin only", async () => {
|
|
158
|
+
const { host, calls } = fakeHost(answered(200, ISSUED));
|
|
159
|
+
const r = await requestOwnerReset(
|
|
160
|
+
target(),
|
|
161
|
+
{ email: "owner@example.com" },
|
|
162
|
+
{
|
|
163
|
+
host,
|
|
164
|
+
localUser,
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
expect(r).toEqual({
|
|
168
|
+
ok: true,
|
|
169
|
+
value: {
|
|
170
|
+
email: "owner@example.com",
|
|
171
|
+
resetUrl: "https://admin.example.com/reset?token=tok%2Fwith%2Bchars",
|
|
172
|
+
expiresAt: "2026-10-15T12:52:56.000Z",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
expect(calls).toHaveLength(1);
|
|
176
|
+
const [call] = calls;
|
|
177
|
+
expect(call?.argv.slice(0, 9)).toEqual([
|
|
178
|
+
"docker",
|
|
179
|
+
"compose",
|
|
180
|
+
"-f",
|
|
181
|
+
join(dir, "docker-compose.yml"),
|
|
182
|
+
"exec",
|
|
183
|
+
"-T",
|
|
184
|
+
"caelo-admin",
|
|
185
|
+
"bun",
|
|
186
|
+
"-e",
|
|
187
|
+
]);
|
|
188
|
+
const stdin = JSON.parse(call?.opts?.stdinText ?? "{}") as {
|
|
189
|
+
endpoint: string;
|
|
190
|
+
token: string;
|
|
191
|
+
body: unknown;
|
|
192
|
+
};
|
|
193
|
+
expect(stdin.endpoint).toBe("/api/internal/owner-reset");
|
|
194
|
+
expect(stdin.body).toEqual({
|
|
195
|
+
email: "owner@example.com",
|
|
196
|
+
requestedByHostUser: "michael@box.lan",
|
|
197
|
+
});
|
|
198
|
+
const [message] = stdin.token.split(".");
|
|
199
|
+
expect(Buffer.from(message ?? "", "base64url").toString()).toEndWith(`:${OWNER_RESET_SCOPE}`);
|
|
200
|
+
// `ps` shows argv to every user of the host: neither the secret nor the
|
|
201
|
+
// token may be in it.
|
|
202
|
+
const argv = call?.argv.join(" ") ?? "";
|
|
203
|
+
expect(argv).not.toContain(SECRET);
|
|
204
|
+
expect(argv).not.toContain(stdin.token);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("tells an install whose running admin lacks the secret to run `up`", async () => {
|
|
208
|
+
const { host } = fakeHost(answered(401, { ok: false, error: "missing-secret" }));
|
|
209
|
+
const r = await requestOwnerReset(target(), {}, { host, localUser });
|
|
210
|
+
expect(r.ok ? "" : r.error).toContain("missing-secret");
|
|
211
|
+
expect(r.ok ? "" : r.error).toMatch(/Run `bunx @caelo-cms\/provisioning@\S+ up`/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("names a missing secret file and a stopped admin with the fix", async () => {
|
|
215
|
+
rmSync(join(dir, "internal-secret.json"));
|
|
216
|
+
const none = await requestOwnerReset(
|
|
217
|
+
target(),
|
|
218
|
+
{},
|
|
219
|
+
{ host: fakeHost(answered(200, ISSUED)).host, localUser },
|
|
220
|
+
);
|
|
221
|
+
expect(none.ok ? "" : none.error).toContain("internal-secret.json does not exist");
|
|
222
|
+
|
|
223
|
+
writeInternalSecret(dir, SECRET);
|
|
224
|
+
const stopped = await requestOwnerReset(
|
|
225
|
+
target(),
|
|
226
|
+
{},
|
|
227
|
+
{
|
|
228
|
+
host: fakeHost({ exitCode: 1, stdout: "", stderr: 'service "caelo-admin" is not running' })
|
|
229
|
+
.host,
|
|
230
|
+
localUser,
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
expect(stopped.ok ? "" : stopped.error).toContain('service "caelo-admin" is not running');
|
|
234
|
+
expect(stopped.ok ? "" : stopped.error).toContain("up`");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("names an admin from before #770 for an upgrade", async () => {
|
|
238
|
+
const { host } = fakeHost({
|
|
239
|
+
exitCode: 0,
|
|
240
|
+
stdout: JSON.stringify({ status: 404, text: "Not Found" }),
|
|
241
|
+
stderr: "",
|
|
242
|
+
});
|
|
243
|
+
const r = await requestOwnerReset(target(), {}, { host, localUser });
|
|
244
|
+
expect(r.ok ? "" : r.error).toContain("before #770");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("refuses a host user the admin's audit row would not accept", async () => {
|
|
248
|
+
const { host, calls } = fakeHost(answered(200, ISSUED));
|
|
249
|
+
const r = await requestOwnerReset(
|
|
250
|
+
target(),
|
|
251
|
+
{},
|
|
252
|
+
{ host, localUser: () => ({ user: "evil user\n", host: "box" }) },
|
|
253
|
+
);
|
|
254
|
+
expect(r.ok).toBe(false);
|
|
255
|
+
expect(calls).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("ownerResetTarget", () => {
|
|
260
|
+
const meta = {
|
|
261
|
+
installId: "example.com",
|
|
262
|
+
projectId: "proj",
|
|
263
|
+
region: "europe-west1",
|
|
264
|
+
domain: "example.com",
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
it("addresses gcp-firebase by project + region and gcp by project + domain", () => {
|
|
268
|
+
expect(ownerResetTarget({ ...meta, provider: "gcp-firebase" })).toEqual({
|
|
269
|
+
ok: true,
|
|
270
|
+
value: { provider: "gcp-firebase", projectId: "proj", region: "europe-west1" },
|
|
271
|
+
});
|
|
272
|
+
expect(ownerResetTarget({ ...meta, provider: "gcp" })).toEqual({
|
|
273
|
+
ok: true,
|
|
274
|
+
value: { provider: "gcp", projectId: "proj", domain: "example.com" },
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("refuses aws and azure naming #952 and the ways in meanwhile", () => {
|
|
279
|
+
for (const provider of ["aws", "azure"] as const) {
|
|
280
|
+
const r = ownerResetTarget({ ...meta, provider });
|
|
281
|
+
expect(r.ok).toBe(false);
|
|
282
|
+
expect(r.ok ? "" : r.error).toContain("#952");
|
|
283
|
+
expect(r.ok ? "" : r.error).toContain("Settings -> Users");
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("sends a self-hosted install to its .caelo/ directory", () => {
|
|
288
|
+
const r = ownerResetTarget({ ...meta, provider: "self-hosted", projectId: null });
|
|
289
|
+
expect(r.ok ? "" : r.error).toContain(".caelo/");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("the reset link is never logged", () => {
|
|
294
|
+
const LOGGERS = ["log", "info", "warn", "error", "debug"] as const;
|
|
295
|
+
|
|
296
|
+
async function captured(run: () => Promise<unknown>): Promise<string> {
|
|
297
|
+
const lines: string[] = [];
|
|
298
|
+
const spies = [
|
|
299
|
+
...LOGGERS.map((m) =>
|
|
300
|
+
spyOn(console, m).mockImplementation((...a: unknown[]) => {
|
|
301
|
+
lines.push(a.map(String).join(" "));
|
|
302
|
+
}),
|
|
303
|
+
),
|
|
304
|
+
spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
|
305
|
+
lines.push(String(chunk));
|
|
306
|
+
return true;
|
|
307
|
+
}),
|
|
308
|
+
spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
309
|
+
lines.push(String(chunk));
|
|
310
|
+
return true;
|
|
311
|
+
}),
|
|
312
|
+
];
|
|
313
|
+
try {
|
|
314
|
+
await run();
|
|
315
|
+
} finally {
|
|
316
|
+
for (const s of spies) s.mockRestore();
|
|
317
|
+
}
|
|
318
|
+
return lines.join("\n");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
it("on gcp-firebase, gcp and self-hosted, nothing is printed while the link is fetched", async () => {
|
|
322
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-owner-reset-log-"));
|
|
323
|
+
writeInternalSecret(dir, "x".repeat(96));
|
|
324
|
+
try {
|
|
325
|
+
const firebaseRun = async (args: string[]): Promise<GcloudResult> => {
|
|
326
|
+
const line = args.join(" ");
|
|
327
|
+
if (line.startsWith("run services list")) return ok("caelo-production-admin-abc\n");
|
|
328
|
+
if (line.startsWith("run services describe")) return ok("https://admin-abc.a.run.app\n");
|
|
329
|
+
return fakeGcloud([])(args);
|
|
330
|
+
};
|
|
331
|
+
const results: unknown[] = [];
|
|
332
|
+
const output = await captured(async () => {
|
|
333
|
+
results.push(
|
|
334
|
+
await requestOwnerReset(
|
|
335
|
+
{ provider: "gcp-firebase", projectId: "proj", region: "europe-west1" },
|
|
336
|
+
{},
|
|
337
|
+
{ run: firebaseRun, fetch: fakeFetch(() => Response.json(ISSUED)).doFetch },
|
|
338
|
+
),
|
|
339
|
+
await requestOwnerReset(
|
|
340
|
+
{ provider: "gcp", projectId: "proj", domain: "example.com" },
|
|
341
|
+
{},
|
|
342
|
+
{ run: fakeGcloud([]), fetch: fakeFetch(() => Response.json(ISSUED)).doFetch },
|
|
343
|
+
),
|
|
344
|
+
await requestOwnerReset(
|
|
345
|
+
{ provider: "self-hosted", caeloDir: dir, adminUrl: "https://admin.example.com" },
|
|
346
|
+
{},
|
|
347
|
+
{
|
|
348
|
+
host: fakeHost(answered(200, ISSUED)).host,
|
|
349
|
+
localUser: () => ({ user: "op", host: "box" }),
|
|
350
|
+
},
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
expect(results.every((r) => (r as { ok: boolean }).ok)).toBe(true);
|
|
355
|
+
expect(output).not.toContain(TOKEN);
|
|
356
|
+
expect(output).not.toContain(encodeURIComponent(TOKEN));
|
|
357
|
+
} finally {
|
|
358
|
+
rmSync(dir, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("the printed lines are the only place the link appears", () => {
|
|
363
|
+
const text = describeOwnerReset({
|
|
364
|
+
email: "owner@example.com",
|
|
365
|
+
resetUrl: "https://admin.example.com/reset?token=abc",
|
|
366
|
+
expiresAt: "2026-10-15T12:52:56.000Z",
|
|
367
|
+
}).join("\n");
|
|
368
|
+
expect(text).toContain("not saved anywhere");
|
|
369
|
+
});
|
|
370
|
+
});
|
package/src/owner-reset.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* #769 — `owner-reset` asks the admin for a reset link through IAP (as
|
|
4
|
+
* #769 — `owner-reset` on gcp-firebase asks the admin for a reset link through IAP (as
|
|
5
5
|
* caelo-mcp) with the internal token for scope `auth.owner_reset`, and builds
|
|
6
6
|
* the link on the admin URL the operator can open.
|
|
7
7
|
*/
|
|
@@ -13,7 +13,7 @@ import { describeOwnerReset, OWNER_RESET_SCOPE, requestOwnerReset } from "./owne
|
|
|
13
13
|
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
14
14
|
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
15
15
|
const ADMIN = "https://caelo-production-admin-abc-ew.a.run.app";
|
|
16
|
-
const target = { projectId: "proj", region: "europe-west1" };
|
|
16
|
+
const target = { provider: "gcp-firebase" as const, projectId: "proj", region: "europe-west1" };
|
|
17
17
|
|
|
18
18
|
function fakeGcloud(
|
|
19
19
|
overrides: Record<string, GcloudResult> = {},
|