@coffre/vault 0.14.5 → 0.15.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/README.md +6 -4
- package/dist/cloudflare.d.ts +341 -4
- package/dist/cloudflare.js +802 -6
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +181 -6
- package/dist/node.js +358 -16
- package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +3 -2
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +64 -10
- package/src/store.ts +832 -36
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1366 -220
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-Cbi45kLB.js +0 -5197
package/src/decide.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
allows, EVERYWHERE, INSTANCE_ROLES, NOTHING, ROLES, ownsProject, readsSensitive, sessionScope, useOnly, withinCredential,
|
|
3
|
+
type HeldKey, type Holdings, type Permission, type Place, type Policy,
|
|
4
|
+
} from '@coffre/core/access';
|
|
5
|
+
import { handsBack, live, PRINCIPAL, type Access, type Purpose, type RefusalCode, type UseCode, type UseWay } from '@coffre/core/vault';
|
|
6
|
+
|
|
7
|
+
import { overCap, type BulkRead } from './bulk.ts';
|
|
8
|
+
import {
|
|
9
|
+
noEdits, objectKey, sealed, type BindingInput, type ConnectionInput, type CredentialInput, type EnvironmentInput, type Input, type PolicyInput, type PrincipalInput, type Ref,
|
|
10
|
+
type RolesInput, type SealKeys, type SecretInput,
|
|
11
|
+
} from './inputs.ts';
|
|
3
12
|
import type { EnvironmentRow } from './store.ts';
|
|
4
13
|
|
|
5
14
|
/**
|
|
@@ -196,3 +205,429 @@ export function referenceRefusal(
|
|
|
196
205
|
?? (nature === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null)
|
|
197
206
|
?? (nature !== 'plain' && useOnly(reader.live, source) ? 'use_only' : null);
|
|
198
207
|
}
|
|
208
|
+
|
|
209
|
+
// --- a use, on sealed inputs (0.15) ---------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A use as its log entry records it (plan, PR 3; the request, not the
|
|
213
|
+
* outcome): on which credential, which key, through which environment,
|
|
214
|
+
* why, and when. `place` is 0.14's `through`: a fork an inherited value is
|
|
215
|
+
* read in, which decides nothing but names the read; null when the key is
|
|
216
|
+
* read in its own environment (a reference's holder's), where it is
|
|
217
|
+
* decided either way.
|
|
218
|
+
*/
|
|
219
|
+
export type UseRequest = {
|
|
220
|
+
/**
|
|
221
|
+
* Its way in, by id: a credential's, or, with `way`, an MCP connection's or a Cloudflare binding's; behind Cloudflare
|
|
222
|
+
* Access, the person's object's key (`objectKey`), the only id a person has (T147).
|
|
223
|
+
*/
|
|
224
|
+
credential: string;
|
|
225
|
+
/** What `credential` is the id of (`Way`): a credential, absent. */
|
|
226
|
+
way?: Way;
|
|
227
|
+
secretId: string;
|
|
228
|
+
place: string | null;
|
|
229
|
+
purpose: Purpose;
|
|
230
|
+
keepsSensitive: boolean;
|
|
231
|
+
/** Its time, ms since the epoch: what expiry is decided at, live and replayed. */
|
|
232
|
+
at: number;
|
|
233
|
+
/**
|
|
234
|
+
* A named use, which hands a plain value back as its text and logs no entry of it (T145). Any other use, by ids and
|
|
235
|
+
* so any one the log records, opens a wrapped key or nothing: a plain value is `bad_claim` to it, live and replayed
|
|
236
|
+
* alike (review r1 of D153).
|
|
237
|
+
*/
|
|
238
|
+
plain?: true;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export type { UseCode };
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What a use came in on (T147; formal/WaysIn.tla): a credential; an MCP connection; a Cloudflare service binding; or,
|
|
245
|
+
* behind Cloudflare Access, which signs a browser in with no row of coffre's, the person alone.
|
|
246
|
+
*/
|
|
247
|
+
export type Way = 'credential' | UseWay;
|
|
248
|
+
|
|
249
|
+
/** The object a use's way in is: a credential's, a connection's or a binding's. Behind Access, none: the account alone. */
|
|
250
|
+
export type WayInput = CredentialInput | ConnectionInput | BindingInput;
|
|
251
|
+
|
|
252
|
+
/** One input as a use read it: its row (null: none), the seal on it, and the seal its object's newest change left (null: none, or gone). */
|
|
253
|
+
export type Read<T extends Input> = { row: T | null; seal: Buffer | null; newest: Buffer | null };
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Everything a use reads, in one snapshot: what the use's statement returns
|
|
257
|
+
* (PR 5), or what a replay built from the log; its seals checked once
|
|
258
|
+
* `checked` (`checkSeals`). `principal` and `bulk` are read for the
|
|
259
|
+
* credential's principal, and null with no credential row; `source` for a
|
|
260
|
+
* reference, null otherwise; `environments`, by id, every one
|
|
261
|
+
* `useEnvironments` names. Read, `credential` is the way in's object, of
|
|
262
|
+
* its own kind (`WayInput`; none behind Access, whose `principal` is read by
|
|
263
|
+
* the request's key); checked, the credential it stands for (`wayChecked`).
|
|
264
|
+
*/
|
|
265
|
+
export type UseView<checked extends boolean = false> = {
|
|
266
|
+
credential: checked extends true ? Checked<CredentialInput> : Read<WayInput>;
|
|
267
|
+
principal: In<PrincipalInput, checked> | null;
|
|
268
|
+
roles: In<RolesInput, checked>;
|
|
269
|
+
policy: In<PolicyInput, checked>;
|
|
270
|
+
secret: In<SecretInput, checked>;
|
|
271
|
+
source: In<SecretInput, checked> | null;
|
|
272
|
+
environments: ReadonlyMap<string, In<EnvironmentInput, checked>>;
|
|
273
|
+
bulk: BulkRead | null;
|
|
274
|
+
};
|
|
275
|
+
export type CheckedView = UseView<true>;
|
|
276
|
+
|
|
277
|
+
/** An input once its seal is checked: whether the seal is the vault's over its row, and whether it is its object's newest. */
|
|
278
|
+
export type Checked<T extends Input> = { row: T | null; valid: boolean; current: boolean };
|
|
279
|
+
|
|
280
|
+
type In<T extends Input, checked extends boolean> = checked extends true ? Checked<T> : Read<T>;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Each input's seal checked, against the object it was read as: a row
|
|
284
|
+
* filed under another object's key carries no seal of the vault's for it.
|
|
285
|
+
* A row with none (none read) is valid, and current while its object has
|
|
286
|
+
* no newest seal either: never made, or gone.
|
|
287
|
+
*/
|
|
288
|
+
export function checkSeals(
|
|
289
|
+
keys: SealKeys, view: UseView, request: Pick<UseRequest, 'credential' | 'secretId' | 'way'>,
|
|
290
|
+
/** How a seal is checked: `sealed`, or a batch's `sealedOnce`, which checks an object its keys share once. */
|
|
291
|
+
seals: typeof sealed = sealed,
|
|
292
|
+
): CheckedView {
|
|
293
|
+
const check = <T extends Input>(read: Read<T>, ref: Ref): Checked<T> => ({
|
|
294
|
+
row: read.row,
|
|
295
|
+
valid: read.row === null ? read.seal === null : seals(keys, ref, read.row, read.seal),
|
|
296
|
+
current: read.row === null ? read.newest === null : read.seal !== null && read.newest !== null && read.seal.equals(read.newest),
|
|
297
|
+
});
|
|
298
|
+
const credential = wayChecked(check, view, request);
|
|
299
|
+
const secret = view.secret.row;
|
|
300
|
+
return {
|
|
301
|
+
credential,
|
|
302
|
+
principal: view.principal === null || credential.row === null ? null : check(view.principal, { kind: 'principal', id: credential.row.principal }),
|
|
303
|
+
roles: check(view.roles, { kind: 'roles', id: 'instance' }),
|
|
304
|
+
policy: check(view.policy, { kind: 'policy', id: 'instance' }),
|
|
305
|
+
secret: check(view.secret, { kind: 'secret', id: request.secretId }),
|
|
306
|
+
source: view.source === null || secret?.source == null ? null : check(view.source, { kind: 'secret', id: secret.source }),
|
|
307
|
+
environments: new Map([...view.environments].map(([id, read]) => [id, check(read, { kind: 'environment', id })])),
|
|
308
|
+
bulk: view.bulk,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A use's way in, its seal checked against its own object, and read as the credential it stands for (T147;
|
|
314
|
+
* formal/WaysIn.tla, WayReadInUse, AccountReadInUse), so that every rule after weighs it as one:
|
|
315
|
+
*
|
|
316
|
+
* a credential itself
|
|
317
|
+
* an MCP connection its person's credential, of no scope, as a session is: what its person holds, to its end
|
|
318
|
+
* a Cloudflare binding its service's workload credential, of its scope with its policy's reads (`sessionScope`), as
|
|
319
|
+
* the session a sign-in through it would make; one no Cloudflare call is taken through (another
|
|
320
|
+
* profile, no policy) reaches nothing
|
|
321
|
+
* Cloudflare Access the account alone, of its generation, a person's or an Access service token's, which Access
|
|
322
|
+
* signs in with no row of coffre's: the account is all there is to end; a service's, a bearer
|
|
323
|
+
* of no scope, as a service token of 0.4
|
|
324
|
+
*
|
|
325
|
+
* Behind Access, the account read must be the one the request's key names; none, there is no way in.
|
|
326
|
+
*/
|
|
327
|
+
function wayChecked(check: <T extends Input>(read: Read<T>, ref: Ref) => Checked<T>, view: UseView, request: Pick<UseRequest, 'credential' | 'way'>): Checked<CredentialInput> {
|
|
328
|
+
const way = request.way ?? 'credential';
|
|
329
|
+
if (way === 'access') {
|
|
330
|
+
const account = view.principal?.row ?? null;
|
|
331
|
+
if (account === null || objectKey({ kind: 'principal', id: account.principal }) !== request.credential) return { row: null, valid: true, current: true };
|
|
332
|
+
const caller = account.principal.startsWith('user:') ? 'person' : 'bearer';
|
|
333
|
+
return { row: { kind: 'credential', id: request.credential, principal: account.principal, caller, scope: null, expiresAt: null, revoked: false, generation: account.generation }, valid: true, current: true };
|
|
334
|
+
}
|
|
335
|
+
const read = view.credential;
|
|
336
|
+
if (read.row !== null && read.row.kind !== way) return { row: null, valid: false, current: false };
|
|
337
|
+
const checked = check(read, { kind: way, id: request.credential });
|
|
338
|
+
return { ...checked, row: checked.row === null ? null : asCredential(checked.row) };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A way in's object as the credential it stands for (`wayChecked`). */
|
|
342
|
+
export function asCredential(object: WayInput): CredentialInput {
|
|
343
|
+
switch (object.kind) {
|
|
344
|
+
case 'credential':
|
|
345
|
+
return object;
|
|
346
|
+
case 'connection':
|
|
347
|
+
return { kind: 'credential', id: object.id, principal: object.principal, caller: 'person', scope: null, expiresAt: object.expiresAt, revoked: object.revoked, generation: object.generation };
|
|
348
|
+
case 'binding': {
|
|
349
|
+
const policy = bindingPolicy(object);
|
|
350
|
+
return {
|
|
351
|
+
kind: 'credential', id: object.id, principal: object.principal, caller: 'workload',
|
|
352
|
+
scope: policy === null ? [] : sessionScope({ scope: null }, { scope: object.scope === null ? null : JSON.stringify(object.scope) }, policy),
|
|
353
|
+
expiresAt: null, revoked: object.revoked || policy === null, generation: object.generation,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A binding's policy, as a Cloudflare call through it is taken (`bindingWorkload`): of profile `cloudflare`, with one;
|
|
361
|
+
* else null. Its text is under the binding's seal, and the vault made it canonical JSON: one that does not parse is a
|
|
362
|
+
* bug of the vault's, which fails the call (and its replay) rather than read as a binding ended.
|
|
363
|
+
*/
|
|
364
|
+
function bindingPolicy(binding: BindingInput): { reads?: Parameters<typeof sessionScope>[2]['reads'] } | null {
|
|
365
|
+
if (binding.profile !== 'cloudflare' || binding.policy === null) return null;
|
|
366
|
+
const policy = JSON.parse(binding.policy) as unknown;
|
|
367
|
+
return policy !== null && typeof policy === 'object' && !Array.isArray(policy) ? (policy as { reads?: Parameters<typeof sessionScope>[2]['reads'] }) : null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Whether the credential reaches at all: one missing, revoked, ended at the request's time, or of another generation
|
|
372
|
+
* than its active account's. A removal moves the account's on, which ends every credential of it before its sweep
|
|
373
|
+
* revokes each, the account admitted again or not (formal/Credentials.tla, UseDecidesGeneration). One still removed
|
|
374
|
+
* is refused as such, by the rules after.
|
|
375
|
+
*/
|
|
376
|
+
function credentialRefused(view: CheckedView, request: UseRequest): boolean {
|
|
377
|
+
const credential = view.credential.row;
|
|
378
|
+
return credential === null || credentialEnded(credential, view.principal?.row ?? null, request.at);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Whether a credential has ended at `at`: revoked, past its end, or of another generation than its active account's. */
|
|
382
|
+
export function credentialEnded(credential: CredentialInput, account: PrincipalInput | null, at: number): boolean {
|
|
383
|
+
return credential.revoked || (credential.expiresAt !== null && at >= credential.expiresAt)
|
|
384
|
+
|| (account?.status === 'active' && account.generation !== credential.generation);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Why the caller may not act at `place`, the environment a key is sought or held in, or null: 0.14's rules on the
|
|
389
|
+
* reader its principal, credential and roles make there, for the request's purpose, and nothing of any key's. A
|
|
390
|
+
* place with no row: `missing`.
|
|
391
|
+
*/
|
|
392
|
+
function standingAt(view: CheckedView, request: UseRequest, place: string): UseCode | null {
|
|
393
|
+
const sought = view.environments.get(place)?.row ?? null;
|
|
394
|
+
if (sought === null) return 'missing';
|
|
395
|
+
// A personal config is weighed with its parent, as its owner reads it while they read the parent (R8).
|
|
396
|
+
const parents = standingParents(view, place).flatMap((read) => (read.row === null ? [] : [read.row]));
|
|
397
|
+
return placeRefusal(view.principal!.row, view.credential.row!, view.roles.row ?? noEdits(), sought, parents, request);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 0.14's rules for the request's permission at `sought`, on the reader its principal, credential and roles make there. */
|
|
401
|
+
function placeRefusal(
|
|
402
|
+
principal: PrincipalInput | null, credential: CredentialInput, roles: RolesInput, sought: EnvironmentInput, parents: readonly EnvironmentInput[],
|
|
403
|
+
request: Pick<UseRequest, 'purpose' | 'at'>,
|
|
404
|
+
): UseCode | null {
|
|
405
|
+
const rows = [sought, ...parents];
|
|
406
|
+
const reader = readerOf(principal, credential, roles, rows, request.at);
|
|
407
|
+
const at: SecretPlace = { projectId: sought.projectId, environmentId: sought.id };
|
|
408
|
+
const environments = new Map(rows.map(({ id, projectId, slug, owner, parentId, deleted, archived, projectArchived }) => [id, { projectId, slug, owner, parentId, deleted, archived: archived || projectArchived }]));
|
|
409
|
+
const [code] = keyRefusals(reader, { principal: credential.principal, permission: request.purpose === 'use' ? 'secret.use' : 'secret.read', secrets: [at], at: [at], environments, needs: [null] });
|
|
410
|
+
return code ?? null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The roles coffre ships (`ROLES`, `INSTANCE_ROLES`), as the named statement's gate weighs them where the roles' edits
|
|
415
|
+
* name none (`use_stands`, 0045): each column's `secret.read` and `secret.use`, all it asks of them. Passed with every
|
|
416
|
+
* named call, so that the roles have one source and an edit of one ships no migration.
|
|
417
|
+
*/
|
|
418
|
+
export const GATE_ROLES: string = JSON.stringify({ roles: gateColumns(ROLES), instanceRoles: gateColumns(INSTANCE_ROLES) });
|
|
419
|
+
|
|
420
|
+
function gateColumns(roles: Readonly<Record<string, { permissions: readonly Permission[]; protected: readonly Permission[] }>>) {
|
|
421
|
+
const weighed = (column: readonly Permission[]) => column.filter((permission) => permission === 'secret.read' || permission === 'secret.use');
|
|
422
|
+
return Object.fromEntries(Object.entries(roles).map(([name, role]) => [name, { permissions: weighed(role.permissions), protected: weighed(role.protected) }]));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Why the caller of a named use has no standing at the place it names, or null if they have: `standingRefusal` on its
|
|
427
|
+
* rows as read, before their seals are checked, which only the vault can (D150 fallback D, W268; formal/Probing.tla,
|
|
428
|
+
* StandingFirst). The named statement decides this first, from rows no key decides (the credential, its account and
|
|
429
|
+
* grants, the roles, the place), and reads the key only for a caller who stands (`use_stands`, 0045; SQLite's twin
|
|
430
|
+
* asks this). A named place is a live environment of its own, never a fork's or a personal config's (`nowhere`), so no
|
|
431
|
+
* parent weighs. Its seals checked and current, this is `standingRefusal` at that place: `use-stands.test.ts` holds
|
|
432
|
+
* the statement's gate to it.
|
|
433
|
+
*/
|
|
434
|
+
export function namedStanding(
|
|
435
|
+
principal: PrincipalInput | null, credential: CredentialInput, roles: RolesInput, place: EnvironmentInput, request: Pick<UseRequest, 'purpose' | 'at'>,
|
|
436
|
+
): UseCode | null {
|
|
437
|
+
if (credentialEnded(credential, principal, request.at)) return 'credential';
|
|
438
|
+
return placeRefusal(principal, credential, roles, place, [], request);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The environment a personal config at `place` is of, as the view read it: the standing weighs it too. */
|
|
442
|
+
function standingParents(view: CheckedView, place: string): Checked<EnvironmentInput>[] {
|
|
443
|
+
const sought = view.environments.get(place)?.row ?? null;
|
|
444
|
+
if (sought === null || sought.owner === null || sought.parentId === null) return [];
|
|
445
|
+
const parent = view.environments.get(sought.parentId);
|
|
446
|
+
return parent === undefined ? [] : [parent];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** `decideChecked` on what a use read, its seals checked with the vault's keys. */
|
|
450
|
+
export function decideUse(keys: SealKeys, view: UseView, request: UseRequest): UseCode | null {
|
|
451
|
+
return decideChecked(checkSeals(keys, view, request), request);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The environments a use's decision weighs, in the order it reaches them:
|
|
456
|
+
* the one it is read through, the key's own (where its permission is
|
|
457
|
+
* checked), where its value is, and a personal config's parent. Read from the rows the view has, so the use's statement
|
|
458
|
+
* and the replay read the same ones.
|
|
459
|
+
*/
|
|
460
|
+
export function useEnvironments(view: Pick<UseView<boolean>, 'secret' | 'source' | 'environments'>, request: Pick<UseRequest, 'place'>): string[] {
|
|
461
|
+
const secret = view.secret.row;
|
|
462
|
+
// A key the place does not hold: the place it was sought in, where the caller's standing decides (T145).
|
|
463
|
+
if (secret === null) return request.place === null ? [] : [request.place];
|
|
464
|
+
const value = secret.source === null ? secret : view.source?.row ?? null;
|
|
465
|
+
const ids = [...(request.place === null ? [] : [request.place]), secret.environmentId];
|
|
466
|
+
if (value !== null) {
|
|
467
|
+
ids.push(value.environmentId);
|
|
468
|
+
const own = view.environments.get(value.environmentId)?.row;
|
|
469
|
+
if (own?.owner != null && own.parentId !== null) ids.push(own.parentId);
|
|
470
|
+
}
|
|
471
|
+
return [...new Set(ids)];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function sealRefusal(inputs: readonly Checked<Input>[]): UseCode | null {
|
|
475
|
+
for (const input of inputs) {
|
|
476
|
+
if (input.row !== null && !input.valid) return 'tampered';
|
|
477
|
+
if (!input.current) return 'stale';
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Why the caller may not act where a use's key is held, or, for a key the place does not hold, where it was sought
|
|
484
|
+
* (T145; formal/Probing.tla, MissingByStanding), or null: `decideChecked`'s first step (T158, review d5 of #416). The
|
|
485
|
+
* credential first, its row's seal and its account's, then whether it reaches at all, which no place or key decides
|
|
486
|
+
* (the app's 401: review r1 of D153); then the seals of what the standing reads (the roles, the policy, the key's row,
|
|
487
|
+
* which says where it is held, that place and a personal config's parent), then 0.14's rules there.
|
|
488
|
+
*/
|
|
489
|
+
export function standingRefusal(view: CheckedView, request: UseRequest): UseCode | null {
|
|
490
|
+
const caller = sealRefusal([view.credential, ...(view.principal === null ? [] : [view.principal])]);
|
|
491
|
+
if (caller !== null) return caller;
|
|
492
|
+
if (view.credential.row === null || credentialRefused(view, request)) return 'credential';
|
|
493
|
+
const stand = view.secret.row === null ? request.place : view.secret.row.environmentId;
|
|
494
|
+
const refused = sealRefusal([view.roles, view.policy, view.secret,
|
|
495
|
+
...(stand === null ? [] : [view.environments.get(stand)!, ...standingParents(view, stand)])]);
|
|
496
|
+
if (refused !== null) return refused;
|
|
497
|
+
return stand === null ? null : standingAt(view, request, stand);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Why a use is refused, or null to allow it: a function of what it read
|
|
502
|
+
* and of the request alone, so that the replay of the log decides the same
|
|
503
|
+
* (docs/design/sealed-inputs.md).
|
|
504
|
+
*
|
|
505
|
+
* 1. **The caller's standing, before anything of the key's own** (T158;
|
|
506
|
+
* review d5 of #416; `standingRefusal`): the seals of what it reads (the
|
|
507
|
+
* credential, the principal, the roles, the policy, the key's row, which
|
|
508
|
+
* says where it is held, and that environment, or the one it was sought
|
|
509
|
+
* in), then the credential
|
|
510
|
+
* (missing, revoked, ended at the request's time, or of an earlier
|
|
511
|
+
* generation than its account's: a removal's, before its sweep revokes
|
|
512
|
+
* it), then 0.14's rules there for the request's permission
|
|
513
|
+
* (`standingAt`). A caller refused there is refused the same for every
|
|
514
|
+
* key, held, gone or never made; and the app answers every refusal alike
|
|
515
|
+
* besides (D153).
|
|
516
|
+
* 2. **Seals,** every other input's, before anything its rows say: `tampered`
|
|
517
|
+
* for a row whose seal is not the vault's, `stale` for one that is not
|
|
518
|
+
* its object's newest. A row put back from before a change can make a
|
|
519
|
+
* use refused or allowed that the log's state would decide otherwise;
|
|
520
|
+
* checked before its rows, every such use is `stale`, never another
|
|
521
|
+
* refusal (HotPathOne's `StaleFirst`, `TamperVisible`: the standing's
|
|
522
|
+
* inputs first, then the key's).
|
|
523
|
+
* 3. **The key:** a key that is no more, a reference whose source is
|
|
524
|
+
* gone or that has ended (`ended`), a reference to a reference, a value with no wrapped
|
|
525
|
+
* key (a plain one, which the vault never opens), and a place it is read
|
|
526
|
+
* through that 0.14's `throughOk` refuses (`bad_claim`): one but a live,
|
|
527
|
+
* unarchived environment of an unarchived project, the key's, other than
|
|
528
|
+
* the key's own.
|
|
529
|
+
* Permission is decided at the key's own environment (a reference's
|
|
530
|
+
* holder's), whatever it is read through.
|
|
531
|
+
* 4. **0.14's rules, unchanged:** `keyRefusals`, then `useOnlyRefusals`,
|
|
532
|
+
* then `sensitiveRefusals`, on the reader its principal, credential,
|
|
533
|
+
* roles and environments make (`readerOf`). Nothing is `vet`ted: a
|
|
534
|
+
* reference's end is its holder's row, sealed (`ended` above).
|
|
535
|
+
* 5. **The bulk cap,** on what the use read of it, with the policy's count.
|
|
536
|
+
*
|
|
537
|
+
* Step 1 refuses only what step 4 would: 0.14's rules decide a key's
|
|
538
|
+
* permission at its holder (`keyRefusals`' `at`), and the reader's policy
|
|
539
|
+
* there is that environment's alone, so a refusal at the holder is one of
|
|
540
|
+
* the key's, perhaps by another code.
|
|
541
|
+
*/
|
|
542
|
+
export function decideChecked(view: CheckedView, request: UseRequest): UseCode | null {
|
|
543
|
+
const credential = view.credential.row;
|
|
544
|
+
const secret = view.secret.row;
|
|
545
|
+
if ((credential === null) !== (view.principal === null) || (credential === null) !== (view.bulk === null)) {
|
|
546
|
+
throw new Error("a use reads its credential's principal and bulk rows with it, and none without it");
|
|
547
|
+
}
|
|
548
|
+
if ((secret?.source == null) !== (view.source === null)) throw new Error("a use reads a reference's source with it, and none for a key that is no reference");
|
|
549
|
+
const ids = useEnvironments(view, request);
|
|
550
|
+
const environments = ids.map((id) => {
|
|
551
|
+
const read = view.environments.get(id);
|
|
552
|
+
if (read === undefined) throw new Error(`a use weighs environment ${id}, which it did not read`);
|
|
553
|
+
return read;
|
|
554
|
+
});
|
|
555
|
+
const notHere = standingRefusal(view, request);
|
|
556
|
+
if (notHere !== null) return notHere;
|
|
557
|
+
if (credential === null) return 'credential';
|
|
558
|
+
// Then the key's own seals, a key's row put back or hidden around the log `stale` (live never refuses otherwise
|
|
559
|
+
// where the log would allow), and only then what its rows say.
|
|
560
|
+
const keyRefused = sealRefusal([view.secret, ...(view.source === null ? [] : [view.source]), ...environments]);
|
|
561
|
+
if (keyRefused !== null) return keyRefused;
|
|
562
|
+
if (secret === null) return 'missing';
|
|
563
|
+
const value = secret.source === null ? secret : view.source!.row;
|
|
564
|
+
if (value === null) return 'ended';
|
|
565
|
+
// A reference whose end its row says (`ended_at`): no version of its own, no source. 0.14's `reference.end`.
|
|
566
|
+
if (secret.source === null && secret.version === '') return 'ended';
|
|
567
|
+
// A reference reads a key's own value: one to another reference is none the change path makes.
|
|
568
|
+
if (value.source !== null && secret.source !== null) return 'bad_claim';
|
|
569
|
+
// A use opens a wrapped key, or hands back a plain value's text, which its digest binds (T145): any other is no value.
|
|
570
|
+
if (value.key === null && value.digest === null) return 'bad_claim';
|
|
571
|
+
const rows = new Map(ids.flatMap((id) => {
|
|
572
|
+
const row = view.environments.get(id)!.row;
|
|
573
|
+
return row === null ? [] : [[id, row] as const];
|
|
574
|
+
}));
|
|
575
|
+
// Read through a fork (0.14's `throughOk`): a live, unarchived environment of the key's project, unarchived too, not its own. It
|
|
576
|
+
// decides nothing: the key is decided where it is held.
|
|
577
|
+
if (request.place !== null) {
|
|
578
|
+
const fork = rows.get(request.place);
|
|
579
|
+
if (request.place === secret.environmentId || fork === undefined || fork.projectId !== secret.projectId || fork.deleted || fork.archived || fork.projectArchived) return 'bad_claim';
|
|
580
|
+
}
|
|
581
|
+
const policy = view.policy.row;
|
|
582
|
+
if (policy === null) return 'no_policy';
|
|
583
|
+
const reader = readerOf(view.principal!.row, credential, view.roles.row ?? noEdits(), rows.values(), request.at);
|
|
584
|
+
const at: SecretPlace = { projectId: secret.projectId, environmentId: secret.environmentId };
|
|
585
|
+
const place: SecretPlace = { projectId: value.projectId, environmentId: value.environmentId };
|
|
586
|
+
// As 0.14's snapshot has them: archived, the environment or its project.
|
|
587
|
+
const environmentRows = new Map([...rows].map(([id, { projectId, slug, owner, parentId, deleted, archived, projectArchived }]) => [id, { projectId, slug, owner, parentId, deleted, archived: archived || projectArchived }]));
|
|
588
|
+
const permission: Permission = request.purpose === 'use' ? 'secret.use' : 'secret.read';
|
|
589
|
+
const byPlace = keyRefusals(reader, { principal: credential.principal, permission, secrets: [place], at: [at], environments: environmentRows, needs: [null] });
|
|
590
|
+
const listed = useOnlyRefusals(byPlace, reader, { action: 'secret.read', purpose: request.purpose, secrets: [{ ...place, secretId: value.id }] });
|
|
591
|
+
const [code] = sensitiveRefusals(listed, reader, {
|
|
592
|
+
action: 'secret.read', principal: credential.principal, purpose: request.purpose, keepsSensitive: request.keepsSensitive,
|
|
593
|
+
secrets: [{ projectId: value.projectId, sensitive: value.nature === 'sensitive' }], viaReference: [secret.source !== null],
|
|
594
|
+
});
|
|
595
|
+
if (code != null) return code;
|
|
596
|
+
// A plain value counts toward no cap, as when the app handed it back itself; only a named use hands one back.
|
|
597
|
+
if (value.key === null) return request.plain === true ? null : 'bad_claim';
|
|
598
|
+
return overCap(view.bulk!, policy.bulk) ? 'bulk_limit' : null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* What a use's principal holds through its credential, at `at`, as
|
|
603
|
+
* `#standing` and `through` in vault.ts make it from 0.14's rows: their
|
|
604
|
+
* role and grants, live and lapsed; the credential's scope and kind; and
|
|
605
|
+
* the policy, the release's roles with the log's edits over them and, of the environments the
|
|
606
|
+
* use weighs, those protected and not deleted. `allows` asks the policy
|
|
607
|
+
* only whether the place it checks is protected, and every place a use
|
|
608
|
+
* checks is one of those, so the policy is as good as the whole one there.
|
|
609
|
+
*/
|
|
610
|
+
export function readerOf(principal: PrincipalInput | null, credential: CredentialInput, roles: RolesInput, environments: Iterable<EnvironmentInput>, at: number): Reader {
|
|
611
|
+
const through = { ...(credential.scope === null ? {} : { credentialScope: credential.scope }), kind: credential.caller };
|
|
612
|
+
const status = principal === null || !PRINCIPAL.test(credential.principal) ? 'unknown' : principal.status;
|
|
613
|
+
if (principal === null || status !== 'active') {
|
|
614
|
+
const none = { ...NOTHING, ...through };
|
|
615
|
+
return { status, live: none, all: none };
|
|
616
|
+
}
|
|
617
|
+
const guarded = [...environments].filter((environment) => environment.protected && !environment.deleted);
|
|
618
|
+
// By project, in a Map: a project id a plain object already holds (`constructor`) is a project like another.
|
|
619
|
+
const byProject = new Map<string, { environmentId: string; slug: string | null }[]>();
|
|
620
|
+
for (const environment of guarded) byProject.set(environment.projectId, [...(byProject.get(environment.projectId) ?? []), { environmentId: environment.id, slug: environment.slug }]);
|
|
621
|
+
const policy: Policy = {
|
|
622
|
+
roles: { ...ROLES, ...roles.roles },
|
|
623
|
+
instanceRoles: { ...INSTANCE_ROLES, ...roles.instanceRoles },
|
|
624
|
+
protected: guarded.map((environment) => environment.id),
|
|
625
|
+
protectedIn: Object.fromEntries(byProject),
|
|
626
|
+
};
|
|
627
|
+
const person = credential.principal.startsWith('user:');
|
|
628
|
+
const standing = person ? { role: principal.role, scope: principal.scope } : { role: 'member' as const, scope: EVERYWHERE };
|
|
629
|
+
const grants = (only: (grant: PrincipalInput['grants'][number]) => boolean) =>
|
|
630
|
+
principal.grants.filter(only).map(({ projectId, environmentId, role }) => ({ projectId, environmentId, role }));
|
|
631
|
+
const holdings = (held: Holdings['grants']): Holdings => ({ ...standing, grants: held, policy, service: !person, ...through });
|
|
632
|
+
return { status: 'active', live: holdings(grants((grant) => live(grant, at))), all: holdings(grants(() => true)) };
|
|
633
|
+
}
|