@pikku/mongodb 0.12.12 → 0.12.14
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/CHANGELOG.md +369 -0
- package/dist/src/mongodb-secret-service.d.ts +8 -3
- package/dist/src/mongodb-secret-service.js +41 -15
- package/dist/src/mongodb-workflow-service.d.ts +0 -1
- package/dist/src/mongodb-workflow-service.js +0 -13
- package/package.json +2 -2
- package/src/mongodb-secret-service.ts +69 -25
- package/src/mongodb-services.test.ts +80 -0
- package/src/mongodb-workflow-service.ts +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,374 @@
|
|
|
1
1
|
# @pikku/mongodb
|
|
2
2
|
|
|
3
|
+
## 0.12.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 78b29f0: `SecretService` now returns a `SecretValue<T>` rather than the bare value, so a
|
|
8
|
+
vault secret cannot reach a sink by accident.
|
|
9
|
+
|
|
10
|
+
`SecretValue` is nominally typed, which means it is not assignable to `string`
|
|
11
|
+
(or to any other concretely-typed field). Every sink with a real type — a
|
|
12
|
+
database column, an email body, a session payload — rejects it with no lint
|
|
13
|
+
rule involved. The sinks typed `any`, `unknown`, or a free generic — the logger,
|
|
14
|
+
queue payloads, webhook and email inputs, and a function's own output — are
|
|
15
|
+
guarded with `Safe<T>`, which collapses a `SecretValue` found anywhere inside
|
|
16
|
+
`T`, however deeply nested, to `never`.
|
|
17
|
+
|
|
18
|
+
Unwrap deliberately at the point the secret reaches the wire:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const secret = await secrets.getSecret('BETTER_AUTH_SECRET')
|
|
22
|
+
betterAuth({ secret: secret.reveal() })
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Two behaviours cover what types cannot see. Structured serialization redacts —
|
|
26
|
+
`JSON.stringify` and node's inspect both yield `[secret]`, so an audit or log
|
|
27
|
+
write stays honest without crashing the request. String coercion throws
|
|
28
|
+
`SecretCoercionError`, because a template literal is always a leak.
|
|
29
|
+
|
|
30
|
+
`AuditLog.write` is guarded the same way as the logger, since an audit event
|
|
31
|
+
carries `input` and `metadata` as `unknown` and nominality alone cannot stop a
|
|
32
|
+
secret landing in one.
|
|
33
|
+
|
|
34
|
+
`.reveal()` is the deliberate escape hatch, and what it hands back is an
|
|
35
|
+
ordinary string as far as every sink signature is concerned. **PKU953** closes
|
|
36
|
+
that gap: under `pikku all --security` the inspector reports a revealed secret
|
|
37
|
+
that flows into a logger, an audit, a queue, an email or a webhook — `console` included.
|
|
38
|
+
|
|
39
|
+
This also fixed a real one: `remote-addon-auth.ts` called `String(token)` on an
|
|
40
|
+
`unknown` and wrote the result straight into an `Authorization` header.
|
|
41
|
+
|
|
42
|
+
- Updated dependencies [62ea4cc]
|
|
43
|
+
- Updated dependencies [9dddff8]
|
|
44
|
+
- Updated dependencies [78b29f0]
|
|
45
|
+
- @pikku/core@0.12.76
|
|
46
|
+
|
|
47
|
+
## 0.12.13
|
|
48
|
+
|
|
49
|
+
### Patch Changes
|
|
50
|
+
|
|
51
|
+
- 6a307f0: Fix four latent correctness bugs in the function, RPC and error runtimes, and
|
|
52
|
+
remove dead code from the workflow service surface.
|
|
53
|
+
|
|
54
|
+
`WorkflowService.getNodesWithoutSteps` is gone. It was declared on the abstract
|
|
55
|
+
service and implemented by all five storage backends, and nothing ever called
|
|
56
|
+
it — hence the non-core packages in this changeset, which only lose that method.
|
|
57
|
+
|
|
58
|
+
**An RPC could execute twice.** `RPCService.invoke`, its addon path, and
|
|
59
|
+
`rpcWithWire` each wrapped the _execution_ of a resolved function in a `try`
|
|
60
|
+
whose `catch` treated `RPCNotFoundError` as "not found locally" and re-dispatched
|
|
61
|
+
the call through `deploymentService`. A nested `rpc.invoke` to a missing name,
|
|
62
|
+
raised from inside an already-running function, therefore re-ran that function on
|
|
63
|
+
a remote instance after its local side effects had already committed. Resolution
|
|
64
|
+
is now separated from execution on all three paths, so only a genuinely
|
|
65
|
+
unresolvable name reaches the fallback. What escapes to callers is unchanged.
|
|
66
|
+
|
|
67
|
+
**`addonNamespace` leaked between sibling calls.** The function runner's
|
|
68
|
+
middleware path restored `rpc`, `functionId`, `audit` and `addonNamespace` after
|
|
69
|
+
an invocation; the non-middleware path restored the first three but not the
|
|
70
|
+
fourth. A call into an addon function with no middleware left the addon's
|
|
71
|
+
namespace on the wire, so subsequent sibling calls resolved the wrong
|
|
72
|
+
per-instance singletons and `credentialOverrides`.
|
|
73
|
+
|
|
74
|
+
**Errors registered on a subclass never resolved.** `misc.errors` was typed
|
|
75
|
+
`Map<PikkuError, ErrorDetails>` — instances — while `addError` stores
|
|
76
|
+
constructors, a mismatch hidden by its `error: any` parameter. The instance
|
|
77
|
+
lookup in `getErrorResponse` was consequently dead, and lookup fell through to a
|
|
78
|
+
scan comparing `constructor.name`, so a subclass of a registered error got no
|
|
79
|
+
status mapping at all. The map is now typed `Map<PikkuErrorConstructor, …>` and
|
|
80
|
+
lookup walks the prototype chain first. Name matching is retained, deliberately,
|
|
81
|
+
as the fallback that keeps error mapping working when two copies of
|
|
82
|
+
`@pikku/core` are installed.
|
|
83
|
+
|
|
84
|
+
**`createWeakUID` collided across instances.** The prefix was
|
|
85
|
+
`Date.now().toString(36)` evaluated at module load, so any two instances loading
|
|
86
|
+
the module in the same millisecond emitted identical `channelId` and `requestId`
|
|
87
|
+
sequences — reproducibly, not just in principle. It is now seeded lazily from
|
|
88
|
+
`crypto.randomUUID()`.
|
|
89
|
+
|
|
90
|
+
Also: `pikkuState` keys its global map with `Symbol.for` rather than `Symbol`, so
|
|
91
|
+
two copies of the package share registrations instead of silently getting
|
|
92
|
+
disjoint state; and the local channel upgrade path no longer keys its middleware
|
|
93
|
+
cache on the raw request path, which grew the cache without bound while the
|
|
94
|
+
cached value never varied by path.
|
|
95
|
+
|
|
96
|
+
- afef587: Close eleven security weaknesses found in a review of `@pikku/core`. Most are
|
|
97
|
+
breaking, and two invalidate data or credentials already in the wild — read the
|
|
98
|
+
migration notes before upgrading.
|
|
99
|
+
|
|
100
|
+
**Breaking: AI agent thread ownership now fails closed.** Reading, listing,
|
|
101
|
+
resuming or approving an existing thread or run requires a resolved session
|
|
102
|
+
principal (`userId`, or `orgId` for `sessionScope: 'org'`), regardless of the
|
|
103
|
+
agent's `auth` setting. Previously a request without a session had no ownership
|
|
104
|
+
model at all: the caller-supplied `resourceId` was accepted as the ownership
|
|
105
|
+
key, so any caller could read or resume another party's thread by naming its
|
|
106
|
+
`resourceId`. Worse, `threadOwnerConstraint` returned `undefined` for a
|
|
107
|
+
sessionless caller, and `undefined` means _no filter_ rather than _no rows_ —
|
|
108
|
+
so `getAgentThreads` returned every thread in the deployment. It now returns
|
|
109
|
+
`string[]`, empty for a sessionless caller, which every storage backend already
|
|
110
|
+
treats as matching nothing. Sessionless agents still run one-shot conversations,
|
|
111
|
+
each with a fresh unguessable owner; what they lose is cross-request continuity.
|
|
112
|
+
Wire a session to restore it.
|
|
113
|
+
|
|
114
|
+
**Breaking: stored secrets and credentials must be re-entered.** `deriveKey` ran
|
|
115
|
+
a single unsalted round of SHA-256 over the passphrase and used the digest
|
|
116
|
+
directly as the AES-GCM key — roughly one hash per brute-force guess, with one
|
|
117
|
+
rainbow table working against every deployment. It is now PBKDF2-HMAC-SHA256 at
|
|
118
|
+
600,000 iterations over a random salt. There is no compatibility path: every
|
|
119
|
+
value held by the kysely, mongodb and redis secret services and the kysely
|
|
120
|
+
credential service becomes undecryptable. They fail loud, naming the key and
|
|
121
|
+
`key_version`, so the app hard-fails on first secret read until each is re-set.
|
|
122
|
+
|
|
123
|
+
The KEK salt is scoped to the key version and stored alongside it, rather than
|
|
124
|
+
per secret, so a bulk read costs one derivation instead of N — `getSecrets` over
|
|
125
|
+
50 secrets went from ~2.3s to ~48ms, and rotation from ~4.6s to ~94ms. This adds
|
|
126
|
+
a salt table (kysely), hash field (redis) or collection (mongodb), created
|
|
127
|
+
automatically on first use.
|
|
128
|
+
|
|
129
|
+
**Breaking: `PIKKU_REMOTE_SECRET` must be at least 32 characters.** The
|
|
130
|
+
remote-RPC session envelope moved from PBKDF2 to HKDF, which expands
|
|
131
|
+
high-entropy key material rather than stretching a low-entropy passphrase. That
|
|
132
|
+
took a remote hop from ~269ms to ~0.4ms — PBKDF2 was running twice per request —
|
|
133
|
+
but HKDF supplies no brute-force resistance, so the secret must carry the
|
|
134
|
+
entropy itself. A shorter secret now throws `WeakKeyMaterialError` at both ends.
|
|
135
|
+
Generate one with `openssl rand -base64 32` and roll it out to every service in
|
|
136
|
+
the mesh together: existing bearer tokens are format-incompatible, so a partial
|
|
137
|
+
rollout produces 401s until every instance is updated. The Cloudflare, Lambda
|
|
138
|
+
and Azure deployment services each hand-rolled a copy of `buildRemoteHeaders`
|
|
139
|
+
and now call the shared one, which is what keeps the two sides in step.
|
|
140
|
+
|
|
141
|
+
**Breaking: previously signed content URLs stop verifying.** `LocalContent`
|
|
142
|
+
signed only `{signedAt, expiresAt, notBefore}`, so a signature proved when a URL
|
|
143
|
+
was issued but never what it was issued for — any valid token was a skeleton
|
|
144
|
+
key, and swapping the pathname from a public thumbnail to a private document
|
|
145
|
+
still verified. The signature now binds the request path. Separately, the
|
|
146
|
+
verifier returned "valid" when no JWT service was wired, which is how
|
|
147
|
+
`pikku serve` ran: a forged `?signedAt=0&expiresAt=99999999999999` was accepted.
|
|
148
|
+
It now rejects with 403, `LocalContent` requires a `JWTService`, and
|
|
149
|
+
`pikku serve`/`pikku dev` mint an ephemeral per-process signing key so local
|
|
150
|
+
development works without shipping a fail-open path. In-flight signed URLs must
|
|
151
|
+
be re-issued.
|
|
152
|
+
|
|
153
|
+
**Request body size limits now apply to every adapter.** The `maxBodySize` cap
|
|
154
|
+
existed only in `PikkuFetchHTTPRequest`. The real hole was uWebSockets, which
|
|
155
|
+
drove `res.onData` itself and concatenated every chunk with no bound and nothing
|
|
156
|
+
downstream able to intervene; it now drops chunks past the limit and replies 413
|
|
157
|
+
before routing. Fastify delegates to its native `bodyLimit` (set only when
|
|
158
|
+
`maxBodySize` is configured, so fastify's stricter 1 MB default is never
|
|
159
|
+
loosened), and `PikkuExpressServer` feeds the limit into its body parsers. Two
|
|
160
|
+
paths can only reject rather than prevent, and are documented as such:
|
|
161
|
+
`express-middleware` mounted on your own app receives an already-parsed body, so
|
|
162
|
+
that deployment must bound its own parser; Next server actions bottom out at
|
|
163
|
+
`experimental.serverActions.bodySizeLimit`.
|
|
164
|
+
|
|
165
|
+
**Breaking: the console addon's privileged functions are gated by default.**
|
|
166
|
+
`wireAddon` gains a `scopes` option that applies to every function in the
|
|
167
|
+
addon's namespace, and the console scaffold now generates
|
|
168
|
+
`wireAddon({ name: 'console', package: '@pikku/addon-console', scopes: ['admin'] })`.
|
|
169
|
+
Previously the console's entire privileged surface — around 54 functions
|
|
170
|
+
including `credentialGet`, which returns a resolved OAuth token for an arbitrary
|
|
171
|
+
`userId`, `updateFunctionBody`, and `installAddon`, which shells out to a
|
|
172
|
+
package install — was protected only by an optional host-registered
|
|
173
|
+
`addGlobalPermission`. `resolveGlobalPermissions` returns `[]` when none is
|
|
174
|
+
registered and permission checking then no-ops, so an app that never registered
|
|
175
|
+
one served those functions to anyone, and with the template's default
|
|
176
|
+
`scaffold.rpc: "no-auth"` that meant unauthenticated. All of them now return 403
|
|
177
|
+
`MissingScopeError` without an `admin`, `admin:*` or `*` scope. **Regenerating
|
|
178
|
+
is required** — an app holding an old `console.gen.ts` stays open.
|
|
179
|
+
`installAddon` and `installOpenapiAddon` additionally declare their own
|
|
180
|
+
`auth: true, scopes: ['admin']`, and `getAgentThreads` now scopes its listing to
|
|
181
|
+
the session's own threads unless the caller holds admin.
|
|
182
|
+
|
|
183
|
+
Addon scopes are enforced in `runPikkuFunc` rather than at the RPC boundary,
|
|
184
|
+
because a wiring can reference an addon function directly — the inspector
|
|
185
|
+
records the addon's `packageName` on HTTP, channel, schedule, queue, CLI,
|
|
186
|
+
trigger, gateway and MCP wirings — and those paths never call `resolveNamespace`.
|
|
187
|
+
Enforcing at the RPC seam would have covered only the `namespace:function` form
|
|
188
|
+
while reading as complete.
|
|
189
|
+
|
|
190
|
+
**Breaking: `wireAddon`'s `auth` and `tags` now apply on direct wirings too.**
|
|
191
|
+
Both were read only by `resolveAddonFunction`, so they had exactly the hole
|
|
192
|
+
scopes had: `wireAddon({ name: 'console', package: '@pikku/addon-console', auth:
|
|
193
|
+
true, tags: ['admin'] })` gated `rpc('console:credentialGet')` and gated nothing
|
|
194
|
+
at all on an HTTP route wired straight to `credentialGet`. A consumer who
|
|
195
|
+
reached for the documented way to lock an addon down got a control that was
|
|
196
|
+
silently inert on every wiring except one. Both now resolve in `runPikkuFunc`.
|
|
197
|
+
`auth` merges as an OR — `auth: false` from an addon is ignored on a direct
|
|
198
|
+
wiring, because an addon may require a session the wiring did not but must never
|
|
199
|
+
waive one it did. Addon tags resolve to concrete middleware against the **root**
|
|
200
|
+
tag groups before the call rather than being folded into the function's
|
|
201
|
+
inherited middleware: `addTagMiddleware('admin', …)` is written by the consuming
|
|
202
|
+
app and registers under the root package, whereas `combineMiddleware` would look
|
|
203
|
+
the tag up under the addon's own `packageName`, where it does not exist.
|
|
204
|
+
|
|
205
|
+
One consequence worth naming: an addon that wires `auth: true` and also runs its
|
|
206
|
+
own sessionless internal work — a scheduled task or queue worker inside the
|
|
207
|
+
addon calling a sibling function — is now gated where it previously was not,
|
|
208
|
+
because a bare `rpc('fn')` from inside the addon reaches `runPikkuFunc` with the
|
|
209
|
+
addon's `packageName` like any other call. This reverses a decision that
|
|
210
|
+
deliberately scoped the gate to the namespaced boundary; that reasoning held
|
|
211
|
+
only while the boundary was real, and a direct wiring can enter an addon without
|
|
212
|
+
crossing it, so "already inside" is not something the runtime can infer. Such an
|
|
213
|
+
addon should carry authorization on the function via
|
|
214
|
+
`pikkuFunc({ permissions })`, which has always been enforced on every path. A
|
|
215
|
+
follow-up will add execution provenance so an intra-addon call can be
|
|
216
|
+
distinguished from an external one and skip the addon-level check; that needs a
|
|
217
|
+
marker no caller outside the process can set, which is its own design problem
|
|
218
|
+
and does not belong in a security fix.
|
|
219
|
+
|
|
220
|
+
**Codegen now warns when an exposed function has no gate (PKU574).** The
|
|
221
|
+
generated `POST /rpc/:rpcName` dispatcher forwards to `rpc.exposed`, which
|
|
222
|
+
refuses anything without `expose: true` — but nothing checked whether the
|
|
223
|
+
target was gated, because a dispatcher cannot know what it dispatches to. The
|
|
224
|
+
console shipped ~54 privileged functions through that gap and the toolchain was
|
|
225
|
+
silent. The inspector now reports every function that is exposed, sessionless,
|
|
226
|
+
and carries no `auth`, `scopes` or `permissions` of its own and none from a
|
|
227
|
+
governing `wireAddon`. It is a `warn`, not a critical: `expose: true` on an
|
|
228
|
+
ungated sessionless function is correct for a genuinely public endpoint, so it
|
|
229
|
+
blocks a build only under `--fail-on-warn`.
|
|
230
|
+
|
|
231
|
+
Two pieces of metadata were missing for this to be answerable statically, and
|
|
232
|
+
both are now recorded. A `pikkuSessionlessFunc`'s own `auth: true` was read at
|
|
233
|
+
runtime but never written to function meta, so a self-gated function was
|
|
234
|
+
indistinguishable from an ungated one — `sessionless` carries the baseline, and
|
|
235
|
+
`auth` now carries the tightening. And `wireAddon`'s `scopes`, `auth` and `tags`
|
|
236
|
+
were not parsed at all: the inspector recorded the addon's `rpcEndpoint` and its
|
|
237
|
+
secret, variable and credential overrides, and dropped every one of its gates.
|
|
238
|
+
An addon whose gates are not statically knowable is treated as gated, because a
|
|
239
|
+
false positive on a correctly-secured addon costs more than the one case it
|
|
240
|
+
would catch.
|
|
241
|
+
|
|
242
|
+
**Breaking: an application's global permissions now apply inside addons.**
|
|
243
|
+
`resolveGlobalPermissions` read only the bucket matching the function's own
|
|
244
|
+
package, but the generated `addGlobalPermission` wrapper takes no package
|
|
245
|
+
argument and always registers under the root. An app-wide rule like "every
|
|
246
|
+
request needs a signed-in user" therefore stopped at the addon boundary, and the
|
|
247
|
+
bucket an addon's functions did read was one no host could write to — which is
|
|
248
|
+
why the console addon's recommended `addGlobalPermission([isAdmin],
|
|
249
|
+
'@pikku/addon-console')` was never a gate anybody could actually install. A
|
|
250
|
+
function now resolves the root bucket and its own package's, root first.
|
|
251
|
+
Unioning is safe in a way nothing else here would be: globals AND, so adding the
|
|
252
|
+
root ones can only tighten. Package buckets stay one-way — a package's globals
|
|
253
|
+
never reach root functions, or an installed addon could gate the whole
|
|
254
|
+
application. Apps with both a root global and addon-provided functions will see
|
|
255
|
+
those functions gated where they previously were not.
|
|
256
|
+
|
|
257
|
+
**Codegen now records whether each HTTP route requires a session, and warns
|
|
258
|
+
about inert addon tags (PKU575).** Four separate things can demand a session —
|
|
259
|
+
the function's `sessionless`, its own `auth`, the route's (or its group's)
|
|
260
|
+
`auth`, and the addon it belongs to — and answering "which routes are open?"
|
|
261
|
+
meant joining all four by hand and knowing which wins. Each route's meta now
|
|
262
|
+
carries the resolved `requiresSession` alongside the route's own `auth`. Scopes
|
|
263
|
+
count as requiring one, since they are matched against the session's and fail
|
|
264
|
+
closed. Anything not statically knowable resolves to `true`, matching PKU574:
|
|
265
|
+
a route that looks stricter than it is costs less than one that looks open and
|
|
266
|
+
isn't. Separately, `wireAddon({ tags: ['admin'] })` reads like a gate and is
|
|
267
|
+
applied like one right up until no `addTagMiddleware('admin', …)` exists, at
|
|
268
|
+
which point it resolves to an empty list and gates nothing; that now warns.
|
|
269
|
+
Only addon tags are reported — a tag on a function is as likely to be
|
|
270
|
+
organizational, and warning about those would bury the case that matters.
|
|
271
|
+
|
|
272
|
+
**Object-shorthand permissions were missing from meta.** The inspector visited
|
|
273
|
+
`ts.PropertyAssignment` but not `ts.ShorthandPropertyAssignment`, so
|
|
274
|
+
`permissions: { canAdminOrg }` — enforced identically to the longhand form at
|
|
275
|
+
runtime, since `verifyPermissions` has a non-array branch — was recorded as _no
|
|
276
|
+
permissions at all_. That is the most dangerous direction for meta to be wrong
|
|
277
|
+
in: an audit reading it sees an open door where one is shut. It cost this review
|
|
278
|
+
a false IDOR report across ~35 billing and org functions before the source
|
|
279
|
+
contradicted the metadata.
|
|
280
|
+
|
|
281
|
+
**Functions that authorize in their own body can say so.** A webhook receiver
|
|
282
|
+
verifying a signature, or a handler redeeming a signed invite, is genuinely
|
|
283
|
+
closed while carrying no session, scope or permission — indistinguishable in
|
|
284
|
+
meta from one nobody remembered to gate, and so warned about forever by PKU574.
|
|
285
|
+
`selfAuthenticated: true` on the function config records the claim and silences
|
|
286
|
+
the warning for that function. It is declarative only: nothing at runtime reads
|
|
287
|
+
it and it grants nothing. Detection was rejected deliberately — inferring it
|
|
288
|
+
from the body means a function that _looks_ like it checks something silences
|
|
289
|
+
the warning while checking nothing, and a warning that is usually wrong stops
|
|
290
|
+
being read.
|
|
291
|
+
|
|
292
|
+
**Breaking: `scaffold.<feature>` is now `boolean | { auth?, path? }`, and `true`
|
|
293
|
+
means authenticated.** The old `'auth' | 'no-auth' | false` read like a
|
|
294
|
+
starter-file preference while being a live authorization decision, set three
|
|
295
|
+
directories from the functions it governed — the shape the console incident
|
|
296
|
+
took. A surface now becomes public only by writing `{ auth: false }`, so
|
|
297
|
+
omitting a field can never open anything: the failure mode of a forgotten flag
|
|
298
|
+
is a locked door. `{ path }` additionally overrides where the file is
|
|
299
|
+
generated, which previously could only be set for all features at once via
|
|
300
|
+
`pikkuDir`.
|
|
301
|
+
|
|
302
|
+
The legacy strings are **refused, not coerced**. `resolveScaffoldFeature` throws
|
|
303
|
+
naming the key and its replacement (`"rpc": "no-auth"` → `"rpc": { "auth":
|
|
304
|
+
false }`), and it does so at config load, not downstream. An earlier design used
|
|
305
|
+
a bare `string` for the output path, under which `"no-auth"` would have parsed
|
|
306
|
+
as a file named `no-auth` and every unmigrated config would have silently
|
|
307
|
+
produced nonsense; the object form makes any string invalid, so the failure is
|
|
308
|
+
loud.
|
|
309
|
+
|
|
310
|
+
The collapse is deliberately not uniform in effect. For `rpc` the flag was a
|
|
311
|
+
blanket "no anonymous RPC in this app" set on a dispatcher that cannot know what
|
|
312
|
+
it dispatches to; for `userAdmin` it was redundant, since the generated
|
|
313
|
+
functions are already `pikkuFunc` with `scopes: ['admin:users:list']`. But for
|
|
314
|
+
`agent`, `workflow`, `events` and `scenarios` it is the only gate — those
|
|
315
|
+
generate real endpoints the app never authors — so `true` keeps them
|
|
316
|
+
authenticated rather than opening them as a side effect of a config cleanup.
|
|
317
|
+
`webhook` and `remoteRpc` have no auth dimension at all (`serialize-remote-rpc.ts`
|
|
318
|
+
hardcodes `auth: false`), so an `{ auth }` on them is ignored. The three
|
|
319
|
+
configs in this repo are migrated preserving their current behaviour exactly.
|
|
320
|
+
|
|
321
|
+
**Queue job identities are signed.** A job carried the producer's `pikkuUserId`
|
|
322
|
+
as a plain string and the worker resolved a session from it with no
|
|
323
|
+
verification, so write access to the queue backend was act-as-any-user. The
|
|
324
|
+
identity is now `pq1.<claim>.<hmac>`, HMAC-SHA256 over the claim and the
|
|
325
|
+
canonicalized job payload, keyed by HKDF expansion of a new
|
|
326
|
+
`PIKKU_QUEUE_IDENTITY_SECRET`. Producers opt in by wrapping their queue service
|
|
327
|
+
with `SignedQueueService`. This fails safe rather than closed: with no secret
|
|
328
|
+
configured the identity is dropped and jobs still process, warning once per
|
|
329
|
+
process, so no existing deployment breaks on upgrade — it simply loses queue
|
|
330
|
+
identity until the secret is set. The payload rather than the job id is bound
|
|
331
|
+
because SQS, Cloudflare Queues, Azure and the in-memory service all mint ids
|
|
332
|
+
after `add` returns.
|
|
333
|
+
|
|
334
|
+
**Workflow inline state is read from the run record.** `isInline` consulted a
|
|
335
|
+
process-local `Map`, while `WorkflowRun.inline` is durable. Any instance that
|
|
336
|
+
did not start a run disagreed with the record, so one instance could dispatch a
|
|
337
|
+
queued job for a workflow another was already executing in-process. It is now
|
|
338
|
+
async and resolves through the durable identity, cached only when a context
|
|
339
|
+
already exists so a passive reader allocates nothing. The same `Map` also leaked:
|
|
340
|
+
`nextStepKey` fabricated replay state on every step, and `releaseContext`
|
|
341
|
+
refused to free anything carrying it, so runs whose steps executed outside a
|
|
342
|
+
`beginReplay` bracket — the step-worker queue path — stranded their context and
|
|
343
|
+
step state for the process lifetime. Contexts are now released by an explicit
|
|
344
|
+
execution counter. Step ordinals reset per execution rather than accumulating
|
|
345
|
+
across step-worker invocations in one process, which makes step naming
|
|
346
|
+
independent of how work was distributed.
|
|
347
|
+
|
|
348
|
+
**Secret reads fail loud in every store.** `MongoDBSecretService.getSecrets`
|
|
349
|
+
skipped rows that failed to decrypt, and the redis equivalent dropped every
|
|
350
|
+
rejection via `Promise.allSettled`, including the "No KEK available for
|
|
351
|
+
key_version N" configuration error. Both now throw, naming the key and its key
|
|
352
|
+
version, matching the kysely behaviour. This matters most alongside the KEK
|
|
353
|
+
change above: without it, an upgrade surfaces as a partial secrets map and an
|
|
354
|
+
opaque downstream failure instead of an error naming the secret to re-enter.
|
|
355
|
+
|
|
356
|
+
**A second middleware registration for a pattern no longer erases the first.**
|
|
357
|
+
`addHTTPMiddleware`, `addTagMiddleware` and `addChannelMiddleware` groups are
|
|
358
|
+
keyed by pattern or tag and held one source file each, so a second file's call
|
|
359
|
+
overwrote the first's. Codegen emits its imports from what is stored, so the
|
|
360
|
+
losing file was never imported and its middleware never registered — the
|
|
361
|
+
runtime composes repeated registrations for a pattern happily, and only codegen
|
|
362
|
+
dropped one. Adding an unrelated `addHTTPMiddleware('*', …)` to an app was
|
|
363
|
+
enough to silently unregister the generated better-auth session bridge, which
|
|
364
|
+
fails open and gives no sign until a request arrives without a session. A group
|
|
365
|
+
now carries every registration made for it, and all of them are imported.
|
|
366
|
+
|
|
367
|
+
- Updated dependencies [6a307f0]
|
|
368
|
+
- Updated dependencies [afef587]
|
|
369
|
+
- Updated dependencies [8075f6a]
|
|
370
|
+
- @pikku/core@0.12.74
|
|
371
|
+
|
|
3
372
|
## 0.12.12
|
|
4
373
|
|
|
5
374
|
### Patch Changes
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type SecretValue } from '@pikku/core';
|
|
2
|
+
import type { SecretService, SecretValues } from '@pikku/core/services';
|
|
2
3
|
import type { Db } from 'mongodb';
|
|
3
4
|
export interface MongoDBSecretServiceConfig {
|
|
4
5
|
key: string;
|
|
@@ -17,14 +18,18 @@ export declare class MongoDBSecretService implements SecretService {
|
|
|
17
18
|
private auditReads;
|
|
18
19
|
private secrets;
|
|
19
20
|
private secretsAudit;
|
|
21
|
+
private kekSaltDocs;
|
|
22
|
+
private kekSalts;
|
|
23
|
+
private keks;
|
|
20
24
|
constructor(db: Db, config: MongoDBSecretServiceConfig);
|
|
21
25
|
init(): Promise<void>;
|
|
22
26
|
private logAudit;
|
|
27
|
+
private getKEKSalt;
|
|
23
28
|
private getKEK;
|
|
24
|
-
getSecret<T = string>(key: string): Promise<T
|
|
29
|
+
getSecret<T = string>(key: string): Promise<SecretValue<T>>;
|
|
25
30
|
hasSecret(key: string): Promise<boolean>;
|
|
26
31
|
setSecret(key: string, value: unknown): Promise<void>;
|
|
27
32
|
deleteSecret(key: string): Promise<void>;
|
|
28
|
-
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<T
|
|
33
|
+
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
|
|
29
34
|
rotateKEK(): Promise<number>;
|
|
30
35
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createSecretValue, isSecretValue } from '@pikku/core';
|
|
2
|
+
import { deriveKEK, generateKEKSalt, envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
|
|
2
3
|
export class MongoDBSecretService {
|
|
3
4
|
db;
|
|
4
5
|
initialized = false;
|
|
@@ -9,6 +10,9 @@ export class MongoDBSecretService {
|
|
|
9
10
|
auditReads;
|
|
10
11
|
secrets;
|
|
11
12
|
secretsAudit;
|
|
13
|
+
kekSaltDocs;
|
|
14
|
+
kekSalts = new Map();
|
|
15
|
+
keks = new Map();
|
|
12
16
|
constructor(db, config) {
|
|
13
17
|
this.db = db;
|
|
14
18
|
this.key = config.key;
|
|
@@ -22,6 +26,7 @@ export class MongoDBSecretService {
|
|
|
22
26
|
return;
|
|
23
27
|
this.secrets = this.db.collection('secrets');
|
|
24
28
|
this.secretsAudit = this.db.collection('secrets_audit');
|
|
29
|
+
this.kekSaltDocs = this.db.collection('secret_kek_salts');
|
|
25
30
|
await this.secrets.createIndex({ _id: 1 });
|
|
26
31
|
if (this.audit) {
|
|
27
32
|
await this.secretsAudit.createIndex({ secretKey: 1 });
|
|
@@ -40,28 +45,46 @@ export class MongoDBSecretService {
|
|
|
40
45
|
performedAt: new Date(),
|
|
41
46
|
});
|
|
42
47
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
async getKEKSalt(version) {
|
|
49
|
+
const cached = this.kekSalts.get(version);
|
|
50
|
+
if (cached)
|
|
51
|
+
return cached;
|
|
52
|
+
const doc = await this.kekSaltDocs.findOneAndUpdate({ _id: version }, { $setOnInsert: { salt: generateKEKSalt(), createdAt: new Date() } }, { upsert: true, returnDocument: 'after' });
|
|
53
|
+
if (!doc?.salt) {
|
|
54
|
+
throw new Error(`Failed to persist a KEK salt for key_version ${version}`);
|
|
55
|
+
}
|
|
56
|
+
this.kekSalts.set(version, doc.salt);
|
|
57
|
+
return doc.salt;
|
|
58
|
+
}
|
|
59
|
+
async getKEK(version) {
|
|
60
|
+
const cached = this.keks.get(version);
|
|
61
|
+
if (cached)
|
|
62
|
+
return cached;
|
|
63
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey;
|
|
64
|
+
if (!passphrase) {
|
|
65
|
+
throw new Error(`No KEK available for key_version ${version}`);
|
|
66
|
+
}
|
|
67
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version));
|
|
68
|
+
this.keks.set(version, kek);
|
|
69
|
+
return kek;
|
|
49
70
|
}
|
|
50
71
|
async getSecret(key) {
|
|
51
72
|
const row = await this.secrets.findOne({ _id: key });
|
|
52
73
|
if (!row)
|
|
53
74
|
throw new Error('Requested secret not found');
|
|
54
|
-
const kek = this.getKEK(row.keyVersion);
|
|
75
|
+
const kek = await this.getKEK(row.keyVersion);
|
|
55
76
|
const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
56
77
|
await this.logAudit(key, 'read');
|
|
57
|
-
return result;
|
|
78
|
+
return createSecretValue(result);
|
|
58
79
|
}
|
|
59
80
|
async hasSecret(key) {
|
|
60
81
|
const count = await this.secrets.countDocuments({ _id: key }, { limit: 1 });
|
|
61
82
|
return count > 0;
|
|
62
83
|
}
|
|
63
84
|
async setSecret(key, value) {
|
|
64
|
-
|
|
85
|
+
// Encrypting the wrapper would store its redaction, not the secret —
|
|
86
|
+
// writing a secret back to the vault is exactly what this method is for.
|
|
87
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), isSecretValue(value) ? value.reveal() : value);
|
|
65
88
|
const now = new Date();
|
|
66
89
|
await this.secrets.updateOne({ _id: key }, {
|
|
67
90
|
$set: {
|
|
@@ -88,11 +111,12 @@ export class MongoDBSecretService {
|
|
|
88
111
|
const out = {};
|
|
89
112
|
for (const row of rows) {
|
|
90
113
|
try {
|
|
91
|
-
const kek = this.getKEK(row.keyVersion);
|
|
92
|
-
out[row._id] = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
114
|
+
const kek = await this.getKEK(row.keyVersion);
|
|
115
|
+
out[row._id] = createSecretValue(await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek));
|
|
93
116
|
}
|
|
94
|
-
catch {
|
|
95
|
-
|
|
117
|
+
catch (cause) {
|
|
118
|
+
throw new Error(`Failed to decrypt secret "${row._id}" (key_version ${row.keyVersion}): ` +
|
|
119
|
+
`the configured KEK does not match the key it was wrapped under`, { cause });
|
|
96
120
|
}
|
|
97
121
|
}
|
|
98
122
|
return out;
|
|
@@ -105,8 +129,10 @@ export class MongoDBSecretService {
|
|
|
105
129
|
.find({ keyVersion: { $lt: this.keyVersion } })
|
|
106
130
|
.project({ _id: 1, wrappedDek: 1 })
|
|
107
131
|
.toArray();
|
|
132
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1);
|
|
133
|
+
const newKEK = await this.getKEK(this.keyVersion);
|
|
108
134
|
for (const row of rows) {
|
|
109
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
135
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek);
|
|
110
136
|
await this.secrets.updateOne({ _id: row._id }, {
|
|
111
137
|
$set: {
|
|
112
138
|
wrappedDek: newWrappedDEK,
|
|
@@ -49,7 +49,6 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
49
49
|
failedNodeIds: string[];
|
|
50
50
|
branchKeys: Record<string, string>;
|
|
51
51
|
}>;
|
|
52
|
-
getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
|
|
53
52
|
getStepInstances(runId: string): Promise<Array<{
|
|
54
53
|
stepName: string;
|
|
55
54
|
status: StepStatus;
|
|
@@ -284,19 +284,6 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
284
284
|
}
|
|
285
285
|
return { completedNodeIds, failedNodeIds, branchKeys };
|
|
286
286
|
}
|
|
287
|
-
async getNodesWithoutSteps(runId, nodeIds) {
|
|
288
|
-
if (nodeIds.length === 0)
|
|
289
|
-
return [];
|
|
290
|
-
const result = await this.steps
|
|
291
|
-
.find({
|
|
292
|
-
workflowRunId: runId,
|
|
293
|
-
stepName: { $in: nodeIds },
|
|
294
|
-
})
|
|
295
|
-
.project({ stepName: 1 })
|
|
296
|
-
.toArray();
|
|
297
|
-
const existingStepNames = new Set(result.map((r) => r.stepName));
|
|
298
|
-
return nodeIds.filter((id) => !existingStepNames.has(id));
|
|
299
|
-
}
|
|
300
287
|
async getStepInstances(runId) {
|
|
301
288
|
const rows = await this.steps
|
|
302
289
|
.find({ workflowRunId: runId })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/mongodb",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.14",
|
|
4
4
|
"description": "MongoDB services for Pikku — workflow state, agent runs, secrets and channel stores",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"prepublishOnly": "yarn build"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@pikku/core": "^0.12.
|
|
21
|
+
"@pikku/core": "^0.12.76"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"mongodb": "^6.12.0"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createSecretValue, isSecretValue, type SecretValue } from '@pikku/core'
|
|
2
|
+
import type { SecretService, SecretValues } from '@pikku/core/services'
|
|
2
3
|
import type { Db, Collection } from 'mongodb'
|
|
3
4
|
import {
|
|
5
|
+
deriveKEK,
|
|
6
|
+
generateKEKSalt,
|
|
4
7
|
envelopeEncrypt,
|
|
5
8
|
envelopeDecrypt,
|
|
6
9
|
envelopeRewrap,
|
|
@@ -23,6 +26,12 @@ interface SecretDoc {
|
|
|
23
26
|
updatedAt: Date
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
interface KekSaltDoc {
|
|
30
|
+
_id: number
|
|
31
|
+
salt: string
|
|
32
|
+
createdAt: Date
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
interface SecretAuditDoc {
|
|
27
36
|
_id: string
|
|
28
37
|
secretKey: string
|
|
@@ -39,6 +48,9 @@ export class MongoDBSecretService implements SecretService {
|
|
|
39
48
|
private auditReads: boolean
|
|
40
49
|
private secrets!: Collection<SecretDoc>
|
|
41
50
|
private secretsAudit!: Collection<SecretAuditDoc>
|
|
51
|
+
private kekSaltDocs!: Collection<KekSaltDoc>
|
|
52
|
+
private kekSalts = new Map<number, string>()
|
|
53
|
+
private keks = new Map<number, CryptoKey>()
|
|
42
54
|
|
|
43
55
|
constructor(
|
|
44
56
|
private db: Db,
|
|
@@ -56,6 +68,7 @@ export class MongoDBSecretService implements SecretService {
|
|
|
56
68
|
|
|
57
69
|
this.secrets = this.db.collection<SecretDoc>('secrets')
|
|
58
70
|
this.secretsAudit = this.db.collection<SecretAuditDoc>('secrets_audit')
|
|
71
|
+
this.kekSaltDocs = this.db.collection<KekSaltDoc>('secret_kek_salts')
|
|
59
72
|
|
|
60
73
|
await this.secrets.createIndex({ _id: 1 })
|
|
61
74
|
|
|
@@ -81,20 +94,45 @@ export class MongoDBSecretService implements SecretService {
|
|
|
81
94
|
})
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
private
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
97
|
+
private async getKEKSalt(version: number): Promise<string> {
|
|
98
|
+
const cached = this.kekSalts.get(version)
|
|
99
|
+
if (cached) return cached
|
|
100
|
+
|
|
101
|
+
const doc = await this.kekSaltDocs.findOneAndUpdate(
|
|
102
|
+
{ _id: version },
|
|
103
|
+
{ $setOnInsert: { salt: generateKEKSalt(), createdAt: new Date() } },
|
|
104
|
+
{ upsert: true, returnDocument: 'after' }
|
|
105
|
+
)
|
|
106
|
+
if (!doc?.salt) {
|
|
107
|
+
throw new Error(`Failed to persist a KEK salt for key_version ${version}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.kekSalts.set(version, doc.salt)
|
|
111
|
+
return doc.salt
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async getKEK(version: number): Promise<CryptoKey> {
|
|
115
|
+
const cached = this.keks.get(version)
|
|
116
|
+
if (cached) return cached
|
|
117
|
+
|
|
118
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey
|
|
119
|
+
if (!passphrase) {
|
|
120
|
+
throw new Error(`No KEK available for key_version ${version}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
|
|
124
|
+
this.keks.set(version, kek)
|
|
125
|
+
return kek
|
|
88
126
|
}
|
|
89
127
|
|
|
90
|
-
async getSecret<T = string>(key: string): Promise<T
|
|
128
|
+
async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
|
|
91
129
|
const row = await this.secrets.findOne({ _id: key })
|
|
92
130
|
if (!row) throw new Error('Requested secret not found')
|
|
93
131
|
|
|
94
|
-
const kek = this.getKEK(row.keyVersion)
|
|
132
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
95
133
|
const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
|
|
96
134
|
await this.logAudit(key, 'read')
|
|
97
|
-
return result
|
|
135
|
+
return createSecretValue(result)
|
|
98
136
|
}
|
|
99
137
|
|
|
100
138
|
async hasSecret(key: string): Promise<boolean> {
|
|
@@ -103,7 +141,12 @@ export class MongoDBSecretService implements SecretService {
|
|
|
103
141
|
}
|
|
104
142
|
|
|
105
143
|
async setSecret(key: string, value: unknown): Promise<void> {
|
|
106
|
-
|
|
144
|
+
// Encrypting the wrapper would store its redaction, not the secret —
|
|
145
|
+
// writing a secret back to the vault is exactly what this method is for.
|
|
146
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
147
|
+
await this.getKEK(this.keyVersion),
|
|
148
|
+
isSecretValue(value) ? value.reveal() : value
|
|
149
|
+
)
|
|
107
150
|
const now = new Date()
|
|
108
151
|
|
|
109
152
|
await this.secrets.updateOne(
|
|
@@ -131,26 +174,28 @@ export class MongoDBSecretService implements SecretService {
|
|
|
131
174
|
await this.logAudit(key, 'delete')
|
|
132
175
|
}
|
|
133
176
|
|
|
134
|
-
async getSecrets<
|
|
135
|
-
|
|
136
|
-
|
|
177
|
+
async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
178
|
+
keys: (keyof T & string)[]
|
|
179
|
+
): Promise<Partial<SecretValues<T>>> {
|
|
137
180
|
const rows = await this.secrets
|
|
138
181
|
.find({ _id: { $in: keys } } as any)
|
|
139
182
|
.toArray()
|
|
140
183
|
const out: Record<string, unknown> = {}
|
|
141
184
|
for (const row of rows) {
|
|
142
185
|
try {
|
|
143
|
-
const kek = this.getKEK(row.keyVersion)
|
|
144
|
-
out[row._id] =
|
|
145
|
-
kek,
|
|
146
|
-
|
|
147
|
-
|
|
186
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
187
|
+
out[row._id] = createSecretValue(
|
|
188
|
+
await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
|
|
189
|
+
)
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Failed to decrypt secret "${row._id}" (key_version ${row.keyVersion}): ` +
|
|
193
|
+
`the configured KEK does not match the key it was wrapped under`,
|
|
194
|
+
{ cause }
|
|
148
195
|
)
|
|
149
|
-
} catch {
|
|
150
|
-
// skip secrets that fail to decrypt
|
|
151
196
|
}
|
|
152
197
|
}
|
|
153
|
-
return out as T
|
|
198
|
+
return out as Partial<SecretValues<T>>
|
|
154
199
|
}
|
|
155
200
|
|
|
156
201
|
async rotateKEK(): Promise<number> {
|
|
@@ -163,12 +208,11 @@ export class MongoDBSecretService implements SecretService {
|
|
|
163
208
|
.project({ _id: 1, wrappedDek: 1 })
|
|
164
209
|
.toArray()
|
|
165
210
|
|
|
211
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
212
|
+
const newKEK = await this.getKEK(this.keyVersion)
|
|
213
|
+
|
|
166
214
|
for (const row of rows) {
|
|
167
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
168
|
-
this.previousKey,
|
|
169
|
-
this.key,
|
|
170
|
-
row.wrappedDek
|
|
171
|
-
)
|
|
215
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
|
|
172
216
|
await this.secrets.updateOne(
|
|
173
217
|
{ _id: row._id },
|
|
174
218
|
{
|
|
@@ -106,6 +106,86 @@ function registerTests(name: string, getDb: () => Db) {
|
|
|
106
106
|
assert.equal(logs[0]!.action, 'write')
|
|
107
107
|
})
|
|
108
108
|
})
|
|
109
|
+
|
|
110
|
+
describe(`MongoDBSecretService decryption failures [${name}]`, () => {
|
|
111
|
+
const kek = 'test-key-encryption-key-32chars!'
|
|
112
|
+
const otherKek = 'a-totally-different-kek-32chars!'
|
|
113
|
+
|
|
114
|
+
test('getSecrets throws naming a secret it cannot decrypt', async () => {
|
|
115
|
+
const writer = new MongoDBSecretService(getDb(), { key: kek })
|
|
116
|
+
await writer.init()
|
|
117
|
+
await writer.setSecret('undecryptable', { token: 'abc' })
|
|
118
|
+
|
|
119
|
+
const reader = new MongoDBSecretService(getDb(), { key: otherKek })
|
|
120
|
+
await reader.init()
|
|
121
|
+
|
|
122
|
+
await assert.rejects(
|
|
123
|
+
() => reader.getSecrets(['undecryptable']),
|
|
124
|
+
(error: Error) => {
|
|
125
|
+
assert.equal(
|
|
126
|
+
error.message,
|
|
127
|
+
'Failed to decrypt secret "undecryptable" (key_version 1): ' +
|
|
128
|
+
'the configured KEK does not match the key it was wrapped under'
|
|
129
|
+
)
|
|
130
|
+
assert.ok(error.cause, 'the underlying crypto failure is preserved')
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('getSecrets throws when no KEK is available for a stored key_version', async () => {
|
|
137
|
+
const writer = new MongoDBSecretService(getDb(), {
|
|
138
|
+
key: kek,
|
|
139
|
+
keyVersion: 7,
|
|
140
|
+
})
|
|
141
|
+
await writer.init()
|
|
142
|
+
await writer.setSecret('old-version', 'value')
|
|
143
|
+
|
|
144
|
+
const reader = new MongoDBSecretService(getDb(), {
|
|
145
|
+
key: kek,
|
|
146
|
+
keyVersion: 8,
|
|
147
|
+
})
|
|
148
|
+
await reader.init()
|
|
149
|
+
|
|
150
|
+
await assert.rejects(
|
|
151
|
+
() => reader.getSecrets(['old-version']),
|
|
152
|
+
(error: Error) => {
|
|
153
|
+
assert.equal(
|
|
154
|
+
error.message,
|
|
155
|
+
'Failed to decrypt secret "old-version" (key_version 7): ' +
|
|
156
|
+
'the configured KEK does not match the key it was wrapped under'
|
|
157
|
+
)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('getSecrets returns every secret when they all decrypt', async () => {
|
|
164
|
+
const service = new MongoDBSecretService(getDb(), { key: kek })
|
|
165
|
+
await service.init()
|
|
166
|
+
await service.setSecret('batch-a', { v: 1 })
|
|
167
|
+
await service.setSecret('batch-b', 'two')
|
|
168
|
+
|
|
169
|
+
const out = await service.getSecrets<{
|
|
170
|
+
'batch-a': { v: number }
|
|
171
|
+
'batch-b': string
|
|
172
|
+
}>(['batch-a', 'batch-b'])
|
|
173
|
+
|
|
174
|
+
assert.deepEqual(out['batch-a']!.reveal(), { v: 1 })
|
|
175
|
+
assert.equal(out['batch-b']!.reveal(), 'two')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('getSecrets omits keys that were never stored', async () => {
|
|
179
|
+
const service = new MongoDBSecretService(getDb(), { key: kek })
|
|
180
|
+
await service.init()
|
|
181
|
+
await service.setSecret('present', 'here')
|
|
182
|
+
|
|
183
|
+
const out = await service.getSecrets(['present', 'never-stored'])
|
|
184
|
+
|
|
185
|
+
assert.deepEqual(Object.keys(out), ['present'])
|
|
186
|
+
assert.equal(out.present!.reveal(), 'here')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
109
189
|
}
|
|
110
190
|
|
|
111
191
|
describe('MongoDB Services - In-Memory', () => {
|
|
@@ -487,24 +487,6 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
487
487
|
return { completedNodeIds, failedNodeIds, branchKeys }
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
-
async getNodesWithoutSteps(
|
|
491
|
-
runId: string,
|
|
492
|
-
nodeIds: string[]
|
|
493
|
-
): Promise<string[]> {
|
|
494
|
-
if (nodeIds.length === 0) return []
|
|
495
|
-
|
|
496
|
-
const result = await this.steps
|
|
497
|
-
.find({
|
|
498
|
-
workflowRunId: runId,
|
|
499
|
-
stepName: { $in: nodeIds },
|
|
500
|
-
})
|
|
501
|
-
.project({ stepName: 1 })
|
|
502
|
-
.toArray()
|
|
503
|
-
|
|
504
|
-
const existingStepNames = new Set(result.map((r) => r.stepName))
|
|
505
|
-
return nodeIds.filter((id) => !existingStepNames.has(id))
|
|
506
|
-
}
|
|
507
|
-
|
|
508
490
|
async getStepInstances(
|
|
509
491
|
runId: string
|
|
510
492
|
): Promise<
|