@coffre/vault 0.5.1 → 0.6.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/src/vault.ts CHANGED
@@ -3,7 +3,6 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  import {
4
4
  allows,
5
5
  assignableToEnvironment,
6
- convertEveryProjectGrants,
7
6
  EVERYWHERE,
8
7
  givesService,
9
8
  grantKind,
@@ -15,6 +14,7 @@ import {
15
14
  mayManageAccess,
16
15
  normalScope,
17
16
  NOTHING,
17
+ ownsInstance,
18
18
  runsInstance,
19
19
  setsUpServices,
20
20
  type Holdings,
@@ -49,6 +49,7 @@ import {
49
49
  type AccessChange,
50
50
  type AccessFault,
51
51
  type AdmitInput,
52
+ type Admitted,
52
53
  type Checkpoint,
53
54
  type CheckpointKey,
54
55
  type EndReferencesInput,
@@ -77,7 +78,7 @@ import {
77
78
  } from '@coffre/core/vault';
78
79
  import type { Database, Queryable, Transaction } from '@coffre/db';
79
80
  import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
80
- import { newestAccess, reportStands } from '@coffre/db/grants';
81
+ import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
81
82
  import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
82
83
 
83
84
  import { KeyAccounting } from './accounting.ts';
@@ -120,8 +121,6 @@ export type PreparedVault = {
120
121
  options: Required<VaultOptions>;
121
122
  /** How far the log is verified; `verifyChain` in log.ts. The furthest any call got to. */
122
123
  verified: Anchor;
123
- /** Root admins known to have a member row; rows are never deleted. */
124
- rooted: Set<string>;
125
124
  /** What member rows are sealed under now; rows.ts. */
126
125
  rowKey: Buffer;
127
126
  /** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
@@ -136,7 +135,11 @@ export type PreparedVault = {
136
135
  kekChecked: boolean;
137
136
  /** Why not, once decided that they do not. */
138
137
  wrongKek: string | null;
139
- /** Whether this process has replaced the grants on every project of 0.4 there were when it started (`#convert`). */
138
+ /**
139
+ * Whether this process has done what it does once, at its start
140
+ * (`#convert`): the grants on every project of 0.4 there were replaced,
141
+ * then everyone the deployment names made an Owner (`#declare`).
142
+ */
140
143
  converted: boolean;
141
144
  };
142
145
 
@@ -146,7 +149,6 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
146
149
  ...(await keysOf(config.signingKeys)),
147
150
  options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
148
151
  verified: UNVERIFIED,
149
- rooted: new Set(),
150
152
  reported: new Set(),
151
153
  since: null,
152
154
  superseded: null,
@@ -182,7 +184,7 @@ const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
182
184
  const PERSON = /^user:/;
183
185
  const SERVICE = /^token:/;
184
186
 
185
- /** Who acts for the vault itself, as when it gives a root admin a member row. */
187
+ /** Who acts for the vault itself, as when it makes an Owner its deployment names. */
186
188
  const VAULT_ACTOR = 'system:vault';
187
189
 
188
190
  // A KEK's check: a known value, the size of a data key, wrapped under it in
@@ -215,6 +217,14 @@ const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
215
217
  /** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
216
218
  const CLOSED_SETTINGS: Settings = { serviceAccounts: { projects: { only: [] }, environments: 'all' } };
217
219
 
220
+ /** Why a change to who is an Owner, scoped or not, is refused to anyone else: Owners make and unmake Owners. */
221
+ const OWNERS_ONLY = 'only an Owner of the whole instance makes someone an Owner, or changes or removes one';
222
+
223
+ /** Why nobody demotes or removes a person the deployment names (`owners`), themselves included. */
224
+ function declaredOwner(principal: string): string {
225
+ return `the deployment names ${principal.slice('user:'.length)} an Owner (owners, in the vault's configuration): take them off that list and deploy first`;
226
+ }
227
+
218
228
  /** Why a person's change to a service account is refused. */
219
229
  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
230
 
@@ -270,7 +280,7 @@ const MESSAGES: Record<RefusalCode, string> = {
270
280
  deleted: 'this project or environment was deleted',
271
281
  ended: 'the reference read through was ended',
272
282
  not_allowed: 'not allowed to change this',
273
- root_admin: 'root admins are set in the vault configuration',
283
+ last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
274
284
  invalid: 'not something the rules allow',
275
285
  log_broken: 'the vault log does not hold from the last checkpoint',
276
286
  wrong_kek: "this vault's key does not open the data it holds",
@@ -573,7 +583,8 @@ class VaultService implements Vault {
573
583
  // database; calls that race settle it alike, and a rotation they race
574
584
  // to write goes in once (`#rotate`).
575
585
  if (prepared.since === null && prepared.superseded === null) await this.#settle();
576
- if (prepared.superseded === null && !prepared.converted) await this.#convert();
586
+ // A log with no entry of the vault's holds nothing to convert, and stays empty until someone acts.
587
+ if (prepared.superseded === null && prepared.since !== null && !prepared.converted) await this.#convert();
577
588
  return prepared.superseded;
578
589
  }
579
590
 
@@ -591,14 +602,18 @@ class VaultService implements Vault {
591
602
  * genuine row put back). `reportStands` is the rule, which `coffre
592
603
  * migrate` applies too, holding no key, to every entry in the vault's
593
604
  * name: it lists and marks a member so reported, forged or not.
594
- * Removing them clears both.
605
+ * Removing them clears both. Then everyone the deployment names becomes
606
+ * an Owner (`#declare`): after, so that what their grants become is what
607
+ * `coffre migrate` says, by the same rule over the same rows
608
+ * (`everyProjectConversion`), which cannot tell who the configuration
609
+ * names. Until both finish, each call does again what is left, a few
610
+ * reads once nothing is.
595
611
  */
596
612
  async #convert(): Promise<void> {
597
613
  for (const principal of await store.everyProjectHolders(this.#db)) {
598
- if (this.#isRootAdmin(principal)) continue;
599
614
  await this.#decideNow([principal], (d) => this.#replaceEveryProject(d, principal));
600
615
  }
601
- this.#prepared.converted = true;
616
+ this.#prepared.converted = await this.#declare(this.#config.owners.map((email) => `user:${email}`));
602
617
  }
603
618
 
604
619
  async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
@@ -611,15 +626,7 @@ class VaultService implements Vault {
611
626
  // The projects there are now, read under the log's head, which a deletion holds.
612
627
  await lockLogHead(d.tx);
613
628
  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
- });
629
+ const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await store.liveProjects(d.tx));
623
630
  const after = { role: conversion.role, scope: conversion.scope };
624
631
  const replacedBy = { role: after.role, scope: after.scope, grants: conversion.grants.length };
625
632
  d.grants.set(principal, [...subject.stored]);
@@ -934,7 +941,6 @@ class VaultService implements Vault {
934
941
  }
935
942
 
936
943
  async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted', entries: NewEntry[]): Promise<Outcome<never>> {
937
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
938
944
  return this.#decide([principal], async () => {
939
945
  throw new Refused(refusal(code, MESSAGES[code]), entries);
940
946
  });
@@ -995,7 +1001,6 @@ class VaultService implements Vault {
995
1001
  throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
996
1002
  });
997
1003
  }
998
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
999
1004
  const check = async (d: Decision) => {
1000
1005
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
1001
1006
  // One read of the environments the decision is about, as the store has them, not as the app sent them:
@@ -1175,7 +1180,6 @@ class VaultService implements Vault {
1175
1180
  }),
1176
1181
  };
1177
1182
  };
1178
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1179
1183
  return this.#decide([principal], async (d) => {
1180
1184
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1181
1185
  // Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
@@ -1250,7 +1254,6 @@ class VaultService implements Vault {
1250
1254
  ...traced(input),
1251
1255
  }),
1252
1256
  });
1253
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1254
1257
  if (sealed.some((seal) => seal === null)) {
1255
1258
  return this.#decide([principal], async () => {
1256
1259
  throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i]!, 'deny', 'bad_claim')));
@@ -1307,69 +1310,116 @@ class VaultService implements Vault {
1307
1310
  });
1308
1311
  }
1309
1312
 
1310
- // --- who holds what -----------------------------------------------------------
1313
+ // --- Owners of the whole instance -------------------------------------------------
1311
1314
 
1312
- #isRootAdmin(principal: string): boolean {
1313
- return principal.startsWith('user:') && this.#config.rootAdmins.includes(principal.slice('user:'.length));
1315
+ /** Whether the deployment names `principal` an Owner of the whole instance (`owners`). */
1316
+ #listed(principal: string): boolean {
1317
+ return principal.startsWith('user:') && this.#config.owners.includes(principal.slice('user:'.length));
1314
1318
  }
1315
1319
 
1316
1320
  /**
1317
- * Give a root admin a member row the first time anyone asks about them:
1318
- * the app's sign-ins and sessions point at it, and their reads queue on
1319
- * it like anyone's. Logged, and made in a transaction of its own, under
1320
- * the log's lock: a decision that held the head and then waited for a
1321
- * member row would take the locks out of order. The row never makes
1322
- * anyone a root admin; the configuration does. Rows are never deleted, so
1323
- * one read finds it made already, as on every fresh isolate but the first,
1324
- * without queueing on the log's lock.
1321
+ * An Owner of the whole instance other than `except`, as their row says
1322
+ * and its check holds (`#integrity`), or null when there is none. A row
1323
+ * changed around the vault owns nothing, and is reported. Read under the
1324
+ * log's head, which every change to a member takes before it writes, the
1325
+ * answer stands until the reader commits.
1325
1326
  */
1326
- async #rootRow(principal: string): Promise<void> {
1327
- if (this.#prepared.rooted.has(principal) || (await this.#settled()) !== null) return;
1328
- if ((await store.member(this.#db, principal)) !== undefined) {
1329
- this.#prepared.rooted.add(principal);
1330
- return;
1327
+ async #anOwner(db: Queryable, reports: NewEntry[], except: string | null = null): Promise<string | null> {
1328
+ for (const row of await store.instanceOwners(db)) {
1329
+ if (row.principal === except) continue;
1330
+ if ((await this.#integrity(db, row.principal, row, await store.grants(db, row.principal), reports)) === null) return row.principal;
1331
1331
  }
1332
- await this.#db.transaction(async (tx) => {
1333
- await lockLogHead(tx);
1334
- if ((await store.member(tx, principal)) !== undefined) return;
1335
- const appended = await this.#append(tx, [
1336
- {
1337
- actor: VAULT_ACTOR,
1338
- action: 'member.add',
1339
- decision: 'allow',
1340
- subjectPrincipal: principal,
1341
- metadata: JSON.stringify({ role: 'member', rootAdmin: true }),
1342
- },
1343
- ]);
1344
- const { occurredAt: at, seqStart } = appended;
1345
- const row = {
1346
- principal,
1347
- status: 'active' as const,
1348
- role: 'member' as const,
1349
- scope: EVERYWHERE,
1350
- generation: 0,
1351
- createdAt: at,
1352
- createdBy: VAULT_ACTOR,
1353
- statusChangedAt: at,
1354
- statusChangedBy: VAULT_ACTOR,
1355
- accessSeq: seqStart,
1356
- };
1357
- await store.insertMember(tx, { ...row, mac: memberMac(this.#prepared.rowKey, row, []) });
1332
+ return null;
1333
+ }
1334
+
1335
+ /**
1336
+ * Make each of `principals`, people the deployment names (`owners`), an
1337
+ * Owner of the whole instance, whoever else is one: asked about all of
1338
+ * them at the start of each process (`#convert`), and at `access` about
1339
+ * one who owns nothing, which is a new instance's first sign-in, before
1340
+ * the vault has written to its log, and the way back for someone whose
1341
+ * row was changed around the vault since. An unlocked read of each finds
1342
+ * who is one already, the quick way out: a hint, never a report, and
1343
+ * never `#record`, which would settle the vault, and so convert, again;
1344
+ * one it cannot tell, a row changing under it as another process makes
1345
+ * them an Owner, is due. Then one decision, under the others' rows, reads
1346
+ * again (`#makeOwner`). One whose row the log never admitted stays as it is,
1347
+ * and is asked about again at each of their requests. Whether it ran.
1348
+ */
1349
+ async #declare(principals: readonly string[]): Promise<boolean> {
1350
+ const at = await this.#now(this.#db);
1351
+ const due: string[] = [];
1352
+ for (const principal of principals) {
1353
+ const subject = await this.#standing(this.#db, principal, await store.member(this.#db, principal), at, []).catch((error: unknown) => {
1354
+ if (error instanceof Retry) return null;
1355
+ throw error;
1356
+ });
1357
+ if (!(subject?.status === 'active' && ownsInstance(subject.live))) due.push(principal);
1358
+ }
1359
+ if (due.length === 0) return true;
1360
+ const decided = await this.#decideNow(due, async (d) => {
1361
+ for (const principal of due) await this.#makeOwner(d, principal, 'owners');
1358
1362
  });
1359
- this.#prepared.rooted.add(principal);
1363
+ return decided.ok;
1360
1364
  }
1361
1365
 
1366
+ /**
1367
+ * Refuse a change that would leave `principal`, an Owner of the whole
1368
+ * instance, no longer one, unless another is. Counted under the log's
1369
+ * head: two changes at once, each demoting or removing a different
1370
+ * Owner, lock different rows, and only the head puts one after the other.
1371
+ */
1372
+ async #keepAnOwner(d: Decision, actor: string, principal: string, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
1373
+ await lockLogHead(d.tx);
1374
+ if ((await this.#anOwner(d.tx, d.reports, principal)) !== null) return;
1375
+ throw refused('last_owner', actor === principal
1376
+ ? "you're the last Owner of the whole instance: make someone else an Owner first"
1377
+ : `${principal.slice('user:'.length)} is the last Owner of the whole instance: make someone else an Owner first`);
1378
+ }
1379
+
1380
+ /**
1381
+ * Make `principal`, a person, an Owner of the whole instance, as the
1382
+ * vault, for `reason`: admitted, brought back, or given the role, the
1383
+ * grants they hold kept. One whose row fails its check is started over
1384
+ * first, as removing them would; one the log never admitted, whose row was
1385
+ * written around the vault, is left as it is. Whether it made them one.
1386
+ */
1387
+ async #makeOwner(d: Decision, principal: string, reason: 'owners'): Promise<boolean> {
1388
+ const row = d.members.get(principal);
1389
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1390
+ if (subject.status === 'active' && ownsInstance(subject.live)) return false;
1391
+ const tampered = subject.status === 'tampered';
1392
+ if (tampered && (await this.#logged(d.tx, principal)) === undefined) return false;
1393
+ const correlation = { operationId: randomUUID() };
1394
+ const owner = { role: 'owner' as const, scope: EVERYWHERE };
1395
+ d.grants.set(principal, [...subject.stored]);
1396
+ d.touched.add(principal);
1397
+ if (tampered) await this.#startOver(d, VAULT_ACTOR, principal, row, subject.fault!, correlation, (code, message) => new Refused(refusal(code, message ?? MESSAGES[code]), []));
1398
+ if (row === undefined && !tampered) {
1399
+ d.log.push(accessEntry(VAULT_ACTOR, 'member.add', principal, 'allow', correlation, { ...owner, reason }));
1400
+ d.writes.push((at) => store.insertMember(d.tx, {
1401
+ principal, status: 'active', ...owner, generation: 0, createdAt: at, createdBy: VAULT_ACTOR, statusChangedAt: at, statusChangedBy: VAULT_ACTOR, ...UNSEALED,
1402
+ }));
1403
+ } else if (tampered || row?.status === 'removed') {
1404
+ // Coming back keeps the generation a removal, or the start over, moved on to.
1405
+ d.log.push(accessEntry(VAULT_ACTOR, 'member.restore', principal, 'allow', correlation, { ...owner, reason }));
1406
+ d.writes.push((at) => store.updateMember(d.tx, principal, { status: 'active', ...owner, statusChangedAt: at, statusChangedBy: VAULT_ACTOR }));
1407
+ } else {
1408
+ d.log.push(accessEntry(VAULT_ACTOR, 'member.role', principal, 'allow', correlation, { ...owner, previousRole: row!.role, previousScope: row!.scope, reason }));
1409
+ d.writes.push(() => store.updateMember(d.tx, principal, owner));
1410
+ }
1411
+ return true;
1412
+ }
1413
+
1414
+
1415
+ // --- who holds what -----------------------------------------------------------
1416
+
1362
1417
  /**
1363
1418
  * What `principal` holds, their row checked first (`#integrity`): a row
1364
- * that fails holds nothing, and is `tampered`. A root admin's come from the
1365
- * configuration, which no row can change.
1419
+ * that fails holds nothing, and is `tampered`.
1366
1420
  */
1367
1421
  async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
1368
1422
  const none = NOTHING;
1369
- if (this.#isRootAdmin(principal)) {
1370
- const root: Holdings = { isRootAdmin: true, role: 'owner', scope: EVERYWHERE, grants: [] };
1371
- return { principal, status: 'active', live: root, all: root, fault: null, stored: [] };
1372
- }
1373
1423
  const grants = row === undefined ? [] : await store.grants(db, principal);
1374
1424
  const fault = await this.#integrity(db, principal, row, grants, reports);
1375
1425
  if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
@@ -1380,23 +1430,19 @@ class VaultService implements Vault {
1380
1430
  return {
1381
1431
  principal,
1382
1432
  status: 'active',
1383
- live: { isRootAdmin: false, role, scope, grants: held.filter((grant) => live(grant, at)) },
1384
- all: { isRootAdmin: false, role, scope, grants: held },
1433
+ live: { role, scope, grants: held.filter((grant) => live(grant, at)) },
1434
+ all: { role, scope, grants: held },
1385
1435
  fault,
1386
1436
  stored: grants,
1387
1437
  };
1388
1438
  }
1389
1439
 
1390
1440
  #access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean): Access {
1391
- if (this.#isRootAdmin(principal)) {
1392
- return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true, role: 'owner', scope: EVERYWHERE, grants: [], since: null, by: null };
1393
- }
1394
1441
  const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
1395
1442
  return {
1396
1443
  principal,
1397
1444
  status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
1398
1445
  generation: row?.generation ?? 0,
1399
- isRootAdmin: false,
1400
1446
  ...(active ? roleOf(principal, row) : { role: 'member', scope: EVERYWHERE }),
1401
1447
  grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
1402
1448
  since: row ? iso(row.statusChangedAt) : null,
@@ -1408,14 +1454,26 @@ class VaultService implements Vault {
1408
1454
  * What `principal` holds now. Read without locks, the fast way; a row that
1409
1455
  * seems to fail its check is read again in one snapshot before anyone is
1410
1456
  * called tampered, since a change committed between two of the reads
1411
- * looks like one.
1457
+ * looks like one. A person the deployment names (`owners`) who owns
1458
+ * nothing becomes an Owner of the whole instance first (`#declare`). A
1459
+ * vault that holds no key the log's newest entries are under, given the
1460
+ * wrong vault key, cannot tell a row it cannot check from one changed
1461
+ * around it: it says so, an outage, rather than call the member tampered.
1462
+ * A row it can check still answers, so a forged entry that stops it
1463
+ * writing leaves the Owners able to sign in and find it.
1412
1464
  */
1413
1465
  async access(principal: string): Promise<Access> {
1414
- await this.#settled();
1415
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1466
+ const superseded = await this.#settled();
1467
+ const access = await this.#read(principal, superseded);
1468
+ if (superseded !== null || !this.#listed(principal) || (access.status === 'active' && ownsInstance(access))) return access;
1469
+ return (await this.#declare([principal])) ? this.#read(principal, superseded) : access;
1470
+ }
1471
+
1472
+ /** `access`, as the rows are now. */
1473
+ async #read(principal: string, superseded: string | null): Promise<Access> {
1416
1474
  const read = async (db: Queryable, reports: NewEntry[]) => {
1417
1475
  const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
1418
- const fault = this.#isRootAdmin(principal) ? null : await this.#integrity(db, principal, row, held, reports);
1476
+ const fault = await this.#integrity(db, principal, row, held, reports);
1419
1477
  return this.#access(principal, row, held, at, fault !== null);
1420
1478
  };
1421
1479
  const found: NewEntry[] = [];
@@ -1429,6 +1487,7 @@ class VaultService implements Vault {
1429
1487
  }
1430
1488
  const reports: NewEntry[] = [];
1431
1489
  const access = await this.#db.transaction((tx) => read(tx, reports), SNAPSHOT);
1490
+ if (access.status === 'tampered' && superseded !== null) throw new Error(superseded);
1432
1491
  await this.#record(reports);
1433
1492
  return access;
1434
1493
  }
@@ -1448,7 +1507,6 @@ class VaultService implements Vault {
1448
1507
  const held = await store.grants(db);
1449
1508
  const newest = await store.newestAccessEntries(db);
1450
1509
  for (const row of rows) {
1451
- if (this.#isRootAdmin(row.principal)) continue;
1452
1510
  const grants = held.filter((grant) => grant.principal === row.principal);
1453
1511
  const entry = newest.get(row.principal);
1454
1512
  const holds = entry !== undefined && this.#authentic(entry) && this.#sealed(row, grants) && entry.seq === row.accessSeq;
@@ -1458,7 +1516,7 @@ class VaultService implements Vault {
1458
1516
 
1459
1517
  // --- changing access ----------------------------------------------------------
1460
1518
 
1461
- setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>> {
1519
+ setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[]; admitted?: Admitted }>> {
1462
1520
  const { actor, principal } = input;
1463
1521
  const changes = input.changes.map(({ projectId, environmentId, role, expiresAt }) => ({ projectId, environmentId, environmentSlug: null, role, expiresAt }));
1464
1522
  const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
@@ -1474,7 +1532,6 @@ class VaultService implements Vault {
1474
1532
  return this.#decide([actor, principal], async (d) => {
1475
1533
  validateCorrelation(input);
1476
1534
  if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
1477
- if (this.#isRootAdmin(principal)) throw refused('root_admin');
1478
1535
  const places = new Set<string>();
1479
1536
  for (const change of changes) {
1480
1537
  if (grantKind(change) === null) {
@@ -1521,19 +1578,22 @@ class VaultService implements Vault {
1521
1578
  const manages = where.every((place) => mayManageAccess(acting.live, place));
1522
1579
  if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused('not_allowed');
1523
1580
  // 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)) {
1581
+ if (actor === principal && changes.some((change) => change.role !== null)) {
1525
1582
  throw refused('not_allowed', 'nobody grants themselves a role: ask another admin');
1526
1583
  }
1527
1584
 
1528
1585
  const row = d.members.get(principal);
1529
1586
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1530
1587
  if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
1531
- if (row?.status === 'removed') throw refused('removed');
1532
- if (row === undefined) throw refused('not_a_member');
1588
+ // A service account admitted with its first grants, as `admit` would: all of it, or none.
1589
+ const admitted = input.admit === true && row?.status !== 'active' ? await this.#admitWith(d, acting, principal, row, input) : undefined;
1590
+ if (admitted === undefined && row?.status === 'removed') throw refused('removed');
1591
+ if (admitted === undefined && row === undefined) throw refused('not_a_member');
1533
1592
  if (!manages) {
1534
1593
  // The setting as of the log's head, which a change to it takes too.
1535
1594
  const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
1536
- const account = await this.#account(d.tx, subject, row);
1595
+ // One just admitted holds nothing yet, and is theirs.
1596
+ const account = admitted !== undefined || row === undefined ? { grants: [], admittedBy: actor } : await this.#account(d.tx, subject, row);
1537
1597
  // Each place's new role they must hold, and what it held before: taking a grant away is changing it.
1538
1598
  const gives = changes.every((change, i) => {
1539
1599
  const before = subject.live.grants.find((grant) => placeKey({ ...grant, environmentSlug: null }) === placeKey(change));
@@ -1546,10 +1606,23 @@ class VaultService implements Vault {
1546
1606
  d.grants.set(principal, [...held]);
1547
1607
  const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
1548
1608
  if (d.writes.length > 0) d.touched.add(principal);
1549
- return { changes: outcomes };
1609
+ return { changes: outcomes, ...(admitted === undefined ? {} : { admitted }) };
1550
1610
  });
1551
1611
  }
1552
1612
 
1613
+ /**
1614
+ * Admit a service account that `setAccess` gives its first grants, as
1615
+ * `admit` would, its refusals logged as `member.add`'s: someone who does
1616
+ * not run the instance sets one up as they would through `admit`.
1617
+ */
1618
+ async #admitWith(d: Decision, acting: Standing, principal: string, row: Member | undefined, input: SetAccessInput): Promise<Admitted> {
1619
+ const refused = (code: RefusalCode, message = MESSAGES[code]) =>
1620
+ new Refused(refusal(code, message), [accessEntry(acting.principal, 'member.add', principal, 'deny', input, { changes: input.changes }, code)]);
1621
+ if (!SERVICE.test(principal)) throw refused('invalid', 'only a service account is admitted with its grants: a person, by an instance role');
1622
+ if (!runsInstance(acting.live)) await this.#setsUp(d, acting, true, refused);
1623
+ return this.#enlist(d, acting, principal, row, undefined, input, refused);
1624
+ }
1625
+
1553
1626
  /** One place's change, logged when it changes what is live. */
1554
1627
  #apply(
1555
1628
  d: Decision,
@@ -1601,7 +1674,7 @@ class VaultService implements Vault {
1601
1674
  return current === undefined ? 'created' : 'updated';
1602
1675
  }
1603
1676
 
1604
- admit(input: AdmitInput): Promise<Outcome<{ created: boolean; role: InstanceRole; scope: Scope; generation: number }>> {
1677
+ admit(input: AdmitInput): Promise<Outcome<Admitted>> {
1605
1678
  const { actor, principal } = input;
1606
1679
  const asked = input.role === undefined ? {} : { role: input.role, scope: input.scope ?? EVERYWHERE };
1607
1680
  const refused = (code: RefusalCode, message = MESSAGES[code]) =>
@@ -1612,21 +1685,10 @@ class VaultService implements Vault {
1612
1685
  validateCorrelation(input);
1613
1686
  const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
1614
1687
  if (acting.status === 'tampered') throw refused('tampered');
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
- }
1688
+ // A service account, with no grant yet, any person who could give it one sets up (`setsUpServices`).
1689
+ const service = SERVICE.test(principal) && (input.role ?? 'member') === 'member' && input.scope === undefined;
1690
+ if (!runsInstance(acting.live)) await this.#setsUp(d, acting, service, refused);
1628
1691
  if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
1629
- if (this.#isRootAdmin(principal)) throw refused('root_admin');
1630
1692
  if (input.role !== undefined && !isInstanceRole(input.role)) throw refused('invalid', `no such instance role: ${input.role}`);
1631
1693
  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
1694
  const wanted = input.role === undefined ? undefined : { role: input.role, scope: input.role === 'member' ? EVERYWHERE : normalScope(input.scope ?? EVERYWHERE) };
@@ -1638,39 +1700,22 @@ class VaultService implements Vault {
1638
1700
  if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
1639
1701
  d.touched.add(principal);
1640
1702
  d.grants.set(principal, [...subject.stored]);
1641
- const entry = (action: string, detail: Record<string, unknown>) =>
1642
- d.log.push(accessEntry(actor, action, principal, 'allow', input, detail));
1643
1703
 
1644
1704
  if (row === undefined || row.status === 'removed') {
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 });
1648
- d.writes.push(async (at) => {
1649
- if (row === undefined) {
1650
- await store.insertMember(d.tx, {
1651
- principal,
1652
- status: 'active',
1653
- role,
1654
- scope,
1655
- generation: 0,
1656
- createdAt: at,
1657
- createdBy: actor,
1658
- statusChangedAt: at,
1659
- statusChangedBy: actor,
1660
- ...UNSEALED,
1661
- });
1662
- } else {
1663
- await store.updateMember(d.tx, principal, { status: 'active', role, scope, statusChangedAt: at, statusChangedBy: actor });
1664
- }
1665
- });
1666
- // A removal moved the generation on already; coming back keeps it.
1667
- return { created: true, role, scope, generation: row?.generation ?? 0 };
1705
+ // Added or brought back, as anything but an Owner of the whole instance, one the deployment names would be a listed Member.
1706
+ if (this.#listed(principal) && !(wanted !== undefined && ownsInstance(wanted))) throw refused('not_allowed', declaredOwner(principal));
1707
+ return this.#enlist(d, acting, principal, row, wanted, input, refused);
1668
1708
  }
1669
1709
  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');
1710
+ // One the deployment names stays an Owner of the whole instance, whoever asks: the list says where to change that.
1711
+ if (changed && this.#listed(principal) && !ownsInstance(wanted)) throw refused('not_allowed', declaredOwner(principal));
1712
+ // Read under both rows' locks: neither the actor's role nor the subject's changes before this commits.
1713
+ if (changed && (wanted.role === 'owner' || subject.live.role === 'owner') && !ownsInstance(acting.live)) throw refused('not_allowed', OWNERS_ONLY);
1714
+ // Nobody sets their own role, but an Owner of the whole instance, who holds all there is: running the instance never lets anyone widen what they hold themselves.
1715
+ if (changed && actor === principal && !ownsInstance(acting.live)) throw refused('not_allowed', 'nobody changes their own instance role: ask another admin');
1716
+ if (changed && ownsInstance(subject.live) && !ownsInstance(wanted)) await this.#keepAnOwner(d, actor, principal, refused);
1672
1717
  if (changed) {
1673
- entry('member.role', { role: wanted.role, scope: wanted.scope, previousRole: row.role, previousScope: row.scope });
1718
+ d.log.push(accessEntry(actor, 'member.role', principal, 'allow', input, { role: wanted.role, scope: wanted.scope, previousRole: row.role, previousScope: row.scope }));
1674
1719
  d.writes.push(() => store.updateMember(d.tx, principal, { role: wanted.role, scope: wanted.scope }));
1675
1720
  return { created: false, ...wanted, generation: row.generation };
1676
1721
  }
@@ -1678,6 +1723,63 @@ class VaultService implements Vault {
1678
1723
  });
1679
1724
  }
1680
1725
 
1726
+ /**
1727
+ * Whether `acting`, who does not run the instance, may admit `principal`:
1728
+ * only a service account (`service`: one asked for with no role), as a
1729
+ * person who could give it a grant somewhere (`setsUpServices`), and one
1730
+ * removed only if they removed it themselves, never one someone else
1731
+ * took away.
1732
+ */
1733
+ async #setsUp(d: Decision, acting: Standing, service: boolean, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
1734
+ const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
1735
+ if (!service || !PERSON.test(acting.principal) || !setsUpServices(acting.live, setting, placesOf(await store.liveProjects(d.tx)))) {
1736
+ 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');
1737
+ }
1738
+ }
1739
+
1740
+ /**
1741
+ * Admit `principal`, who has no row or a removed one: a fresh start, as a
1742
+ * member unless given a role (`wanted`), at the generation a removal
1743
+ * moved on. The caller has checked who may, and locked the row.
1744
+ */
1745
+ #enlist(
1746
+ d: Decision,
1747
+ acting: Standing,
1748
+ principal: string,
1749
+ row: Member | undefined,
1750
+ wanted: { role: InstanceRole; scope: Scope } | undefined,
1751
+ correlation: Correlation,
1752
+ refused: (code: RefusalCode, message?: string) => Refused,
1753
+ ): Admitted {
1754
+ const actor = acting.principal;
1755
+ if (row?.status === 'removed' && row.statusChangedBy !== actor && !runsInstance(acting.live)) {
1756
+ throw refused('not_allowed', `${shownMember(row.statusChangedBy)} removed this service account: only they, or someone who runs the whole instance, may bring it back`);
1757
+ }
1758
+ if (wanted?.role === 'owner' && !ownsInstance(acting.live)) throw refused('not_allowed', OWNERS_ONLY);
1759
+ const { role, scope } = wanted ?? { role: 'member' as const, scope: EVERYWHERE };
1760
+ d.log.push(accessEntry(actor, row === undefined ? 'member.add' : 'member.restore', principal, 'allow', correlation, { role, scope }));
1761
+ d.writes.push(async (at) => {
1762
+ if (row === undefined) {
1763
+ await store.insertMember(d.tx, {
1764
+ principal,
1765
+ status: 'active',
1766
+ role,
1767
+ scope,
1768
+ generation: 0,
1769
+ createdAt: at,
1770
+ createdBy: actor,
1771
+ statusChangedAt: at,
1772
+ statusChangedBy: actor,
1773
+ ...UNSEALED,
1774
+ });
1775
+ } else {
1776
+ await store.updateMember(d.tx, principal, { status: 'active', role, scope, statusChangedAt: at, statusChangedBy: actor });
1777
+ }
1778
+ });
1779
+ // A removal moved the generation on already; coming back keeps it.
1780
+ return { created: true, role, scope, generation: row?.generation ?? 0 };
1781
+ }
1782
+
1681
1783
  remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>> {
1682
1784
  const { actor, principal } = input;
1683
1785
  const refused = (code: RefusalCode, message = MESSAGES[code]) =>
@@ -1685,13 +1787,13 @@ class VaultService implements Vault {
1685
1787
  return this.#decide([actor, principal], async (d) => {
1686
1788
  validateCorrelation(input);
1687
1789
  if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
1688
- if (this.#isRootAdmin(principal)) throw refused('root_admin');
1689
1790
  const row = d.members.get(principal);
1690
1791
  const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
1691
1792
  if (acting.status === 'tampered') throw refused('tampered');
1692
1793
  const holder = acting.live;
1693
1794
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1694
1795
  const held = subject.stored;
1796
+ if (this.#listed(principal)) throw refused('not_allowed', declaredOwner(principal));
1695
1797
  if (subject.status === 'tampered') {
1696
1798
  if (!runsInstance(holder)) throw refused('not_allowed', 'only admins and owners of the whole instance may remove a member whose record failed its check');
1697
1799
  return this.#startOver(d, actor, principal, row, subject.fault!, input, refused);
@@ -1703,6 +1805,8 @@ class VaultService implements Vault {
1703
1805
  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
1806
  }
1705
1807
  if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
1808
+ if (subject.live.role === 'owner' && !ownsInstance(holder)) throw refused('not_allowed', OWNERS_ONLY);
1809
+ if (ownsInstance(subject.live)) await this.#keepAnOwner(d, actor, principal, refused);
1706
1810
 
1707
1811
  const revoked = held.filter((grant) => live(grant, d.at));
1708
1812
  for (const grant of revoked) {
@@ -2048,14 +2152,16 @@ class VaultService implements Vault {
2048
2152
  });
2049
2153
  }
2050
2154
 
2051
- async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; rootAdmins: string[] }> {
2155
+ async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
2052
2156
  await this.#settled();
2053
2157
  const { signers, since } = this.#prepared;
2054
2158
  // A key it replaced vouches only for prefixes before its first entry under the current one: none, before it has one.
2055
2159
  const until = Number(since ?? 0n);
2056
2160
  return {
2057
2161
  checkpointKeys: Object.fromEntries(signers.map(({ keyId, publicKey }, i) => [keyId, { publicKey, until: i === 0 ? null : until }])),
2058
- rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`),
2162
+ owners: [...this.#config.owners],
2163
+ // For an app of 0.5 while it runs beside this vault: there are none, and its lists and checks need the field.
2164
+ rootAdmins: [],
2059
2165
  };
2060
2166
  }
2061
2167