@coffre/vault 0.2.0 → 0.4.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 +7 -1
- package/dist/cloudflare.js +7 -1
- package/dist/node.js +3 -1
- package/dist/{vault-BexBMpZL.js → vault-CeKwkzSV.js} +551 -104
- package/package.json +4 -4
- package/src/cloudflare.ts +4 -0
- package/src/local.ts +2 -0
- package/src/replay.ts +19 -5
- package/src/rows.ts +9 -3
- package/src/store.ts +136 -43
- package/src/vault.ts +435 -52
package/src/vault.ts
CHANGED
|
@@ -3,14 +3,19 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import {
|
|
4
4
|
allows,
|
|
5
5
|
assignableToEnvironment,
|
|
6
|
+
everyProjectPath,
|
|
7
|
+
grantKind,
|
|
6
8
|
isRole,
|
|
7
9
|
mayManageAccess,
|
|
10
|
+
type GrantPlace,
|
|
8
11
|
type Holdings,
|
|
9
12
|
type Permission,
|
|
13
|
+
type Place,
|
|
10
14
|
type Role,
|
|
11
15
|
} from '@coffre/core/access';
|
|
12
16
|
import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
13
17
|
import { checkContext, type SecretContext } from '@coffre/core/envelope';
|
|
18
|
+
import { shownMember } from '@coffre/core/schemas';
|
|
14
19
|
import {
|
|
15
20
|
DEK_BYTES,
|
|
16
21
|
KEY_CHECK,
|
|
@@ -34,12 +39,14 @@ import {
|
|
|
34
39
|
type AdmitInput,
|
|
35
40
|
type Checkpoint,
|
|
36
41
|
type CheckpointKey,
|
|
42
|
+
type EndReferencesInput,
|
|
37
43
|
type Grant,
|
|
38
44
|
type GrantChange,
|
|
39
45
|
type KeyChecks,
|
|
40
46
|
type LogHead,
|
|
41
47
|
type LogVerification,
|
|
42
48
|
type Outcome,
|
|
49
|
+
type ReferenceInput,
|
|
43
50
|
type Refusal,
|
|
44
51
|
type RefusalCode,
|
|
45
52
|
type RemoveInput,
|
|
@@ -48,6 +55,7 @@ import {
|
|
|
48
55
|
type SetAccessInput,
|
|
49
56
|
type UnwrapInput,
|
|
50
57
|
type Vault,
|
|
58
|
+
type Via,
|
|
51
59
|
type VerifyLogInput,
|
|
52
60
|
type WrapInput,
|
|
53
61
|
type WrappedKey,
|
|
@@ -148,9 +156,8 @@ const KEY_BUDGET_MS = 5_000;
|
|
|
148
156
|
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
149
157
|
const LOCK_TIMEOUT_MS = 15_000;
|
|
150
158
|
|
|
151
|
-
|
|
152
|
-
const PRINCIPAL = /^(user|token
|
|
153
|
-
const LIVE_PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
159
|
+
/** A member: `user:<email>` or `token:<name>`. */
|
|
160
|
+
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
154
161
|
|
|
155
162
|
/** Who acts for the vault itself, as when it gives a root admin a member row. */
|
|
156
163
|
const VAULT_ACTOR = 'system:vault';
|
|
@@ -225,6 +232,8 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
225
232
|
expired: 'the grant that covered this has expired',
|
|
226
233
|
bulk_limit: 'too many secrets read in too short a time',
|
|
227
234
|
bad_claim: 'the key does not belong to this secret',
|
|
235
|
+
deleted: 'this project or environment was deleted',
|
|
236
|
+
ended: 'the reference read through was ended',
|
|
228
237
|
not_allowed: 'not allowed to change this',
|
|
229
238
|
root_admin: 'root admins are set in the vault configuration',
|
|
230
239
|
invalid: 'not something the rules allow',
|
|
@@ -259,6 +268,13 @@ type Decision = {
|
|
|
259
268
|
reports: NewEntry[];
|
|
260
269
|
/** Run once the entries are appended, with the seq each was given. */
|
|
261
270
|
after: ((seqOf: (entry: NewEntry) => number) => void)[];
|
|
271
|
+
/**
|
|
272
|
+
* Checks made again once the log's head is locked, just before this
|
|
273
|
+
* decision's entries append: what another decision could have changed
|
|
274
|
+
* since it checked, such as a reference ended while a read through it
|
|
275
|
+
* was deciding. A `Refused` there refuses the decision.
|
|
276
|
+
*/
|
|
277
|
+
underLock: ((locked: Queryable) => Promise<void>)[];
|
|
262
278
|
};
|
|
263
279
|
|
|
264
280
|
/** The actions of the entries the vault writes about keys. */
|
|
@@ -348,7 +364,7 @@ class VaultService implements Vault {
|
|
|
348
364
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
349
365
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
350
366
|
const d: Decision = {
|
|
351
|
-
tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [],
|
|
367
|
+
tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
352
368
|
};
|
|
353
369
|
const result = await decide(d);
|
|
354
370
|
const entries = [...reports, ...d.log];
|
|
@@ -356,7 +372,7 @@ class VaultService implements Vault {
|
|
|
356
372
|
// Each member's newest access entry, which their row names (rows.ts).
|
|
357
373
|
const accessSeq = new Map<string, bigint>();
|
|
358
374
|
if (entries.length > 0) {
|
|
359
|
-
const appended = await this.#append(tx, entries);
|
|
375
|
+
const appended = await this.#append(tx, entries, d.underLock);
|
|
360
376
|
at = appended.occurredAt;
|
|
361
377
|
entries.forEach((entry, i) => {
|
|
362
378
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
|
|
@@ -585,13 +601,15 @@ class VaultService implements Vault {
|
|
|
585
601
|
* that an instance still running with the replaced KEK, as during a
|
|
586
602
|
* deploy, cannot write after the rotation.
|
|
587
603
|
*/
|
|
588
|
-
#append(tx: Transaction, entries: readonly NewEntry[]): Promise<Appended> {
|
|
604
|
+
#append(tx: Transaction, entries: readonly NewEntry[], underLock: readonly ((locked: Queryable) => Promise<void>)[] = []): Promise<Appended> {
|
|
589
605
|
const prepared = this.#prepared;
|
|
590
606
|
return appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
591
607
|
const rotation = await store.latestVaultEntry(locked, [KEY_ROTATE]);
|
|
592
|
-
if (rotation
|
|
593
|
-
|
|
594
|
-
|
|
608
|
+
if (rotation !== undefined && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
|
|
609
|
+
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`;
|
|
610
|
+
throw new Error(prepared.superseded);
|
|
611
|
+
}
|
|
612
|
+
for (const check of underLock) await check(locked);
|
|
595
613
|
});
|
|
596
614
|
}
|
|
597
615
|
|
|
@@ -602,18 +620,44 @@ class VaultService implements Vault {
|
|
|
602
620
|
async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
|
|
603
621
|
const { principal } = input;
|
|
604
622
|
validateText(input.purpose);
|
|
623
|
+
const vias = input.items.map((item) => validateVia(item.via));
|
|
605
624
|
const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
|
|
606
625
|
if (!loaded.ok) return loaded;
|
|
607
|
-
|
|
626
|
+
// RPC can preserve shared objects, and two holders can read one source: each item its own secret.
|
|
627
|
+
const items = loaded.versions.map((version) => ({ ...version, secret: { ...version.secret } }));
|
|
608
628
|
validateItems(items, 'wrapped');
|
|
629
|
+
const sealed = await this.#referencesAt(vias);
|
|
609
630
|
const versionIds = new Map(items.map((item) => [item.secret, item.id]));
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
631
|
+
const viaOf = new Map(items.map((item, i) => [item.secret, { via: vias[i], sealed: sealed[i] }]));
|
|
632
|
+
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => {
|
|
633
|
+
const { via, sealed: seal } = viaOf.get(secret)!;
|
|
634
|
+
return {
|
|
635
|
+
...keyEntry('secret.read', principal, secret, decision, code, input, { purpose: input.purpose, ...(via === null ? {} : viaDetail(via, seal)) }),
|
|
636
|
+
secretVersionId: versionIds.get(secret),
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
// A reference reads only the current version of the source its entry names.
|
|
640
|
+
if (items.some((item, i) => vias[i] !== null && (sealed[i] === null || sealed[i]!.source.secretId !== item.secret.secretId || !item.current))) {
|
|
641
|
+
return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
|
|
642
|
+
}
|
|
643
|
+
const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
|
|
614
644
|
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
615
645
|
return this.#keys(
|
|
616
|
-
{
|
|
646
|
+
{
|
|
647
|
+
action: 'secret.read',
|
|
648
|
+
principal,
|
|
649
|
+
permission: 'secret.read',
|
|
650
|
+
secrets: items.map((item) => item.secret),
|
|
651
|
+
// Through a reference, the reader's grants on the holder's environment decide.
|
|
652
|
+
places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
|
|
653
|
+
vet: ends.length === 0 ? undefined : async (db) => {
|
|
654
|
+
const ended = await store.endedReferences(db, ends);
|
|
655
|
+
return sealed.map((seal) => (seal !== null && ended.has(seal.seq) ? 'ended' : null));
|
|
656
|
+
},
|
|
657
|
+
remote,
|
|
658
|
+
entry,
|
|
659
|
+
input,
|
|
660
|
+
},
|
|
617
661
|
() =>
|
|
618
662
|
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
619
663
|
const key = await this.#open(wrapped, secret, operation);
|
|
@@ -671,7 +715,7 @@ class VaultService implements Vault {
|
|
|
671
715
|
const sameSecret = (secret: SecretRef, source: SecretRef) =>
|
|
672
716
|
secret.projectId === source.projectId && secret.environmentId === source.environmentId && secret.secretId === source.secretId;
|
|
673
717
|
if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) {
|
|
674
|
-
return this.#
|
|
718
|
+
return this.#refuseVersions(principal, 'bad_claim', loaded.versions.map((source) => ({
|
|
675
719
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
|
|
676
720
|
})));
|
|
677
721
|
}
|
|
@@ -698,7 +742,7 @@ class VaultService implements Vault {
|
|
|
698
742
|
);
|
|
699
743
|
}
|
|
700
744
|
|
|
701
|
-
/**
|
|
745
|
+
/** Versions need no lock: they change only by being erased, once their place is archived, and their ids determine the whole batch before any key call. */
|
|
702
746
|
async #versions(
|
|
703
747
|
input: Correlation & { principal: string; items: { secretVersionId: string }[] },
|
|
704
748
|
action: KeyAction,
|
|
@@ -713,8 +757,13 @@ class VaultService implements Vault {
|
|
|
713
757
|
}
|
|
714
758
|
}
|
|
715
759
|
const found = new Map((await store.versions(this.#db, ids)).map((version) => [version.id, version]));
|
|
760
|
+
if ([...found.values()].some((version) => version.deleted)) {
|
|
761
|
+
return this.#refuseVersions(input.principal, 'deleted', [...found.values()].map((version) => ({
|
|
762
|
+
...keyEntry(action, input.principal, version.secret, 'deny', 'deleted', input, detail), secretVersionId: version.id,
|
|
763
|
+
})));
|
|
764
|
+
}
|
|
716
765
|
if (ids.some((id) => !found.has(id))) {
|
|
717
|
-
return this.#
|
|
766
|
+
return this.#refuseVersions(input.principal, 'bad_claim', ids.map((id) => {
|
|
718
767
|
const version = found.get(id);
|
|
719
768
|
if (version === undefined) return {
|
|
720
769
|
actor: input.principal, action, decision: 'deny', code: 'bad_claim',
|
|
@@ -727,10 +776,10 @@ class VaultService implements Vault {
|
|
|
727
776
|
return { ok: true, versions: ids.map((id) => found.get(id)!) };
|
|
728
777
|
}
|
|
729
778
|
|
|
730
|
-
async #
|
|
779
|
+
async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted', entries: NewEntry[]): Promise<Outcome<never>> {
|
|
731
780
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
732
781
|
return this.#decide([principal], async () => {
|
|
733
|
-
throw new Refused(refusal(
|
|
782
|
+
throw new Refused(refusal(code, MESSAGES[code]), entries);
|
|
734
783
|
});
|
|
735
784
|
}
|
|
736
785
|
|
|
@@ -766,6 +815,13 @@ class VaultService implements Vault {
|
|
|
766
815
|
principal: string;
|
|
767
816
|
permission: Permission;
|
|
768
817
|
secrets: readonly SecretRef[];
|
|
818
|
+
/** Where each secret's permission is checked, when not where it is: a reference's holder. */
|
|
819
|
+
places?: readonly SecretPlace[];
|
|
820
|
+
/**
|
|
821
|
+
* What else refuses each secret, checked in the decision and again
|
|
822
|
+
* once the log's head is locked, before its entries append.
|
|
823
|
+
*/
|
|
824
|
+
vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
|
|
769
825
|
remote: boolean;
|
|
770
826
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
771
827
|
input: Correlation & { purpose?: string };
|
|
@@ -785,7 +841,17 @@ class VaultService implements Vault {
|
|
|
785
841
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
786
842
|
const check = async (d: Decision) => {
|
|
787
843
|
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
788
|
-
|
|
844
|
+
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
845
|
+
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
846
|
+
// matches the slug there, and a place deleted on either side opens for none.
|
|
847
|
+
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
848
|
+
const environments = await store.environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
|
|
849
|
+
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
850
|
+
const codes: (RefusalCode | null)[] = secrets.map((secret, i) =>
|
|
851
|
+
refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!)));
|
|
852
|
+
if (call.vet !== undefined && !codes.some((code) => code !== null)) {
|
|
853
|
+
(await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
854
|
+
}
|
|
789
855
|
let first = codes.find((code) => code !== null) ?? null;
|
|
790
856
|
if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
|
|
791
857
|
if (first !== null) {
|
|
@@ -849,6 +915,16 @@ class VaultService implements Vault {
|
|
|
849
915
|
}
|
|
850
916
|
throw error;
|
|
851
917
|
}
|
|
918
|
+
// A reference ended while this decided refuses it, rather than a read logged after its end.
|
|
919
|
+
const { vet } = call;
|
|
920
|
+
if (vet !== undefined) {
|
|
921
|
+
d.underLock.push(async (locked) => {
|
|
922
|
+
const codes = await vet(locked);
|
|
923
|
+
const first = codes.find((code) => code !== null);
|
|
924
|
+
if (first === undefined || first === null) return;
|
|
925
|
+
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
|
|
926
|
+
});
|
|
927
|
+
}
|
|
852
928
|
const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
|
|
853
929
|
const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
|
|
854
930
|
try {
|
|
@@ -868,6 +944,16 @@ class VaultService implements Vault {
|
|
|
868
944
|
if (done.length < outcomes.length) {
|
|
869
945
|
throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
|
|
870
946
|
}
|
|
947
|
+
// Again under the log's head, the append's, taken a statement early: a
|
|
948
|
+
// deletion renames its place under it, so one that committed since the
|
|
949
|
+
// check above is seen here, and nothing is released after it.
|
|
950
|
+
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
951
|
+
await lockLogHead(d.tx);
|
|
952
|
+
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
953
|
+
const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
954
|
+
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
|
|
955
|
+
throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
|
|
956
|
+
}
|
|
871
957
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
872
958
|
d.log.push(...released);
|
|
873
959
|
const answer = result(done);
|
|
@@ -886,6 +972,184 @@ class VaultService implements Vault {
|
|
|
886
972
|
return (await store.releasesSince(d.tx, principal, d.at - windowMs)) + n > count;
|
|
887
973
|
}
|
|
888
974
|
|
|
975
|
+
// --- references ---------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Make each holder a reference to its source: the actor writes the holder
|
|
979
|
+
* and reads the source, by their own grants, and the vault's
|
|
980
|
+
* `reference.create` entry is the reference. The app's row only points at
|
|
981
|
+
* it; a read through one is checked against the entry (`#referencesAt`).
|
|
982
|
+
*/
|
|
983
|
+
async reference(input: ReferenceInput): Promise<Outcome<{ seqs: number[] }>> {
|
|
984
|
+
const { principal, items } = input;
|
|
985
|
+
validateText(principal);
|
|
986
|
+
validateCorrelation(input);
|
|
987
|
+
for (const item of items) {
|
|
988
|
+
if (typeof item.id !== 'string' || !UUID.test(item.id)) throw new Error('a reference id must be a lowercase UUID');
|
|
989
|
+
if (typeof item.source?.secretId !== 'string' || !UUID.test(item.source.secretId)) throw new Error('a source must be named by its secret id');
|
|
990
|
+
checkContext(item.holder);
|
|
991
|
+
validateText(item.holder.path);
|
|
992
|
+
}
|
|
993
|
+
if (new Set(items.map((item) => item.id)).size !== items.length) throw new Error('each reference needs its own id');
|
|
994
|
+
if (items.length === 0) return { ok: true, seqs: [] };
|
|
995
|
+
const [sources, projectOf] = await Promise.all([
|
|
996
|
+
store.secretsByIds(this.#db, items.map((item) => item.source.secretId)),
|
|
997
|
+
store.projectsOfEnvironments(this.#db, items.map((item) => item.holder.environmentId)),
|
|
998
|
+
]);
|
|
999
|
+
const entry = (item: ReferenceInput['items'][number], decision: 'allow' | 'deny', code: string | null): NewEntry => {
|
|
1000
|
+
const source = sources.get(item.source.secretId);
|
|
1001
|
+
return {
|
|
1002
|
+
actor: principal,
|
|
1003
|
+
action: 'reference.create',
|
|
1004
|
+
decision,
|
|
1005
|
+
code,
|
|
1006
|
+
projectId: projectOf.has(item.holder.environmentId) ? item.holder.projectId : null,
|
|
1007
|
+
environmentId: projectOf.has(item.holder.environmentId) ? item.holder.environmentId : null,
|
|
1008
|
+
operationId: input.operationId ?? null,
|
|
1009
|
+
requestId: input.requestId ?? null,
|
|
1010
|
+
// A new holder's row is not committed yet: it is named here, as a wrap names its secret.
|
|
1011
|
+
metadata: JSON.stringify({
|
|
1012
|
+
reference: item.id,
|
|
1013
|
+
subject: item.holder.path,
|
|
1014
|
+
secretId: item.holder.secretId,
|
|
1015
|
+
source: source === undefined ? { secretId: item.source.secretId } : { path: source.path },
|
|
1016
|
+
...(source === undefined ? {} : { also: { projectId: source.projectId, environmentId: source.environmentId, secretId: source.secretId } }),
|
|
1017
|
+
...traced(input),
|
|
1018
|
+
}),
|
|
1019
|
+
};
|
|
1020
|
+
};
|
|
1021
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1022
|
+
return this.#decide([principal], async (d) => {
|
|
1023
|
+
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1024
|
+
// Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
|
|
1025
|
+
const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
|
|
1026
|
+
const source = sources.get(item.source.secretId);
|
|
1027
|
+
return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
1028
|
+
}));
|
|
1029
|
+
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1030
|
+
const codes = items.map((item): RefusalCode | null => {
|
|
1031
|
+
const source = sources.get(item.source.secretId);
|
|
1032
|
+
if (source === undefined || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return 'bad_claim';
|
|
1033
|
+
if (source.secretId === item.holder.secretId) return 'invalid';
|
|
1034
|
+
return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
|
|
1035
|
+
?? refuses(reader, 'secret.read', placeIn(source, environments), gone(source));
|
|
1036
|
+
});
|
|
1037
|
+
const first = codes.find((code) => code !== null) ?? null;
|
|
1038
|
+
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
|
|
1039
|
+
// Again under the log's head, as a read is: neither side was deleted since the check above.
|
|
1040
|
+
d.underLock.push(async (locked) => {
|
|
1041
|
+
const now = await store.environmentsById(locked, [...environments.keys()]);
|
|
1042
|
+
if (![...now.values()].some((environment) => environment.deleted)) return;
|
|
1043
|
+
throw new Refused(refusal('deleted', MESSAGES.deleted), items.map((item) => entry(item, 'deny', 'deleted')));
|
|
1044
|
+
});
|
|
1045
|
+
const entries = items.map((item) => entry(item, 'allow', null));
|
|
1046
|
+
d.log.push(...entries);
|
|
1047
|
+
const answer = { seqs: [] as number[] };
|
|
1048
|
+
d.after.push((seqOf) => (answer.seqs = entries.map(seqOf)));
|
|
1049
|
+
return answer;
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* End references, each with a `reference.end` naming its creation by
|
|
1055
|
+
* `related_seq`, where the next read through it looks. `replaced` takes
|
|
1056
|
+
* write on the holder; `broken`, write on the holder or managing the
|
|
1057
|
+
* source's project's access. Ending one already ended is refused.
|
|
1058
|
+
*/
|
|
1059
|
+
async endReferences(input: EndReferencesInput): Promise<Outcome<{ seqs: number[] }>> {
|
|
1060
|
+
const { principal, reason } = input;
|
|
1061
|
+
validateText(principal);
|
|
1062
|
+
validateCorrelation(input);
|
|
1063
|
+
if (reason !== 'replaced' && reason !== 'broken' && reason !== 'abandoned') throw new Error('a reference ends replaced, broken or abandoned');
|
|
1064
|
+
const vias = input.items.map((item) => validateVia(item)!);
|
|
1065
|
+
if (vias.some((via) => via === null) || new Set(vias.map((via) => via.seq)).size !== vias.length) throw new Error('each reference to end, once');
|
|
1066
|
+
if (vias.length === 0) return { ok: true, seqs: [] };
|
|
1067
|
+
const sealed = await this.#referencesAt(vias);
|
|
1068
|
+
// A holder's row is committed after its reference is sealed, if the write went through: an end names it only then.
|
|
1069
|
+
const holders = await store.secretsByIds(this.#db, sealed.flatMap((seal) => (seal === null ? [] : [seal.holder.secretId])));
|
|
1070
|
+
const entry = (via: Via, seal: Sealed | null, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
|
|
1071
|
+
actor: principal,
|
|
1072
|
+
action: 'reference.end',
|
|
1073
|
+
decision,
|
|
1074
|
+
code,
|
|
1075
|
+
projectId: seal?.holder.projectId ?? null,
|
|
1076
|
+
environmentId: seal?.holder.environmentId ?? null,
|
|
1077
|
+
// So that the holder key's own log shows the reference ending.
|
|
1078
|
+
secretId: seal !== null && holders.has(seal.holder.secretId) ? seal.holder.secretId : null,
|
|
1079
|
+
relatedSeq: seal === null ? null : seal.seq,
|
|
1080
|
+
operationId: input.operationId ?? null,
|
|
1081
|
+
requestId: input.requestId ?? null,
|
|
1082
|
+
metadata: JSON.stringify({
|
|
1083
|
+
reference: via.reference,
|
|
1084
|
+
reason,
|
|
1085
|
+
...(seal === null
|
|
1086
|
+
? { seq: via.seq }
|
|
1087
|
+
: {
|
|
1088
|
+
subject: seal.holder.path,
|
|
1089
|
+
secretId: seal.holder.secretId,
|
|
1090
|
+
source: { path: seal.source.path },
|
|
1091
|
+
also: { projectId: seal.source.projectId, environmentId: seal.source.environmentId, secretId: seal.source.secretId },
|
|
1092
|
+
}),
|
|
1093
|
+
...traced(input),
|
|
1094
|
+
}),
|
|
1095
|
+
});
|
|
1096
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1097
|
+
if (sealed.some((seal) => seal === null)) {
|
|
1098
|
+
return this.#decide([principal], async () => {
|
|
1099
|
+
throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i]!, 'deny', 'bad_claim')));
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
const seals = sealed as Sealed[];
|
|
1103
|
+
const ended = async (db: Queryable) => {
|
|
1104
|
+
const done = await store.endedReferences(db, seals.map((seal) => seal.seq));
|
|
1105
|
+
return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
|
|
1106
|
+
};
|
|
1107
|
+
return this.#decide([principal], async (d) => {
|
|
1108
|
+
const actor = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1109
|
+
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1110
|
+
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1111
|
+
const codes: (RefusalCode | null)[] = seals.map((seal) => {
|
|
1112
|
+
const holder = refuses(actor, 'secret.write', placeIn(seal.holder, environments), false);
|
|
1113
|
+
// Abandoning is its maker's alone, for a write of theirs that stored nothing: the vault
|
|
1114
|
+
// never reads the app's rows, so whose seal it is decides, and no one else's end says it.
|
|
1115
|
+
if (reason === 'abandoned' && seal.createdBy !== principal) return 'not_allowed';
|
|
1116
|
+
// Replacing or abandoning is the holder's side: write where it is held.
|
|
1117
|
+
if (holder === null || reason !== 'broken') return holder;
|
|
1118
|
+
return refuses(actor, 'grant.manage', { projectId: seal.source.projectId }, false) === null ? null : holder;
|
|
1119
|
+
});
|
|
1120
|
+
if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1121
|
+
const first = codes.find((code) => code !== null) ?? null;
|
|
1122
|
+
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
|
|
1123
|
+
// Two ends of one reference at once: the second finds the first under the head's lock.
|
|
1124
|
+
d.underLock.push(async (locked) => {
|
|
1125
|
+
if ((await ended(locked)).every((code) => code === null)) return;
|
|
1126
|
+
throw new Refused(refusal('ended', MESSAGES.ended), vias.map((via, i) => entry(via, seals[i]!, 'deny', 'ended')));
|
|
1127
|
+
});
|
|
1128
|
+
const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
|
|
1129
|
+
d.log.push(...entries);
|
|
1130
|
+
const answer = { seqs: [] as number[] };
|
|
1131
|
+
d.after.push((seqOf) => (answer.seqs = entries.map(seqOf)));
|
|
1132
|
+
return answer;
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* The reference each read names, as the vault's own `reference.create`
|
|
1138
|
+
* entry at its seq says it is: an entry of the vault's, allowed, under its
|
|
1139
|
+
* MAC, about that reference. Null for an item that names none, and for
|
|
1140
|
+
* one that names something else: a row the vault never sealed.
|
|
1141
|
+
*/
|
|
1142
|
+
async #referencesAt(vias: readonly (Via | null)[]): Promise<(Sealed | null)[]> {
|
|
1143
|
+
const found = await store.entriesAt(this.#db, vias.flatMap((via) => (via === null ? [] : [BigInt(via.seq)])));
|
|
1144
|
+
return vias.map((via) => {
|
|
1145
|
+
if (via === null) return null;
|
|
1146
|
+
const entry = found.get(BigInt(via.seq));
|
|
1147
|
+
if (entry === undefined || entry.author !== 'vault' || entry.action !== 'reference.create' || entry.decision !== 'allow') return null;
|
|
1148
|
+
if (!this.#authentic(entry)) return null;
|
|
1149
|
+
return sealedReference(entry, via.reference);
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
889
1153
|
// --- who holds what -----------------------------------------------------------
|
|
890
1154
|
|
|
891
1155
|
#isRootAdmin(principal: string): boolean {
|
|
@@ -951,7 +1215,7 @@ class VaultService implements Vault {
|
|
|
951
1215
|
const grants = row === undefined ? [] : await store.grants(db, principal);
|
|
952
1216
|
const fault = await this.#integrity(db, principal, row, grants, reports);
|
|
953
1217
|
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
954
|
-
if (!
|
|
1218
|
+
if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
|
|
955
1219
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
956
1220
|
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
957
1221
|
const isOwner = row.owner && principal.startsWith('user:');
|
|
@@ -969,10 +1233,10 @@ class VaultService implements Vault {
|
|
|
969
1233
|
if (this.#isRootAdmin(principal)) {
|
|
970
1234
|
return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true, isOwner: true, grants: [], since: null, by: null };
|
|
971
1235
|
}
|
|
972
|
-
const active =
|
|
1236
|
+
const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
|
|
973
1237
|
return {
|
|
974
1238
|
principal,
|
|
975
|
-
status: tampered ? 'tampered' : !
|
|
1239
|
+
status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
|
|
976
1240
|
generation: row?.generation ?? 0,
|
|
977
1241
|
isRootAdmin: false,
|
|
978
1242
|
isOwner: active && row.owner && principal.startsWith('user:'),
|
|
@@ -1038,45 +1302,59 @@ class VaultService implements Vault {
|
|
|
1038
1302
|
|
|
1039
1303
|
setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>> {
|
|
1040
1304
|
const { actor, principal } = input;
|
|
1041
|
-
const
|
|
1305
|
+
const changes = input.changes.map((change) => ({ ...change, environmentSlug: change.environmentSlug ?? null }));
|
|
1306
|
+
const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
|
|
1042
1307
|
// One place refused is shown where it is, to whoever reads that place's log; an invalid one may be no place at all.
|
|
1043
|
-
const [only] =
|
|
1044
|
-
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
...(
|
|
1308
|
+
const [only] = changes.length === 1 ? changes : [];
|
|
1309
|
+
const refused = (code: RefusalCode, message = MESSAGES[code]) => {
|
|
1310
|
+
const where = only === undefined || code === 'invalid' ? { ids: {}, detail: {} } : located(only);
|
|
1311
|
+
return new Refused(refusal(code, message), [{
|
|
1312
|
+
...accessEntry(actor, action, principal, 'deny', input, { changes: input.changes, ...where.detail }, code),
|
|
1313
|
+
...where.ids,
|
|
1048
1314
|
}]);
|
|
1315
|
+
};
|
|
1316
|
+
const onProjects = changes.flatMap(({ projectId, environmentId, role }) => (projectId === null ? [] : [{ projectId, environmentId, role }]));
|
|
1049
1317
|
return this.#decide([actor, principal], async (d) => {
|
|
1050
1318
|
validateCorrelation(input);
|
|
1051
|
-
if (!
|
|
1319
|
+
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
|
|
1052
1320
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1053
1321
|
const places = new Set<string>();
|
|
1054
|
-
for (const change of
|
|
1055
|
-
const
|
|
1322
|
+
for (const change of changes) {
|
|
1323
|
+
const shape = placeShape(change);
|
|
1324
|
+
if (shape !== null) throw refused('invalid', shape);
|
|
1325
|
+
const key = placeKey(change);
|
|
1056
1326
|
if (places.has(key)) throw refused('invalid', 'each place may be changed once per call');
|
|
1057
1327
|
places.add(key);
|
|
1058
1328
|
if (change.role !== null && !isRole(change.role)) throw refused('invalid', `no such role: ${change.role}`);
|
|
1059
|
-
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) {
|
|
1060
|
-
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
1329
|
+
if (change.role !== null && (change.environmentId !== null || change.environmentSlug !== null) && !assignableToEnvironment(change.role)) {
|
|
1330
|
+
throw refused('invalid', `${change.role} can only be granted on a project, or on every project`);
|
|
1061
1331
|
}
|
|
1062
1332
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1063
1333
|
if (Number.isNaN(expiresAt) || (expiresAt !== null && expiresAt <= d.at)) {
|
|
1064
1334
|
throw refused('invalid', 'an end date must be in the future');
|
|
1065
1335
|
}
|
|
1066
1336
|
}
|
|
1337
|
+
// Every project is always there; a project or an environment must be,
|
|
1338
|
+
// and to be granted, not deleted. Read under the log's head, which a
|
|
1339
|
+
// deletion holds while it renames the place, so the place is as it commits.
|
|
1340
|
+
if (onProjects.length > 0) await lockLogHead(d.tx);
|
|
1067
1341
|
const known = await store.places(
|
|
1068
1342
|
d.tx,
|
|
1069
|
-
|
|
1070
|
-
|
|
1343
|
+
onProjects.map((change) => change.projectId),
|
|
1344
|
+
onProjects.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
|
|
1071
1345
|
);
|
|
1072
|
-
for (const { projectId, environmentId } of
|
|
1346
|
+
for (const { projectId, environmentId, role } of onProjects) {
|
|
1073
1347
|
if (!known.projects.has(projectId) || (environmentId !== null && known.environments.get(environmentId) !== projectId)) {
|
|
1074
1348
|
throw refused('invalid', `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
|
|
1075
1349
|
}
|
|
1350
|
+
// A grant there is refused; a revocation, as a deletion makes, is not.
|
|
1351
|
+
if (role !== null && (known.deleted.has(projectId) || (environmentId !== null && known.deleted.has(environmentId)))) {
|
|
1352
|
+
throw refused('deleted');
|
|
1353
|
+
}
|
|
1076
1354
|
}
|
|
1077
1355
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1078
1356
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1079
|
-
if (!
|
|
1357
|
+
if (!changes.every((change) => mayManageAccess(acting.live, change))) throw refused('not_allowed');
|
|
1080
1358
|
|
|
1081
1359
|
const row = d.members.get(principal);
|
|
1082
1360
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
@@ -1086,9 +1364,9 @@ class VaultService implements Vault {
|
|
|
1086
1364
|
// The grants the check verified, not a second read: what the decision changes, and seals.
|
|
1087
1365
|
const held = subject.stored;
|
|
1088
1366
|
d.grants.set(principal, [...held]);
|
|
1089
|
-
const
|
|
1367
|
+
const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
|
|
1090
1368
|
if (d.writes.length > 0) d.touched.add(principal);
|
|
1091
|
-
return { changes };
|
|
1369
|
+
return { changes: outcomes };
|
|
1092
1370
|
});
|
|
1093
1371
|
}
|
|
1094
1372
|
|
|
@@ -1098,21 +1376,23 @@ class VaultService implements Vault {
|
|
|
1098
1376
|
actor: string,
|
|
1099
1377
|
principal: string,
|
|
1100
1378
|
held: readonly GrantRow[],
|
|
1101
|
-
change: GrantChange,
|
|
1379
|
+
change: GrantChange & GrantPlace,
|
|
1102
1380
|
correlation: Correlation,
|
|
1103
1381
|
): AccessChange {
|
|
1104
|
-
const existing = held.find((grant) => grant
|
|
1382
|
+
const existing = held.find((grant) => placeKey(grant) === placeKey(change));
|
|
1105
1383
|
const current = existing !== undefined && live(existing, d.at) ? existing : undefined;
|
|
1106
1384
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1107
|
-
const place = { projectId: change.projectId, environmentId: change.environmentId };
|
|
1385
|
+
const place = { projectId: change.projectId, environmentId: change.environmentId, environmentSlug: change.environmentSlug };
|
|
1386
|
+
const where = located(place);
|
|
1108
1387
|
const entry = (action: string, role: string | null) =>
|
|
1109
1388
|
d.log.push({
|
|
1110
1389
|
...accessEntry(actor, action, principal, 'allow', correlation, {
|
|
1111
1390
|
role,
|
|
1112
1391
|
expiresAt: expiresAt === null ? null : iso(expiresAt),
|
|
1113
1392
|
previousRole: current?.role ?? null,
|
|
1393
|
+
...where.detail,
|
|
1114
1394
|
}),
|
|
1115
|
-
...
|
|
1395
|
+
...where.ids,
|
|
1116
1396
|
});
|
|
1117
1397
|
// A lapsed grant is cleared with no entry: it changes nothing anyone holds.
|
|
1118
1398
|
const clear = () => {
|
|
@@ -1152,7 +1432,7 @@ class VaultService implements Vault {
|
|
|
1152
1432
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1153
1433
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1154
1434
|
if (!acting.live.isOwner) throw refused('not_allowed', 'only owners may add or restore members');
|
|
1155
|
-
if (!
|
|
1435
|
+
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1156
1436
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1157
1437
|
if (input.owner === true && !principal.startsWith('user:')) {
|
|
1158
1438
|
throw refused('invalid', 'service accounts cannot be owners');
|
|
@@ -1204,7 +1484,7 @@ class VaultService implements Vault {
|
|
|
1204
1484
|
new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
|
|
1205
1485
|
return this.#decide([actor, principal], async (d) => {
|
|
1206
1486
|
validateCorrelation(input);
|
|
1207
|
-
if (!
|
|
1487
|
+
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1208
1488
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1209
1489
|
const row = d.members.get(principal);
|
|
1210
1490
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
@@ -1221,14 +1501,15 @@ class VaultService implements Vault {
|
|
|
1221
1501
|
|
|
1222
1502
|
const revoked = held.filter((grant) => live(grant, d.at));
|
|
1223
1503
|
for (const grant of revoked) {
|
|
1504
|
+
const where = located(grant);
|
|
1224
1505
|
d.log.push({
|
|
1225
1506
|
...accessEntry(actor, 'access.revoke', principal, 'allow', input, {
|
|
1226
1507
|
role: null,
|
|
1227
1508
|
expiresAt: null,
|
|
1228
1509
|
previousRole: grant.role,
|
|
1510
|
+
...where.detail,
|
|
1229
1511
|
}),
|
|
1230
|
-
|
|
1231
|
-
environmentId: grant.environmentId,
|
|
1512
|
+
...where.ids,
|
|
1232
1513
|
});
|
|
1233
1514
|
}
|
|
1234
1515
|
const generation = row.generation + 1;
|
|
@@ -1750,12 +2031,83 @@ function validateItems(items: readonly { secret: SecretRef; key?: string; wrappe
|
|
|
1750
2031
|
}
|
|
1751
2032
|
}
|
|
1752
2033
|
|
|
1753
|
-
/**
|
|
1754
|
-
|
|
2034
|
+
/** A reference as its `reference.create` entry seals it. */
|
|
2035
|
+
type Sealed = {
|
|
2036
|
+
reference: string;
|
|
2037
|
+
seq: bigint;
|
|
2038
|
+
holder: { projectId: string; environmentId: string; secretId: string; path: string };
|
|
2039
|
+
source: { projectId: string; environmentId: string; secretId: string; path: string };
|
|
2040
|
+
createdBy: string;
|
|
2041
|
+
createdAt: number;
|
|
2042
|
+
};
|
|
2043
|
+
|
|
2044
|
+
/** What a genuine `reference.create` entry seals, if it is about `reference`; null if not, or malformed. */
|
|
2045
|
+
function sealedReference(entry: StoredEntry, reference: string): Sealed | null {
|
|
2046
|
+
let detail: { reference?: unknown; subject?: unknown; secretId?: unknown; source?: { path?: unknown }; also?: Record<string, unknown> };
|
|
2047
|
+
try {
|
|
2048
|
+
detail = JSON.parse(entry.metadata) as typeof detail;
|
|
2049
|
+
} catch {
|
|
2050
|
+
return null;
|
|
2051
|
+
}
|
|
2052
|
+
const text = (value: unknown): value is string => typeof value === 'string' && value !== '';
|
|
2053
|
+
const also = detail.also ?? {};
|
|
2054
|
+
if (detail.reference !== reference || entry.projectId === null || entry.environmentId === null) return null;
|
|
2055
|
+
if (!text(detail.subject) || !text(detail.secretId) || !text(detail.source?.path)) return null;
|
|
2056
|
+
if (!text(also.projectId) || !text(also.environmentId) || !text(also.secretId)) return null;
|
|
2057
|
+
return {
|
|
2058
|
+
reference,
|
|
2059
|
+
seq: entry.seq,
|
|
2060
|
+
holder: { projectId: entry.projectId, environmentId: entry.environmentId, secretId: detail.secretId, path: detail.subject },
|
|
2061
|
+
source: { projectId: also.projectId, environmentId: also.environmentId, secretId: also.secretId, path: detail.source!.path as string },
|
|
2062
|
+
createdBy: entry.actor,
|
|
2063
|
+
createdAt: entry.occurredAt,
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
/** A read's reference, checked for shape: null for none. */
|
|
2068
|
+
function validateVia(via: Via | null | undefined): Via | null {
|
|
2069
|
+
if (via === undefined || via === null) return null;
|
|
2070
|
+
if (typeof via.reference !== 'string' || !UUID.test(via.reference)) throw new Error('a reference id must be a lowercase UUID');
|
|
2071
|
+
if (!Number.isSafeInteger(via.seq) || via.seq < 0) throw new Error("a reference's seq must be a whole number");
|
|
2072
|
+
return { reference: via.reference, seq: via.seq };
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/**
|
|
2076
|
+
* How a read through a reference is logged: the way in, and `also`, the
|
|
2077
|
+
* holder's place, which puts the read in the holder's project's log too.
|
|
2078
|
+
*/
|
|
2079
|
+
function viaDetail(via: Via, seal: Sealed | null): Record<string, unknown> {
|
|
2080
|
+
if (seal === null) return { via: { reference: via.reference, seq: via.seq } };
|
|
2081
|
+
return {
|
|
2082
|
+
via: { reference: via.reference, seq: via.seq, path: seal.holder.path, createdBy: seal.createdBy, createdAt: iso(seal.createdAt) },
|
|
2083
|
+
also: { projectId: seal.holder.projectId, environmentId: seal.holder.environmentId, secretId: seal.holder.secretId },
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** An environment a secret is in, or that holds a reference to one. */
|
|
2088
|
+
type SecretPlace = { projectId: string; environmentId: string };
|
|
2089
|
+
|
|
2090
|
+
/**
|
|
2091
|
+
* Where a decision checks a permission: an environment, with its slug as
|
|
2092
|
+
* the store has it when it was read and is in the project claimed;
|
|
2093
|
+
* otherwise none, which no grant on a slug matches.
|
|
2094
|
+
*/
|
|
2095
|
+
function placeIn(place: SecretPlace, environments: ReadonlyMap<string, { projectId: string; slug: string }>): Place {
|
|
2096
|
+
const environment = environments.get(place.environmentId);
|
|
2097
|
+
const slug = environment?.projectId === place.projectId ? environment.slug : null;
|
|
2098
|
+
return { projectId: place.projectId, environmentId: place.environmentId, environmentSlug: slug };
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* Why `reader` may not do `permission` at `where`, or null if they may. A
|
|
2103
|
+
* deleted place is refused before any grant is asked, so no kind of grant,
|
|
2104
|
+
* on the project, the environment or every project, can reach into one.
|
|
2105
|
+
*/
|
|
2106
|
+
function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
|
|
1755
2107
|
if (reader.status === 'tampered') return 'tampered';
|
|
1756
2108
|
if (reader.status === 'removed') return 'removed';
|
|
1757
2109
|
if (reader.status === 'unknown') return 'not_a_member';
|
|
1758
|
-
|
|
2110
|
+
if (deleted) return 'deleted';
|
|
1759
2111
|
if (allows(reader.live, permission, where)) return null;
|
|
1760
2112
|
// Would a grant that has lapsed have covered it?
|
|
1761
2113
|
return allows(reader.all, permission, where) ? 'expired' : 'no_grant';
|
|
@@ -1831,10 +2183,41 @@ function live(grant: GrantRow, at: number): boolean {
|
|
|
1831
2183
|
return grant.expiresAt === null || grant.expiresAt > at;
|
|
1832
2184
|
}
|
|
1833
2185
|
|
|
2186
|
+
/**
|
|
2187
|
+
* Where an entry about a grant says it is: its project and environment; or,
|
|
2188
|
+
* on every project, neither, and the place as a path in its payload. Only a
|
|
2189
|
+
* grant that names no place at all is on every project (`grantKind`).
|
|
2190
|
+
*/
|
|
2191
|
+
function located(place: GrantPlace): { ids: Pick<NewEntry, 'projectId' | 'environmentId'>; detail: { place?: string } } {
|
|
2192
|
+
return grantKind(place) === 'every-project'
|
|
2193
|
+
? { ids: {}, detail: { place: everyProjectPath(place.environmentSlug) } }
|
|
2194
|
+
: { ids: { projectId: place.projectId, environmentId: place.environmentId }, detail: {} };
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/** One key per place a member can hold a grant at, by what the grant names first: its environment, its project, or neither. */
|
|
2198
|
+
function placeKey(place: GrantPlace): string {
|
|
2199
|
+
if (place.environmentId !== null) return `environment:${place.environmentId}`;
|
|
2200
|
+
return place.projectId !== null ? `project:${place.projectId}` : everyProjectPath(place.environmentSlug);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/** Why a change's place is not one a grant can be at, or null when it is. */
|
|
2204
|
+
function placeShape(place: GrantPlace): string | null {
|
|
2205
|
+
const kind = grantKind(place);
|
|
2206
|
+
if (kind === null) return place.projectId === null ? 'an environment is named in its project' : 'a slug names environments on every project, not in one';
|
|
2207
|
+
if (kind === 'every-project' && place.environmentSlug !== null && !SLUG.test(place.environmentSlug)) {
|
|
2208
|
+
return `not an environment slug: ${place.environmentSlug}`;
|
|
2209
|
+
}
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/** An environment's slug, as the schema has it. */
|
|
2214
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
2215
|
+
|
|
1834
2216
|
function view(grant: GrantRow): Grant {
|
|
1835
2217
|
return {
|
|
1836
2218
|
projectId: grant.projectId,
|
|
1837
2219
|
environmentId: grant.environmentId,
|
|
2220
|
+
environmentSlug: grant.environmentSlug,
|
|
1838
2221
|
role: grant.role as Role,
|
|
1839
2222
|
expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
|
|
1840
2223
|
grantedAt: iso(grant.grantedAt),
|