@coffre/vault 0.9.0 → 0.10.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 +10 -11
- package/dist/cloudflare.js +8 -2
- package/dist/{index-BkHJR9Gt.d.ts → index-CByXzyi-.d.ts} +7 -12
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/node.d.ts +2 -2
- package/dist/node.js +4 -2
- package/dist/{src-BcttALFQ.js → src-D82jyprb.js} +2 -6
- package/dist/{vault-lBbyZ3kQ.js → vault-COqZ12ny.js} +389 -53
- package/package.json +3 -3
- package/src/cloudflare.ts +3 -0
- package/src/config.ts +12 -19
- package/src/index.ts +1 -1
- package/src/local.ts +2 -0
- package/src/store.ts +84 -1
- package/src/vault.ts +389 -57
package/src/vault.ts
CHANGED
|
@@ -3,25 +3,39 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import {
|
|
4
4
|
allows,
|
|
5
5
|
assignableToEnvironment,
|
|
6
|
+
DEFAULT_POLICY,
|
|
7
|
+
definitionProblem,
|
|
8
|
+
EVERY_ENVIRONMENT,
|
|
6
9
|
EVERYWHERE,
|
|
10
|
+
FIXED_ROLES,
|
|
7
11
|
givesService,
|
|
12
|
+
guarded,
|
|
8
13
|
grantKind,
|
|
14
|
+
INSTANCE_ROLES,
|
|
15
|
+
isBuiltinRole,
|
|
9
16
|
isCredentialScope,
|
|
10
17
|
isInstanceRole,
|
|
11
18
|
isRole,
|
|
12
19
|
isScope,
|
|
13
20
|
managesService,
|
|
14
21
|
mayManageAccess,
|
|
22
|
+
normalDefinition,
|
|
15
23
|
normalScope,
|
|
16
24
|
NOTHING,
|
|
17
25
|
ownsInstance,
|
|
26
|
+
PROJECT_ONLY_PERMISSIONS,
|
|
27
|
+
ROLE_ID,
|
|
28
|
+
ROLES,
|
|
18
29
|
runsInstance,
|
|
19
30
|
setsUpServices,
|
|
31
|
+
UNWRITTEN_FORK_MS,
|
|
20
32
|
type Holdings,
|
|
21
33
|
type InstanceRole,
|
|
22
34
|
type Permission,
|
|
23
35
|
type Place,
|
|
36
|
+
type Policy,
|
|
24
37
|
type Role,
|
|
38
|
+
type RoleDefinition,
|
|
25
39
|
type Scope,
|
|
26
40
|
type ServiceAccount,
|
|
27
41
|
withinCredential,
|
|
@@ -46,11 +60,14 @@ import {
|
|
|
46
60
|
checkpointMessage,
|
|
47
61
|
checkpointVerifier,
|
|
48
62
|
describeAccessFault,
|
|
49
|
-
|
|
63
|
+
handsBack,
|
|
64
|
+
POLICY_REVISION,
|
|
65
|
+
policyRevision,
|
|
50
66
|
type Access,
|
|
51
67
|
type AccessChange,
|
|
52
68
|
type AccessFault,
|
|
53
69
|
type AdmitInput,
|
|
70
|
+
type ChangePolicyInput,
|
|
54
71
|
type Admitted,
|
|
55
72
|
type Checkpoint,
|
|
56
73
|
type CheckpointKey,
|
|
@@ -61,6 +78,7 @@ import {
|
|
|
61
78
|
type LogHead,
|
|
62
79
|
type LogVerification,
|
|
63
80
|
type Outcome,
|
|
81
|
+
type PolicyChange,
|
|
64
82
|
type Purpose,
|
|
65
83
|
type ReferenceInput,
|
|
66
84
|
type Refusal,
|
|
@@ -73,6 +91,7 @@ import {
|
|
|
73
91
|
type Settings,
|
|
74
92
|
type Through,
|
|
75
93
|
type UnwrapInput,
|
|
94
|
+
type Wrapped,
|
|
76
95
|
type Vault,
|
|
77
96
|
type Via,
|
|
78
97
|
type VerifyLogInput,
|
|
@@ -82,7 +101,7 @@ import {
|
|
|
82
101
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
83
102
|
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
84
103
|
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
85
|
-
import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
104
|
+
import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, newestVaultSeq, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
86
105
|
|
|
87
106
|
import { KeyAccounting } from './accounting.ts';
|
|
88
107
|
import { signer, type Signer } from './checkpoint.ts';
|
|
@@ -217,6 +236,42 @@ const SETTINGS = 'settings.change';
|
|
|
217
236
|
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
218
237
|
const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
|
|
219
238
|
|
|
239
|
+
/** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
|
|
240
|
+
const POLICY = 'policy.change';
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What a `policy.change` entry keeps: the roles an Owner edited or made,
|
|
244
|
+
* each whole, and the protected environments. A role as it ships is kept by
|
|
245
|
+
* none, so a release that changes a default changes it where nobody edited it.
|
|
246
|
+
*/
|
|
247
|
+
type PolicyEdits = { roles: Record<string, RoleDefinition>; instanceRoles: Partial<Record<InstanceRole, RoleDefinition>>; protected: ProtectedPlace[] };
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A protected environment, with its project's id, what a grant on the whole
|
|
251
|
+
* project reaches of it, and when it was protected, by the vault's clock:
|
|
252
|
+
* an id with no row ten minutes later is a fork that was never written.
|
|
253
|
+
*/
|
|
254
|
+
type ProtectedPlace = { projectId: string; environmentId: string; at: number };
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
const NO_EDITS: PolicyEdits = { roles: {}, instanceRoles: {}, protected: [] };
|
|
258
|
+
|
|
259
|
+
/** A role holding nothing, which every role but Owner is under the closed policy. */
|
|
260
|
+
const NOTHING_HELD = (definition: RoleDefinition): RoleDefinition => ({ ...definition, permissions: [], protected: [] });
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The policy when the log has some, but none the vault wrote within reach:
|
|
264
|
+
* whoever inserted rows may have hidden an Owner's narrowing, so no role
|
|
265
|
+
* holds anything, a service account's neither, but Owner, which holds
|
|
266
|
+
* everything always and so may write the policy again.
|
|
267
|
+
*/
|
|
268
|
+
const CLOSED_POLICY: Policy = {
|
|
269
|
+
roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])),
|
|
270
|
+
instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])) as Policy['instanceRoles'],
|
|
271
|
+
protected: [EVERY_ENVIRONMENT],
|
|
272
|
+
protectedIn: {},
|
|
273
|
+
};
|
|
274
|
+
|
|
220
275
|
/** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
|
|
221
276
|
const CLOSED_SETTINGS: Settings = { serviceAccounts: { projects: { only: [] }, environments: 'all' } };
|
|
222
277
|
|
|
@@ -269,6 +324,9 @@ class Outage {
|
|
|
269
324
|
}
|
|
270
325
|
}
|
|
271
326
|
|
|
327
|
+
/** A rewrap that would make a value less sensitive than it is. */
|
|
328
|
+
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';
|
|
329
|
+
|
|
272
330
|
function refusal(code: RefusalCode, message: string): Refusal {
|
|
273
331
|
return { code, message };
|
|
274
332
|
}
|
|
@@ -283,10 +341,11 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
283
341
|
deleted: 'this project or environment was deleted',
|
|
284
342
|
ended: 'the reference read through was ended',
|
|
285
343
|
personal: "a personal config's values are its owner's alone",
|
|
286
|
-
|
|
344
|
+
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",
|
|
287
345
|
not_allowed: 'not allowed to change this',
|
|
288
346
|
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
289
347
|
invalid: 'not something the rules allow',
|
|
348
|
+
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
290
349
|
log_broken: 'the vault log does not hold from the last checkpoint',
|
|
291
350
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
292
351
|
tampered: "this member's record failed the vault's integrity check",
|
|
@@ -398,23 +457,29 @@ class VaultService implements Vault {
|
|
|
398
457
|
}
|
|
399
458
|
|
|
400
459
|
/**
|
|
401
|
-
* Decide in one transaction, with `principals`' rows locked first
|
|
402
|
-
* `Refused` or an `Outage` rolls back all but its own
|
|
403
|
-
* commit on their own: then the refusal is the answer, and
|
|
404
|
-
* fails the call.
|
|
460
|
+
* Decide in one transaction, with `principals`' rows locked first, then
|
|
461
|
+
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
462
|
+
* entries, which commit on their own: then the refusal is the answer, and
|
|
463
|
+
* the outage fails the call.
|
|
464
|
+
*
|
|
465
|
+
* Every change to the policy, the settings, a member or a place appends
|
|
466
|
+
* under the head, so whatever the decision reads of them stands until it
|
|
467
|
+
* commits: no decision acts on a policy read before it. A key operation
|
|
468
|
+
* alone takes the head later (`late`), once its key service has answered,
|
|
469
|
+
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
405
470
|
*/
|
|
406
|
-
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T
|
|
471
|
+
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
407
472
|
const superseded = await this.#settled();
|
|
408
473
|
if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
|
|
409
|
-
return this.#decideNow(principals, decide);
|
|
474
|
+
return this.#decideNow(principals, decide, late);
|
|
410
475
|
}
|
|
411
476
|
|
|
412
477
|
/** `#decide`, once the vault is settled. */
|
|
413
|
-
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T
|
|
478
|
+
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
414
479
|
const reports: NewEntry[] = [];
|
|
415
480
|
for (let attempt = 1; ; attempt += 1) {
|
|
416
481
|
try {
|
|
417
|
-
return await this.#decideOnce(principals, decide, reports);
|
|
482
|
+
return await this.#decideOnce(principals, decide, reports, late);
|
|
418
483
|
} catch (error) {
|
|
419
484
|
// A member who had no row when this decision locked theirs, and has
|
|
420
485
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
@@ -424,11 +489,12 @@ class VaultService implements Vault {
|
|
|
424
489
|
}
|
|
425
490
|
}
|
|
426
491
|
|
|
427
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[]): Promise<Outcome<T>> {
|
|
492
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
|
|
428
493
|
try {
|
|
429
494
|
const result = await this.#db.transaction(async (tx) => {
|
|
430
495
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
431
496
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
497
|
+
if (!late) await lockLogHead(tx);
|
|
432
498
|
const d: Decision = {
|
|
433
499
|
tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
434
500
|
};
|
|
@@ -630,7 +696,6 @@ class VaultService implements Vault {
|
|
|
630
696
|
if (everyProject.length === 0) return;
|
|
631
697
|
if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
|
|
632
698
|
// The projects there are now, read under the log's head, which a deletion holds.
|
|
633
|
-
await lockLogHead(d.tx);
|
|
634
699
|
const before = roleOf(principal, row);
|
|
635
700
|
const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await store.liveProjects(d.tx));
|
|
636
701
|
const after = { role: conversion.role, scope: conversion.scope };
|
|
@@ -790,6 +855,7 @@ class VaultService implements Vault {
|
|
|
790
855
|
async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
|
|
791
856
|
const { principal } = input;
|
|
792
857
|
validateText(input.purpose);
|
|
858
|
+
if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
|
|
793
859
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
794
860
|
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
795
861
|
const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
|
|
@@ -838,7 +904,8 @@ class VaultService implements Vault {
|
|
|
838
904
|
{
|
|
839
905
|
action: 'secret.read',
|
|
840
906
|
principal,
|
|
841
|
-
|
|
907
|
+
// The proxy's use opens a value for coffre to use for its reader, never to hand back: `secret.use`. Anything else reads.
|
|
908
|
+
permission: input.purpose === 'use' ? 'secret.use' : 'secret.read',
|
|
842
909
|
secrets: items.map((item) => item.secret),
|
|
843
910
|
// Through a reference, the reader's grants on the holder's environment decide.
|
|
844
911
|
places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
|
|
@@ -867,7 +934,7 @@ class VaultService implements Vault {
|
|
|
867
934
|
);
|
|
868
935
|
}
|
|
869
936
|
|
|
870
|
-
async wrap(input: WrapInput): Promise<Outcome<
|
|
937
|
+
async wrap(input: WrapInput): Promise<Outcome<Wrapped>> {
|
|
871
938
|
const { principal, items } = input;
|
|
872
939
|
validateItems(items, 'key');
|
|
873
940
|
validateText(principal);
|
|
@@ -893,11 +960,11 @@ class VaultService implements Vault {
|
|
|
893
960
|
dek.fill(0);
|
|
894
961
|
}
|
|
895
962
|
}),
|
|
896
|
-
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
|
|
963
|
+
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[], sensitive: items.map(({ secret }) => secret.sensitive === true) }),
|
|
897
964
|
);
|
|
898
965
|
}
|
|
899
966
|
|
|
900
|
-
async rewrap(input: RewrapInput): Promise<Outcome<
|
|
967
|
+
async rewrap(input: RewrapInput): Promise<Outcome<Wrapped>> {
|
|
901
968
|
const { principal } = input;
|
|
902
969
|
const loaded = await this.#versions(input, 'key.rewrap');
|
|
903
970
|
if (!loaded.ok) return loaded;
|
|
@@ -911,6 +978,13 @@ class VaultService implements Vault {
|
|
|
911
978
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
|
|
912
979
|
})));
|
|
913
980
|
}
|
|
981
|
+
// A value is made looser only by writing it again: a sensitive version's key, or one of a secret whose newest is,
|
|
982
|
+
// is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
|
|
983
|
+
if (items.some((item, i) => item.secret.sensitive !== true && (loaded.versions[i].secret.sensitive || loaded.versions[i].newestSensitive))) {
|
|
984
|
+
return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
|
|
985
|
+
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
|
|
986
|
+
})), LOOSER);
|
|
987
|
+
}
|
|
914
988
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
915
989
|
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
|
|
916
990
|
...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version }),
|
|
@@ -919,10 +993,26 @@ class VaultService implements Vault {
|
|
|
919
993
|
const remote =
|
|
920
994
|
this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
921
995
|
return this.#keys(
|
|
922
|
-
{
|
|
996
|
+
{
|
|
997
|
+
action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input,
|
|
998
|
+
// Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any.
|
|
999
|
+
vet: async (db) => {
|
|
1000
|
+
const newest = await store.newestSensitive(db, items.map(({ secret }) => secret.secretId));
|
|
1001
|
+
return items.map(({ secret }) => (secret.sensitive !== true && newest.has(secret.secretId) ? 'invalid' : null));
|
|
1002
|
+
},
|
|
1003
|
+
// An old value brought back, not the current one, is made live by whoever could have read it: a secret by its
|
|
1004
|
+
// readers, a sensitive one, which nobody reads, by those who use it, a plain one by any writer, as it was shown
|
|
1005
|
+
// to them. Whatever reads the key next, a reference to it included, then reads what its restorer could. Typing a
|
|
1006
|
+
// value, or tightening the current one, takes no more than writing.
|
|
1007
|
+
needs: async (db) => {
|
|
1008
|
+
const restored = await store.restoredNatures(db, items.map(({ secret }) => ({ secretId: secret.secretId, from: sources.get(secret)!.id })));
|
|
1009
|
+
return restored.map((nature) => (nature === 'secret' ? ['secret.read'] as const : nature === 'sensitive' ? ['secret.use'] as const : null));
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
923
1012
|
(contextOf) =>
|
|
924
1013
|
items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
|
|
925
|
-
|
|
1014
|
+
// Opened as the source was written, sensitive or not, and wrapped as the new version is.
|
|
1015
|
+
const key = await this.#open(wrapped, contextOf(sources.get(secret)!.secret), operation);
|
|
926
1016
|
if (key === null) return null;
|
|
927
1017
|
try {
|
|
928
1018
|
return { wrapped: serialisable(await this.#config.keks.wrap(key, contextOf(secret), operation)), wipe: () => {} };
|
|
@@ -930,7 +1020,7 @@ class VaultService implements Vault {
|
|
|
930
1020
|
key.fill(0);
|
|
931
1021
|
}
|
|
932
1022
|
}),
|
|
933
|
-
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
|
|
1023
|
+
(done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[], sensitive: items.map(({ secret }) => secret.sensitive === true) }),
|
|
934
1024
|
);
|
|
935
1025
|
}
|
|
936
1026
|
|
|
@@ -968,9 +1058,9 @@ class VaultService implements Vault {
|
|
|
968
1058
|
return { ok: true, versions: ids.map((id) => found.get(id)!) };
|
|
969
1059
|
}
|
|
970
1060
|
|
|
971
|
-
async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted', entries: NewEntry[]): Promise<Outcome<never>> {
|
|
1061
|
+
async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted' | 'invalid', entries: NewEntry[], message = MESSAGES[code]): Promise<Outcome<never>> {
|
|
972
1062
|
return this.#decide([principal], async () => {
|
|
973
|
-
throw new Refused(refusal(code,
|
|
1063
|
+
throw new Refused(refusal(code, message), entries);
|
|
974
1064
|
});
|
|
975
1065
|
}
|
|
976
1066
|
|
|
@@ -1013,9 +1103,14 @@ class VaultService implements Vault {
|
|
|
1013
1103
|
* once the log's head is locked, before its entries append.
|
|
1014
1104
|
*/
|
|
1015
1105
|
vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
|
|
1106
|
+
/**
|
|
1107
|
+
* Any one of which each secret takes besides `permission`, where it
|
|
1108
|
+
* is, read in the decision and again under the head; none for null.
|
|
1109
|
+
*/
|
|
1110
|
+
needs?: (db: Queryable) => Promise<(readonly Permission[] | null)[]>;
|
|
1016
1111
|
remote: boolean;
|
|
1017
1112
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1018
|
-
input: Correlation & Through & { purpose?: string };
|
|
1113
|
+
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
|
|
1019
1114
|
},
|
|
1020
1115
|
/**
|
|
1021
1116
|
* One per secret; null for a bad claim, an error for an outage or an
|
|
@@ -1048,17 +1143,35 @@ class VaultService implements Vault {
|
|
|
1048
1143
|
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1049
1144
|
owners.clear();
|
|
1050
1145
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
1146
|
+
const needs = call.needs === undefined ? [] : await call.needs(d.tx);
|
|
1051
1147
|
const codes: (RefusalCode | null)[] = secrets.map((secret, i) => {
|
|
1052
1148
|
const personal = personalRefusal(reader, principal, call.permission, secret, at[i]!, environments);
|
|
1053
1149
|
if (personal !== undefined) return personal;
|
|
1150
|
+
const also = needs[i];
|
|
1151
|
+
if (also != null) {
|
|
1152
|
+
const place = placeIn(at[i]!, environments);
|
|
1153
|
+
const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i]!)));
|
|
1154
|
+
if (refused.every((code) => code !== null)) return refused[0]!;
|
|
1155
|
+
}
|
|
1156
|
+
// Through a reference, the holder's grants decide, its source's never: a reference takes its value and nature
|
|
1157
|
+
// (a sensitive one stays use-only) from its source, and its permissions from where it lives. Its maker held what
|
|
1158
|
+
// the source's nature asks when it was made (`reference`).
|
|
1054
1159
|
return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
|
|
1055
1160
|
});
|
|
1056
1161
|
if (call.vet !== undefined && !codes.some((code) => code !== null)) {
|
|
1057
1162
|
(await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1058
1163
|
}
|
|
1164
|
+
// A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
|
|
1165
|
+
// a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
|
|
1166
|
+
// 0.9, mid-deploy, would write the copy as a secret its people read.
|
|
1167
|
+
const copying = action === 'secret.read' && call.input.purpose === 'copy';
|
|
1168
|
+
const handedBack = action === 'secret.read' && principal.startsWith('user:') && handsBack({ purpose: call.input.purpose ?? '', keepsSensitive: call.input.keepsSensitive === true });
|
|
1169
|
+
if (handedBack || (copying && call.input.keepsSensitive !== true)) {
|
|
1170
|
+
secrets.forEach((secret, i) => {
|
|
1171
|
+
if (codes[i] === null && secret.sensitive === true) codes[i] = 'sensitive';
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1059
1174
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1060
|
-
// A person's values are only ever used for them, where people get stand-ins: by whatever credential of theirs asks.
|
|
1061
|
-
if (first === null && action === 'secret.read' && this.#config.people === 'stand-ins' && principal.startsWith('user:') && HANDED_BACK.includes(call.input.purpose as Purpose)) first = 'stand_ins';
|
|
1062
1175
|
if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
|
|
1063
1176
|
if (first !== null) {
|
|
1064
1177
|
throw new Refused(
|
|
@@ -1087,7 +1200,6 @@ class VaultService implements Vault {
|
|
|
1087
1200
|
if (call.remote) {
|
|
1088
1201
|
const intent = await this.#decide([principal], async (d) => {
|
|
1089
1202
|
await check(d);
|
|
1090
|
-
await lockLogHead(d.tx);
|
|
1091
1203
|
const at = await this.#now(d.tx);
|
|
1092
1204
|
const appended = await this.#append(d.tx, [{
|
|
1093
1205
|
actor: principal,
|
|
@@ -1155,6 +1267,16 @@ class VaultService implements Vault {
|
|
|
1155
1267
|
// check above is seen here, and nothing is released after it.
|
|
1156
1268
|
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
1157
1269
|
await lockLogHead(d.tx);
|
|
1270
|
+
// And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
|
|
1271
|
+
// since the check above is seen here, and nothing it refuses now is released.
|
|
1272
|
+
try {
|
|
1273
|
+
await check(d);
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
if (error instanceof Refused) {
|
|
1276
|
+
throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', error.entries[item].code ?? error.refusal.code)));
|
|
1277
|
+
}
|
|
1278
|
+
throw error;
|
|
1279
|
+
}
|
|
1158
1280
|
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1159
1281
|
const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
1160
1282
|
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
|
|
@@ -1169,7 +1291,7 @@ class VaultService implements Vault {
|
|
|
1169
1291
|
} finally {
|
|
1170
1292
|
for (const value of done) value.wipe();
|
|
1171
1293
|
}
|
|
1172
|
-
});
|
|
1294
|
+
}, true);
|
|
1173
1295
|
}
|
|
1174
1296
|
|
|
1175
1297
|
/** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
|
|
@@ -1225,7 +1347,11 @@ class VaultService implements Vault {
|
|
|
1225
1347
|
};
|
|
1226
1348
|
};
|
|
1227
1349
|
return this.#decide([principal], async (d) => {
|
|
1350
|
+
// Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
|
|
1351
|
+
// nor a role narrowed, before this commits.
|
|
1228
1352
|
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1353
|
+
// Each source's nature, its current version's, under the head: what its maker must hold there.
|
|
1354
|
+
const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
1229
1355
|
// Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
|
|
1230
1356
|
const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
|
|
1231
1357
|
const source = sources.get(item.source.secretId);
|
|
@@ -1238,17 +1364,15 @@ class VaultService implements Vault {
|
|
|
1238
1364
|
if (source.secretId === item.holder.secretId) return 'invalid';
|
|
1239
1365
|
// A personal config's values are bound to its owner: no reference holds one, nor reads one.
|
|
1240
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);
|
|
1241
1370
|
return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
|
|
1242
|
-
?? refuses(reader, 'secret.read',
|
|
1371
|
+
?? refuses(reader, 'secret.read', at, gone(source))
|
|
1372
|
+
?? (natures.get(source.secretId) === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null);
|
|
1243
1373
|
});
|
|
1244
1374
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1245
1375
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
|
|
1246
|
-
// Again under the log's head, as a read is: neither side was deleted since the check above.
|
|
1247
|
-
d.underLock.push(async (locked) => {
|
|
1248
|
-
const now = await store.environmentsById(locked, [...environments.keys()]);
|
|
1249
|
-
if (![...now.values()].some((environment) => environment.deleted)) return;
|
|
1250
|
-
throw new Refused(refusal('deleted', MESSAGES.deleted), items.map((item) => entry(item, 'deny', 'deleted')));
|
|
1251
|
-
});
|
|
1252
1376
|
const entries = items.map((item) => entry(item, 'allow', null));
|
|
1253
1377
|
d.log.push(...entries);
|
|
1254
1378
|
const answer = { seqs: [] as number[] };
|
|
@@ -1311,6 +1435,7 @@ class VaultService implements Vault {
|
|
|
1311
1435
|
return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
|
|
1312
1436
|
};
|
|
1313
1437
|
return this.#decide([principal], async (d) => {
|
|
1438
|
+
// Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
|
|
1314
1439
|
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1315
1440
|
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1316
1441
|
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
@@ -1326,11 +1451,6 @@ class VaultService implements Vault {
|
|
|
1326
1451
|
if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1327
1452
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1328
1453
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
|
|
1329
|
-
// Two ends of one reference at once: the second finds the first under the head's lock.
|
|
1330
|
-
d.underLock.push(async (locked) => {
|
|
1331
|
-
if ((await ended(locked)).every((code) => code === null)) return;
|
|
1332
|
-
throw new Refused(refusal('ended', MESSAGES.ended), vias.map((via, i) => entry(via, seals[i]!, 'deny', 'ended')));
|
|
1333
|
-
});
|
|
1334
1454
|
const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
|
|
1335
1455
|
d.log.push(...entries);
|
|
1336
1456
|
const answer = { seqs: [] as number[] };
|
|
@@ -1412,11 +1532,11 @@ class VaultService implements Vault {
|
|
|
1412
1532
|
/**
|
|
1413
1533
|
* Refuse a change that would leave `principal`, an Owner of the whole
|
|
1414
1534
|
* instance, no longer one, unless another is. Counted under the log's
|
|
1415
|
-
* head: two changes at once, each demoting or
|
|
1416
|
-
* Owner, lock different rows, and only the head puts
|
|
1535
|
+
* head, which every decision holds: two changes at once, each demoting or
|
|
1536
|
+
* removing a different Owner, lock different rows, and only the head puts
|
|
1537
|
+
* one after the other.
|
|
1417
1538
|
*/
|
|
1418
1539
|
async #keepAnOwner(d: Decision, actor: string, principal: string, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
|
|
1419
|
-
await lockLogHead(d.tx);
|
|
1420
1540
|
if ((await this.#anOwner(d.tx, d.reports, principal)) !== null) return;
|
|
1421
1541
|
throw refused('last_owner', actor === principal
|
|
1422
1542
|
? "you're the last Owner of the whole instance: make someone else an Owner first"
|
|
@@ -1473,17 +1593,20 @@ class VaultService implements Vault {
|
|
|
1473
1593
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
1474
1594
|
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
1475
1595
|
const { role, scope } = roleOf(principal, row);
|
|
1596
|
+
// 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);
|
|
1598
|
+
const service = !principal.startsWith('user:');
|
|
1476
1599
|
return {
|
|
1477
1600
|
principal,
|
|
1478
1601
|
status: 'active',
|
|
1479
|
-
live: { role, scope, grants: held.filter((grant) => live(grant, at)) },
|
|
1480
|
-
all: { role, scope, grants: held },
|
|
1602
|
+
live: { role, scope, grants: held.filter((grant) => live(grant, at)), policy, service },
|
|
1603
|
+
all: { role, scope, grants: held, policy, service },
|
|
1481
1604
|
fault,
|
|
1482
1605
|
stored: grants,
|
|
1483
1606
|
};
|
|
1484
1607
|
}
|
|
1485
1608
|
|
|
1486
|
-
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean): Access {
|
|
1609
|
+
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
|
|
1487
1610
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
|
|
1488
1611
|
return {
|
|
1489
1612
|
principal,
|
|
@@ -1493,6 +1616,7 @@ class VaultService implements Vault {
|
|
|
1493
1616
|
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
1494
1617
|
since: row ? iso(row.statusChangedAt) : null,
|
|
1495
1618
|
by: row?.statusChangedBy ?? null,
|
|
1619
|
+
policy,
|
|
1496
1620
|
};
|
|
1497
1621
|
}
|
|
1498
1622
|
|
|
@@ -1520,7 +1644,8 @@ class VaultService implements Vault {
|
|
|
1520
1644
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
1521
1645
|
const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
|
|
1522
1646
|
const fault = await this.#integrity(db, principal, row, held, reports);
|
|
1523
|
-
|
|
1647
|
+
const { policy } = await this.#policyAt(db, reports, at);
|
|
1648
|
+
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
1524
1649
|
};
|
|
1525
1650
|
const found: NewEntry[] = [];
|
|
1526
1651
|
const quick = await read(this.#db, found).catch((error: unknown) => {
|
|
@@ -1588,10 +1713,7 @@ class VaultService implements Vault {
|
|
|
1588
1713
|
const key = placeKey(change);
|
|
1589
1714
|
if (places.has(key)) throw refused('invalid', 'each place may be changed once per call');
|
|
1590
1715
|
places.add(key);
|
|
1591
|
-
if (change.role !== null &&
|
|
1592
|
-
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) {
|
|
1593
|
-
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
1594
|
-
}
|
|
1716
|
+
if (change.role !== null && typeof change.role !== 'string') throw refused('invalid', `no such role: ${String(change.role)}`);
|
|
1595
1717
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1596
1718
|
if (Number.isNaN(expiresAt) || (expiresAt !== null && expiresAt <= d.at)) {
|
|
1597
1719
|
throw refused('invalid', 'an end date must be in the future');
|
|
@@ -1600,7 +1722,15 @@ class VaultService implements Vault {
|
|
|
1600
1722
|
// A project or an environment must be there, and to be granted, not
|
|
1601
1723
|
// deleted. Read under the log's head, which a deletion holds while it
|
|
1602
1724
|
// renames the place, so the place is as it commits.
|
|
1603
|
-
|
|
1725
|
+
// The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
|
|
1726
|
+
// 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);
|
|
1728
|
+
for (const change of changes) {
|
|
1729
|
+
if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
|
|
1730
|
+
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
|
|
1731
|
+
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1604
1734
|
const known = await store.places(
|
|
1605
1735
|
d.tx,
|
|
1606
1736
|
changes.map((change) => change.projectId!),
|
|
@@ -1931,6 +2061,143 @@ class VaultService implements Vault {
|
|
|
1931
2061
|
return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
|
|
1932
2062
|
}
|
|
1933
2063
|
|
|
2064
|
+
// --- the instance's access policy -------------------------------------------
|
|
2065
|
+
|
|
2066
|
+
async policy(): Promise<Policy> {
|
|
2067
|
+
await this.#settled();
|
|
2068
|
+
const reports: NewEntry[] = [];
|
|
2069
|
+
const { policy } = await this.#policyAt(this.#db, reports, await this.#now(this.#db));
|
|
2070
|
+
await this.#record(reports);
|
|
2071
|
+
return policy;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* The policy the vault's newest `policy.change` says, by its MAC, and its
|
|
2076
|
+
* edits, as of `at`: one in its name it did not write is passed over, and
|
|
2077
|
+
* reported. With such entries and none of its own within reach, the
|
|
2078
|
+
* policy is unreadable, and closed: every environment is protected, and
|
|
2079
|
+
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2080
|
+
* could unprotect them. Its edits are then unknown, not none.
|
|
2081
|
+
*/
|
|
2082
|
+
async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2083
|
+
const entry = await newestAuthentic(
|
|
2084
|
+
(before) => store.newestVaultEntries(db, POLICY, LOOK_BACK.page, before),
|
|
2085
|
+
this.#check(reports, null),
|
|
2086
|
+
);
|
|
2087
|
+
if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
|
|
2088
|
+
const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
|
|
2089
|
+
return edits === null ? { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false } : { policy: await livePolicy(db, edits, at), edits, readable: true };
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
|
|
2093
|
+
const { actor, change, revision } = input;
|
|
2094
|
+
if (revision !== undefined && (typeof revision !== 'string' || !POLICY_REVISION.test(revision))) throw new Error('revision names a policy as policyRevision writes it');
|
|
2095
|
+
// Where a protection is, once its rows are found: its project, and its one environment if that has a row yet. A fork's,
|
|
2096
|
+
// protected before its row is written, is named by the change alone, as the log's columns name only rows.
|
|
2097
|
+
const at: { projectId?: string; environmentId?: string } = {};
|
|
2098
|
+
const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
|
|
2099
|
+
actor,
|
|
2100
|
+
action: POLICY,
|
|
2101
|
+
decision,
|
|
2102
|
+
code,
|
|
2103
|
+
operationId: input.operationId ?? null,
|
|
2104
|
+
requestId: input.requestId ?? null,
|
|
2105
|
+
...at,
|
|
2106
|
+
metadata: JSON.stringify({ ...detail, ...traced(input) }),
|
|
2107
|
+
});
|
|
2108
|
+
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
2109
|
+
new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
|
|
2110
|
+
return this.#decide([actor], async (d) => {
|
|
2111
|
+
validateCorrelation(input);
|
|
2112
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2113
|
+
if (acting.status === 'tampered') throw refused('tampered');
|
|
2114
|
+
if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
|
|
2115
|
+
// Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
|
|
2116
|
+
// caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
|
|
2117
|
+
// from that one, or is refused, to be asked again: a protected prod renamed since is a policy changed too.
|
|
2118
|
+
const newest = await newestVaultSeq(d.tx, POLICY);
|
|
2119
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
2120
|
+
if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
|
|
2121
|
+
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
2122
|
+
// Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
|
|
2123
|
+
// rows hide. An Owner of the whole instance alone writes it again, from the roles as they ship and nothing protected.
|
|
2124
|
+
if (!readable && !owner) throw refused('not_allowed', "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
|
|
2125
|
+
// Those the policy no longer holds stay out: a deleted environment's id, and an unwritten fork's past its window,
|
|
2126
|
+
// neither of which comes back, the window being one a fork writes its row well within.
|
|
2127
|
+
const next: PolicyEdits = {
|
|
2128
|
+
roles: { ...edits.roles },
|
|
2129
|
+
instanceRoles: { ...edits.instanceRoles },
|
|
2130
|
+
protected: edits.protected.filter((place) => before.protected.includes(place.environmentId)).map((place) => ({ ...place })),
|
|
2131
|
+
};
|
|
2132
|
+
if (change?.kind === 'role' || change?.kind === 'instance-role') {
|
|
2133
|
+
// Roles are the whole instance's: an Owner of all of it alone changes them (R1, R15), never through a scoped credential.
|
|
2134
|
+
if (!owner) throw refused('not_allowed', 'only an Owner of the whole instance edits roles');
|
|
2135
|
+
const instance = change.kind === 'instance-role';
|
|
2136
|
+
const fixed = instance ? FIXED_ROLES.instanceRoles.includes(change.role as InstanceRole) : FIXED_ROLES.roles.includes(change.role);
|
|
2137
|
+
if (typeof change.role !== 'string' || fixed) throw refused('invalid', `${change.role} is not edited: it holds ${change.role === 'member' ? 'nothing' : 'everything'}, always`);
|
|
2138
|
+
if (instance && !isInstanceRole(change.role)) throw refused('invalid', `no instance role ${change.role}: an Owner makes project roles`);
|
|
2139
|
+
if (!instance && !isBuiltinRole(change.role) && !ROLE_ID.test(change.role)) throw refused('invalid', 'a role is named by lowercase letters, digits and dashes, 2 to 32, starting with a letter');
|
|
2140
|
+
const table = instance ? next.instanceRoles as Record<string, RoleDefinition> : next.roles;
|
|
2141
|
+
if (change.definition === null) {
|
|
2142
|
+
// Put back as it ships, or, one an Owner made, gone: while no grant names it.
|
|
2143
|
+
if (!instance && !isBuiltinRole(change.role)) {
|
|
2144
|
+
if (table[change.role] === undefined) throw refused('invalid', `no role ${change.role}`);
|
|
2145
|
+
const held = await store.grantsNaming(d.tx, change.role);
|
|
2146
|
+
if (held > 0) throw refused('invalid', `${held === 1 ? '1 grant names' : `${held} grants name`} ${change.role}: revoke ${held === 1 ? 'it' : 'them'} first`);
|
|
2147
|
+
}
|
|
2148
|
+
// What it ships as is held to the same rule as any edit: a project-wide permission back on a role granted on environments.
|
|
2149
|
+
const shipped = instance ? undefined : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
|
|
2150
|
+
if (shipped !== undefined && shipped.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && (await store.grantsNaming(d.tx, change.role, { onEnvironments: true })) > 0) {
|
|
2151
|
+
throw refused('invalid', `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
|
|
2152
|
+
}
|
|
2153
|
+
delete table[change.role];
|
|
2154
|
+
} else {
|
|
2155
|
+
const problem = definitionProblem(change.definition);
|
|
2156
|
+
if (problem !== null) throw refused('invalid', problem);
|
|
2157
|
+
const definition = normalDefinition(change.definition);
|
|
2158
|
+
// A project role granted on an environment keeps no project-wide permission while any grant there names it.
|
|
2159
|
+
if (!instance && definition.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && (await store.grantsNaming(d.tx, change.role, { onEnvironments: true })) > 0) {
|
|
2160
|
+
throw refused('invalid', `${change.role} is granted on environments: a project-wide permission would make those grants incoherent`);
|
|
2161
|
+
}
|
|
2162
|
+
const shipped = instance ? INSTANCE_ROLES[change.role as InstanceRole] : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
|
|
2163
|
+
if (shipped !== undefined && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
|
|
2164
|
+
else table[change.role] = definition;
|
|
2165
|
+
}
|
|
2166
|
+
} else if (change?.kind === 'protect') {
|
|
2167
|
+
if (typeof change.projectId !== 'string' || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0
|
|
2168
|
+
|| change.environmentIds.some((id) => typeof id !== 'string' || !UUID.test(id)) || typeof change.protected !== 'boolean') {
|
|
2169
|
+
throw refused('invalid', 'protection names a project and some of its environments, by id, and whether they are protected');
|
|
2170
|
+
}
|
|
2171
|
+
if (!(await store.projectLive(d.tx, change.projectId))) throw refused('invalid', 'no such project');
|
|
2172
|
+
at.projectId = change.projectId;
|
|
2173
|
+
// Each a live environment of the project, or none yet: a fork's id, protected before its row is written.
|
|
2174
|
+
const found = await store.environmentsById(d.tx, change.environmentIds);
|
|
2175
|
+
if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused('invalid', 'each environment is one of the project\'s');
|
|
2176
|
+
if (change.environmentIds.length === 1 && found.has(change.environmentIds[0]!)) at.environmentId = change.environmentIds[0];
|
|
2177
|
+
if (change.protected) {
|
|
2178
|
+
if (!allows(acting.live, 'environment.manage', { projectId: change.projectId })) throw refused('not_allowed', 'protecting an environment takes managing its project\'s environments');
|
|
2179
|
+
const known = new Set(next.protected.map((place) => place.environmentId));
|
|
2180
|
+
next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
|
|
2181
|
+
.sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
|
|
2182
|
+
} else {
|
|
2183
|
+
// Unprotecting widens what people hold there: an Owner of the whole instance's to do (R15).
|
|
2184
|
+
if (!owner) throw refused('not_allowed', 'only an Owner of the whole instance unprotects an environment');
|
|
2185
|
+
next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
|
|
2186
|
+
}
|
|
2187
|
+
} else {
|
|
2188
|
+
throw refused('invalid', 'a change is to a role, an instance role, or protection');
|
|
2189
|
+
}
|
|
2190
|
+
// The policy now, and the newest change, which it is as of: this one's, when it changed anything.
|
|
2191
|
+
const answer = { policy: await livePolicy(d.tx, next, d.at), seq: newest === null ? null : Number(newest) };
|
|
2192
|
+
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
2193
|
+
const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
|
|
2194
|
+
d.log.push(made);
|
|
2195
|
+
d.after.push((seqOf) => (answer.seq = seqOf(made)));
|
|
2196
|
+
}
|
|
2197
|
+
return answer;
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
|
|
1934
2201
|
setSettings(input: SetSettingsInput): Promise<Outcome<{ settings: Settings }>> {
|
|
1935
2202
|
const { actor } = input;
|
|
1936
2203
|
const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
|
|
@@ -1951,7 +2218,6 @@ class VaultService implements Vault {
|
|
|
1951
2218
|
if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
|
|
1952
2219
|
if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
|
|
1953
2220
|
// Under the log's head, so two changes at once each name the one before.
|
|
1954
|
-
await lockLogHead(d.tx);
|
|
1955
2221
|
const previous = await this.#settingsAt(d.tx, d.reports);
|
|
1956
2222
|
const settings: Settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
|
|
1957
2223
|
if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry('allow', { ...settings, previous }));
|
|
@@ -2442,6 +2708,7 @@ function validateItems(items: readonly { secret: SecretRef; key?: string; wrappe
|
|
|
2442
2708
|
if (field === 'key' && typeof key !== 'string') throw new Error('DEK must be base64');
|
|
2443
2709
|
if (field === 'wrapped' && (wrapped === undefined || wrapped === null)) throw new Error('wrapped key is required');
|
|
2444
2710
|
checkContext(context(secret));
|
|
2711
|
+
if (secret.sensitive !== undefined && typeof secret.sensitive !== 'boolean') throw new Error('a secret is sensitive or not: true, false or absent');
|
|
2445
2712
|
if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error('secret version must be a positive integer');
|
|
2446
2713
|
if (typeof secret.path !== 'string' || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error('secret path must be a well-formed string');
|
|
2447
2714
|
if (key !== undefined) {
|
|
@@ -2578,10 +2845,13 @@ function personalRefusal(
|
|
|
2578
2845
|
if (own?.owner == null && held?.owner == null) return undefined;
|
|
2579
2846
|
if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
|
|
2580
2847
|
const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
|
|
2581
|
-
|
|
2848
|
+
// Its owner reads what they wrote while they read or use the environment it configures (R8).
|
|
2849
|
+
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
2850
|
+
const refused = refuses(reader, 'secret.use', parent, gone) === null ? null : refuses(reader, 'secret.read', parent, gone);
|
|
2582
2851
|
if (refused !== null) return refused;
|
|
2583
|
-
// The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key.
|
|
2584
|
-
|
|
2852
|
+
// The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key. Reading their
|
|
2853
|
+
// own values takes use there: a Contributor's handle reads what its owner wrote, and nothing of the parent's.
|
|
2854
|
+
return withinCredential(reader.live, permission === 'secret.read' ? 'secret.use' : permission, parent) ? null : 'no_grant';
|
|
2585
2855
|
}
|
|
2586
2856
|
|
|
2587
2857
|
function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
|
|
@@ -2781,9 +3051,71 @@ function iso(ms: number): string {
|
|
|
2781
3051
|
return new Date(ms).toISOString();
|
|
2782
3052
|
}
|
|
2783
3053
|
|
|
2784
|
-
/** A
|
|
3054
|
+
/** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
|
|
3055
|
+
function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
|
|
3056
|
+
const edits = stored.policy as Partial<PolicyEdits> | undefined;
|
|
3057
|
+
if (typeof edits !== 'object' || edits === null) return null;
|
|
3058
|
+
const { roles, instanceRoles, protected: protectedIds } = edits;
|
|
3059
|
+
if (typeof roles !== 'object' || roles === null || typeof instanceRoles !== 'object' || instanceRoles === null || !Array.isArray(protectedIds)) return null;
|
|
3060
|
+
const defined = (table: object) => Object.values(table).every((definition) => definitionProblem(definition as RoleDefinition) === null);
|
|
3061
|
+
if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
|
|
3062
|
+
if (protectedIds.some((place) => typeof place !== 'object' || place === null || !UUID.test((place as ProtectedPlace).projectId) || !UUID.test((place as ProtectedPlace).environmentId)
|
|
3063
|
+
|| !Number.isSafeInteger((place as ProtectedPlace).at))) return null;
|
|
3064
|
+
return { roles: roles as Record<string, RoleDefinition>, instanceRoles: instanceRoles as PolicyEdits['instanceRoles'], protected: protectedIds as ProtectedPlace[] };
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
/**
|
|
3068
|
+
* The policy `edits` say as of `at`: the roles as they ship, each edited
|
|
3069
|
+
* one replaced and each made one added; and the protected environments, but
|
|
3070
|
+
* for those deleted since, the project around one's included: a deleted
|
|
3071
|
+
* prod protects nothing, and holds nobody back from giving a service
|
|
3072
|
+
* account a role on all of its project (`givesService`). A fork's id with
|
|
3073
|
+
* no row yet stays, protected before it is written, ten minutes: one with
|
|
3074
|
+
* none after that is a fork refused or abandoned, never written. Each
|
|
3075
|
+
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3076
|
+
* keep them all: no write is needed.
|
|
3077
|
+
*/
|
|
3078
|
+
async function livePolicy(db: Queryable, edits: PolicyEdits, at: number): Promise<Policy> {
|
|
3079
|
+
const rows = await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
3080
|
+
const live = edits.protected.filter((place) => {
|
|
3081
|
+
const row = rows.get(place.environmentId);
|
|
3082
|
+
return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|
|
3083
|
+
});
|
|
3084
|
+
const slugOf = (place: ProtectedPlace) => rows.get(place.environmentId)?.slug ?? null;
|
|
3085
|
+
return {
|
|
3086
|
+
roles: { ...ROLES, ...edits.roles },
|
|
3087
|
+
instanceRoles: { ...INSTANCE_ROLES, ...edits.instanceRoles },
|
|
3088
|
+
protected: live.map((place) => place.environmentId),
|
|
3089
|
+
protectedIn: live.reduce<Record<string, { environmentId: string; slug: string | null }[]>>(
|
|
3090
|
+
(byProject, place) => ({ ...byProject, [place.projectId]: [...(byProject[place.projectId] ?? []), { environmentId: place.environmentId, slug: slugOf(place) }] }),
|
|
3091
|
+
{},
|
|
3092
|
+
),
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
/** A change, as its entry names it. */
|
|
3097
|
+
function describeChange(change: PolicyChange | undefined): Record<string, unknown> | null {
|
|
3098
|
+
if (change === undefined || change === null || typeof change !== 'object') return null;
|
|
3099
|
+
if (change.kind === 'protect') return { kind: 'protect', projectId: change.projectId, environmentIds: change.environmentIds, protected: change.protected };
|
|
3100
|
+
return { kind: change.kind, role: change.role, definition: change.definition };
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
/** What a change changed, as it was: the role's definition, or which environments were protected. */
|
|
3104
|
+
function describeBefore(change: PolicyChange, before: Policy): Record<string, unknown> {
|
|
3105
|
+
if (change.kind === 'protect') return { protected: change.environmentIds.filter((id) => before.protected.includes(id)) };
|
|
3106
|
+
const table = change.kind === 'instance-role' ? before.instanceRoles as Record<string, RoleDefinition> : before.roles;
|
|
3107
|
+
return { definition: Object.hasOwn(table, change.role) ? table[change.role] : null };
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
/** A secret's binding context: its ids, a personal config's owner, hashed, as the decision read it, and whether it is sensitive. */
|
|
2785
3111
|
function context(secret: SecretRef, owner?: string): SecretContext {
|
|
2786
|
-
return {
|
|
3112
|
+
return {
|
|
3113
|
+
projectId: secret.projectId,
|
|
3114
|
+
environmentId: secret.environmentId,
|
|
3115
|
+
secretId: secret.secretId,
|
|
3116
|
+
...(owner === undefined ? {} : { owner }),
|
|
3117
|
+
...(secret.sensitive === true ? { sensitive: true as const } : {}),
|
|
3118
|
+
};
|
|
2787
3119
|
}
|
|
2788
3120
|
|
|
2789
3121
|
function unwrappable(wrapped: WrappedKey) {
|