@pithy-sh/secrets 0.1.0
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/LICENSE +21 -0
- package/README.md +15 -0
- package/package.json +52 -0
- package/pithy.manifest.json +48 -0
- package/src/admin/health.ts +94 -0
- package/src/admin/status.ts +462 -0
- package/src/audit/actions.ts +53 -0
- package/src/capability.ts +219 -0
- package/src/cli/audit.ts +33 -0
- package/src/cli/dispatch.ts +192 -0
- package/src/cli/partialWrite.ts +69 -0
- package/src/cli/rotationLedger.ts +98 -0
- package/src/cli/validate.ts +38 -0
- package/src/cli/writeTargets.ts +125 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/crypto/envelope.ts +188 -0
- package/src/crypto/versionedValue.ts +82 -0
- package/src/data/secretRotations.ts +49 -0
- package/src/data/statusDb.ts +29 -0
- package/src/data/systemSecrets.ts +44 -0
- package/src/data/tables.ts +16 -0
- package/src/dev/devSecretsFile.ts +167 -0
- package/src/dev/loadDevSecrets.ts +128 -0
- package/src/dev/seedDevSecrets.ts +447 -0
- package/src/env/bindings.ts +84 -0
- package/src/error/errors.ts +155 -0
- package/src/http/guards.ts +107 -0
- package/src/http/responses.ts +225 -0
- package/src/http/rotate.ts +224 -0
- package/src/http/routes.ts +300 -0
- package/src/http/schemas.ts +53 -0
- package/src/http/view.ts +74 -0
- package/src/index.ts +50 -0
- package/src/keyspace.ts +70 -0
- package/src/keyspaceWrite.ts +135 -0
- package/src/management/writeSecret.ts +120 -0
- package/src/manager/configWriter.ts +19 -0
- package/src/manager/dispatcher.ts +142 -0
- package/src/manager/managerRegistry.ts +53 -0
- package/src/manager/retryPolicy.ts +44 -0
- package/src/manager/rotationWorkflow.ts +26 -0
- package/src/manager/secretsConfigWriter.ts +61 -0
- package/src/manager/worker.ts +119 -0
- package/src/manager/wrangler.jsonc +76 -0
- package/src/manager/writeWorkflow.ts +162 -0
- package/src/migrations/0001_init.ts +53 -0
- package/src/mintValue.ts +53 -0
- package/src/provision/provisionSecrets.ts +206 -0
- package/src/provision/resolveManagerConfig.ts +175 -0
- package/src/registry.ts +453 -0
- package/src/rotation/atRestKeyRotation.ts +146 -0
- package/src/rotation/keyRotation.ts +139 -0
- package/src/rotation/rotateValue.ts +412 -0
- package/src/rotation/rotationLedger.ts +167 -0
- package/src/rotation/valueRotator.ts +76 -0
- package/src/scope.ts +120 -0
- package/src/secretsStore.ts +765 -0
- package/src/sharedSecretsStore.ts +187 -0
- package/src/store/rotationTracker.ts +189 -0
- package/src/store/systemSecretsStore.ts +223 -0
- package/src/test-utils/devEncryptionKeys.ts +30 -0
- package/src/test-utils/secretFixtures.ts +178 -0
- package/src/valueBearing.ts +42 -0
- package/src/version.generated.ts +16 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SecretRotation } from "@pithy-sh/core/src/capability/secretOrigin";
|
|
5
|
+
import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
6
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
7
|
+
import type { DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
8
|
+
import { type Kysely, sql } from "kysely";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { RotationStatus, RotationTrigger } from "../data/secretRotations";
|
|
11
|
+
import type { SecretsTables } from "../data/tables";
|
|
12
|
+
import { SecretBackend, type SecretRegistry, SecretValueType } from "../registry";
|
|
13
|
+
import type { CarriesNoValue } from "../valueBearing";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The read behind "is any secret overdue" — metadata about secrets, and never a secret.
|
|
17
|
+
*
|
|
18
|
+
* ## The one constraint everything here is arranged around
|
|
19
|
+
*
|
|
20
|
+
* A status read must not be able to disclose a value. Not "the projection happens to omit it" — the
|
|
21
|
+
* shapes below must be **incapable** of carrying one, so widening them is a compile error rather than a
|
|
22
|
+
* review somebody has to catch. `packages/auth/src/admin/users.ts` states the first half of the rule:
|
|
23
|
+
* *a projection cannot leak a column that was not selected*. This file adds the second: a type that has
|
|
24
|
+
* no field for a value cannot be handed one, whatever a future query selects.
|
|
25
|
+
*
|
|
26
|
+
* Four layers, because each catches what the others cannot:
|
|
27
|
+
*
|
|
28
|
+
* 1. **The queries name their columns.** `encrypted_value` and `iv` never reach the Worker's memory on
|
|
29
|
+
* a status read, so nothing downstream can leak them by accident.
|
|
30
|
+
* 2. **The shapes are Zod objects and rows are parsed through them**, so an unknown column is stripped
|
|
31
|
+
* rather than passed along — a `selectAll()` written here later still discloses nothing.
|
|
32
|
+
* 3. **{@link SECRET_STATUS_CARRIES_NO_VALUE} is a compile-time tripwire.** Adding a banned field to
|
|
33
|
+
* either shape makes this module fail to typecheck. The type behind it is `../valueBearing.ts`,
|
|
34
|
+
* moved out of this file so that `http/responses.ts` — a module a browser imports — can make the
|
|
35
|
+
* same promise without acquiring this file's Kysely reader and the D1 layer behind it (#419).
|
|
36
|
+
* 4. **`status.test.ts` asserts the exact field set**, so *any* new field — not only a banned one — has
|
|
37
|
+
* to be argued for by somebody editing a test that says why.
|
|
38
|
+
*
|
|
39
|
+
* ## `errorMessage` and `metadataSnapshot` are refused, and they are the interesting ones
|
|
40
|
+
*
|
|
41
|
+
* Both columns exist on `pithy_secrets_rotations` and neither is exposed. They are free text written at
|
|
42
|
+
* a failure site, which is precisely where a value gets pasted by accident — an exception message that
|
|
43
|
+
* interpolated what it was decrypting, a snapshot taken "for debugging". A failed rotation is still
|
|
44
|
+
* reported: {@link SecretRotationRecord.status} says `failed`, which is the fact an owner acts on. If a
|
|
45
|
+
* reason is ever wanted it belongs here as a **code** the capability defines, never as the message.
|
|
46
|
+
*
|
|
47
|
+
* ## What is reported, and what is deliberately not
|
|
48
|
+
*
|
|
49
|
+
* One row per **named** registry entry, from the registry the Worker actually composed — so a status
|
|
50
|
+
* read covers every capability's secrets (auth's signing key, email's link key), not only the ones the
|
|
51
|
+
* adopter typed. Keyed entries are excluded: a keyspace has no single value, and its members are
|
|
52
|
+
* per-tenant rows created at runtime, so listing them would turn a status read into a tenant
|
|
53
|
+
* enumeration. Stored rows that no registry entry names are excluded for the same reason — reaching
|
|
54
|
+
* them means listing the table, and the table is where keyspace members live.
|
|
55
|
+
*
|
|
56
|
+
* The whole-store at-rest key rotation records itself under a sentinel name (`AT_REST_ROTATION_NAME`)
|
|
57
|
+
* that no registry entry can carry, so it falls out of every query here without a filter. It is an
|
|
58
|
+
* event about the store, not about a secret.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** The Kysely instance these reads run against — typed over the secrets tables, CamelCasePlugin installed. */
|
|
62
|
+
export type SecretsStatusDb = Kysely<DatabaseSchema<SecretsTables>>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One rotation attempt, as an owner may see it: when, how it ended, what caused it, and who.
|
|
66
|
+
*
|
|
67
|
+
* Ordered newest first by the reader. `completedAt` null with status `in_progress` is a rotation still
|
|
68
|
+
* running; a `failed` row says only that it failed, on purpose — see the file comment.
|
|
69
|
+
*/
|
|
70
|
+
export const SecretRotationRecord = z
|
|
71
|
+
.object({
|
|
72
|
+
startedAt: SQLiteDate.describe("When the rotation attempt began. Ms-epoch in SQLite, a `Date` here."),
|
|
73
|
+
completedAt: SQLiteDate.nullable().describe(
|
|
74
|
+
"When it finished, or null while it is still running. Ms-epoch in SQLite, a `Date` here.",
|
|
75
|
+
),
|
|
76
|
+
status: RotationStatus.describe(
|
|
77
|
+
"How it ended: `in_progress`, `success`, or `failed`. A failure reports as a status and never as a message.",
|
|
78
|
+
),
|
|
79
|
+
trigger: RotationTrigger.describe(
|
|
80
|
+
"What caused it — a scheduled `cron` run, a `manual` action, or the `baseline` marker written when a secret is first stored.",
|
|
81
|
+
),
|
|
82
|
+
rotatedBy: z.string().describe("Who or what initiated it: a workflow instance id, an operator id, or `baseline`."),
|
|
83
|
+
})
|
|
84
|
+
.describe("One rotation attempt, in metadata only. Carries no value, no ciphertext, and no failure text.");
|
|
85
|
+
export type SecretRotationRecord = z.output<typeof SecretRotationRecord>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One secret's status: what the registry declares about it, what the store knows about it, and whether
|
|
89
|
+
* it is late.
|
|
90
|
+
*
|
|
91
|
+
* **Null is load-bearing in three different ways here, and they are not the same fact.**
|
|
92
|
+
* `lastRotatedAt: null` means never rotated — which is not zero, not the epoch, and not "rotated a long
|
|
93
|
+
* time ago". `createdAt: null` means nothing is stored under this name in the secrets D1: either the
|
|
94
|
+
* secret was declared and never written, or it lives in Cloudflare's Secrets Store, which is why
|
|
95
|
+
* {@link SecretStatus.backend} is reported — without it the nulls are unreadable. `overdue: null` means
|
|
96
|
+
* the question has no answer, either because no cadence is declared or because there is no date to
|
|
97
|
+
* measure from.
|
|
98
|
+
*/
|
|
99
|
+
export const SecretStatus = z
|
|
100
|
+
.object({
|
|
101
|
+
name: z.string().describe("The secret's registry name."),
|
|
102
|
+
backend: SecretBackend.describe(
|
|
103
|
+
"Where the value physically lives. Reported because it decides what a null `createdAt` means: a `cf-secrets-store` secret never has a row in this database.",
|
|
104
|
+
),
|
|
105
|
+
valueType: SecretValueType.describe("How the value is interpreted, from the registry: `text` or `json`."),
|
|
106
|
+
rotatable: z
|
|
107
|
+
.boolean()
|
|
108
|
+
.describe(
|
|
109
|
+
"Whether a value-rotator may manage this secret. It changes what automation may do and never what an owner may see — a `false` secret reports exactly like a `true` one.",
|
|
110
|
+
),
|
|
111
|
+
rotation: SecretRotation.nullable().describe(
|
|
112
|
+
"How this secret is replaced, from its registry entry: `local` (the kit mints another), `provider` (its issuer is called and returns one), or `manual` (a human in a console, with the issuer and the page named). Null when the entry declares none, which is a different fact from `manual` — nobody has said, rather than somebody has said it takes a human. **Not derivable from `rotatable`, and not a duplicate of it**: `SECRETS_ENCRYPTION_KEYS` is `local` and `rotatable: false`, while a payments credential is `rotatable: true` and rotates only by hand. Metadata by construction — a kind, an issuer and a documentation URL, none of which a value fits in.",
|
|
113
|
+
),
|
|
114
|
+
keyVersion: z
|
|
115
|
+
.number()
|
|
116
|
+
.int()
|
|
117
|
+
.nullable()
|
|
118
|
+
.describe(
|
|
119
|
+
"Which master-key version the stored envelope sits under, or null when nothing is stored here. A number, never key material.",
|
|
120
|
+
),
|
|
121
|
+
createdAt: SQLiteDate.nullable().describe(
|
|
122
|
+
"When the secret was first written to this store, or null when it is not stored here.",
|
|
123
|
+
),
|
|
124
|
+
updatedAt: SQLiteDate.nullable().describe("When its value was last written, or null when it is not stored here."),
|
|
125
|
+
lastRotatedAt: SQLiteDate.nullable().describe(
|
|
126
|
+
"The newest rotation that completed successfully, or **null for never rotated** — which is a different fact from rotated long ago, and must not render as one.",
|
|
127
|
+
),
|
|
128
|
+
rotationCount: z
|
|
129
|
+
.number()
|
|
130
|
+
.int()
|
|
131
|
+
.nonnegative()
|
|
132
|
+
.describe("How many rotation attempts are recorded for this secret, successful or not."),
|
|
133
|
+
rotateEveryDays: z
|
|
134
|
+
.number()
|
|
135
|
+
.int()
|
|
136
|
+
.positive()
|
|
137
|
+
.nullable()
|
|
138
|
+
.describe(
|
|
139
|
+
"The cadence the registry declares for this secret, or null when it declares none. The capability's own statement of what late means.",
|
|
140
|
+
),
|
|
141
|
+
overdue: z
|
|
142
|
+
.boolean()
|
|
143
|
+
.nullable()
|
|
144
|
+
.describe(
|
|
145
|
+
"Whether it is past its declared cadence. Null when the question has no answer: no cadence declared, or nothing to measure from.",
|
|
146
|
+
),
|
|
147
|
+
})
|
|
148
|
+
.describe("One secret's status — registry declaration, store metadata, and freshness. Never a value.");
|
|
149
|
+
export type SecretStatus = z.output<typeof SecretStatus>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The compile-time half of the constraint. `true` only while neither shape names a value-bearing field;
|
|
153
|
+
* add one and this assignment fails, naming the file rather than waiting for a reviewer.
|
|
154
|
+
*/
|
|
155
|
+
export const SECRET_STATUS_CARRIES_NO_VALUE: CarriesNoValue<SecretStatus> & CarriesNoValue<SecretRotationRecord> = true;
|
|
156
|
+
|
|
157
|
+
/** Milliseconds in a day. Cadences are declared in days because that is the unit people reason about. */
|
|
158
|
+
const MS_PER_DAY = 86_400_000;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a secret is past its declared cadence.
|
|
162
|
+
*
|
|
163
|
+
* Returns null rather than false when it cannot be decided — no cadence declared, or no date to measure
|
|
164
|
+
* from. False would claim the secret is fine, which is a different and more comfortable answer than
|
|
165
|
+
* "nobody has said what fine is", and comfort is the wrong default on this surface.
|
|
166
|
+
*/
|
|
167
|
+
export function overdueAgainst(reference: Date | null, rotateEveryDays: number | null, now: Date): boolean | null {
|
|
168
|
+
if (rotateEveryDays === null || reference === null) return null;
|
|
169
|
+
return now.getTime() - reference.getTime() > rotateEveryDays * MS_PER_DAY;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The named (non-keyed) entries of a registry, sorted — the set a status read reports over. */
|
|
173
|
+
function reportableNames(registry: SecretRegistry): string[] {
|
|
174
|
+
return Object.entries(registry)
|
|
175
|
+
.filter(([, entry]) => !entry.keyed)
|
|
176
|
+
.map(([name]) => name)
|
|
177
|
+
.sort();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** What the secrets table knows about one name. No envelope columns are selected, so none can be returned. */
|
|
181
|
+
interface StoredFacts {
|
|
182
|
+
keyVersion: number;
|
|
183
|
+
createdAt: Date;
|
|
184
|
+
updatedAt: Date;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** What the rotations table knows about one name, aggregated. */
|
|
188
|
+
interface RotationFacts {
|
|
189
|
+
rotationCount: number;
|
|
190
|
+
lastRotatedAt: Date | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One row of a batch read as the read found it: its facts, or that the row is there and would not decode.
|
|
195
|
+
*
|
|
196
|
+
* **The state rides on the value (`#384`, `#387`).** Every read in this file is a batch — a chunk of names
|
|
197
|
+
* against one statement — so a row that throws is a row that costs every other name in the chunk. Holding
|
|
198
|
+
* the outcome on the value means a caller cannot reach the facts without narrowing, and forgetting the
|
|
199
|
+
* unreadable case is a compile error rather than a silent empty.
|
|
200
|
+
*
|
|
201
|
+
* **Absent is a third fact and it is not in this union.** A name with no row is simply not a key in the
|
|
202
|
+
* map, which is what `SecretStatus`'s nulls already mean: declared and never written, or living in
|
|
203
|
+
* Cloudflare's Secrets Store. *Missing* and *malformed* have different remedies — write it, versus repair
|
|
204
|
+
* the row — and folding either into the other reports a stored secret as unprovisioned.
|
|
205
|
+
*
|
|
206
|
+
* `unreadable` carries nothing, for the reason `#384` gives: there is nothing safe to put on it. What the
|
|
207
|
+
* decode rejected is a column value from a row about a secret, and the name it is filed under is already
|
|
208
|
+
* the key.
|
|
209
|
+
*/
|
|
210
|
+
type Decoded<T> = { state: "readable"; facts: T } | { state: "unreadable" };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Store metadata per name, in as few statements as D1's bound-parameter cap allows.
|
|
214
|
+
*
|
|
215
|
+
* The column list is the security boundary — `encryptedValue` and `iv` are on this table and are not
|
|
216
|
+
* named, so a status read never pulls a ciphertext into the Worker at all.
|
|
217
|
+
*
|
|
218
|
+
* **The date decode is per row since `#387`.** It sat inside `for (const row of rows)` unguarded, which is
|
|
219
|
+
* easy to miss because it does not read like parsing a row — it reads like converting a field. One corrupt
|
|
220
|
+
* ms-epoch threw out of the loop and lost the whole chunk, so every secret in it reported nothing on
|
|
221
|
+
* account of one.
|
|
222
|
+
*/
|
|
223
|
+
async function storedFacts(db: SecretsStatusDb, names: string[]): Promise<Map<string, Decoded<StoredFacts>>> {
|
|
224
|
+
const found = new Map<string, Decoded<StoredFacts>>();
|
|
225
|
+
for (const chunk of chunkByBoundParameters(names, 0)) {
|
|
226
|
+
const rows = await db
|
|
227
|
+
.selectFrom("pithySecretsSystemSecrets")
|
|
228
|
+
.select(["name", "keyVersion", "createdAt", "updatedAt"])
|
|
229
|
+
.where("name", "in", chunk)
|
|
230
|
+
.execute();
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
// Dates decode here rather than at the shape, so every date this module handles is a `Date` and
|
|
233
|
+
// the ms-epoch the column actually holds stops being something a caller could get wrong.
|
|
234
|
+
//
|
|
235
|
+
// The `catch` takes no binding. A `SQLiteDate` rejection carries the offending column value as the
|
|
236
|
+
// issue's `input`, and these rows sit beside `error_message` and `metadata_snapshot` — free text
|
|
237
|
+
// written where a value was in scope. Nothing derived from the failure may travel, and nothing can,
|
|
238
|
+
// because there is nothing in scope to attach.
|
|
239
|
+
try {
|
|
240
|
+
found.set(row.name, {
|
|
241
|
+
state: "readable",
|
|
242
|
+
facts: {
|
|
243
|
+
keyVersion: row.keyVersion,
|
|
244
|
+
createdAt: SQLiteDate.parse(row.createdAt),
|
|
245
|
+
updatedAt: SQLiteDate.parse(row.updatedAt),
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
} catch {
|
|
249
|
+
found.set(row.name, { state: "unreadable" });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return found;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Rotation counts and the newest successful completion per name, in one grouped statement per chunk.
|
|
258
|
+
*
|
|
259
|
+
* `max(case when …)` rather than a second query: the newest *successful* completion is a different row
|
|
260
|
+
* from the newest attempt, and a failed rotation must never advance the freshness of a secret it did
|
|
261
|
+
* not rotate. The raw fragment names physical columns because `CamelCasePlugin` transforms identifiers
|
|
262
|
+
* the builder produces and leaves raw SQL alone — the same rule `packages/auth/src/admin/users.ts`
|
|
263
|
+
* follows for its `escape` clause.
|
|
264
|
+
*
|
|
265
|
+
* **The third site of `#387`'s shape, and it was not in the issue.** `#387` named `storedFacts` and
|
|
266
|
+
* `readSecretRotations`; this loop decodes `lastRotatedAt` exactly as `storedFacts` decodes its two, from
|
|
267
|
+
* the same aggregate over the same table, and was unguarded for the same reason — it reads like a field
|
|
268
|
+
* conversion. Worth stating plainly: the sweep that produced the issue looked at this file and did not
|
|
269
|
+
* see it, which is the argument for asking the question again rather than for trusting a list.
|
|
270
|
+
*/
|
|
271
|
+
async function rotationFacts(db: SecretsStatusDb, names: string[]): Promise<Map<string, Decoded<RotationFacts>>> {
|
|
272
|
+
const found = new Map<string, Decoded<RotationFacts>>();
|
|
273
|
+
for (const chunk of chunkByBoundParameters(names, 0)) {
|
|
274
|
+
const rows = await db
|
|
275
|
+
.selectFrom("pithySecretsRotations")
|
|
276
|
+
.select((eb) => [
|
|
277
|
+
"name",
|
|
278
|
+
eb.fn.countAll<number>().as("rotationCount"),
|
|
279
|
+
sql<number | null>`max(case when status = 'success' then completed_at end)`.as("lastRotatedAt"),
|
|
280
|
+
])
|
|
281
|
+
.where("name", "in", chunk)
|
|
282
|
+
.groupBy("name")
|
|
283
|
+
.execute();
|
|
284
|
+
for (const row of rows) {
|
|
285
|
+
// Null is not a failure here and must not become one: it is the aggregate saying this secret has
|
|
286
|
+
// never rotated successfully, which is the fact `SecretStatus.lastRotatedAt` is documented to carry.
|
|
287
|
+
// Only a non-null value that will not decode is unreadable.
|
|
288
|
+
try {
|
|
289
|
+
found.set(row.name, {
|
|
290
|
+
state: "readable",
|
|
291
|
+
facts: {
|
|
292
|
+
rotationCount: Number(row.rotationCount),
|
|
293
|
+
lastRotatedAt: row.lastRotatedAt === null ? null : SQLiteDate.parse(row.lastRotatedAt),
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
} catch {
|
|
297
|
+
found.set(row.name, { state: "unreadable" });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return found;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* One declared secret's place in a status read: its status, or that a row about it would not decode.
|
|
306
|
+
*
|
|
307
|
+
* **A bad row is held against its own name (`#170`, `#384`).** The read is registry-driven and every name
|
|
308
|
+
* in it is one an operator declared, so the whole list still comes back and the one that could not be read
|
|
309
|
+
* says so under the name it belongs to. `#350` already made a throw here survivable — the capability
|
|
310
|
+
* reports `unavailable` and its siblings are fine — and that is a different thing from correct: the
|
|
311
|
+
* information was still lost for every secret because of one row, and the manifest could not say which.
|
|
312
|
+
*
|
|
313
|
+
* The name is safe to carry, and that was checked rather than assumed. It comes from
|
|
314
|
+
* `reportableNames(registry)`, so it is a registry literal an operator wrote. Keyed entries are excluded
|
|
315
|
+
* from this read, so no `<keyspace>/<key>` — a stored name embedding a tenant identifier from caller input
|
|
316
|
+
* — can appear here. That is the trap `#384` hit and had to correct.
|
|
317
|
+
*/
|
|
318
|
+
export type SecretStatusEntry =
|
|
319
|
+
| {
|
|
320
|
+
/** The row decoded, and this secret's status is below. */
|
|
321
|
+
state: "readable";
|
|
322
|
+
/** Its declaration, its store metadata, and whether it is late. */
|
|
323
|
+
status: SecretStatus;
|
|
324
|
+
}
|
|
325
|
+
| {
|
|
326
|
+
/** A row this secret's status is built from did not decode. Its facts are not knowable from here. */
|
|
327
|
+
state: "unreadable";
|
|
328
|
+
/** Which secret. A registry name, never a stored one. */
|
|
329
|
+
name: string;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* One entry of a secret's rotation history: the record, or that the row would not decode.
|
|
334
|
+
*
|
|
335
|
+
* Unlike {@link SecretStatusEntry} this carries no name, and that is not an oversight. Every row in the
|
|
336
|
+
* page is the *same* secret's — the name is the argument the read was called with, and it is echoed once
|
|
337
|
+
* by the caller. What distinguishes a row here is its position in a history, which the array preserves: a
|
|
338
|
+
* bad row costs its own entry and the rows around it still resolve, in order.
|
|
339
|
+
*
|
|
340
|
+
* Nothing else rides on the unreadable member. `startedAt` is the field most likely to be the one that
|
|
341
|
+
* would not decode, so a "when" would be exactly the thing that is missing.
|
|
342
|
+
*/
|
|
343
|
+
export type SecretRotationEntry =
|
|
344
|
+
| {
|
|
345
|
+
/** The row decoded. */
|
|
346
|
+
state: "readable";
|
|
347
|
+
/** One rotation attempt, in metadata only. */
|
|
348
|
+
record: SecretRotationRecord;
|
|
349
|
+
}
|
|
350
|
+
| {
|
|
351
|
+
/** The row did not decode, and holds its own place in the history rather than emptying it. */
|
|
352
|
+
state: "unreadable";
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** Options for {@link readSecretStatus}. */
|
|
356
|
+
export interface SecretStatusOptions {
|
|
357
|
+
/** The clock `overdue` is measured against. Injected so a freshness test does not have to wait 90 days. */
|
|
358
|
+
now?: Date;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Every declared secret's status, by name.
|
|
363
|
+
*
|
|
364
|
+
* Registry-driven rather than table-driven, because the interesting cases are the ones with no row: a
|
|
365
|
+
* secret declared and never written is a real answer, and a secret that lives in Cloudflare's Secrets
|
|
366
|
+
* Store has no row here by design. A table-driven read would report neither and would additionally have
|
|
367
|
+
* to enumerate keyspace members to find them.
|
|
368
|
+
*/
|
|
369
|
+
export async function readSecretStatus(
|
|
370
|
+
db: SecretsStatusDb,
|
|
371
|
+
registry: SecretRegistry,
|
|
372
|
+
options: SecretStatusOptions = {},
|
|
373
|
+
): Promise<SecretStatusEntry[]> {
|
|
374
|
+
const names = reportableNames(registry);
|
|
375
|
+
if (names.length === 0) return [];
|
|
376
|
+
const now = options.now ?? new Date();
|
|
377
|
+
const [stored, rotations] = await Promise.all([storedFacts(db, names), rotationFacts(db, names)]);
|
|
378
|
+
|
|
379
|
+
return names.map((name): SecretStatusEntry => {
|
|
380
|
+
// Present because `reportableNames` derived the list from this registry.
|
|
381
|
+
const entry = registry[name] as SecretRegistry[string];
|
|
382
|
+
const storedEntry = stored.get(name);
|
|
383
|
+
const rotationEntry = rotations.get(name);
|
|
384
|
+
// Either table having an undecodable row about this secret makes its status unknowable, and both are
|
|
385
|
+
// held the same way. **Absent is not that**: `undefined` here is a name with no row, which is the
|
|
386
|
+
// answer this read exists to give — declared and never written, or stored in Cloudflare's Secrets
|
|
387
|
+
// Store. Missing and malformed stay separate before either becomes an error.
|
|
388
|
+
if (storedEntry?.state === "unreadable" || rotationEntry?.state === "unreadable") {
|
|
389
|
+
return { state: "unreadable", name };
|
|
390
|
+
}
|
|
391
|
+
const row = storedEntry?.facts;
|
|
392
|
+
const rotation = rotationEntry?.facts;
|
|
393
|
+
const lastRotatedAt = rotation?.lastRotatedAt ?? null;
|
|
394
|
+
const rotateEveryDays = entry.rotateEveryDays ?? null;
|
|
395
|
+
// Measured from the last successful rotation, and from first write when there has never been one:
|
|
396
|
+
// a key created two years ago and never rotated is late, and reporting it as unanswerable would
|
|
397
|
+
// hide exactly the secret this read exists for.
|
|
398
|
+
const reference = lastRotatedAt ?? row?.createdAt ?? null;
|
|
399
|
+
// **Left unguarded on purpose, and this is the note saying so.** `#387` was filed naming this parse as
|
|
400
|
+
// a third site and the claim was withdrawn on checking. It runs over registry declarations
|
|
401
|
+
// `defineSecretRegistry` already refused at define time, plus facts normalized above — so a throw here
|
|
402
|
+
// is an author error in a registry, not a bad row in a database. Guarding it would convert a defect
|
|
403
|
+
// that should be loud into a secret quietly reporting as unreadable.
|
|
404
|
+
const status = SecretStatus.parse({
|
|
405
|
+
name,
|
|
406
|
+
backend: entry.backend,
|
|
407
|
+
valueType: entry.valueType,
|
|
408
|
+
rotatable: entry.rotatable,
|
|
409
|
+
// Null rather than absent, and the declaration verbatim. `defineSecretRegistry` has already refused
|
|
410
|
+
// a malformed one, so this is a copy of something checked at define time rather than a second
|
|
411
|
+
// judgment about it — and a secret that declares nothing says so, instead of being reported as the
|
|
412
|
+
// kind a client would guess.
|
|
413
|
+
rotation: entry.rotation ?? null,
|
|
414
|
+
keyVersion: row?.keyVersion ?? null,
|
|
415
|
+
createdAt: row?.createdAt ?? null,
|
|
416
|
+
updatedAt: row?.updatedAt ?? null,
|
|
417
|
+
lastRotatedAt,
|
|
418
|
+
rotationCount: rotation?.rotationCount ?? 0,
|
|
419
|
+
rotateEveryDays,
|
|
420
|
+
overdue: overdueAgainst(reference, rotateEveryDays, now),
|
|
421
|
+
});
|
|
422
|
+
return { state: "readable", status };
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* One secret's rotation history, newest first, capped at `limit`.
|
|
428
|
+
*
|
|
429
|
+
* `id` breaks a tie on `startedAt`: two rotations recorded in the same millisecond would otherwise
|
|
430
|
+
* straddle the cap in an order SQLite is free to change between reads. It is a sort key and is not
|
|
431
|
+
* selected — a surrogate row id is not a fact about a secret.
|
|
432
|
+
*
|
|
433
|
+
* **Guarded per row since `#387`.** This ended `rows.map((row) => SecretRotationRecord.parse(row))`, so one
|
|
434
|
+
* malformed row threw out of the whole read — and the read is a *history*, per secret, so a single bad row
|
|
435
|
+
* cost every rotation record the caller asked for. A history is the surface an incident review reads, and
|
|
436
|
+
* losing all of it because the oldest row has a bad timestamp is the failure mode least worth having.
|
|
437
|
+
*/
|
|
438
|
+
export async function readSecretRotations(
|
|
439
|
+
db: SecretsStatusDb,
|
|
440
|
+
name: string,
|
|
441
|
+
limit: number,
|
|
442
|
+
): Promise<SecretRotationEntry[]> {
|
|
443
|
+
const rows = await db
|
|
444
|
+
.selectFrom("pithySecretsRotations")
|
|
445
|
+
.select(["startedAt", "completedAt", "status", "trigger", "rotatedBy"])
|
|
446
|
+
.where("name", "=", name)
|
|
447
|
+
.orderBy("startedAt", "desc")
|
|
448
|
+
.orderBy("id", "desc")
|
|
449
|
+
.limit(limit)
|
|
450
|
+
.execute();
|
|
451
|
+
return rows.map((row): SecretRotationEntry => {
|
|
452
|
+
// The `catch` takes no binding. A `ZodError` from this parse carries the offending column value as its
|
|
453
|
+
// issue `input`, and the row it came from is one whose neighboring columns are `error_message` and
|
|
454
|
+
// `metadata_snapshot` — free text written at a failure site. Nothing derived from the rejection may
|
|
455
|
+
// travel, and with nothing in scope there is nothing that could.
|
|
456
|
+
try {
|
|
457
|
+
return { state: "readable", record: SecretRotationRecord.parse(row) };
|
|
458
|
+
} catch {
|
|
459
|
+
return { state: "unreadable" };
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The audit actions this capability emits, through the core `emit()` seam.
|
|
6
|
+
*
|
|
7
|
+
* **The two reads are audited.** This surface discloses no value, but it does disclose the
|
|
8
|
+
* shape of a project's secret estate — every name, which are stale, and which have never been rotated.
|
|
9
|
+
* That is a target list, and a credential quietly pulling it changes nothing, so without these lines it
|
|
10
|
+
* would leave no trace anywhere. "Who enumerated the secrets on the ninth" is a question with an
|
|
11
|
+
* answer, and these are what make it one.
|
|
12
|
+
*
|
|
13
|
+
* **The three member actions are writes, and they are the reason this list is not only reads.** A
|
|
14
|
+
* keyspace member is a per-tenant credential an application mints and stores on a request path
|
|
15
|
+
* (`../keyspaceWrite`), which is an administrative act however routine it looks — somebody's key to
|
|
16
|
+
* somebody's system now exists, or no longer does. The action codes are owned here so an adopter does
|
|
17
|
+
* not invent one for the kit's most sensitive write; the emitter and the actor come from the call site,
|
|
18
|
+
* because an accessor has neither. See `SecretsAccessor`'s `KeyedWriteAudit`.
|
|
19
|
+
*
|
|
20
|
+
* Emitted with `c.var.emit`, never by importing `@pithy-sh/audit` — the seam is always present
|
|
21
|
+
* (`noopEmit` when no audit capability is composed), so there is no null check and no hard dependency.
|
|
22
|
+
* Counts and names only in metadata: the trail is long-lived and more widely readable than this
|
|
23
|
+
* surface, so nothing about a value goes into it. Nothing about a value is available to put there.
|
|
24
|
+
*/
|
|
25
|
+
export const SecretsAuditActions = {
|
|
26
|
+
/** A management client read the status of every declared secret. */
|
|
27
|
+
statusRead: "secrets/status_read",
|
|
28
|
+
/** A management client read one secret's rotation history. */
|
|
29
|
+
rotationsRead: "secrets/rotations_read",
|
|
30
|
+
/**
|
|
31
|
+
* A management client replaced one secret's value.
|
|
32
|
+
*
|
|
33
|
+
* The same code `pithy secrets rotate` emits, on purpose: it is the same act, and an incident review
|
|
34
|
+
* asking *who rolled the production key on the twelfth* must not have to know which door it came through
|
|
35
|
+
* to find it. Which door it was is in the actor — a `control-plane` actor with a connection id is the
|
|
36
|
+
* dashboard, a CLI actor is a terminal.
|
|
37
|
+
*
|
|
38
|
+
* **Emitted on failure as well as success, and `critical` for `unrecorded`.** A rotation that rolled a
|
|
39
|
+
* credential at its issuer and failed to store the successor is the one administrative act here that
|
|
40
|
+
* leaves a system broken, and a trail that records only the rotations that worked is a trail that is
|
|
41
|
+
* silent about exactly the ones somebody is looking for.
|
|
42
|
+
*/
|
|
43
|
+
rotated: "secrets/rotated",
|
|
44
|
+
/** An application stored a keyspace member — created it, or replaced it (`mode` says which). */
|
|
45
|
+
memberWritten: "secrets/member_written",
|
|
46
|
+
/** An application added a version to a keyspace member, keeping the prior one valid. */
|
|
47
|
+
memberRotated: "secrets/member_rotated",
|
|
48
|
+
/** An application removed a keyspace member and every version of it. */
|
|
49
|
+
memberRemoved: "secrets/member_removed",
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/** One of the secrets capability's audit actions. */
|
|
53
|
+
export type SecretsAuditAction = (typeof SecretsAuditActions)[keyof typeof SecretsAuditActions];
|