@coffre/vault 0.4.5 → 0.5.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/src/vault.ts CHANGED
@@ -3,15 +3,27 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  import {
4
4
  allows,
5
5
  assignableToEnvironment,
6
- everyProjectPath,
6
+ convertEveryProjectGrants,
7
+ EVERYWHERE,
8
+ givesService,
7
9
  grantKind,
10
+ isCredentialScope,
11
+ isInstanceRole,
8
12
  isRole,
13
+ isScope,
14
+ managesService,
9
15
  mayManageAccess,
10
- type GrantPlace,
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 { appendEntries, lockLogHead, type Appended, type NewEntry } from '@coffre/db/log';
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.#newestAccessSeq(db, principal, reports);
440
- // No row, and no entry: someone never admitted. No row, but entries: one
441
- // deleted, unless it was admitted since the row was read.
442
- if (row === undefined ? newest === null : newest === row.accessSeq) return null;
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 seq of the newest access entry about `principal` that carries the vault's MAC, or null. */
449
- async #newestAccessSeq(db: Queryable, principal: string, reports: NewEntry[]): Promise<bigint | null> {
450
- for (const entry of await store.accessEntriesAbout(db, principal, 32)) {
451
- if (this.#authentic(entry)) return entry.seq;
452
- this.#report(reports, principal, 'forged_entry', String(entry.seq), entry.seq);
453
- }
454
- return null;
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({ owner: false, rootAdmin: true }),
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
- owner: false,
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 = { isRootAdmin: false, isOwner: false, grants: [] };
1368
+ const none = NOTHING;
1211
1369
  if (this.#isRootAdmin(principal)) {
1212
- const root = { isRootAdmin: true, isOwner: true, grants: [] };
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 isOwner = row.owner && principal.startsWith('user:');
1379
+ const { role, scope } = roleOf(principal, row);
1222
1380
  return {
1223
1381
  principal,
1224
1382
  status: 'active',
1225
- live: { isRootAdmin: false, isOwner, grants: held.filter((grant) => live(grant, at)) },
1226
- all: { isRootAdmin: false, isOwner, grants: held },
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, isOwner: true, grants: [], since: null, by: null };
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
- isOwner: active && row.owner && principal.startsWith('user:'),
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((change) => ({ ...change, environmentSlug: change.environmentSlug ?? null }));
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
- const shape = placeShape(change);
1324
- if (shape !== null) throw refused('invalid', shape);
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 && (change.environmentId !== null || change.environmentSlug !== null) && !assignableToEnvironment(change.role)) {
1330
- throw refused('invalid', `${change.role} can only be granted on a project, or on every project`);
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
- // Every project is always there; a project or an environment must be,
1338
- // and to be granted, not deleted. Read under the log's head, which a
1339
- // deletion holds while it renames the place, so the place is as it commits.
1340
- if (onProjects.length > 0) await lockLogHead(d.tx);
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
- onProjects.map((change) => change.projectId),
1344
- onProjects.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
1503
+ changes.map((change) => change.projectId!),
1504
+ changes.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
1345
1505
  );
1346
- for (const { projectId, environmentId, role } of onProjects) {
1347
- if (!known.projects.has(projectId) || (environmentId !== null && known.environments.get(environmentId) !== projectId)) {
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
- const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
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
- if (!changes.every((change) => mayManageAccess(acting.live, change))) throw refused('not_allowed');
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 & GrantPlace,
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; owner: boolean; generation: number }>> {
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, { owner: input.owner ?? null }, code),
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.isOwner) throw refused('not_allowed', 'only owners may add or restore members');
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.owner === true && !principal.startsWith('user:')) {
1438
- throw refused('invalid', 'service accounts cannot be owners');
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, owner: boolean) =>
1446
- d.log.push(accessEntry(actor, action, principal, 'allow', input, { owner }));
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: no owner role unless given again.
1450
- const owner = input.owner ?? false;
1451
- entry(row === undefined ? 'member.add' : 'member.restore', owner);
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
- owner,
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', owner, statusChangedAt: at, statusChangedBy: actor });
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, owner, generation: row?.generation ?? 0 };
1667
+ return { created: true, role, scope, generation: row?.generation ?? 0 };
1471
1668
  }
1472
- const owner = input.owner ?? row.owner;
1473
- if (owner !== row.owner) {
1474
- entry('member.owner', owner);
1475
- d.writes.push(() => store.updateMember(d.tx, principal, { owner }));
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, owner, generation: row.generation };
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.isOwner) throw refused('not_allowed', 'only owners may remove a member whose record failed its check');
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.isOwner) throw refused('not_allowed', 'only owners may remove members');
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
- owner: false,
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
- owner: false,
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 its payload. Only a
2189
- * grant that names no place at all is on every project (`grantKind`).
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: GrantPlace): { ids: Pick<NewEntry, 'projectId' | 'environmentId'>; detail: { place?: string } } {
2192
- return grantKind(place) === 'every-project'
2193
- ? { ids: {}, detail: { place: everyProjectPath(place.environmentSlug) } }
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
- function placeKey(place: GrantPlace): string {
2199
- if (place.environmentId !== null) return `environment:${place.environmentId}`;
2200
- return place.projectId !== null ? `project:${place.projectId}` : everyProjectPath(place.environmentSlug);
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
- /** Why a change's place is not one a grant can be at, or null when it is. */
2204
- function placeShape(place: GrantPlace): string | null {
2205
- const kind = grantKind(place);
2206
- if (kind === null) return place.projectId === null ? 'an environment is named in its project' : 'a slug names environments on every project, not in one';
2207
- if (kind === 'every-project' && place.environmentSlug !== null && !SLUG.test(place.environmentSlug)) {
2208
- return `not an environment slug: ${place.environmentSlug}`;
2209
- }
2210
- return null;
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
- /** An environment's slug, as the schema has it. */
2214
- const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
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),