@pithy-sh/cloudflare 0.3.1 → 0.4.1
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/client/errors.d.ts +7 -4
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +75 -6
- package/dist/secrets/secretsStoreManager.d.ts +57 -6
- package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
- package/dist/secrets/secretsStoreManager.js +113 -18
- package/dist/test-utils/fixtures.d.ts +7 -0
- package/dist/test-utils/fixtures.d.ts.map +1 -1
- package/dist/test-utils/fixtures.js +7 -0
- package/dist/tokens/accountTokensManager.d.ts +64 -6
- package/dist/tokens/accountTokensManager.d.ts.map +1 -1
- package/dist/tokens/accountTokensManager.js +85 -8
- package/dist/tokens/permissions.d.ts +13 -0
- package/dist/tokens/permissions.d.ts.map +1 -1
- package/dist/tokens/permissions.js +17 -2
- package/dist/tokens/profiles.d.ts +15 -0
- package/dist/tokens/profiles.d.ts.map +1 -1
- package/dist/tokens/profiles.js +44 -3
- package/dist/workers/buildsManager.js +11 -3
- package/package.json +2 -2
- package/src/client/errors.ts +78 -14
- package/src/secrets/secretsStoreManager.ts +126 -18
- package/src/test-utils/fixtures.ts +7 -0
- package/src/tokens/accountTokensManager.ts +132 -5
- package/src/tokens/permissions.ts +29 -0
- package/src/tokens/profiles.ts +49 -2
- package/src/workers/buildsManager.ts +10 -3
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { JsonDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
-
import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { ConflictError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { CloudflareNotConfiguredError, cloudflareRequest, decodeResponse } from "../client/errors";
|
|
8
8
|
import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
|
|
@@ -22,6 +22,13 @@ export const CfSecretEntry = z
|
|
|
22
22
|
status: z.enum(["pending", "active", "deleted"]).describe("The secret's lifecycle status in the store."),
|
|
23
23
|
created: JsonDate.describe("When the secret was created (ISO string on the wire, Date in app)."),
|
|
24
24
|
modified: JsonDate.describe("When the secret was last modified (ISO string on the wire, Date in app)."),
|
|
25
|
+
comment: z
|
|
26
|
+
.string()
|
|
27
|
+
.nullable()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe(
|
|
30
|
+
"Freeform text Cloudflare stores beside the value and returns on list and get. The one field of an entry a write sets and a read answers, so it is the only REST-readable fact *about* a value — never key material, and never a substitute for reading the value through its binding. Nullable **and** optional because the live API returns an explicit `null` for an entry nothing annotated, not an absent key: declared `.optional()` alone, every list against a store holding one un-annotated entry failed its own schema and threw `cloudflare/invalid_response`, which took `entriesNamed` and therefore the rotation's edit-only write with it. Found against a real store, not a mock (#647).",
|
|
31
|
+
),
|
|
25
32
|
})
|
|
26
33
|
.describe("A single Cloudflare Secrets Store secret's metadata (never its plaintext value).");
|
|
27
34
|
export type CfSecretEntry = z.output<typeof CfSecretEntry>;
|
|
@@ -29,9 +36,30 @@ export type CfSecretEntry = z.output<typeof CfSecretEntry>;
|
|
|
29
36
|
/** What {@link CloudflareSecretsStoreManager.createSecretIfAbsent} did — see it for each value. */
|
|
30
37
|
export type CreateSecretOutcome = "created" | "present" | "unconfirmed";
|
|
31
38
|
|
|
32
|
-
/**
|
|
33
|
-
function
|
|
34
|
-
return
|
|
39
|
+
/** Oldest first, the smaller id breaking a tie — one order whoever asks, so two readers never disagree. */
|
|
40
|
+
function byAge(a: CfSecretEntry, b: CfSecretEntry): number {
|
|
41
|
+
return a.created.getTime() - b.created.getTime() || a.id.localeCompare(b.id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A `deleted` entry is not there (#643); a `pending` one is, since creating over it would duplicate it.
|
|
46
|
+
* Cloudflare lists the three statuses without saying what each means, and a deleted entry is the one
|
|
47
|
+
* reading that cannot be "there".
|
|
48
|
+
*/
|
|
49
|
+
function isLive(entry: CfSecretEntry): boolean {
|
|
50
|
+
return entry.status !== "deleted";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `comment` a write sends, or no such field at all when the caller passed none.
|
|
55
|
+
*
|
|
56
|
+
* Omitted rather than sent empty, so a write with no comment is byte-identical to what this manager
|
|
57
|
+
* sent before the field existed — and so a caller that has nothing to say cannot blank an annotation
|
|
58
|
+
* somebody else wrote. The comment is a plain string here on purpose: what belongs in one is
|
|
59
|
+
* `@pithy-sh/secrets`' question, and the codec lives where it is read rather than where it is posted.
|
|
60
|
+
*/
|
|
61
|
+
function commentField(comment: string | undefined): { comment?: string } {
|
|
62
|
+
return comment === undefined ? {} : { comment };
|
|
35
63
|
}
|
|
36
64
|
|
|
37
65
|
/** Config for the Secrets Store manager: the shared client config plus the store it targets. */
|
|
@@ -81,11 +109,20 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
|
|
|
81
109
|
* update leaves the prior value intact and bound — there is no window where the secret is absent
|
|
82
110
|
* from the store (which, for a secret like the master encryption key, is a platform-level outage).
|
|
83
111
|
* Scopes are re-sent so an entry converges on the same shape whichever branch wrote it.
|
|
112
|
+
*
|
|
113
|
+
* **It upserts, and at a name nothing reads that is the whole problem.** A caller replacing a value
|
|
114
|
+
* that must already exist wants {@link updateExistingSecret}: here a stale or misspelled name finds
|
|
115
|
+
* nothing, creates an entry, and answers 200 — leaving live key material at an address no binding
|
|
116
|
+
* resolves while the value the caller meant to replace goes on being served. This one is for a
|
|
117
|
+
* caller that genuinely means either branch.
|
|
118
|
+
*
|
|
119
|
+
* `comment` is the non-secret text written beside the value, and the only thing about a value REST
|
|
120
|
+
* can read back. Omit it and whatever comment is on the entry is left exactly as it stands.
|
|
84
121
|
*/
|
|
85
|
-
async putSecret(name: string, value: string): Promise<void> {
|
|
122
|
+
async putSecret(name: string, value: string, comment?: string): Promise<void> {
|
|
86
123
|
const existing = await this.findByName(name);
|
|
87
124
|
if (!existing) {
|
|
88
|
-
await this.createSecret(name, value);
|
|
125
|
+
await this.createSecret(name, value, comment);
|
|
89
126
|
return;
|
|
90
127
|
}
|
|
91
128
|
|
|
@@ -95,6 +132,7 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
|
|
|
95
132
|
store_id: this.storeId,
|
|
96
133
|
value,
|
|
97
134
|
scopes: [...DEFAULT_SCOPES],
|
|
135
|
+
...commentField(comment),
|
|
98
136
|
}),
|
|
99
137
|
);
|
|
100
138
|
}
|
|
@@ -115,17 +153,16 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
|
|
|
115
153
|
* if another is older this one is the loser and removes itself. The oldest wins, deterministically, whichever
|
|
116
154
|
* run looks. A failure with no entry there afterwards — an outage, a refused token — is thrown as it came.
|
|
117
155
|
*/
|
|
118
|
-
async createSecretIfAbsent(name: string, value: string): Promise<CreateSecretOutcome> {
|
|
156
|
+
async createSecretIfAbsent(name: string, value: string, comment?: string): Promise<CreateSecretOutcome> {
|
|
119
157
|
if (await this.findByName(name)) return "present";
|
|
120
158
|
let ownId: string | undefined;
|
|
121
159
|
try {
|
|
122
|
-
ownId = await this.createSecret(name, value);
|
|
160
|
+
ownId = await this.createSecret(name, value, comment);
|
|
123
161
|
} catch (error) {
|
|
124
162
|
if (await this.findByName(name)) return "unconfirmed";
|
|
125
163
|
throw error;
|
|
126
164
|
}
|
|
127
|
-
const
|
|
128
|
-
const oldest = oldestEntry(same);
|
|
165
|
+
const oldest = (await this.entriesNamed(name))[0];
|
|
129
166
|
if (oldest === undefined || ownId === undefined || oldest.id === ownId) return "created";
|
|
130
167
|
// Another run's entry of the same name is older: it is the one that stands.
|
|
131
168
|
await cloudflareRequest(`delete duplicate secret ${name}`, () =>
|
|
@@ -137,6 +174,62 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
|
|
|
137
174
|
return "present";
|
|
138
175
|
}
|
|
139
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Write a value to an entry that must already be there, refusing to create one.
|
|
179
|
+
*
|
|
180
|
+
* **The verb that exists because {@link putSecret} upserts.** A put addressed to a name nothing reads
|
|
181
|
+
* — a composed name gone stale, a misspelled binding, an environment segment that never got
|
|
182
|
+
* substituted — finds no entry, creates one, and answers 200. The caller hears success, the value it
|
|
183
|
+
* meant to replace is untouched and still bound, and an orphan entry nobody reads sits in the store
|
|
184
|
+
* holding live key material. That is how a sibling codebase lost an environment's secrets: the
|
|
185
|
+
* master-key envelope went to a name the `SECRETS_ENCRYPTION_KEYS` binding does not resolve, every
|
|
186
|
+
* row was re-encrypted under the new key, and the binding kept serving the old one. Nothing returned
|
|
187
|
+
* an error, anywhere.
|
|
188
|
+
*
|
|
189
|
+
* So the create branch is removed rather than guarded. A caller replacing a value has already
|
|
190
|
+
* asserted the entry exists — it is bound, something reads it — and where that assertion is false the
|
|
191
|
+
* honest answer is a refusal before any write, not a second entry. {@link createSecretIfAbsent}
|
|
192
|
+
* establishes a secret, this replaces one, and `putSecret` stays for a caller that means either.
|
|
193
|
+
*
|
|
194
|
+
* **Two live entries of one name is a refusal too, and the same one in spirit.** The oldest wins
|
|
195
|
+
* everywhere else here, which is right for a create that raced. It is wrong for a replace: editing
|
|
196
|
+
* one of two is a coin toss over which value the binding goes on serving. The store is ambiguous, so
|
|
197
|
+
* the caller is told, before anything is written.
|
|
198
|
+
*
|
|
199
|
+
* Name-addressed rather than id-addressed, deliberately: the name is what a caller composes and what
|
|
200
|
+
* a binding resolves, so addressing by it keeps the misaddressing this verb exists to catch inside
|
|
201
|
+
* the one thing that can catch it. Returns the entry id written, so a caller reading back afterwards
|
|
202
|
+
* can assert it is looking at the same address. `comment` is written beside the value when given.
|
|
203
|
+
*/
|
|
204
|
+
async updateExistingSecret(name: string, value: string, comment?: string): Promise<string> {
|
|
205
|
+
const named = await this.entriesNamed(name);
|
|
206
|
+
const target = named[0];
|
|
207
|
+
if (!target) {
|
|
208
|
+
throw new NotFoundError({
|
|
209
|
+
message: `Secret '${name}' is not in the store, so there is nothing to update.`,
|
|
210
|
+
action: "Run `pithy secrets provision` to establish the entry, then run this again.",
|
|
211
|
+
detail: `update secret ${name}: no live entry of that name, and this verb never creates one`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (named.length > 1) {
|
|
215
|
+
throw new ConflictError({
|
|
216
|
+
message: `Secret '${name}' names ${named.length} live entries in the store, so a replacement is ambiguous.`,
|
|
217
|
+
action: "Delete the entries nothing binds from the Secrets Store in the Cloudflare dashboard, then re-run.",
|
|
218
|
+
detail: `update secret ${name}: ${named.map((entry) => entry.id).join(", ")}`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
await cloudflareRequest(`update secret ${name}`, () =>
|
|
222
|
+
this.getClient().secretsStore.stores.secrets.edit(target.id, {
|
|
223
|
+
account_id: this.accountId,
|
|
224
|
+
store_id: this.storeId,
|
|
225
|
+
value,
|
|
226
|
+
scopes: [...DEFAULT_SCOPES],
|
|
227
|
+
...commentField(comment),
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
return target.id;
|
|
231
|
+
}
|
|
232
|
+
|
|
140
233
|
/** Delete a secret by name. Resolves the id via `listSecrets`, then issues DELETE by id. */
|
|
141
234
|
async deleteSecret(name: string): Promise<void> {
|
|
142
235
|
const existing = await this.findByName(name);
|
|
@@ -196,6 +289,23 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
|
|
|
196
289
|
});
|
|
197
290
|
}
|
|
198
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Every live entry carrying this name, oldest first.
|
|
294
|
+
*
|
|
295
|
+
* **The plural is the point.** Cloudflare does not document that the store refuses a duplicate name,
|
|
296
|
+
* and #643 established that it does not — so "the entry named X" is a claim rather than a given. A
|
|
297
|
+
* caller whose correctness depends on there being exactly one checks `length` here and says what it
|
|
298
|
+
* wants done when the store disagrees; {@link findByName} is the answer for a caller that only needs
|
|
299
|
+
* the entry the rest of this manager would act on.
|
|
300
|
+
*
|
|
301
|
+
* **A `deleted` entry is not one** (#643): Cloudflare lists a secret's lifecycle status as `pending`,
|
|
302
|
+
* `active` or `deleted` without saying what each means, and a deleted entry is the one reading that
|
|
303
|
+
* cannot be "there". `pending` is taken as there, since creating over it would duplicate it.
|
|
304
|
+
*/
|
|
305
|
+
async entriesNamed(name: string): Promise<CfSecretEntry[]> {
|
|
306
|
+
return (await this.listSecrets()).filter((entry) => entry.name === name && isLive(entry)).sort(byAge);
|
|
307
|
+
}
|
|
308
|
+
|
|
199
309
|
/** Whether a secret with the given name currently exists in the store. */
|
|
200
310
|
async exists(name: string): Promise<boolean> {
|
|
201
311
|
return (await this.findByName(name)) !== undefined;
|
|
@@ -207,11 +317,11 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
|
|
|
207
317
|
}
|
|
208
318
|
|
|
209
319
|
/** Create a single secret with the default scopes, and return the id Cloudflare gave it when it says one. */
|
|
210
|
-
private async createSecret(name: string, value: string): Promise<string | undefined> {
|
|
320
|
+
private async createSecret(name: string, value: string, comment?: string): Promise<string | undefined> {
|
|
211
321
|
return cloudflareRequest(`create secret ${name}`, async () => {
|
|
212
322
|
const response: unknown = await this.getClient().secretsStore.stores.secrets.create(this.storeId, {
|
|
213
323
|
account_id: this.accountId,
|
|
214
|
-
body: [{ name, value, scopes: [...DEFAULT_SCOPES] }],
|
|
324
|
+
body: [{ name, value, scopes: [...DEFAULT_SCOPES], ...commentField(comment) }],
|
|
215
325
|
});
|
|
216
326
|
// The SDK hands back a page of what was created. Read defensively: an id is a convenience here, and a
|
|
217
327
|
// response without one leaves `createSecretIfAbsent` answering `created` as it would have before.
|
|
@@ -225,13 +335,11 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
|
|
|
225
335
|
}
|
|
226
336
|
|
|
227
337
|
/**
|
|
228
|
-
* The entry of this name, or `undefined`.
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* it. Of several entries of one name, the oldest, which is the one {@link createSecretIfAbsent} leaves standing.
|
|
338
|
+
* The entry of this name every verb here acts on, or `undefined`. Of several entries of one name, the
|
|
339
|
+
* oldest — which is the one {@link createSecretIfAbsent} leaves standing. See {@link entriesNamed} for
|
|
340
|
+
* why there can be several, and for what counts as being there at all.
|
|
232
341
|
*/
|
|
233
342
|
private async findByName(name: string): Promise<CfSecretEntry | undefined> {
|
|
234
|
-
|
|
235
|
-
return oldestEntry(all.filter((entry) => entry.name === name && entry.status !== "deleted"));
|
|
343
|
+
return (await this.entriesNamed(name))[0];
|
|
236
344
|
}
|
|
237
345
|
}
|
|
@@ -202,6 +202,13 @@ export const LIVE_FIXTURES = {
|
|
|
202
202
|
consequence: "Turnstile sign-in gating on a workers.dev hostname (#84) skips.",
|
|
203
203
|
doc: "docs/FIXTURES.md#turnstile-widget",
|
|
204
204
|
},
|
|
205
|
+
"workers-route-zone": {
|
|
206
|
+
name: "workers-route-zone",
|
|
207
|
+
keys: ["WORKERS_ROUTE_ZONE"],
|
|
208
|
+
shape: "credential",
|
|
209
|
+
consequence: "The route-scoped CI token round trip (#651) skips: nothing proves a minted token can attach a route.",
|
|
210
|
+
doc: "docs/FIXTURES.md#workers-route-zone",
|
|
211
|
+
},
|
|
205
212
|
} as const satisfies Record<string, LiveFixture>;
|
|
206
213
|
|
|
207
214
|
/** Every declared fixture name. A typo is a type error rather than a silently skipped suite. */
|
|
@@ -56,6 +56,36 @@ export function accountResource(accountId: string): Record<string, string> {
|
|
|
56
56
|
return { [`com.cloudflare.api.account.${accountId}`]: "*" };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The resource scope for a **zone-level** policy: one `com.cloudflare.api.account.zone.<id>` key per
|
|
61
|
+
* zone, each `"*"`.
|
|
62
|
+
*
|
|
63
|
+
* Every zone is named individually, and that is the point (#651). Cloudflare also spells "every zone in
|
|
64
|
+
* this account" — `{ "com.cloudflare.api.account.<id>": { "com.cloudflare.api.account.zone.*": "*" } }` —
|
|
65
|
+
* and a `ci-system` token must never carry it: the credential a pipeline runs under gets the zones the
|
|
66
|
+
* project's own declared domains sit in and no others, so an account holding a customer's zone beside
|
|
67
|
+
* this project's is not one compromise away from both. De-duped, because two environments and two
|
|
68
|
+
* Workers routinely share one zone.
|
|
69
|
+
*/
|
|
70
|
+
export function zoneResources(zoneIds: readonly string[]): Record<string, string> {
|
|
71
|
+
const resources: Record<string, string> = {};
|
|
72
|
+
for (const zoneId of zoneIds) resources[`${ZONE_RESOURCE_PREFIX}${zoneId}`] = "*";
|
|
73
|
+
return resources;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read an explicit `null` as "the response did not say", keeping the field **optional** rather than
|
|
78
|
+
* required-and-undefined.
|
|
79
|
+
*
|
|
80
|
+
* Every optional field decoded off a token list goes through this, and it is load-bearing rather than
|
|
81
|
+
* defensive (#651): `listTokens` decodes each entry with `safeParse` and drops what fails, so one `null`
|
|
82
|
+
* where a schema wanted a value makes a live token invisible to `findTokenByName` — and the next roll
|
|
83
|
+
* mints a *second* credential of the same name beside the first, with nothing saying so.
|
|
84
|
+
*/
|
|
85
|
+
function nullAsAbsent<T extends z.ZodTypeAny>(schema: T) {
|
|
86
|
+
return z.preprocess((value) => value ?? undefined, schema);
|
|
87
|
+
}
|
|
88
|
+
|
|
59
89
|
/**
|
|
60
90
|
* A permission group available to account-owned tokens, as returned by the permission-groups list.
|
|
61
91
|
* Only the id and name matter for resolution — the manager maps a requested name to its id.
|
|
@@ -78,21 +108,83 @@ export const MintedAccountToken = z
|
|
|
78
108
|
id: z.string().describe("The CF-assigned token id, used to address the token for get/delete."),
|
|
79
109
|
value: z.string().describe("The secret token value — returned once on create, never again. Store immediately."),
|
|
80
110
|
name: z.string().optional().describe("The token name as registered with Cloudflare."),
|
|
81
|
-
status:
|
|
111
|
+
status: nullAsAbsent(
|
|
112
|
+
z
|
|
113
|
+
.enum(["active", "disabled", "expired"])
|
|
114
|
+
.describe("The lifecycle state Cloudflare holds for the token.")
|
|
115
|
+
.optional(),
|
|
116
|
+
).describe("The token's lifecycle status."),
|
|
82
117
|
})
|
|
83
118
|
.describe("A newly created account-owned API token, including its one-time secret value.");
|
|
84
119
|
export type MintedAccountToken = z.output<typeof MintedAccountToken>;
|
|
85
120
|
|
|
121
|
+
/**
|
|
122
|
+
* One access policy on an existing token, as the token list returns it.
|
|
123
|
+
*
|
|
124
|
+
* **Every field is `nullish`, and that is load-bearing rather than defensive.** `listTokens` decodes
|
|
125
|
+
* each entry with `safeParse` and drops what fails, so a single `null` where this expected a value
|
|
126
|
+
* makes a live token invisible to `findTokenByName` — and the next roll mints a *second* credential of
|
|
127
|
+
* the same name beside the first, with nothing saying so (#651).
|
|
128
|
+
*/
|
|
129
|
+
export const AccountTokenPolicy = z
|
|
130
|
+
.object({
|
|
131
|
+
effect: nullAsAbsent(
|
|
132
|
+
z.enum(["allow", "deny"]).describe("Allow or deny, as Cloudflare states it on the policy.").optional(),
|
|
133
|
+
).describe(
|
|
134
|
+
"Whether this policy grants its groups on its resources or refuses them. Cloudflare evaluates explicit denies first, so a policy naming a group is not evidence the token may use it. Absent is an allow, which is the API's own default.",
|
|
135
|
+
),
|
|
136
|
+
permission_groups: nullAsAbsent(
|
|
137
|
+
z
|
|
138
|
+
.array(
|
|
139
|
+
z
|
|
140
|
+
.object({ id: z.string().describe("The permission-group id this policy grants.") })
|
|
141
|
+
.describe("One permission group referenced by a live token's policy, by the id the account assigned it."),
|
|
142
|
+
)
|
|
143
|
+
.optional(),
|
|
144
|
+
).describe(
|
|
145
|
+
"The permission groups this policy grants, by id. **Read together with `resources`, never apart**: a zone resource says which zone a policy is about and nothing about what it may do there, so coverage judged from resources alone reads a zone-scoped `Zone Read` as a route grant. Absent has not said the policy grants nothing.",
|
|
146
|
+
),
|
|
147
|
+
resources: z
|
|
148
|
+
.record(z.string(), z.unknown())
|
|
149
|
+
.describe(
|
|
150
|
+
"The resource scope keys this policy applies to — `com.cloudflare.api.account.<id>` for an account policy, `com.cloudflare.api.account.zone.<id>` for a zone one, and either nested inside the other. Read to tell what a live token is scoped to; the values are not interpreted beyond that nesting.",
|
|
151
|
+
),
|
|
152
|
+
})
|
|
153
|
+
.describe("One access policy on an existing token: the groups it grants, and the resources it grants them on.");
|
|
154
|
+
export type AccountTokenPolicy = z.output<typeof AccountTokenPolicy>;
|
|
155
|
+
|
|
86
156
|
/**
|
|
87
157
|
* An existing account-owned token's metadata, as returned by list/get. No `value` — Cloudflare never
|
|
88
|
-
* returns a token's secret after creation — so this is only enough to find a token by name
|
|
89
|
-
* address it for deletion.
|
|
158
|
+
* returns a token's secret after creation — so this is only enough to find a token by name, report on
|
|
159
|
+
* it, and address it for deletion.
|
|
160
|
+
*
|
|
161
|
+
* **It reads what it uses and nothing more.** It briefly carried `expires_on`, `not_before` and
|
|
162
|
+
* `condition` so a re-scoping mint could resend them; that mint is gone (see {@link
|
|
163
|
+
* CloudflareAccountTokensManager.rollToken}), and reading a field only to hand it straight back is a
|
|
164
|
+
* field that can be read wrong.
|
|
90
165
|
*/
|
|
91
166
|
export const AccountTokenSummary = z
|
|
92
167
|
.object({
|
|
93
168
|
id: z.string().describe("The CF-assigned token id, used to address the token for deletion."),
|
|
94
169
|
name: z.string().describe("The token name, the key callers match on for idempotent re-mint."),
|
|
95
|
-
status:
|
|
170
|
+
status: nullAsAbsent(
|
|
171
|
+
z
|
|
172
|
+
.enum(["active", "disabled", "expired"])
|
|
173
|
+
.describe("The lifecycle state Cloudflare holds for the token.")
|
|
174
|
+
.optional(),
|
|
175
|
+
).describe(
|
|
176
|
+
"The token's lifecycle status. Reported, because a `disabled` or `expired` token cannot attach a route however its policies read. `nullish` for the reason {@link AccountTokenPolicy} gives.",
|
|
177
|
+
),
|
|
178
|
+
policies: nullAsAbsent(z.array(AccountTokenPolicy).optional())
|
|
179
|
+
// **An undecodable policy set costs the scope, never the token.** `resources` is required inside a
|
|
180
|
+
// policy, so one shape Cloudflare returns that this does not model would fail the whole entry —
|
|
181
|
+
// `listTokens` drops what fails, `findTokenByName` answers null, and the roll mints a *second* live
|
|
182
|
+
// credential of the same name beside the first. A token's identity does not depend on its policies
|
|
183
|
+
// being readable, and "the scope could not be read" is a state the reporting already has a word for.
|
|
184
|
+
.catch(undefined)
|
|
185
|
+
.describe(
|
|
186
|
+
"The token's access policies, when the list response carries them. **A live token's own scope is the record of how it was minted** — it is how a `ci-system` token minted before zone-scoped routes (#651) can be told from one minted after, without reading the token record (which needs a grant these least-privilege tokens deliberately lack). Absent and empty are different facts: a response that says nothing means the scope could not be read, never that the token has none.",
|
|
187
|
+
),
|
|
96
188
|
})
|
|
97
189
|
.describe("An existing account-owned API token's metadata (never its secret value).");
|
|
98
190
|
export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;
|
|
@@ -230,7 +322,7 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
|
|
|
230
322
|
throw new CloudflareNotConfiguredError(
|
|
231
323
|
{
|
|
232
324
|
message: said.message,
|
|
233
|
-
action:
|
|
325
|
+
action: delegationAction(permissions),
|
|
234
326
|
params: said.params,
|
|
235
327
|
detail: `mint account token '${name}': ${messageOf(error)}`,
|
|
236
328
|
},
|
|
@@ -365,6 +457,18 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
|
|
|
365
457
|
* Either way the caller gets a usable secret to store — the idempotent path for a credential that
|
|
366
458
|
* lives in the Secrets Store, since Cloudflare never returns a token's existing secret. Mirrors the
|
|
367
459
|
* dashboard's roll-or-create.
|
|
460
|
+
*
|
|
461
|
+
* **A roll never changes the policy set, and that is a decision rather than an omission (#651).** It
|
|
462
|
+
* briefly did: `pithy token list` tells an adopter their CI token predates zone-scoped routes, and
|
|
463
|
+
* re-scoping here made the remedy one command. But Cloudflare's token update is a full
|
|
464
|
+
* representation, so doing it meant every mint had to resend the whole token correctly — `condition`,
|
|
465
|
+
* `expires_on`, `not_before`, `status` — and three review rounds each found a new way that went
|
|
466
|
+
* wrong: a cleared IP allowlist, a re-enabled disabled token, a `null` that hid the token and minted a
|
|
467
|
+
* duplicate beside it.
|
|
468
|
+
*
|
|
469
|
+
* {@link mintToken} plus a delete is what actually re-scopes, and `pithy token rotate` has always been
|
|
470
|
+
* exactly that. So the remedy names rotate, and this stays the one thing it can be relied on to be:
|
|
471
|
+
* a new secret on the same token, with nothing else touched.
|
|
368
472
|
*/
|
|
369
473
|
async rollToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken> {
|
|
370
474
|
const existing = await this.findTokenByName(name);
|
|
@@ -374,6 +478,29 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
|
|
|
374
478
|
}
|
|
375
479
|
}
|
|
376
480
|
|
|
481
|
+
/**
|
|
482
|
+
* The remedy line for a 403 on a mint, which depends on **what was being delegated**.
|
|
483
|
+
*
|
|
484
|
+
* Cloudflare only lets a token create a token whose permissions it already holds, so a mint carrying a
|
|
485
|
+
* zone-scoped policy (#651's Workers Routes Write, on the project's declared zones) fails for a
|
|
486
|
+
* bootstrap token that holds every account grant and no zone one. Telling that operator to grant
|
|
487
|
+
* "Account API Tokens Write" is true, useless, and sends them to a checkbox that is already ticked.
|
|
488
|
+
*
|
|
489
|
+
* So the zone half is named when a zone policy is in the set, and only then.
|
|
490
|
+
*/
|
|
491
|
+
function delegationAction(permissions: readonly TokenPermission[]): string {
|
|
492
|
+
const base = "Grant it 'Account API Tokens Write' (Account → API Tokens → Edit), then re-run.";
|
|
493
|
+
const zoneScoped = permissions.filter((permission) =>
|
|
494
|
+
Object.keys(permission.resources).some((key) => key.startsWith(ZONE_RESOURCE_PREFIX)),
|
|
495
|
+
);
|
|
496
|
+
if (zoneScoped.length === 0) return base;
|
|
497
|
+
const groups = [...new Set(zoneScoped.flatMap((permission) => permission.permissionGroupNames))].join(", ");
|
|
498
|
+
return `${base} It must also already hold what it is delegating: ${groups} on the zones being scoped (Zone → Workers Routes → Edit), because Cloudflare only lets a token create a token whose permissions it has.`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The resource-key prefix every zone-scoped policy is written under. */
|
|
502
|
+
const ZONE_RESOURCE_PREFIX = "com.cloudflare.api.account.zone.";
|
|
503
|
+
|
|
377
504
|
/** The rolled secret value Cloudflare returns from a value-roll — a non-empty bearer string. */
|
|
378
505
|
const RolledTokenValue = z.string().min(1);
|
|
379
506
|
|
|
@@ -34,16 +34,45 @@ export const PERMISSION_GROUPS = {
|
|
|
34
34
|
// so `pithy init` and `pithy worker add` can offer the account's real zones instead of asking someone
|
|
35
35
|
// to paste an id off a dashboard page.
|
|
36
36
|
"zone:read": ["Zone Read"],
|
|
37
|
+
// *Attaching* the route, which is the other half of the sentence above and the half nothing could do
|
|
38
|
+
// (#651). `pithy deploy` of an environment with a declared domain calls
|
|
39
|
+
// `POST /zones/<zone>/workers/routes`, and no group in this catalog reached it — a `ci-system` token
|
|
40
|
+
// met "No access to the specified resource" on the first CI deploy that served a custom domain.
|
|
41
|
+
//
|
|
42
|
+
// **Zone-scoped**, and therefore listed in {@link ZONE_SCOPED_PERMISSIONS}: Cloudflare publishes this
|
|
43
|
+
// group under `com.cloudflare.api.account.zone`, so a token carrying it needs a *zone* resource, and
|
|
44
|
+
// the account resource every other key here uses grants it nothing. Which zones is not a choice —
|
|
45
|
+
// it is the project's declared `domains`, resolved at mint time.
|
|
46
|
+
//
|
|
47
|
+
// Still not a group that can alter a zone. "Workers Routes Write" writes routes on a zone; changing
|
|
48
|
+
// the zone itself would be "Zone Write", which this catalog does not have and will not get.
|
|
49
|
+
"routes:write": ["Workers Routes Write"],
|
|
37
50
|
} as const;
|
|
38
51
|
|
|
39
52
|
/** A known permission key — one of {@link PERMISSION_GROUPS}'s keys. */
|
|
40
53
|
export type PermissionKey = keyof typeof PERMISSION_GROUPS;
|
|
41
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The keys whose Cloudflare permission group is **zone-scoped**: a policy carrying one must name zone
|
|
57
|
+
* resources (`com.cloudflare.api.account.zone.<id>`), never the account resource.
|
|
58
|
+
*
|
|
59
|
+
* A minted token's resources are account-scoped by default, which is why this distinction has to be
|
|
60
|
+
* stated rather than inferred: hand a zone-level group the account resource and Cloudflare accepts the
|
|
61
|
+
* token and refuses the call, which is exactly the failure #651 opened with. The zones themselves are
|
|
62
|
+
* never picked by hand — they are the zones the project's declared domains sit in.
|
|
63
|
+
*/
|
|
64
|
+
export const ZONE_SCOPED_PERMISSIONS: readonly PermissionKey[] = ["routes:write"];
|
|
65
|
+
|
|
42
66
|
/** Narrow an arbitrary string to a {@link PermissionKey}. */
|
|
43
67
|
export function isPermissionKey(key: string): key is PermissionKey {
|
|
44
68
|
return key in PERMISSION_GROUPS;
|
|
45
69
|
}
|
|
46
70
|
|
|
71
|
+
/** Whether a key's permission group is zone-scoped — see {@link ZONE_SCOPED_PERMISSIONS}. */
|
|
72
|
+
export function isZoneScopedPermission(key: string): boolean {
|
|
73
|
+
return (ZONE_SCOPED_PERMISSIONS as readonly string[]).includes(key);
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
/**
|
|
48
77
|
* Resolve permission keys to the CF permission-group display names they grant, de-duped in first-seen
|
|
49
78
|
* order. An unknown key fails with an actionable error naming the valid keys — caught at the CLI/config
|
package/src/tokens/profiles.ts
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Capability, TokenProfileSeam } from "@pithy-sh/core/src/capability/capability";
|
|
5
5
|
import { CloudflareNotConfiguredError } from "../client/errors";
|
|
6
|
-
import { accountResource, type TokenPermission } from "./accountTokensManager";
|
|
7
|
-
import {
|
|
6
|
+
import { accountResource, type TokenPermission, zoneResources } from "./accountTokensManager";
|
|
7
|
+
import {
|
|
8
|
+
isPermissionKey,
|
|
9
|
+
isZoneScopedPermission,
|
|
10
|
+
PERMISSION_GROUPS,
|
|
11
|
+
type PermissionKey,
|
|
12
|
+
resolvePermissionKeys,
|
|
13
|
+
} from "./permissions";
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Where a minted token's value is written.
|
|
@@ -221,17 +227,58 @@ export function resolveProfile(
|
|
|
221
227
|
};
|
|
222
228
|
}
|
|
223
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Refuse a zone-scoped key anywhere a policy is being built **account-scoped**.
|
|
232
|
+
*
|
|
233
|
+
* A zone-level permission group handed the account resource is the quiet failure (#651): Cloudflare
|
|
234
|
+
* mints the token without complaint and then answers "No access to the specified resource" at the call,
|
|
235
|
+
* which is a CI failure hours later with nothing in it naming the scope. Which zones a token gets is
|
|
236
|
+
* never a typed choice — it is the project's declared `domains`, resolved at mint time — so a key naming
|
|
237
|
+
* one in a profile or behind `--permission` is a mistake, and this is where it is caught.
|
|
238
|
+
*/
|
|
239
|
+
function assertAccountScoped(keys: readonly string[], where: string): void {
|
|
240
|
+
const zoneScoped = keys.filter(isZoneScopedPermission);
|
|
241
|
+
if (zoneScoped.length === 0) return;
|
|
242
|
+
throw new CloudflareNotConfiguredError({
|
|
243
|
+
message: `${where} names zone-scoped permission(s): ${zoneScoped.join(", ")}.`,
|
|
244
|
+
action:
|
|
245
|
+
"A zone permission is not chosen by hand — declare the Worker's `domains` in pithy.config.ts and the mint scopes the token to those zones. Remove the key.",
|
|
246
|
+
detail: `build token policy: ${zoneScoped.join(", ")} cannot take the account resource`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
224
250
|
/** Build the account-scoped {@link TokenPermission}s for a set of permission keys — the mint input. */
|
|
225
251
|
export function permissionsForKeys(keys: PermissionKey[], accountId: string): TokenPermission[] {
|
|
252
|
+
assertAccountScoped(keys, "This token");
|
|
226
253
|
return [{ permissionGroupNames: resolvePermissionKeys(keys), resources: accountResource(accountId) }];
|
|
227
254
|
}
|
|
228
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The **zone-scoped** policy a project's declared domains require: Workers Routes Write on exactly those
|
|
258
|
+
* zones, and nothing else.
|
|
259
|
+
*
|
|
260
|
+
* This is the second policy on the `ci-system` token, beside the account-scoped one every profile
|
|
261
|
+
* builds. It exists because `pithy deploy` of an environment with a declared domain calls
|
|
262
|
+
* `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
|
|
263
|
+
* it is a policy of its own rather than a permission on the profile because its resources are zones,
|
|
264
|
+
* which a profile cannot name: they are resolved from the domains at mint time.
|
|
265
|
+
*
|
|
266
|
+
* **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
|
|
267
|
+
* so it mints exactly the token it minted before this existed. No new permission for a project that
|
|
268
|
+
* needs none.
|
|
269
|
+
*/
|
|
270
|
+
export function routePermissions(zoneIds: readonly string[]): TokenPermission[] {
|
|
271
|
+
if (zoneIds.length === 0) return [];
|
|
272
|
+
return [{ permissionGroupNames: [...PERMISSION_GROUPS["routes:write"]], resources: zoneResources(zoneIds) }];
|
|
273
|
+
}
|
|
274
|
+
|
|
229
275
|
/**
|
|
230
276
|
* Turn a resolved profile into the {@link TokenPermission}s the account-tokens manager mints from: the
|
|
231
277
|
* profile's permission keys resolved to CF group names, scoped to the account (or the profile's
|
|
232
278
|
* explicit resources). One policy per profile — a token carries exactly the access its profile declares.
|
|
233
279
|
*/
|
|
234
280
|
export function profilePermissions(profile: TokenProfile, accountId: string): TokenPermission[] {
|
|
281
|
+
assertAccountScoped(profile.permissions, `Token profile "${profile.name}"`);
|
|
235
282
|
const resources =
|
|
236
283
|
profile.resources && profile.resources !== "account" ? profile.resources : accountResource(accountId);
|
|
237
284
|
return [{ permissionGroupNames: resolvePermissionKeys(profile.permissions), resources }];
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import {
|
|
6
7
|
CloudflareInvalidResponseError,
|
|
7
|
-
CloudflareRequestError,
|
|
8
8
|
cloudflareApiErrors,
|
|
9
9
|
cloudflareRefusal,
|
|
10
10
|
cloudflareRequest,
|
|
@@ -350,12 +350,19 @@ export class CloudflareBuildsManager extends CloudflareManager {
|
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
/**
|
|
353
|
-
* Whether a thrown
|
|
353
|
+
* Whether a thrown Cloudflare refusal carries the given CF error code. Reads the `[cf-codes:…]`
|
|
354
354
|
* marker `call()` pins to the front of `detail` (stable across the 2000-char truncation); falls back
|
|
355
355
|
* to a substring scan for errors raised without the structured marker.
|
|
356
|
+
*
|
|
357
|
+
* **Narrowed on `PithyError`, not on one subclass of it (#647).** This read used to require a
|
|
358
|
+
* `CloudflareRequestError`, which was true of every refusal for as long as `cloudflareRefusal` composed
|
|
359
|
+
* exactly one class. It no longer does: a 429 or a 5xx is now an `UpstreamError`, so that narrowing
|
|
360
|
+
* would have silently stopped seeing a CF error code the moment Cloudflare answered one under a status
|
|
361
|
+
* that retries. The code lives in `detail`, which every `PithyError` carries, so the payload is what to
|
|
362
|
+
* read and the class is not.
|
|
356
363
|
*/
|
|
357
364
|
function hasErrorCode(error: unknown, code: number): boolean {
|
|
358
|
-
if (!(error instanceof
|
|
365
|
+
if (!(error instanceof PithyError)) return false;
|
|
359
366
|
const detail = error.payload.detail ?? "";
|
|
360
367
|
const marker = detail.match(/^\[cf-codes:([\d,]+)\]/);
|
|
361
368
|
if (marker?.[1]) return marker[1].split(",").includes(String(code));
|