@coffre/vault 0.4.6 → 0.5.1
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 -2
- package/dist/cloudflare.js +7 -1
- package/dist/node.js +3 -1
- package/dist/{vault-CeKwkzSV.js → vault-CP8yU8xq.js} +486 -129
- package/package.json +3 -3
- package/src/cloudflare.ts +3 -0
- package/src/local.ts +2 -0
- package/src/replay.ts +20 -7
- package/src/rows.ts +17 -3
- package/src/store.ts +65 -48
- package/src/vault.ts +373 -94
package/src/vault.ts
CHANGED
|
@@ -3,15 +3,27 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import {
|
|
4
4
|
allows,
|
|
5
5
|
assignableToEnvironment,
|
|
6
|
-
|
|
6
|
+
convertEveryProjectGrants,
|
|
7
|
+
EVERYWHERE,
|
|
8
|
+
givesService,
|
|
7
9
|
grantKind,
|
|
10
|
+
isCredentialScope,
|
|
11
|
+
isInstanceRole,
|
|
8
12
|
isRole,
|
|
13
|
+
isScope,
|
|
14
|
+
managesService,
|
|
9
15
|
mayManageAccess,
|
|
10
|
-
|
|
16
|
+
normalScope,
|
|
17
|
+
NOTHING,
|
|
18
|
+
runsInstance,
|
|
19
|
+
setsUpServices,
|
|
11
20
|
type Holdings,
|
|
21
|
+
type InstanceRole,
|
|
12
22
|
type Permission,
|
|
13
23
|
type Place,
|
|
14
24
|
type Role,
|
|
25
|
+
type Scope,
|
|
26
|
+
type ServiceAccount,
|
|
15
27
|
} from '@coffre/core/access';
|
|
16
28
|
import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
17
29
|
import { checkContext, type SecretContext } from '@coffre/core/envelope';
|
|
@@ -53,6 +65,9 @@ import {
|
|
|
53
65
|
type RewrapInput,
|
|
54
66
|
type SecretRef,
|
|
55
67
|
type SetAccessInput,
|
|
68
|
+
type SetSettingsInput,
|
|
69
|
+
type Settings,
|
|
70
|
+
type Through,
|
|
56
71
|
type UnwrapInput,
|
|
57
72
|
type Vault,
|
|
58
73
|
type Via,
|
|
@@ -62,14 +77,15 @@ import {
|
|
|
62
77
|
} from '@coffre/core/vault';
|
|
63
78
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
64
79
|
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
65
|
-
import {
|
|
80
|
+
import { newestAccess, reportStands } from '@coffre/db/grants';
|
|
81
|
+
import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
66
82
|
|
|
67
83
|
import { KeyAccounting } from './accounting.ts';
|
|
68
84
|
import { signer, type Signer } from './checkpoint.ts';
|
|
69
85
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
70
86
|
import { carries, forward, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, withCause, type Anchor } from './log.ts';
|
|
71
87
|
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
72
|
-
import { memberMac, rowKey, sameGrants, sealed } from './rows.ts';
|
|
88
|
+
import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
|
|
73
89
|
import * as store from './store.ts';
|
|
74
90
|
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
75
91
|
|
|
@@ -120,6 +136,8 @@ export type PreparedVault = {
|
|
|
120
136
|
kekChecked: boolean;
|
|
121
137
|
/** Why not, once decided that they do not. */
|
|
122
138
|
wrongKek: string | null;
|
|
139
|
+
/** Whether this process has replaced the grants on every project of 0.4 there were when it started (`#convert`). */
|
|
140
|
+
converted: boolean;
|
|
123
141
|
};
|
|
124
142
|
|
|
125
143
|
export async function prepareVault(config: ResolvedVaultConfig, options: VaultOptions = {}): Promise<PreparedVault> {
|
|
@@ -134,6 +152,7 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
134
152
|
superseded: null,
|
|
135
153
|
kekChecked: false,
|
|
136
154
|
wrongKek: null,
|
|
155
|
+
converted: false,
|
|
137
156
|
};
|
|
138
157
|
}
|
|
139
158
|
|
|
@@ -159,6 +178,10 @@ const LOCK_TIMEOUT_MS = 15_000;
|
|
|
159
178
|
/** A member: `user:<email>` or `token:<name>`. */
|
|
160
179
|
const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
161
180
|
|
|
181
|
+
/** A person, and a service account: only a person sets one up (`givesService`). */
|
|
182
|
+
const PERSON = /^user:/;
|
|
183
|
+
const SERVICE = /^token:/;
|
|
184
|
+
|
|
162
185
|
/** Who acts for the vault itself, as when it gives a root admin a member row. */
|
|
163
186
|
const VAULT_ACTOR = 'system:vault';
|
|
164
187
|
|
|
@@ -183,6 +206,18 @@ const TAMPERED_SUBJECT = "this member's record failed the vault's integrity chec
|
|
|
183
206
|
/** The action of the vault's entry that signs a prefix of the log. */
|
|
184
207
|
const CHECKPOINT = 'audit.checkpoint';
|
|
185
208
|
|
|
209
|
+
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
210
|
+
const SETTINGS = 'settings.change';
|
|
211
|
+
|
|
212
|
+
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
213
|
+
const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
|
|
214
|
+
|
|
215
|
+
/** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
|
|
216
|
+
const CLOSED_SETTINGS: Settings = { serviceAccounts: { projects: { only: [] }, environments: 'all' } };
|
|
217
|
+
|
|
218
|
+
/** Why a person's change to a service account is refused. */
|
|
219
|
+
const NOT_THEIRS = 'a person gives a service account at most what they hold, where the instance lets them set one up, and only to one whose every grant they hold';
|
|
220
|
+
|
|
186
221
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
187
222
|
const SCHEDULER = 'system:coffre-scheduler';
|
|
188
223
|
|
|
@@ -283,6 +318,17 @@ type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
|
283
318
|
/** What ties an entry to the app's request, and to the one action it is part of. */
|
|
284
319
|
type Correlation = { requestId?: string | null; operationId?: string | null; credentialId?: string | null };
|
|
285
320
|
|
|
321
|
+
/**
|
|
322
|
+
* What the one asking holds through the credential their request came in
|
|
323
|
+
* on: no more than its scope (`allows`). A scope that is not one is a bug
|
|
324
|
+
* of the app's, which fails the call.
|
|
325
|
+
*/
|
|
326
|
+
function through(standing: Standing, { credentialScope }: Through): Standing {
|
|
327
|
+
if (credentialScope == null) return standing;
|
|
328
|
+
if (!isCredentialScope(credentialScope)) throw new Error('credentialScope must be a list of grants, one a place');
|
|
329
|
+
return { ...standing, live: { ...standing.live, credentialScope }, all: { ...standing.all, credentialScope } };
|
|
330
|
+
}
|
|
331
|
+
|
|
286
332
|
/** The credential a CI run's request came in on, as its entries record it: copied, never decided on. */
|
|
287
333
|
function traced(correlation: Correlation): { credentialId?: string } {
|
|
288
334
|
return correlation.credentialId == null ? {} : { credentialId: correlation.credentialId };
|
|
@@ -345,6 +391,11 @@ class VaultService implements Vault {
|
|
|
345
391
|
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
|
|
346
392
|
const superseded = await this.#settled();
|
|
347
393
|
if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
|
|
394
|
+
return this.#decideNow(principals, decide);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** `#decide`, once the vault is settled. */
|
|
398
|
+
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
|
|
348
399
|
const reports: NewEntry[] = [];
|
|
349
400
|
for (let attempt = 1; ; attempt += 1) {
|
|
350
401
|
try {
|
|
@@ -436,22 +487,28 @@ class VaultService implements Vault {
|
|
|
436
487
|
this.#report(reports, principal, 'mac', row.mac.toString('hex'));
|
|
437
488
|
return 'mac';
|
|
438
489
|
}
|
|
439
|
-
const newest = await this.#
|
|
440
|
-
// No row, and no entry: someone never admitted. No
|
|
441
|
-
// deleted, unless it was admitted since the row was
|
|
442
|
-
|
|
490
|
+
const newest = await this.#newestAccess(db, principal, reports);
|
|
491
|
+
// No row, and no entry of the vault's own: someone never admitted. No
|
|
492
|
+
// row, but one: one deleted, unless it was admitted since the row was
|
|
493
|
+
// read. Too many forged to see past: neither a row nor the lack of one holds.
|
|
494
|
+
const seq = typeof newest !== 'string' ? newest.seq : newest === 'unreached' ? undefined : null;
|
|
495
|
+
if (row === undefined ? seq === null : seq === row.accessSeq) return null;
|
|
443
496
|
if (row === undefined && (await store.member(db, principal)) !== undefined) throw new Retry();
|
|
444
|
-
this.#report(reports, principal, 'stale', `${row?.accessSeq ?? 'none'}<${newest}`);
|
|
497
|
+
this.#report(reports, principal, 'stale', `${row?.accessSeq ?? 'none'}<${seq ?? newest}`);
|
|
445
498
|
return 'stale';
|
|
446
499
|
}
|
|
447
500
|
|
|
448
|
-
/** The
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
501
|
+
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
502
|
+
#newestAccess(db: Queryable, principal: string, reports: NewEntry[]): Promise<Newest> {
|
|
503
|
+
return newestAccess(db, principal, this.#check(reports, principal));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
|
|
507
|
+
#check(reports: NewEntry[], principal: string | null): Authenticity {
|
|
508
|
+
return {
|
|
509
|
+
authentic: (entry) => this.#authentic(entry),
|
|
510
|
+
forged: (entry) => this.#report(reports, principal, 'forged_entry', String(entry.seq), entry.seq),
|
|
511
|
+
};
|
|
455
512
|
}
|
|
456
513
|
|
|
457
514
|
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
@@ -472,7 +529,7 @@ class VaultService implements Vault {
|
|
|
472
529
|
}
|
|
473
530
|
|
|
474
531
|
/** A `vault.tampered` entry, once per process for each thing found: `#reported` marks it once committed. */
|
|
475
|
-
#report(reports: NewEntry[], principal: string, code: Fault | 'forged_entry', detail: string, relatedSeq: bigint | null = null): void {
|
|
532
|
+
#report(reports: NewEntry[], principal: string | null, code: Fault | 'forged_entry', detail: string, relatedSeq: bigint | null = null): void {
|
|
476
533
|
const key = `${principal}|${code}|${detail}`;
|
|
477
534
|
if (this.#prepared.reported.has(key) || reports.some((entry) => REPORTED.get(entry) === key)) return;
|
|
478
535
|
const entry: NewEntry = {
|
|
@@ -516,9 +573,109 @@ class VaultService implements Vault {
|
|
|
516
573
|
// database; calls that race settle it alike, and a rotation they race
|
|
517
574
|
// to write goes in once (`#rotate`).
|
|
518
575
|
if (prepared.since === null && prepared.superseded === null) await this.#settle();
|
|
576
|
+
if (prepared.superseded === null && !prepared.converted) await this.#convert();
|
|
519
577
|
return prepared.superseded;
|
|
520
578
|
}
|
|
521
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Replace every grant on every project of 0.4 with what 0.5 has instead
|
|
582
|
+
* (`convertEveryProjectGrants` in @coffre/core/access): a person's
|
|
583
|
+
* instance role and its scope, and project grants on the projects there
|
|
584
|
+
* are now. Once per process, before its first call does anything else:
|
|
585
|
+
* one decision per member who holds any, as `system:vault`, each grant it
|
|
586
|
+
* replaces an `access.revoke` that says what became of it. Another
|
|
587
|
+
* process that converts the same member at once finds nothing left under
|
|
588
|
+
* their lock. A member whose row fails its check is left as they are, and
|
|
589
|
+
* so is one the vault has reported changed around it since the last
|
|
590
|
+
* change to what they hold, even if their row checks out again now (a
|
|
591
|
+
* genuine row put back). `reportStands` is the rule, which `coffre
|
|
592
|
+
* migrate` applies too, holding no key, to every entry in the vault's
|
|
593
|
+
* name: it lists and marks a member so reported, forged or not.
|
|
594
|
+
* Removing them clears both.
|
|
595
|
+
*/
|
|
596
|
+
async #convert(): Promise<void> {
|
|
597
|
+
for (const principal of await store.everyProjectHolders(this.#db)) {
|
|
598
|
+
if (this.#isRootAdmin(principal)) continue;
|
|
599
|
+
await this.#decideNow([principal], (d) => this.#replaceEveryProject(d, principal));
|
|
600
|
+
}
|
|
601
|
+
this.#prepared.converted = true;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
|
|
605
|
+
const row = d.members.get(principal);
|
|
606
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
607
|
+
if (row === undefined || subject.status !== 'active') return;
|
|
608
|
+
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
609
|
+
if (everyProject.length === 0) return;
|
|
610
|
+
if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
|
|
611
|
+
// The projects there are now, read under the log's head, which a deletion holds.
|
|
612
|
+
await lockLogHead(d.tx);
|
|
613
|
+
const before = roleOf(principal, row);
|
|
614
|
+
const conversion = convertEveryProjectGrants({
|
|
615
|
+
person: principal.startsWith('user:'),
|
|
616
|
+
role: before.role,
|
|
617
|
+
everyProject: everyProject.filter((grant) => live(grant, d.at)).map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as Role, expiresAt: grant.expiresAt })),
|
|
618
|
+
grants: subject.stored
|
|
619
|
+
.filter((grant) => grantKind(grant) !== null && live(grant, d.at))
|
|
620
|
+
.map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as Role, expiresAt: grant.expiresAt })),
|
|
621
|
+
projects: await store.liveProjects(d.tx),
|
|
622
|
+
});
|
|
623
|
+
const after = { role: conversion.role, scope: conversion.scope };
|
|
624
|
+
const replacedBy = { role: after.role, scope: after.scope, grants: conversion.grants.length };
|
|
625
|
+
d.grants.set(principal, [...subject.stored]);
|
|
626
|
+
d.touched.add(principal);
|
|
627
|
+
const correlation = { operationId: randomUUID() };
|
|
628
|
+
const drop = (grant: GrantRow) =>
|
|
629
|
+
d.writes.push(async () => {
|
|
630
|
+
await store.deleteGrant(d.tx, principal, grant);
|
|
631
|
+
d.grants.set(principal, d.grants.get(principal)!.filter((held) => held !== grant));
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
for (const grant of everyProject) {
|
|
635
|
+
drop(grant);
|
|
636
|
+
// A lapsed one goes with no entry: it changes nothing anyone holds.
|
|
637
|
+
if (!live(grant, d.at)) continue;
|
|
638
|
+
const narrowed = conversion.narrowed.filter((loss) => loss.grant.environmentSlug === grant.environmentSlug);
|
|
639
|
+
d.log.push(accessEntry(VAULT_ACTOR, 'access.revoke', principal, 'allow', correlation, {
|
|
640
|
+
role: null,
|
|
641
|
+
expiresAt: null,
|
|
642
|
+
previousRole: grant.role,
|
|
643
|
+
...located(grant).detail,
|
|
644
|
+
replacedBy,
|
|
645
|
+
...(narrowed.length === 0 ? {} : { narrowed: narrowed.map(({ grant: _, ...loss }) => loss) }),
|
|
646
|
+
}));
|
|
647
|
+
}
|
|
648
|
+
if (JSON.stringify(standing(after)) !== JSON.stringify(standing(before))) {
|
|
649
|
+
d.log.push(accessEntry(VAULT_ACTOR, 'member.role', principal, 'allow', correlation, {
|
|
650
|
+
...after,
|
|
651
|
+
previousRole: before.role,
|
|
652
|
+
previousScope: before.scope,
|
|
653
|
+
reason: 'every-project',
|
|
654
|
+
}));
|
|
655
|
+
d.writes.push(() => store.updateMember(d.tx, principal, after));
|
|
656
|
+
}
|
|
657
|
+
for (const given of conversion.grants) {
|
|
658
|
+
const place = { projectId: given.projectId, environmentId: given.environmentId, environmentSlug: null };
|
|
659
|
+
// Whatever they held there, lapsed or not, makes way: the conversion never gives less there than it was.
|
|
660
|
+
const there = subject.stored.find((grant) => grantKind(grant) !== null && placeKey(grant) === placeKey(place));
|
|
661
|
+
if (there !== undefined) drop(there);
|
|
662
|
+
d.writes.push(async (at) => {
|
|
663
|
+
const grant = { principal, ...place, role: given.role, expiresAt: given.expiresAt, grantedAt: at, grantedBy: VAULT_ACTOR };
|
|
664
|
+
await store.insertGrant(d.tx, grant);
|
|
665
|
+
d.grants.get(principal)!.push(grant);
|
|
666
|
+
});
|
|
667
|
+
d.log.push({
|
|
668
|
+
...accessEntry(VAULT_ACTOR, 'access.grant', principal, 'allow', correlation, {
|
|
669
|
+
role: given.role,
|
|
670
|
+
expiresAt: given.expiresAt === null ? null : iso(given.expiresAt),
|
|
671
|
+
previousRole: given.replaces,
|
|
672
|
+
reason: 'every-project',
|
|
673
|
+
}),
|
|
674
|
+
...located(place).ids,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
522
679
|
/**
|
|
523
680
|
* Which of its keys count where. Those it replaced, from the KEKs in
|
|
524
681
|
* previousKeks, verify only what came before its first entry under its
|
|
@@ -824,7 +981,7 @@ class VaultService implements Vault {
|
|
|
824
981
|
vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
|
|
825
982
|
remote: boolean;
|
|
826
983
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
827
|
-
input: Correlation & { purpose?: string };
|
|
984
|
+
input: Correlation & Through & { purpose?: string };
|
|
828
985
|
},
|
|
829
986
|
/** One per secret; null for a bad claim, an error for an outage or an unexpected fault. */
|
|
830
987
|
operations: () => ((operation: KeyOperation) => Promise<T | null>)[],
|
|
@@ -840,7 +997,7 @@ class VaultService implements Vault {
|
|
|
840
997
|
}
|
|
841
998
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
842
999
|
const check = async (d: Decision) => {
|
|
843
|
-
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1000
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
844
1001
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
845
1002
|
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
846
1003
|
// matches the slug there, and a place deleted on either side opens for none.
|
|
@@ -1020,7 +1177,7 @@ class VaultService implements Vault {
|
|
|
1020
1177
|
};
|
|
1021
1178
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1022
1179
|
return this.#decide([principal], async (d) => {
|
|
1023
|
-
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1180
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1024
1181
|
// Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
|
|
1025
1182
|
const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
|
|
1026
1183
|
const source = sources.get(item.source.secretId);
|
|
@@ -1105,7 +1262,7 @@ class VaultService implements Vault {
|
|
|
1105
1262
|
return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
|
|
1106
1263
|
};
|
|
1107
1264
|
return this.#decide([principal], async (d) => {
|
|
1108
|
-
const actor = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1265
|
+
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1109
1266
|
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1110
1267
|
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1111
1268
|
const codes: (RefusalCode | null)[] = seals.map((seal) => {
|
|
@@ -1181,14 +1338,15 @@ class VaultService implements Vault {
|
|
|
1181
1338
|
action: 'member.add',
|
|
1182
1339
|
decision: 'allow',
|
|
1183
1340
|
subjectPrincipal: principal,
|
|
1184
|
-
metadata: JSON.stringify({
|
|
1341
|
+
metadata: JSON.stringify({ role: 'member', rootAdmin: true }),
|
|
1185
1342
|
},
|
|
1186
1343
|
]);
|
|
1187
1344
|
const { occurredAt: at, seqStart } = appended;
|
|
1188
1345
|
const row = {
|
|
1189
1346
|
principal,
|
|
1190
1347
|
status: 'active' as const,
|
|
1191
|
-
|
|
1348
|
+
role: 'member' as const,
|
|
1349
|
+
scope: EVERYWHERE,
|
|
1192
1350
|
generation: 0,
|
|
1193
1351
|
createdAt: at,
|
|
1194
1352
|
createdBy: VAULT_ACTOR,
|
|
@@ -1207,9 +1365,9 @@ class VaultService implements Vault {
|
|
|
1207
1365
|
* configuration, which no row can change.
|
|
1208
1366
|
*/
|
|
1209
1367
|
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
|
|
1210
|
-
const none =
|
|
1368
|
+
const none = NOTHING;
|
|
1211
1369
|
if (this.#isRootAdmin(principal)) {
|
|
1212
|
-
const root = { isRootAdmin: true,
|
|
1370
|
+
const root: Holdings = { isRootAdmin: true, role: 'owner', scope: EVERYWHERE, grants: [] };
|
|
1213
1371
|
return { principal, status: 'active', live: root, all: root, fault: null, stored: [] };
|
|
1214
1372
|
}
|
|
1215
1373
|
const grants = row === undefined ? [] : await store.grants(db, principal);
|
|
@@ -1218,12 +1376,12 @@ class VaultService implements Vault {
|
|
|
1218
1376
|
if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
|
|
1219
1377
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
1220
1378
|
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
1221
|
-
const
|
|
1379
|
+
const { role, scope } = roleOf(principal, row);
|
|
1222
1380
|
return {
|
|
1223
1381
|
principal,
|
|
1224
1382
|
status: 'active',
|
|
1225
|
-
live: { isRootAdmin: false,
|
|
1226
|
-
all: { isRootAdmin: false,
|
|
1383
|
+
live: { isRootAdmin: false, role, scope, grants: held.filter((grant) => live(grant, at)) },
|
|
1384
|
+
all: { isRootAdmin: false, role, scope, grants: held },
|
|
1227
1385
|
fault,
|
|
1228
1386
|
stored: grants,
|
|
1229
1387
|
};
|
|
@@ -1231,7 +1389,7 @@ class VaultService implements Vault {
|
|
|
1231
1389
|
|
|
1232
1390
|
#access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean): Access {
|
|
1233
1391
|
if (this.#isRootAdmin(principal)) {
|
|
1234
|
-
return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true,
|
|
1392
|
+
return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true, role: 'owner', scope: EVERYWHERE, grants: [], since: null, by: null };
|
|
1235
1393
|
}
|
|
1236
1394
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
|
|
1237
1395
|
return {
|
|
@@ -1239,8 +1397,8 @@ class VaultService implements Vault {
|
|
|
1239
1397
|
status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
|
|
1240
1398
|
generation: row?.generation ?? 0,
|
|
1241
1399
|
isRootAdmin: false,
|
|
1242
|
-
|
|
1243
|
-
grants: active ? held.filter((grant) => live(grant, at)).map(view) : [],
|
|
1400
|
+
...(active ? roleOf(principal, row) : { role: 'member', scope: EVERYWHERE }),
|
|
1401
|
+
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
1244
1402
|
since: row ? iso(row.statusChangedAt) : null,
|
|
1245
1403
|
by: row?.statusChangedBy ?? null,
|
|
1246
1404
|
};
|
|
@@ -1302,7 +1460,7 @@ class VaultService implements Vault {
|
|
|
1302
1460
|
|
|
1303
1461
|
setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>> {
|
|
1304
1462
|
const { actor, principal } = input;
|
|
1305
|
-
const changes = input.changes.map((
|
|
1463
|
+
const changes = input.changes.map(({ projectId, environmentId, role, expiresAt }) => ({ projectId, environmentId, environmentSlug: null, role, expiresAt }));
|
|
1306
1464
|
const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
|
|
1307
1465
|
// One place refused is shown where it is, to whoever reads that place's log; an invalid one may be no place at all.
|
|
1308
1466
|
const [only] = changes.length === 1 ? changes : [];
|
|
@@ -1313,54 +1471,76 @@ class VaultService implements Vault {
|
|
|
1313
1471
|
...where.ids,
|
|
1314
1472
|
}]);
|
|
1315
1473
|
};
|
|
1316
|
-
const onProjects = changes.flatMap(({ projectId, environmentId, role }) => (projectId === null ? [] : [{ projectId, environmentId, role }]));
|
|
1317
1474
|
return this.#decide([actor, principal], async (d) => {
|
|
1318
1475
|
validateCorrelation(input);
|
|
1319
1476
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
|
|
1320
1477
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1321
1478
|
const places = new Set<string>();
|
|
1322
1479
|
for (const change of changes) {
|
|
1323
|
-
|
|
1324
|
-
|
|
1480
|
+
if (grantKind(change) === null) {
|
|
1481
|
+
throw refused('invalid', change.projectId === null && change.environmentId === null
|
|
1482
|
+
? 'grants are on a project or an environment: a person reaches every project by their instance role'
|
|
1483
|
+
: 'an environment is named in its project');
|
|
1484
|
+
}
|
|
1325
1485
|
const key = placeKey(change);
|
|
1326
1486
|
if (places.has(key)) throw refused('invalid', 'each place may be changed once per call');
|
|
1327
1487
|
places.add(key);
|
|
1328
1488
|
if (change.role !== null && !isRole(change.role)) throw refused('invalid', `no such role: ${change.role}`);
|
|
1329
|
-
if (change.role !== null &&
|
|
1330
|
-
throw refused('invalid', `${change.role} can only be granted on a project
|
|
1489
|
+
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) {
|
|
1490
|
+
throw refused('invalid', `${change.role} can only be granted on a project`);
|
|
1331
1491
|
}
|
|
1332
1492
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1333
1493
|
if (Number.isNaN(expiresAt) || (expiresAt !== null && expiresAt <= d.at)) {
|
|
1334
1494
|
throw refused('invalid', 'an end date must be in the future');
|
|
1335
1495
|
}
|
|
1336
1496
|
}
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
|
|
1497
|
+
// A project or an environment must be there, and to be granted, not
|
|
1498
|
+
// deleted. Read under the log's head, which a deletion holds while it
|
|
1499
|
+
// renames the place, so the place is as it commits.
|
|
1500
|
+
await lockLogHead(d.tx);
|
|
1341
1501
|
const known = await store.places(
|
|
1342
1502
|
d.tx,
|
|
1343
|
-
|
|
1344
|
-
|
|
1503
|
+
changes.map((change) => change.projectId!),
|
|
1504
|
+
changes.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
|
|
1345
1505
|
);
|
|
1346
|
-
|
|
1347
|
-
|
|
1506
|
+
const where: Place[] = changes.map(({ projectId, environmentId, role }) => {
|
|
1507
|
+
const environment = environmentId === null ? undefined : known.environments.get(environmentId);
|
|
1508
|
+
if (!known.projects.has(projectId!) || (environmentId !== null && environment?.projectId !== projectId)) {
|
|
1348
1509
|
throw refused('invalid', `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
|
|
1349
1510
|
}
|
|
1350
1511
|
// 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)))) {
|
|
1512
|
+
if (role !== null && (known.deleted.has(projectId!) || (environmentId !== null && known.deleted.has(environmentId)))) {
|
|
1352
1513
|
throw refused('deleted');
|
|
1353
1514
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1515
|
+
// An environment by its slug as the store has it, which scopes match.
|
|
1516
|
+
return environmentId === null ? { projectId: projectId! } : { projectId: projectId!, environmentId, environmentSlug: environment!.slug };
|
|
1517
|
+
});
|
|
1518
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1356
1519
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1357
|
-
|
|
1520
|
+
// Where they manage access, anything; otherwise only a person to a service account (`givesService`).
|
|
1521
|
+
const manages = where.every((place) => mayManageAccess(acting.live, place));
|
|
1522
|
+
if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused('not_allowed');
|
|
1523
|
+
// Managing access never reads a value: nobody gives themselves a role, though they may give one up.
|
|
1524
|
+
if (actor === principal && !acting.live.isRootAdmin && changes.some((change) => change.role !== null)) {
|
|
1525
|
+
throw refused('not_allowed', 'nobody grants themselves a role: ask another admin');
|
|
1526
|
+
}
|
|
1358
1527
|
|
|
1359
1528
|
const row = d.members.get(principal);
|
|
1360
1529
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1361
1530
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1362
1531
|
if (row?.status === 'removed') throw refused('removed');
|
|
1363
1532
|
if (row === undefined) throw refused('not_a_member');
|
|
1533
|
+
if (!manages) {
|
|
1534
|
+
// The setting as of the log's head, which a change to it takes too.
|
|
1535
|
+
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
1536
|
+
const account = await this.#account(d.tx, subject, row);
|
|
1537
|
+
// Each place's new role they must hold, and what it held before: taking a grant away is changing it.
|
|
1538
|
+
const gives = changes.every((change, i) => {
|
|
1539
|
+
const before = subject.live.grants.find((grant) => placeKey({ ...grant, environmentSlug: null }) === placeKey(change));
|
|
1540
|
+
return [change.role, before?.role].every((role) => role == null || givesService(acting.live, setting, role, where[i]));
|
|
1541
|
+
});
|
|
1542
|
+
if (!gives || !managesService(acting.live, setting, actor, account)) throw refused('not_allowed', NOT_THEIRS);
|
|
1543
|
+
}
|
|
1364
1544
|
// The grants the check verified, not a second read: what the decision changes, and seals.
|
|
1365
1545
|
const held = subject.stored;
|
|
1366
1546
|
d.grants.set(principal, [...held]);
|
|
@@ -1376,7 +1556,7 @@ class VaultService implements Vault {
|
|
|
1376
1556
|
actor: string,
|
|
1377
1557
|
principal: string,
|
|
1378
1558
|
held: readonly GrantRow[],
|
|
1379
|
-
change: GrantChange &
|
|
1559
|
+
change: GrantChange & store.Place,
|
|
1380
1560
|
correlation: Correlation,
|
|
1381
1561
|
): AccessChange {
|
|
1382
1562
|
const existing = held.find((grant) => placeKey(grant) === placeKey(change));
|
|
@@ -1421,40 +1601,57 @@ class VaultService implements Vault {
|
|
|
1421
1601
|
return current === undefined ? 'created' : 'updated';
|
|
1422
1602
|
}
|
|
1423
1603
|
|
|
1424
|
-
admit(input: AdmitInput): Promise<Outcome<{ created: boolean;
|
|
1604
|
+
admit(input: AdmitInput): Promise<Outcome<{ created: boolean; role: InstanceRole; scope: Scope; generation: number }>> {
|
|
1425
1605
|
const { actor, principal } = input;
|
|
1606
|
+
const asked = input.role === undefined ? {} : { role: input.role, scope: input.scope ?? EVERYWHERE };
|
|
1426
1607
|
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
1427
1608
|
new Refused(refusal(code, message), [
|
|
1428
|
-
accessEntry(actor, 'member.add', principal, 'deny', input,
|
|
1609
|
+
accessEntry(actor, 'member.add', principal, 'deny', input, asked, code),
|
|
1429
1610
|
]);
|
|
1430
1611
|
return this.#decide([actor, principal], async (d) => {
|
|
1431
1612
|
validateCorrelation(input);
|
|
1432
|
-
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1613
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1433
1614
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1434
|
-
if (!acting.live
|
|
1615
|
+
if (!runsInstance(acting.live)) {
|
|
1616
|
+
// A service account, with no grant yet, any person who could give it one sets up (`setsUpServices`).
|
|
1617
|
+
const service = SERVICE.test(principal) && (input.role ?? 'member') === 'member' && input.scope === undefined;
|
|
1618
|
+
const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
|
|
1619
|
+
if (!service || !PERSON.test(actor) || !setsUpServices(acting.live, setting, placesOf(await store.liveProjects(d.tx)))) {
|
|
1620
|
+
throw refused('not_allowed', 'only admins and owners of the whole instance may add people or set their roles, and a service account takes access somewhere the instance lets people set one up');
|
|
1621
|
+
}
|
|
1622
|
+
// One removed they bring back only if they removed it themselves, never one someone else took away.
|
|
1623
|
+
const removed = d.members.get(principal);
|
|
1624
|
+
if (removed?.status === 'removed' && removed.statusChangedBy !== actor) {
|
|
1625
|
+
throw refused('not_allowed', `${shownMember(removed.statusChangedBy)} removed this service account: only they, or someone who runs the whole instance, may bring it back`);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1435
1628
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1436
1629
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1437
|
-
if (input.
|
|
1438
|
-
|
|
1630
|
+
if (input.role !== undefined && !isInstanceRole(input.role)) throw refused('invalid', `no such instance role: ${input.role}`);
|
|
1631
|
+
if (input.scope !== undefined && (input.role === undefined || !isScope(input.scope))) throw refused('invalid', 'a scope is a role\'s: { projects, environments }, each all, only or except');
|
|
1632
|
+
const wanted = input.role === undefined ? undefined : { role: input.role, scope: input.role === 'member' ? EVERYWHERE : normalScope(input.scope ?? EVERYWHERE) };
|
|
1633
|
+
if (wanted !== undefined && wanted.role !== 'member' && !principal.startsWith('user:')) {
|
|
1634
|
+
throw refused('invalid', 'service accounts hold project grants only, never an instance role');
|
|
1439
1635
|
}
|
|
1440
1636
|
const row = d.members.get(principal);
|
|
1441
1637
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1442
1638
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1443
1639
|
d.touched.add(principal);
|
|
1444
1640
|
d.grants.set(principal, [...subject.stored]);
|
|
1445
|
-
const entry = (action: string,
|
|
1446
|
-
d.log.push(accessEntry(actor, action, principal, 'allow', input,
|
|
1641
|
+
const entry = (action: string, detail: Record<string, unknown>) =>
|
|
1642
|
+
d.log.push(accessEntry(actor, action, principal, 'allow', input, detail));
|
|
1447
1643
|
|
|
1448
1644
|
if (row === undefined || row.status === 'removed') {
|
|
1449
|
-
// Coming back is a fresh start:
|
|
1450
|
-
const
|
|
1451
|
-
entry(row === undefined ? 'member.add' : 'member.restore',
|
|
1645
|
+
// Coming back is a fresh start: a member, unless given a role again.
|
|
1646
|
+
const { role, scope } = wanted ?? { role: 'member' as const, scope: EVERYWHERE };
|
|
1647
|
+
entry(row === undefined ? 'member.add' : 'member.restore', { role, scope });
|
|
1452
1648
|
d.writes.push(async (at) => {
|
|
1453
1649
|
if (row === undefined) {
|
|
1454
1650
|
await store.insertMember(d.tx, {
|
|
1455
1651
|
principal,
|
|
1456
1652
|
status: 'active',
|
|
1457
|
-
|
|
1653
|
+
role,
|
|
1654
|
+
scope,
|
|
1458
1655
|
generation: 0,
|
|
1459
1656
|
createdAt: at,
|
|
1460
1657
|
createdBy: actor,
|
|
@@ -1463,18 +1660,21 @@ class VaultService implements Vault {
|
|
|
1463
1660
|
...UNSEALED,
|
|
1464
1661
|
});
|
|
1465
1662
|
} else {
|
|
1466
|
-
await store.updateMember(d.tx, principal, { status: 'active',
|
|
1663
|
+
await store.updateMember(d.tx, principal, { status: 'active', role, scope, statusChangedAt: at, statusChangedBy: actor });
|
|
1467
1664
|
}
|
|
1468
1665
|
});
|
|
1469
1666
|
// A removal moved the generation on already; coming back keeps it.
|
|
1470
|
-
return { created: true,
|
|
1667
|
+
return { created: true, role, scope, generation: row?.generation ?? 0 };
|
|
1471
1668
|
}
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1669
|
+
const changed = wanted !== undefined && JSON.stringify(standing(wanted)) !== JSON.stringify(standing(row));
|
|
1670
|
+
// Nobody sets their own role: running the instance never lets anyone widen what they hold themselves.
|
|
1671
|
+
if (changed && actor === principal) throw refused('not_allowed', 'nobody changes their own instance role: ask another admin');
|
|
1672
|
+
if (changed) {
|
|
1673
|
+
entry('member.role', { role: wanted.role, scope: wanted.scope, previousRole: row.role, previousScope: row.scope });
|
|
1674
|
+
d.writes.push(() => store.updateMember(d.tx, principal, { role: wanted.role, scope: wanted.scope }));
|
|
1675
|
+
return { created: false, ...wanted, generation: row.generation };
|
|
1476
1676
|
}
|
|
1477
|
-
return { created: false,
|
|
1677
|
+
return { created: false, role: row.role, scope: row.scope, generation: row.generation };
|
|
1478
1678
|
});
|
|
1479
1679
|
}
|
|
1480
1680
|
|
|
@@ -1487,16 +1687,21 @@ class VaultService implements Vault {
|
|
|
1487
1687
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1488
1688
|
if (this.#isRootAdmin(principal)) throw refused('root_admin');
|
|
1489
1689
|
const row = d.members.get(principal);
|
|
1490
|
-
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1690
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1491
1691
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1492
1692
|
const holder = acting.live;
|
|
1493
1693
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1494
1694
|
const held = subject.stored;
|
|
1495
1695
|
if (subject.status === 'tampered') {
|
|
1496
|
-
if (!holder
|
|
1696
|
+
if (!runsInstance(holder)) throw refused('not_allowed', 'only admins and owners of the whole instance may remove a member whose record failed its check');
|
|
1497
1697
|
return this.#startOver(d, actor, principal, row, subject.fault!, input, refused);
|
|
1498
1698
|
}
|
|
1499
|
-
if (!holder
|
|
1699
|
+
if (!runsInstance(holder)) {
|
|
1700
|
+
// A service account its manager removes: they reach everything it holds (`managesService`).
|
|
1701
|
+
const theirs = SERVICE.test(principal) && PERSON.test(actor) && row?.status === 'active' &&
|
|
1702
|
+
managesService(holder, (await this.#settingsAt(d.tx, d.reports)).serviceAccounts, actor, await this.#account(d.tx, subject, row));
|
|
1703
|
+
if (!theirs) throw refused('not_allowed', 'only admins and owners of the whole instance may remove members, and a service account whoever manages it');
|
|
1704
|
+
}
|
|
1500
1705
|
if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
|
|
1501
1706
|
|
|
1502
1707
|
const revoked = held.filter((grant) => live(grant, d.at));
|
|
@@ -1520,7 +1725,8 @@ class VaultService implements Vault {
|
|
|
1520
1725
|
d.grants.set(principal, []);
|
|
1521
1726
|
await store.updateMember(d.tx, principal, {
|
|
1522
1727
|
status: 'removed',
|
|
1523
|
-
|
|
1728
|
+
role: 'member',
|
|
1729
|
+
scope: EVERYWHERE,
|
|
1524
1730
|
generation,
|
|
1525
1731
|
statusChangedAt: at,
|
|
1526
1732
|
statusChangedBy: actor,
|
|
@@ -1530,6 +1736,79 @@ class VaultService implements Vault {
|
|
|
1530
1736
|
});
|
|
1531
1737
|
}
|
|
1532
1738
|
|
|
1739
|
+
/**
|
|
1740
|
+
* A service account as `managesService` weighs it: its live grants, each
|
|
1741
|
+
* environment by its slug, which the setting's scope matches, and who
|
|
1742
|
+
* admitted it, as its row says.
|
|
1743
|
+
*/
|
|
1744
|
+
async #account(db: Queryable, subject: Standing, row: Member): Promise<ServiceAccount> {
|
|
1745
|
+
const slugs = await store.environmentsById(db, subject.live.grants.flatMap((grant) => (grant.environmentId === null ? [] : [grant.environmentId])));
|
|
1746
|
+
return {
|
|
1747
|
+
grants: subject.live.grants.map((grant) => ({
|
|
1748
|
+
role: grant.role,
|
|
1749
|
+
place: grant.environmentId === null
|
|
1750
|
+
? { projectId: grant.projectId! }
|
|
1751
|
+
: { projectId: grant.projectId!, environmentId: grant.environmentId, environmentSlug: slugs.get(grant.environmentId)?.slug ?? null },
|
|
1752
|
+
})),
|
|
1753
|
+
admittedBy: row.statusChangedBy,
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// --- the instance's settings ------------------------------------------------
|
|
1758
|
+
|
|
1759
|
+
async settings(): Promise<Settings> {
|
|
1760
|
+
await this.#settled();
|
|
1761
|
+
const reports: NewEntry[] = [];
|
|
1762
|
+
const settings = await this.#settingsAt(this.#db, reports);
|
|
1763
|
+
await this.#record(reports);
|
|
1764
|
+
return settings;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* The settings the vault's newest `settings.change` says, by its MAC: one
|
|
1769
|
+
* in its name it did not write is passed over, and reported. With such
|
|
1770
|
+
* entries and none of its own within reach, the setting is closed, never
|
|
1771
|
+
* the default: otherwise whoever can insert rows could open it again.
|
|
1772
|
+
*/
|
|
1773
|
+
async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
|
|
1774
|
+
const entry = await newestAuthentic(
|
|
1775
|
+
(before) => store.newestVaultEntries(db, SETTINGS, LOOK_BACK.page, before),
|
|
1776
|
+
this.#check(reports, null),
|
|
1777
|
+
);
|
|
1778
|
+
if (entry === 'none') return DEFAULT_SETTINGS;
|
|
1779
|
+
if (typeof entry === 'string') return CLOSED_SETTINGS;
|
|
1780
|
+
const { serviceAccounts } = JSON.parse(entry.metadata) as { serviceAccounts?: unknown };
|
|
1781
|
+
return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
setSettings(input: SetSettingsInput): Promise<Outcome<{ settings: Settings }>> {
|
|
1785
|
+
const { actor } = input;
|
|
1786
|
+
const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
|
|
1787
|
+
actor,
|
|
1788
|
+
action: SETTINGS,
|
|
1789
|
+
decision,
|
|
1790
|
+
code,
|
|
1791
|
+
operationId: input.operationId ?? null,
|
|
1792
|
+
requestId: input.requestId ?? null,
|
|
1793
|
+
metadata: JSON.stringify({ ...detail, ...traced(input) }),
|
|
1794
|
+
});
|
|
1795
|
+
const refused = (code: RefusalCode, message = MESSAGES[code]) =>
|
|
1796
|
+
new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
1797
|
+
return this.#decide([actor], async (d) => {
|
|
1798
|
+
validateCorrelation(input);
|
|
1799
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1800
|
+
if (acting.status === 'tampered') throw refused('tampered');
|
|
1801
|
+
if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
|
|
1802
|
+
if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
|
|
1803
|
+
// Under the log's head, so two changes at once each name the one before.
|
|
1804
|
+
await lockLogHead(d.tx);
|
|
1805
|
+
const previous = await this.#settingsAt(d.tx, d.reports);
|
|
1806
|
+
const settings: Settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
|
|
1807
|
+
if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry('allow', { ...settings, previous }));
|
|
1808
|
+
return { settings };
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1533
1812
|
/**
|
|
1534
1813
|
* Remove a member whose row failed its check, from what the log says of
|
|
1535
1814
|
* them rather than what the row does: their grants go, whatever they were,
|
|
@@ -1556,7 +1835,8 @@ class VaultService implements Vault {
|
|
|
1556
1835
|
d.grants.set(principal, []);
|
|
1557
1836
|
const fresh = {
|
|
1558
1837
|
status: 'removed' as const,
|
|
1559
|
-
|
|
1838
|
+
role: 'member' as const,
|
|
1839
|
+
scope: EVERYWHERE,
|
|
1560
1840
|
generation,
|
|
1561
1841
|
createdAt: logged.createdAt,
|
|
1562
1842
|
createdBy: logged.createdBy,
|
|
@@ -2185,39 +2465,38 @@ function live(grant: GrantRow, at: number): boolean {
|
|
|
2185
2465
|
|
|
2186
2466
|
/**
|
|
2187
2467
|
* 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
|
|
2189
|
-
*
|
|
2468
|
+
* for a grant on every project of 0.4, neither, and the place as a path in
|
|
2469
|
+
* its payload, `*`, or `*` and the slug, as 0.4 wrote it.
|
|
2190
2470
|
*/
|
|
2191
|
-
function located(place:
|
|
2192
|
-
return
|
|
2193
|
-
? { ids: {}, detail: { place:
|
|
2471
|
+
function located(place: store.Place): { ids: Pick<NewEntry, 'projectId' | 'environmentId'>; detail: { place?: string } } {
|
|
2472
|
+
return place.projectId === null && place.environmentId === null
|
|
2473
|
+
? { ids: {}, detail: { place: place.environmentSlug === null ? '*' : `*/${place.environmentSlug}` } }
|
|
2194
2474
|
: { ids: { projectId: place.projectId, environmentId: place.environmentId }, detail: {} };
|
|
2195
2475
|
}
|
|
2196
2476
|
|
|
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
|
-
|
|
2199
|
-
|
|
2200
|
-
return
|
|
2477
|
+
/** One key per place a member can hold a grant at, by what the grant names first: its environment, its project, or neither, on every project of 0.4. */
|
|
2478
|
+
/** Every project there is now and each of its environments, as a permission check names them. */
|
|
2479
|
+
function placesOf(projects: readonly { id: string; environments: readonly { id: string; slug: string }[] }[]): Place[] {
|
|
2480
|
+
return projects.flatMap((project) => [
|
|
2481
|
+
{ projectId: project.id },
|
|
2482
|
+
...project.environments.map((environment) => ({ projectId: project.id, environmentId: environment.id, environmentSlug: environment.slug })),
|
|
2483
|
+
]);
|
|
2201
2484
|
}
|
|
2202
2485
|
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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;
|
|
2486
|
+
function placeKey(place: store.Place): string {
|
|
2487
|
+
if (place.environmentId !== null) return `environment:${place.environmentId}`;
|
|
2488
|
+
return place.projectId !== null ? `project:${place.projectId}` : `every-project:${place.environmentSlug ?? ''}`;
|
|
2211
2489
|
}
|
|
2212
2490
|
|
|
2213
|
-
/**
|
|
2214
|
-
|
|
2491
|
+
/** A member's instance role, as their row has it: service accounts are members, whatever a row says. */
|
|
2492
|
+
function roleOf(principal: string, row: Member): { role: InstanceRole; scope: Scope } {
|
|
2493
|
+
return principal.startsWith('user:') ? { role: row.role, scope: row.scope } : { role: 'member', scope: EVERYWHERE };
|
|
2494
|
+
}
|
|
2215
2495
|
|
|
2216
2496
|
function view(grant: GrantRow): Grant {
|
|
2217
2497
|
return {
|
|
2218
2498
|
projectId: grant.projectId,
|
|
2219
2499
|
environmentId: grant.environmentId,
|
|
2220
|
-
environmentSlug: grant.environmentSlug,
|
|
2221
2500
|
role: grant.role as Role,
|
|
2222
2501
|
expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
|
|
2223
2502
|
grantedAt: iso(grant.grantedAt),
|