@coffre/vault 0.10.1 → 0.11.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/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CByXzyi-.d.ts → index-zkW6DEm9.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +2 -2
- package/dist/{src-D82jyprb.js → src-B0d-W-SI.js} +4 -3
- package/dist/{vault-B0YiPBgi.js → vault-DEjPmLNt.js} +590 -192
- package/package.json +3 -3
- package/src/config.ts +6 -5
- package/src/decide.ts +198 -0
- package/src/snapshot.postgres.ts +97 -0
- package/src/snapshot.sqlite.ts +103 -0
- package/src/snapshot.ts +191 -0
- package/src/store.ts +28 -37
- package/src/vault.ts +189 -183
package/src/vault.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
normalScope,
|
|
24
24
|
NOTHING,
|
|
25
25
|
ownsInstance,
|
|
26
|
+
ownsProject,
|
|
26
27
|
PROJECT_ONLY_PERMISSIONS,
|
|
27
28
|
ROLE_ID,
|
|
28
29
|
ROLES,
|
|
@@ -38,7 +39,6 @@ import {
|
|
|
38
39
|
type RoleDefinition,
|
|
39
40
|
type Scope,
|
|
40
41
|
type ServiceAccount,
|
|
41
|
-
withinCredential,
|
|
42
42
|
} from '@coffre/core/access';
|
|
43
43
|
import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
44
44
|
import { checkContext, ownerKey, type SecretContext } from '@coffre/core/envelope';
|
|
@@ -60,7 +60,6 @@ import {
|
|
|
60
60
|
checkpointMessage,
|
|
61
61
|
checkpointVerifier,
|
|
62
62
|
describeAccessFault,
|
|
63
|
-
handsBack,
|
|
64
63
|
POLICY_REVISION,
|
|
65
64
|
policyRevision,
|
|
66
65
|
type Access,
|
|
@@ -101,13 +100,15 @@ import {
|
|
|
101
100
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
102
101
|
import { isUniqueViolation, isUnreachable, SNAPSHOT } from '@coffre/db/dialect';
|
|
103
102
|
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
104
|
-
import { appendEntries, lockLogHead, missingEntry, newestAuthentic,
|
|
103
|
+
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
105
104
|
|
|
106
105
|
import { signer, type Signer } from './checkpoint.ts';
|
|
106
|
+
import { keyRefusals, placeIn, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type SecretPlace } from './decide.ts';
|
|
107
107
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
108
108
|
import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
|
|
109
109
|
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
110
110
|
import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
|
|
111
|
+
import { snapshot, type Asked, type Snapshot } from './snapshot.ts';
|
|
111
112
|
import * as store from './store.ts';
|
|
112
113
|
import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
|
|
113
114
|
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
@@ -317,7 +318,7 @@ class Outage {
|
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
/** A rewrap that would make a value less sensitive than it is. */
|
|
320
|
-
const LOOSER = 'a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more';
|
|
321
|
+
const LOOSER = 'a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more; only its current sensitive value is made a secret in place, by an Owner of its project';
|
|
321
322
|
|
|
322
323
|
function refusal(code: RefusalCode, message: string): Refusal {
|
|
323
324
|
return { code, message };
|
|
@@ -334,6 +335,8 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
334
335
|
ended: 'the reference read through was ended',
|
|
335
336
|
personal: "a personal config's values are its owner's alone",
|
|
336
337
|
sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
|
|
338
|
+
not_owner_issued: 'a service account reads a sensitive value only on a token or trust binding an Owner of its project issued',
|
|
339
|
+
use_only: "a workload uses this value through coffre and never holds it: list it in its binding's reads, where the value is held, if it must",
|
|
337
340
|
not_allowed: 'not allowed to change this',
|
|
338
341
|
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
339
342
|
invalid: 'not something the rules allow',
|
|
@@ -354,6 +357,8 @@ type Decision = {
|
|
|
354
357
|
members: Map<string, Member>;
|
|
355
358
|
/** The database's clock, read once the members are locked. */
|
|
356
359
|
at: number;
|
|
360
|
+
/** What the decision reads, in one statement once the members are locked: under the head, unless it takes the head late. */
|
|
361
|
+
reads: Snapshot;
|
|
357
362
|
log: NewEntry[];
|
|
358
363
|
writes: ((at: number) => Promise<void>)[];
|
|
359
364
|
/** Members whose row or grants the writes change: sealed again once they have run. */
|
|
@@ -378,6 +383,13 @@ type Decision = {
|
|
|
378
383
|
underLock: ((locked: Queryable) => Promise<void>)[];
|
|
379
384
|
};
|
|
380
385
|
|
|
386
|
+
/**
|
|
387
|
+
* What a decision reads besides its members' standing, with it (`Snapshot`):
|
|
388
|
+
* places, references' ends, and the distinct keys a principal would hold
|
|
389
|
+
* released in the vault's window, with `keys` too: the bulk limit's count.
|
|
390
|
+
*/
|
|
391
|
+
type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { principal: string; keys: readonly string[] } };
|
|
392
|
+
|
|
381
393
|
/** The actions of the entries the vault writes about keys. */
|
|
382
394
|
type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
383
395
|
|
|
@@ -386,13 +398,21 @@ type Correlation = { requestId?: string | null; operationId?: string | null; cre
|
|
|
386
398
|
|
|
387
399
|
/**
|
|
388
400
|
* What the one asking holds through the credential their request came in
|
|
389
|
-
* on: no more than its scope (`allows`)
|
|
390
|
-
*
|
|
401
|
+
* on: no more than its scope (`allows`), and, a workload's, handed back
|
|
402
|
+
* only what the scope lists (`useOnly`). A scope that is not one, a kind of
|
|
403
|
+
* caller its principal cannot be, or a workload with no scope to list what
|
|
404
|
+
* it reads, is a bug of the app's, which fails the call. None said is an
|
|
405
|
+
* app older than 0.11, which has no workloads (`Through`).
|
|
391
406
|
*/
|
|
392
|
-
function through(standing: Standing, { credentialScope }: Through): Standing {
|
|
393
|
-
|
|
394
|
-
if (!
|
|
395
|
-
|
|
407
|
+
function through(standing: Standing, { credentialScope, caller }: Through): Standing {
|
|
408
|
+
const { principal } = standing;
|
|
409
|
+
if (caller !== undefined && !(caller === 'person' ? principal.startsWith('user:') : (caller === 'bearer' || caller === 'workload') && principal.startsWith('token:'))) {
|
|
410
|
+
throw new Error(`caller must be person for a user: principal, bearer or workload for a token: one, got: ${String(caller)}`);
|
|
411
|
+
}
|
|
412
|
+
if (caller === 'workload' && credentialScope == null) throw new Error('a workload caller comes with its credential scope, which lists what it reads');
|
|
413
|
+
if (credentialScope != null && !isCredentialScope(credentialScope)) throw new Error('credentialScope must be a list of grants, one a place');
|
|
414
|
+
const held = { ...(credentialScope == null ? {} : { credentialScope }), ...(caller === undefined ? {} : { kind: caller }) };
|
|
415
|
+
return { ...standing, live: { ...standing.live, ...held }, all: { ...standing.all, ...held } };
|
|
396
416
|
}
|
|
397
417
|
|
|
398
418
|
/** The credential a CI run's request came in on, as its entries record it: copied, never decided on. */
|
|
@@ -448,6 +468,22 @@ class VaultService implements Vault {
|
|
|
448
468
|
return (await store.now(db)) + this.#prepared.options.clockOffset();
|
|
449
469
|
}
|
|
450
470
|
|
|
471
|
+
/** A snapshot's clock, as the vault keeps time (`#now`). */
|
|
472
|
+
#at(reads: Snapshot): number {
|
|
473
|
+
return reads.at + this.#prepared.options.clockOffset();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
477
|
+
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
|
|
478
|
+
const { released, ...rest } = asked;
|
|
479
|
+
return snapshot(tx, {
|
|
480
|
+
...rest,
|
|
481
|
+
principals,
|
|
482
|
+
head,
|
|
483
|
+
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
451
487
|
/**
|
|
452
488
|
* Decide in one transaction, with `principals`' rows locked first, then
|
|
453
489
|
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
@@ -460,18 +496,18 @@ class VaultService implements Vault {
|
|
|
460
496
|
* alone takes the head later (`late`), once its key service has answered,
|
|
461
497
|
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
462
498
|
*/
|
|
463
|
-
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
499
|
+
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
|
|
464
500
|
const superseded = await this.#settled();
|
|
465
501
|
if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
|
|
466
|
-
return this.#decideNow(principals, decide, late);
|
|
502
|
+
return this.#decideNow(principals, decide, late, asked);
|
|
467
503
|
}
|
|
468
504
|
|
|
469
505
|
/** `#decide`, once the vault is settled. */
|
|
470
|
-
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
506
|
+
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
|
|
471
507
|
const reports: NewEntry[] = [];
|
|
472
508
|
for (let attempt = 1; ; attempt += 1) {
|
|
473
509
|
try {
|
|
474
|
-
return await this.#decideOnce(principals, decide, reports, late);
|
|
510
|
+
return await this.#decideOnce(principals, decide, reports, late, asked);
|
|
475
511
|
} catch (error) {
|
|
476
512
|
// A member who had no row when this decision locked theirs, and has
|
|
477
513
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
@@ -481,15 +517,15 @@ class VaultService implements Vault {
|
|
|
481
517
|
}
|
|
482
518
|
}
|
|
483
519
|
|
|
484
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
|
|
520
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean, asked: Reads): Promise<Outcome<T>> {
|
|
485
521
|
try {
|
|
486
522
|
const result = await this.#db.transaction(async (tx) => {
|
|
487
523
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
488
524
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
489
|
-
|
|
490
|
-
|
|
525
|
+
// Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
|
|
526
|
+
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
491
527
|
const d: Decision = {
|
|
492
|
-
tx, members, at:
|
|
528
|
+
tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
493
529
|
};
|
|
494
530
|
const result = await decide(d);
|
|
495
531
|
// A read is logged once per place, whichever way it ends (`readsByPlace`).
|
|
@@ -498,7 +534,9 @@ class VaultService implements Vault {
|
|
|
498
534
|
// Each member's newest access entry, which their row names (rows.ts).
|
|
499
535
|
const accessSeq = new Map<string, bigint>();
|
|
500
536
|
if (entries.length > 0) {
|
|
501
|
-
|
|
537
|
+
// Under the head the decision's reads took: a remote key's decision takes it late, and reads again with it.
|
|
538
|
+
if (d.reads.head === null) throw new Error('a decision appends only under the head it read with');
|
|
539
|
+
const appended = await this.#append(tx, entries, d.underLock, d.reads);
|
|
502
540
|
at = appended.occurredAt;
|
|
503
541
|
entries.forEach((entry, i) => {
|
|
504
542
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
|
|
@@ -557,12 +595,12 @@ class VaultService implements Vault {
|
|
|
557
595
|
* MAC are passed over, and reported: otherwise whoever can insert a row
|
|
558
596
|
* could lock any member out.
|
|
559
597
|
*/
|
|
560
|
-
async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[]): Promise<Fault | null> {
|
|
598
|
+
async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[], reads?: Snapshot): Promise<Fault | null> {
|
|
561
599
|
if (row !== undefined && !this.#sealed(row, grants)) {
|
|
562
600
|
this.#report(reports, principal, 'mac', row.mac.toString('hex'));
|
|
563
601
|
return 'mac';
|
|
564
602
|
}
|
|
565
|
-
const newest = await this.#newestAccess(db, principal, reports);
|
|
603
|
+
const newest = await this.#newestAccess(db, principal, reports, reads);
|
|
566
604
|
// No row, and no entry of the vault's own: someone never admitted. No
|
|
567
605
|
// row, but one: one deleted, unless it was admitted since the row was
|
|
568
606
|
// read. Too many forged to see past: neither a row nor the lack of one holds.
|
|
@@ -574,8 +612,14 @@ class VaultService implements Vault {
|
|
|
574
612
|
}
|
|
575
613
|
|
|
576
614
|
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
577
|
-
#newestAccess(db: Queryable, principal: string, reports: NewEntry[]): Promise<Newest> {
|
|
578
|
-
return newestAccess(db, principal, this.#check(reports, principal));
|
|
615
|
+
#newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
|
|
616
|
+
if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
|
|
617
|
+
// The snapshot's newest first, read with the rest; the pages before it, as they are asked.
|
|
618
|
+
const newest = reads.newestAccess.get(principal);
|
|
619
|
+
return newestAuthentic(
|
|
620
|
+
(limit, before) => (before === undefined ? Promise.resolve(newest === undefined ? [] : [newest]) : store.accessEntriesAbout(db, principal, limit, before)),
|
|
621
|
+
this.#check(reports, principal),
|
|
622
|
+
);
|
|
579
623
|
}
|
|
580
624
|
|
|
581
625
|
/** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
|
|
@@ -687,7 +731,7 @@ class VaultService implements Vault {
|
|
|
687
731
|
|
|
688
732
|
async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
|
|
689
733
|
const row = d.members.get(principal);
|
|
690
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
734
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
691
735
|
if (row === undefined || subject.status !== 'active') return;
|
|
692
736
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
693
737
|
if (everyProject.length === 0) return;
|
|
@@ -833,16 +877,29 @@ class VaultService implements Vault {
|
|
|
833
877
|
* that an instance still running with the replaced KEK, as during a
|
|
834
878
|
* deploy, cannot write after the rotation.
|
|
835
879
|
*/
|
|
836
|
-
#append(
|
|
880
|
+
async #append(
|
|
881
|
+
tx: Transaction,
|
|
882
|
+
entries: readonly NewEntry[],
|
|
883
|
+
underLock: readonly ((locked: Queryable) => Promise<void>)[] = [],
|
|
884
|
+
/** A snapshot that holds the head: what it read under it is read again by nothing. */
|
|
885
|
+
held?: Snapshot,
|
|
886
|
+
): Promise<Appended> {
|
|
837
887
|
const prepared = this.#prepared;
|
|
838
|
-
|
|
839
|
-
|
|
888
|
+
const head = held?.head ?? undefined;
|
|
889
|
+
const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
890
|
+
const rotation = head === undefined ? await store.latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? undefined;
|
|
840
891
|
if (rotation !== undefined && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
|
|
841
892
|
prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
|
|
842
893
|
throw new Error(prepared.superseded);
|
|
843
894
|
}
|
|
844
895
|
for (const check of underLock) await check(locked);
|
|
845
|
-
});
|
|
896
|
+
}, head === undefined ? undefined : { head, last: head.last, at: held!.at });
|
|
897
|
+
// The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
|
|
898
|
+
// then the decision's reports.
|
|
899
|
+
if (head !== undefined) {
|
|
900
|
+
held!.head = { ...head, nextSeq: appended.nextSeq, headHash: appended.headHash, last: { seq: appended.nextSeq - 1n, hash: appended.headHash } };
|
|
901
|
+
}
|
|
902
|
+
return appended;
|
|
846
903
|
}
|
|
847
904
|
|
|
848
905
|
// --- keys -------------------------------------------------------------------
|
|
@@ -906,10 +963,10 @@ class VaultService implements Vault {
|
|
|
906
963
|
secrets: items.map((item) => item.secret),
|
|
907
964
|
// Through a reference, the reader's grants on the holder's environment decide.
|
|
908
965
|
places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
966
|
+
// Which items are read through a reference: a sensitive one is never copied, which would move it out of its project.
|
|
967
|
+
viaReference: sealed.map((seal) => seal !== null),
|
|
968
|
+
references: ends,
|
|
969
|
+
vet: ends.length === 0 ? undefined : async (_, reads) => sealed.map((seal) => (seal !== null && reads.ended.has(seal.seq) ? 'ended' : null)),
|
|
913
970
|
remote,
|
|
914
971
|
entry,
|
|
915
972
|
input,
|
|
@@ -975,16 +1032,22 @@ class VaultService implements Vault {
|
|
|
975
1032
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
|
|
976
1033
|
})));
|
|
977
1034
|
}
|
|
978
|
-
// A value is made looser only by writing it again: a sensitive
|
|
979
|
-
// is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
|
|
980
|
-
|
|
1035
|
+
// A value is made looser only by writing it again: a sensitive value's key, or one of a secret whose newest version
|
|
1036
|
+
// is, is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
|
|
1037
|
+
// Nor a version whose value was made sensitive since, in place: the newest version of its very ciphertext. But for one:
|
|
1038
|
+
// a key's current sensitive value made a secret, in place, by an Owner of its project, who reads it anyway through a
|
|
1039
|
+
// service account they give read (R30). Older versions stay as they were held.
|
|
1040
|
+
const loosens = items.map((item, i) => item.secret.sensitive !== true && loaded.versions[i].secret.sensitive === true && loaded.versions[i].current);
|
|
1041
|
+
const strict = (i: number) => loaded.versions[i].secret.sensitive || loaded.versions[i].heldSensitive || loaded.versions[i].newestSensitive;
|
|
1042
|
+
if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) {
|
|
981
1043
|
return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
|
|
982
1044
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
|
|
983
1045
|
})), LOOSER);
|
|
984
1046
|
}
|
|
985
1047
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
1048
|
+
const loosened = new Set(items.flatMap((item, i) => (loosens[i] ? [item.secret] : [])));
|
|
986
1049
|
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
|
|
987
|
-
...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version }),
|
|
1050
|
+
...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version, ...(loosened.has(secret) ? { was: 'sensitive' } : {}) }),
|
|
988
1051
|
secretVersionId: sources.get(secret)!.id,
|
|
989
1052
|
});
|
|
990
1053
|
const remote =
|
|
@@ -992,11 +1055,19 @@ class VaultService implements Vault {
|
|
|
992
1055
|
return this.#keys(
|
|
993
1056
|
{
|
|
994
1057
|
action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input,
|
|
995
|
-
// Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any
|
|
1058
|
+
// Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any,
|
|
1059
|
+
// a value made sensitive in place since is so, and a value loosened is still its key's current one.
|
|
996
1060
|
vet: async (db) => {
|
|
997
1061
|
const newest = await store.newestSensitive(db, items.map(({ secret }) => secret.secretId));
|
|
998
|
-
|
|
1062
|
+
// Each source again: current as the decision had it, its key's pointer and its newest, and its value as held now.
|
|
1063
|
+
const again = new Map((await store.versions(db, items.map(({ secret }) => sources.get(secret)!.id))).map((version) => [version.id, version]));
|
|
1064
|
+
return items.map(({ secret }, i) => {
|
|
1065
|
+
const now = again.get(sources.get(secret)!.id);
|
|
1066
|
+
if (loosens[i]) return now?.current === true ? null : 'invalid';
|
|
1067
|
+
return secret.sensitive !== true && (newest.has(secret.secretId) || now?.heldSensitive === true) ? 'invalid' : null;
|
|
1068
|
+
});
|
|
999
1069
|
},
|
|
1070
|
+
owns: loosens,
|
|
1000
1071
|
// An old value brought back, not the current one, is made live by whoever could have read it: a secret by its
|
|
1001
1072
|
// readers, a sensitive one, which nobody reads, by those who use it, a plain one by any writer, as it was shown
|
|
1002
1073
|
// to them. Whatever reads the key next, a reference to it included, then reads what its restorer could. Typing a
|
|
@@ -1095,16 +1166,23 @@ class VaultService implements Vault {
|
|
|
1095
1166
|
secrets: readonly SecretRef[];
|
|
1096
1167
|
/** Where each secret's permission is checked, when not where it is: a reference's holder. */
|
|
1097
1168
|
places?: readonly SecretPlace[];
|
|
1169
|
+
/** The `reference.create` entries `vet` asks about: read with the decision (`Snapshot`'s `ended`). */
|
|
1170
|
+
references?: readonly bigint[];
|
|
1098
1171
|
/**
|
|
1099
1172
|
* What else refuses each secret, checked in the decision and again
|
|
1100
|
-
* once the log's head is locked, before its entries append
|
|
1173
|
+
* once the log's head is locked, before its entries append: from the
|
|
1174
|
+
* decision's reads, or the database.
|
|
1101
1175
|
*/
|
|
1102
|
-
vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
|
|
1176
|
+
vet?: (db: Queryable, reads: Snapshot) => Promise<(RefusalCode | null)[]>;
|
|
1103
1177
|
/**
|
|
1104
1178
|
* Any one of which each secret takes besides `permission`, where it
|
|
1105
1179
|
* is, read in the decision and again under the head; none for null.
|
|
1106
1180
|
*/
|
|
1107
1181
|
needs?: (db: Queryable) => Promise<(readonly Permission[] | null)[]>;
|
|
1182
|
+
/** Each secret that takes besides its reader being an Owner of its project (`ownsProject`): a sensitive value made a secret. */
|
|
1183
|
+
owns?: readonly boolean[];
|
|
1184
|
+
/** Which secrets are read through a reference, by index: a sensitive one of these is never copied (it would leave its project). */
|
|
1185
|
+
viaReference?: readonly boolean[];
|
|
1108
1186
|
remote: boolean;
|
|
1109
1187
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1110
1188
|
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
|
|
@@ -1128,48 +1206,25 @@ class VaultService implements Vault {
|
|
|
1128
1206
|
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1129
1207
|
const owners = new Map<string, string>();
|
|
1130
1208
|
const check = async (d: Decision) => {
|
|
1131
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
1209
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
|
|
1132
1210
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
1133
1211
|
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
1134
1212
|
// matches the slug there, and a place deleted on either side opens for none.
|
|
1135
1213
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const parents = [...environments.values()].flatMap((environment) => (environment.owner === null || environment.parentId === null ? [] : [environment.parentId]));
|
|
1139
|
-
for (const [id, parent] of await store.environmentsById(d.tx, parents)) environments.set(id, parent);
|
|
1140
|
-
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1214
|
+
// Each a personal config is a config of too, whose read its owner needs (`places`).
|
|
1215
|
+
const environments = d.reads.environments;
|
|
1141
1216
|
owners.clear();
|
|
1142
1217
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
1143
1218
|
const needs = call.needs === undefined ? [] : await call.needs(d.tx);
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i]!)));
|
|
1151
|
-
if (refused.every((code) => code !== null)) return refused[0]!;
|
|
1152
|
-
}
|
|
1153
|
-
// Through a reference, the holder's grants decide, its source's never: a reference takes its value and nature
|
|
1154
|
-
// (a sensitive one stays use-only) from its source, and its permissions from where it lives. Its maker held what
|
|
1155
|
-
// the source's nature asks when it was made (`reference`).
|
|
1156
|
-
return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
|
|
1219
|
+
let codes = keyRefusals(reader, { principal, permission: call.permission, secrets, at, environments, needs, owns: call.owns });
|
|
1220
|
+
if (call.vet !== undefined && !codes.some((code) => code !== null)) codes = await call.vet(d.tx, d.reads);
|
|
1221
|
+
// Where each value is held, the secret itself, a reference's source or a fork's parent: what a workload lists.
|
|
1222
|
+
codes = useOnlyRefusals(codes, reader, { action, purpose: call.input.purpose, secrets });
|
|
1223
|
+
codes = sensitiveRefusals(codes, reader, {
|
|
1224
|
+
action, principal, purpose: call.input.purpose, keepsSensitive: call.input.keepsSensitive, secrets, viaReference: call.viaReference,
|
|
1157
1225
|
});
|
|
1158
|
-
if (call.vet !== undefined && !codes.some((code) => code !== null)) {
|
|
1159
|
-
(await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1160
|
-
}
|
|
1161
|
-
// A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
|
|
1162
|
-
// a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
|
|
1163
|
-
// 0.9, mid-deploy, would write the copy as a secret its people read.
|
|
1164
|
-
const copying = action === 'secret.read' && call.input.purpose === 'copy';
|
|
1165
|
-
const handedBack = action === 'secret.read' && principal.startsWith('user:') && handsBack({ purpose: call.input.purpose ?? '', keepsSensitive: call.input.keepsSensitive === true });
|
|
1166
|
-
if (handedBack || (copying && call.input.keepsSensitive !== true)) {
|
|
1167
|
-
secrets.forEach((secret, i) => {
|
|
1168
|
-
if (codes[i] === null && secret.sensitive === true) codes[i] = 'sensitive';
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
1226
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1172
|
-
if (first === null && action === 'secret.read' &&
|
|
1227
|
+
if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
|
|
1173
1228
|
if (first !== null) {
|
|
1174
1229
|
throw new Refused(
|
|
1175
1230
|
refusal(first, MESSAGES[first]),
|
|
@@ -1178,6 +1233,12 @@ class VaultService implements Vault {
|
|
|
1178
1233
|
}
|
|
1179
1234
|
};
|
|
1180
1235
|
|
|
1236
|
+
// Every decision below reads the places it is about with the reader's standing; a read, the reader's releases too.
|
|
1237
|
+
const reads: Reads = {
|
|
1238
|
+
environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
|
|
1239
|
+
references: call.references ?? [],
|
|
1240
|
+
...(action === 'secret.read' ? { released: { principal, keys: secrets.map((secret) => secret.secretId) } } : {}),
|
|
1241
|
+
};
|
|
1181
1242
|
// The intent's own identity; `operationId` is the app's, for the whole action.
|
|
1182
1243
|
const intentId = call.remote ? randomUUID() : null;
|
|
1183
1244
|
let intentSeq: bigint | null = null;
|
|
@@ -1197,7 +1258,7 @@ class VaultService implements Vault {
|
|
|
1197
1258
|
if (call.remote) {
|
|
1198
1259
|
const intent = await this.#decide([principal], async (d) => {
|
|
1199
1260
|
await check(d);
|
|
1200
|
-
const at =
|
|
1261
|
+
const at = d.at;
|
|
1201
1262
|
const appended = await this.#append(d.tx, [{
|
|
1202
1263
|
actor: principal,
|
|
1203
1264
|
action: 'key.intent',
|
|
@@ -1213,9 +1274,9 @@ class VaultService implements Vault {
|
|
|
1213
1274
|
...traced(call.input),
|
|
1214
1275
|
keys: secrets.map((secret, item) => ({ item, subject: secret.path, secretId: secret.secretId, version: secret.version })),
|
|
1215
1276
|
}),
|
|
1216
|
-
}]);
|
|
1277
|
+
}], [], d.reads);
|
|
1217
1278
|
return { seq: appended.seqStart };
|
|
1218
|
-
});
|
|
1279
|
+
}, false, reads);
|
|
1219
1280
|
if (!intent.ok) return intent;
|
|
1220
1281
|
intentSeq = intent.seq;
|
|
1221
1282
|
}
|
|
@@ -1242,7 +1303,7 @@ class VaultService implements Vault {
|
|
|
1242
1303
|
const { vet } = call;
|
|
1243
1304
|
if (late && vet !== undefined) {
|
|
1244
1305
|
d.underLock.push(async (locked) => {
|
|
1245
|
-
const codes = await vet(locked);
|
|
1306
|
+
const codes = await vet(locked, d.reads);
|
|
1246
1307
|
const first = codes.find((code) => code !== null);
|
|
1247
1308
|
if (first === undefined || first === null) return;
|
|
1248
1309
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
|
|
@@ -1272,13 +1333,13 @@ class VaultService implements Vault {
|
|
|
1272
1333
|
// deletion renames its place under it, so one that committed since the
|
|
1273
1334
|
// check above is seen here, and nothing is released after it.
|
|
1274
1335
|
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
1275
|
-
await lockLogHead(d.tx);
|
|
1276
1336
|
// And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
|
|
1277
|
-
// since the check above is seen here, and nothing it refuses now is released.
|
|
1337
|
+
// since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
|
|
1338
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
1339
|
+
d.at = this.#at(d.reads);
|
|
1278
1340
|
await decided(d);
|
|
1279
1341
|
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1280
|
-
|
|
1281
|
-
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
|
|
1342
|
+
if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
|
|
1282
1343
|
throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
|
|
1283
1344
|
}
|
|
1284
1345
|
}
|
|
@@ -1291,13 +1352,17 @@ class VaultService implements Vault {
|
|
|
1291
1352
|
} finally {
|
|
1292
1353
|
for (const value of done) value.wipe();
|
|
1293
1354
|
}
|
|
1294
|
-
}, late);
|
|
1355
|
+
}, late, reads);
|
|
1295
1356
|
}
|
|
1296
1357
|
|
|
1297
|
-
/**
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1358
|
+
/**
|
|
1359
|
+
* Whether this read's keys would take the reader past the bulk limit: the
|
|
1360
|
+
* distinct keys released to them in the window, these among them. A key
|
|
1361
|
+
* read again exposes nothing new, so it counts once, whatever its version.
|
|
1362
|
+
* Counted under their row's lock, so exactly (`Reads`).
|
|
1363
|
+
*/
|
|
1364
|
+
#overBulkLimit(d: Decision): boolean {
|
|
1365
|
+
return d.reads.released > this.#config.bulkLimit.count;
|
|
1301
1366
|
}
|
|
1302
1367
|
|
|
1303
1368
|
// --- references ---------------------------------------------------------------
|
|
@@ -1349,7 +1414,7 @@ class VaultService implements Vault {
|
|
|
1349
1414
|
return this.#decide([principal], async (d) => {
|
|
1350
1415
|
// Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
|
|
1351
1416
|
// nor a role narrowed, before this commits.
|
|
1352
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1417
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
1353
1418
|
// Each source's nature, its current version's, under the head: what its maker must hold there.
|
|
1354
1419
|
const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
1355
1420
|
// Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
|
|
@@ -1357,19 +1422,11 @@ class VaultService implements Vault {
|
|
|
1357
1422
|
const source = sources.get(item.source.secretId);
|
|
1358
1423
|
return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
1359
1424
|
}));
|
|
1360
|
-
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1361
1425
|
const codes = items.map((item): RefusalCode | null => {
|
|
1362
1426
|
const source = sources.get(item.source.secretId);
|
|
1363
1427
|
if (source === undefined || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return 'bad_claim';
|
|
1364
1428
|
if (source.secretId === item.holder.secretId) return 'invalid';
|
|
1365
|
-
|
|
1366
|
-
if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return 'personal';
|
|
1367
|
-
// Making one hands its source's value to whoever reads the holder, whatever its nature becomes: its maker reads
|
|
1368
|
-
// it at the source, as any direct access there, and, for a sensitive one, which nobody reads, uses it there too.
|
|
1369
|
-
const at = placeIn(source, environments);
|
|
1370
|
-
return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
|
|
1371
|
-
?? refuses(reader, 'secret.read', at, gone(source))
|
|
1372
|
-
?? (natures.get(source.secretId) === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null);
|
|
1429
|
+
return referenceRefusal(reader, item.holder, source, environments, natures.get(source.secretId) ?? 'secret');
|
|
1373
1430
|
});
|
|
1374
1431
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1375
1432
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
|
|
@@ -1436,7 +1493,7 @@ class VaultService implements Vault {
|
|
|
1436
1493
|
};
|
|
1437
1494
|
return this.#decide([principal], async (d) => {
|
|
1438
1495
|
// Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
|
|
1439
|
-
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1496
|
+
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
1440
1497
|
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1441
1498
|
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1442
1499
|
const codes: (RefusalCode | null)[] = seals.map((seal) => {
|
|
@@ -1552,7 +1609,7 @@ class VaultService implements Vault {
|
|
|
1552
1609
|
*/
|
|
1553
1610
|
async #makeOwner(d: Decision, principal: string, reason: 'owners'): Promise<boolean> {
|
|
1554
1611
|
const row = d.members.get(principal);
|
|
1555
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1612
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1556
1613
|
if (subject.status === 'active' && ownsInstance(subject.live)) return false;
|
|
1557
1614
|
const tampered = subject.status === 'tampered';
|
|
1558
1615
|
if (tampered && (await this.#logged(d.tx, principal)) === undefined) return false;
|
|
@@ -1584,17 +1641,17 @@ class VaultService implements Vault {
|
|
|
1584
1641
|
* What `principal` holds, their row checked first (`#integrity`): a row
|
|
1585
1642
|
* that fails holds nothing, and is `tampered`.
|
|
1586
1643
|
*/
|
|
1587
|
-
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
|
|
1644
|
+
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[], reads?: Snapshot): Promise<Standing> {
|
|
1588
1645
|
const none = NOTHING;
|
|
1589
|
-
const grants = row === undefined ? [] : await store.grants(db, principal);
|
|
1590
|
-
const fault = await this.#integrity(db, principal, row, grants, reports);
|
|
1646
|
+
const grants = row === undefined ? [] : reads?.grants.get(principal) ?? (await store.grants(db, principal));
|
|
1647
|
+
const fault = await this.#integrity(db, principal, row, grants, reports, reads);
|
|
1591
1648
|
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
1592
1649
|
if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
|
|
1593
1650
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
1594
1651
|
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
1595
1652
|
const { role, scope } = roleOf(principal, row);
|
|
1596
1653
|
// Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
|
|
1597
|
-
const { policy } = await this.#policyAt(db, reports, at);
|
|
1654
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
1598
1655
|
const service = !principal.startsWith('user:');
|
|
1599
1656
|
return {
|
|
1600
1657
|
principal,
|
|
@@ -1642,9 +1699,10 @@ class VaultService implements Vault {
|
|
|
1642
1699
|
/** `access`, as the rows are now. */
|
|
1643
1700
|
async #read(principal: string, superseded: string | null): Promise<Access> {
|
|
1644
1701
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
1645
|
-
const
|
|
1646
|
-
const
|
|
1647
|
-
const
|
|
1702
|
+
const reads = await snapshot(db, { principals: [principal], members: true });
|
|
1703
|
+
const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
|
|
1704
|
+
const fault = await this.#integrity(db, principal, row, held, reports, reads);
|
|
1705
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
1648
1706
|
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
1649
1707
|
};
|
|
1650
1708
|
const found: NewEntry[] = [];
|
|
@@ -1724,7 +1782,7 @@ class VaultService implements Vault {
|
|
|
1724
1782
|
// renames the place, so the place is as it commits.
|
|
1725
1783
|
// The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
|
|
1726
1784
|
// granted as one that ships, and one deleted meanwhile is granted by nobody.
|
|
1727
|
-
const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
1785
|
+
const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
1728
1786
|
for (const change of changes) {
|
|
1729
1787
|
if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
|
|
1730
1788
|
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
|
|
@@ -1748,7 +1806,7 @@ class VaultService implements Vault {
|
|
|
1748
1806
|
// An environment by its slug as the store has it, which scopes match.
|
|
1749
1807
|
return environmentId === null ? { projectId: projectId! } : { projectId: projectId!, environmentId, environmentSlug: environment!.slug };
|
|
1750
1808
|
});
|
|
1751
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1809
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1752
1810
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1753
1811
|
// Where they manage access, anything; otherwise only a person to a service account (`givesService`).
|
|
1754
1812
|
const manages = where.every((place) => mayManageAccess(acting.live, place));
|
|
@@ -1759,7 +1817,7 @@ class VaultService implements Vault {
|
|
|
1759
1817
|
}
|
|
1760
1818
|
|
|
1761
1819
|
const row = d.members.get(principal);
|
|
1762
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1820
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1763
1821
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1764
1822
|
// A service account admitted with its first grants, as `admit` would: all of it, or none.
|
|
1765
1823
|
const admitted = input.admit === true && row?.status !== 'active' ? await this.#admitWith(d, acting, principal, row, input) : undefined;
|
|
@@ -1859,7 +1917,7 @@ class VaultService implements Vault {
|
|
|
1859
1917
|
]);
|
|
1860
1918
|
return this.#decide([actor, principal], async (d) => {
|
|
1861
1919
|
validateCorrelation(input);
|
|
1862
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1920
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1863
1921
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1864
1922
|
// A service account, with no grant yet, any person who could give it one sets up (`setsUpServices`).
|
|
1865
1923
|
const service = SERVICE.test(principal) && (input.role ?? 'member') === 'member' && input.scope === undefined;
|
|
@@ -1872,7 +1930,7 @@ class VaultService implements Vault {
|
|
|
1872
1930
|
throw refused('invalid', 'service accounts hold project grants only, never an instance role');
|
|
1873
1931
|
}
|
|
1874
1932
|
const row = d.members.get(principal);
|
|
1875
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1933
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1876
1934
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1877
1935
|
d.touched.add(principal);
|
|
1878
1936
|
d.grants.set(principal, [...subject.stored]);
|
|
@@ -1964,10 +2022,10 @@ class VaultService implements Vault {
|
|
|
1964
2022
|
validateCorrelation(input);
|
|
1965
2023
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1966
2024
|
const row = d.members.get(principal);
|
|
1967
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2025
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1968
2026
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1969
2027
|
const holder = acting.live;
|
|
1970
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2028
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1971
2029
|
const held = subject.stored;
|
|
1972
2030
|
if (this.#listed(principal)) throw refused('not_allowed', declaredOwner(principal));
|
|
1973
2031
|
if (subject.status === 'tampered') {
|
|
@@ -2079,14 +2137,19 @@ class VaultService implements Vault {
|
|
|
2079
2137
|
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2080
2138
|
* could unprotect them. Its edits are then unknown, not none.
|
|
2081
2139
|
*/
|
|
2082
|
-
async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2140
|
+
async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2141
|
+
// The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
|
|
2083
2142
|
const entry = await newestAuthentic(
|
|
2084
|
-
(limit, before) =>
|
|
2143
|
+
(limit, before) => (reads !== undefined && before === undefined
|
|
2144
|
+
? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy])
|
|
2145
|
+
: store.newestVaultEntries(db, POLICY, limit, before)),
|
|
2085
2146
|
this.#check(reports, null),
|
|
2086
2147
|
);
|
|
2087
2148
|
if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
|
|
2088
2149
|
const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
|
|
2089
|
-
|
|
2150
|
+
if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
|
|
2151
|
+
const rows = entry === reads?.newestPolicy ? reads.environments : await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
2152
|
+
return { policy: livePolicy(rows, edits, at), edits, readable: true };
|
|
2090
2153
|
}
|
|
2091
2154
|
|
|
2092
2155
|
changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
|
|
@@ -2109,14 +2172,15 @@ class VaultService implements Vault {
|
|
|
2109
2172
|
new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
|
|
2110
2173
|
return this.#decide([actor], async (d) => {
|
|
2111
2174
|
validateCorrelation(input);
|
|
2112
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2175
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2113
2176
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
2114
2177
|
if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
|
|
2115
2178
|
// Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
|
|
2116
2179
|
// caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
|
|
2117
2180
|
// from that one, or is refused, to be asked again: a protected prod renamed since is a policy changed too.
|
|
2118
|
-
|
|
2119
|
-
const
|
|
2181
|
+
// The newest change in the vault's name, read with the decision, as the app reads it: by its number, unchecked.
|
|
2182
|
+
const newest = d.reads.newestPolicy?.seq ?? null;
|
|
2183
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
2120
2184
|
if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
|
|
2121
2185
|
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
2122
2186
|
// Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
|
|
@@ -2180,15 +2244,16 @@ class VaultService implements Vault {
|
|
|
2180
2244
|
next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
|
|
2181
2245
|
.sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
|
|
2182
2246
|
} else {
|
|
2183
|
-
// Unprotecting widens what people hold there: an Owner of the
|
|
2184
|
-
|
|
2185
|
-
|
|
2247
|
+
// Unprotecting widens what people hold there: an Owner of the project's to do, or of the whole instance's (R30).
|
|
2248
|
+
// It takes off only what the project protects: a fork's id, with no row yet, is another project's to unprotect.
|
|
2249
|
+
if (!ownsProject(acting.live, change.projectId)) throw refused('not_allowed', 'only an Owner of its project, or of the whole instance, unprotects an environment');
|
|
2250
|
+
next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
|
|
2186
2251
|
}
|
|
2187
2252
|
} else {
|
|
2188
2253
|
throw refused('invalid', 'a change is to a role, an instance role, or protection');
|
|
2189
2254
|
}
|
|
2190
2255
|
// The policy now, and the newest change, which it is as of: this one's, when it changed anything.
|
|
2191
|
-
const answer = { policy: await
|
|
2256
|
+
const answer = { policy: livePolicy(await store.environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at), seq: newest === null ? null : Number(newest) };
|
|
2192
2257
|
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
2193
2258
|
const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
|
|
2194
2259
|
d.log.push(made);
|
|
@@ -2213,7 +2278,7 @@ class VaultService implements Vault {
|
|
|
2213
2278
|
new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
2214
2279
|
return this.#decide([actor], async (d) => {
|
|
2215
2280
|
validateCorrelation(input);
|
|
2216
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2281
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2217
2282
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
2218
2283
|
if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
|
|
2219
2284
|
if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
|
|
@@ -3051,64 +3116,6 @@ function viaDetail(via: Via, seal: Sealed | null): Record<string, unknown> {
|
|
|
3051
3116
|
};
|
|
3052
3117
|
}
|
|
3053
3118
|
|
|
3054
|
-
/** An environment a secret is in, or that holds a reference to one. */
|
|
3055
|
-
type SecretPlace = { projectId: string; environmentId: string };
|
|
3056
|
-
|
|
3057
|
-
/**
|
|
3058
|
-
* Where a decision checks a permission: an environment, with its slug as
|
|
3059
|
-
* the store has it when it was read and is in the project claimed;
|
|
3060
|
-
* otherwise none, which no grant on a slug matches.
|
|
3061
|
-
*/
|
|
3062
|
-
function placeIn(place: SecretPlace, environments: ReadonlyMap<string, { projectId: string; slug: string }>): Place {
|
|
3063
|
-
const environment = environments.get(place.environmentId);
|
|
3064
|
-
const slug = environment?.projectId === place.projectId ? environment.slug : null;
|
|
3065
|
-
return { projectId: place.projectId, environmentId: place.environmentId, environmentSlug: slug };
|
|
3066
|
-
}
|
|
3067
|
-
|
|
3068
|
-
/**
|
|
3069
|
-
* Why `reader` may not do `permission` at `where`, or null if they may. A
|
|
3070
|
-
* deleted place is refused before any grant is asked, so no kind of grant,
|
|
3071
|
-
* on the project, the environment or every project, can reach into one.
|
|
3072
|
-
*/
|
|
3073
|
-
/**
|
|
3074
|
-
* A key of a personal config is its owner's alone (docs/design/forking.md,
|
|
3075
|
-
* section 4): no grant or role opens or wraps it, an Owner's included,
|
|
3076
|
-
* only its owner, while they read the environment it is a config of,
|
|
3077
|
-
* whatever the call is. Nor does any reference reach into or out of one.
|
|
3078
|
-
* Undefined for a key of any other environment, decided as ever.
|
|
3079
|
-
*/
|
|
3080
|
-
function personalRefusal(
|
|
3081
|
-
reader: Standing,
|
|
3082
|
-
principal: string,
|
|
3083
|
-
permission: Permission,
|
|
3084
|
-
secret: SecretPlace,
|
|
3085
|
-
at: SecretPlace,
|
|
3086
|
-
environments: ReadonlyMap<string, store.EnvironmentRow>,
|
|
3087
|
-
): RefusalCode | null | undefined {
|
|
3088
|
-
const own = environments.get(secret.environmentId);
|
|
3089
|
-
const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
|
|
3090
|
-
if (own?.owner == null && held?.owner == null) return undefined;
|
|
3091
|
-
if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
|
|
3092
|
-
const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
|
|
3093
|
-
// Its owner reads what they wrote while they read or use the environment it configures (R8).
|
|
3094
|
-
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
3095
|
-
const refused = refuses(reader, 'secret.use', parent, gone) === null ? null : refuses(reader, 'secret.read', parent, gone);
|
|
3096
|
-
if (refused !== null) return refused;
|
|
3097
|
-
// The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key. Reading their
|
|
3098
|
-
// own values takes use there: a Contributor's handle reads what its owner wrote, and nothing of the parent's.
|
|
3099
|
-
return withinCredential(reader.live, permission === 'secret.read' ? 'secret.use' : permission, parent) ? null : 'no_grant';
|
|
3100
|
-
}
|
|
3101
|
-
|
|
3102
|
-
function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
|
|
3103
|
-
if (reader.status === 'tampered') return 'tampered';
|
|
3104
|
-
if (reader.status === 'removed') return 'removed';
|
|
3105
|
-
if (reader.status === 'unknown') return 'not_a_member';
|
|
3106
|
-
if (deleted) return 'deleted';
|
|
3107
|
-
if (allows(reader.live, permission, where)) return null;
|
|
3108
|
-
// Would a grant that has lapsed have covered it?
|
|
3109
|
-
return allows(reader.all, permission, where) ? 'expired' : 'no_grant';
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
3119
|
/**
|
|
3113
3120
|
* An entry about a key. A new secret's row is not committed when its key is
|
|
3114
3121
|
* wrapped, so a wrap names the secret in its payload; the others name it.
|
|
@@ -3320,8 +3327,7 @@ function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
|
|
|
3320
3327
|
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3321
3328
|
* keep them all: no write is needed.
|
|
3322
3329
|
*/
|
|
3323
|
-
|
|
3324
|
-
const rows = await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
3330
|
+
function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
|
|
3325
3331
|
const live = edits.protected.filter((place) => {
|
|
3326
3332
|
const row = rows.get(place.environmentId);
|
|
3327
3333
|
return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|