@coffre/vault 0.5.0 → 0.6.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.
@@ -2,13 +2,13 @@ import { ACCESS_ACTIONS, checkpointMessage, checkpointVerifier, describeAccessFa
2
2
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
3
  import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { tablesOf } from "@coffre/db";
5
- import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, convertEveryProjectGrants, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, runsInstance, setsUpServices, unscoped } from "@coffre/core/access";
5
+ import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
7
  import { checkContext } from "@coffre/core/envelope";
8
8
  import { shownMember } from "@coffre/core/schemas";
9
9
  import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
10
- import { accessEntriesAbout, insertGrant, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic } from "@coffre/db/log";
10
+ import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
+ import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries } from "@coffre/db/log";
12
12
  import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
13
13
  //#region src/accounting.ts
14
14
  const KEY_ACTIONS = /* @__PURE__ */ new Set([
@@ -228,6 +228,16 @@ async function allMembers(db) {
228
228
  return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
229
229
  }
230
230
  /**
231
+ * The rows that say their member is an active Owner of the whole instance
232
+ * (`ownsInstance` in @coffre/core/access), as stored: a row changed around
233
+ * the vault says so too, which the caller checks. The scope is read as the
234
+ * row's MAC reads it, parsed: any way of writing everywhere is everywhere.
235
+ */
236
+ async function instanceOwners(db) {
237
+ const { vaultMembers } = tablesOf(db);
238
+ return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf).filter(ownsInstance);
239
+ }
240
+ /**
231
241
  * Lock these members' rows for the rest of the transaction, in one order
232
242
  * everywhere, and read them: those that have none are not in the map. Every
233
243
  * decision about a member takes its row first, so two of them, from any
@@ -331,26 +341,6 @@ async function places(db, projectIds, environmentIds) {
331
341
  deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((row) => row.id))
332
342
  };
333
343
  }
334
- /** Every project not deleted, with its environments not deleted: what a grant on every project of 0.4 reached. */
335
- async function liveProjects(db) {
336
- const { projects, environments } = tablesOf(db);
337
- const [found, inside] = await Promise.all([db.select({
338
- id: projects.id,
339
- deleted: truth(tombstone(projects.slug))
340
- }).from(projects).orderBy(asc(projects.id)), db.select({
341
- id: environments.id,
342
- projectId: environments.projectId,
343
- slug: environments.slug,
344
- deleted: truth(tombstone(environments.slug))
345
- }).from(environments).orderBy(asc(environments.id))]);
346
- return found.filter((project) => !project.deleted).map((project) => ({
347
- id: project.id,
348
- environments: inside.filter((environment) => environment.projectId === project.id && !environment.deleted).map(({ id, slug }) => ({
349
- id,
350
- slug
351
- }))
352
- }));
353
- }
354
344
  /** Who holds a grant on every project of 0.4, lapsed or not, which the vault replaces (`#convert`). */
355
345
  async function everyProjectHolders(db) {
356
346
  const { vaultGrants } = tablesOf(db);
@@ -476,11 +466,6 @@ async function latestVaultEntry(db, actions) {
476
466
  const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(1);
477
467
  return row === void 0 ? void 0 : stored([row])[0];
478
468
  }
479
- /** Up to `limit` of the vault's allowed entries of `action` before `before` (the newest when undefined), newest first. */
480
- async function newestVaultEntries(db, action, limit, before) {
481
- const { auditLog } = tablesOf(db);
482
- return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), eq(auditLog.decision, "allow"), before === void 0 ? void 0 : lt(auditLog.seq, before))).orderBy(desc(auditLog.seq)).limit(limit));
483
- }
484
469
  /** The vault's newest allowed access entry about each member who has one. */
485
470
  async function newestAccessEntries(db) {
486
471
  const { auditLog } = tablesOf(db);
@@ -964,7 +949,6 @@ async function prepareVault(config, options = {}) {
964
949
  clockOffset: options.clockOffset ?? (() => 0)
965
950
  },
966
951
  verified: UNVERIFIED,
967
- rooted: /* @__PURE__ */ new Set(),
968
952
  reported: /* @__PURE__ */ new Set(),
969
953
  since: null,
970
954
  superseded: null,
@@ -1000,7 +984,7 @@ const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
1000
984
  /** A person, and a service account: only a person sets one up (`givesService`). */
1001
985
  const PERSON = /^user:/;
1002
986
  const SERVICE = /^token:/;
1003
- /** Who acts for the vault itself, as when it gives a root admin a member row. */
987
+ /** Who acts for the vault itself, as when it makes an Owner its deployment names. */
1004
988
  const VAULT_ACTOR = "system:vault";
1005
989
  /** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
1006
990
  const KEY_CHECK_SAMPLE = 3;
@@ -1024,6 +1008,12 @@ const CLOSED_SETTINGS = { serviceAccounts: {
1024
1008
  projects: { only: [] },
1025
1009
  environments: "all"
1026
1010
  } };
1011
+ /** Why a change to who is an Owner, scoped or not, is refused to anyone else: Owners make and unmake Owners. */
1012
+ const OWNERS_ONLY = "only an Owner of the whole instance makes someone an Owner, or changes or removes one";
1013
+ /** Why nobody demotes or removes a person the deployment names (`owners`), themselves included. */
1014
+ function declaredOwner(principal) {
1015
+ return `the deployment names ${principal.slice(5)} an Owner (owners, in the vault's configuration): take them off that list and deploy first`;
1016
+ }
1027
1017
  /** Why a person's change to a service account is refused. */
1028
1018
  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";
1029
1019
  /** Who asks for checkpoints: the app's scheduled job. */
@@ -1072,7 +1062,7 @@ const MESSAGES = {
1072
1062
  deleted: "this project or environment was deleted",
1073
1063
  ended: "the reference read through was ended",
1074
1064
  not_allowed: "not allowed to change this",
1075
- root_admin: "root admins are set in the vault configuration",
1065
+ last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
1076
1066
  invalid: "not something the rules allow",
1077
1067
  log_broken: "the vault log does not hold from the last checkpoint",
1078
1068
  wrong_kek: "this vault's key does not open the data it holds",
@@ -1319,7 +1309,7 @@ var VaultService = class {
1319
1309
  async #settled() {
1320
1310
  const prepared = this.#prepared;
1321
1311
  if (prepared.since === null && prepared.superseded === null) await this.#settle();
1322
- if (prepared.superseded === null && !prepared.converted) await this.#convert();
1312
+ if (prepared.superseded === null && prepared.since !== null && !prepared.converted) await this.#convert();
1323
1313
  return prepared.superseded;
1324
1314
  }
1325
1315
  /**
@@ -1336,14 +1326,16 @@ var VaultService = class {
1336
1326
  * genuine row put back). `reportStands` is the rule, which `coffre
1337
1327
  * migrate` applies too, holding no key, to every entry in the vault's
1338
1328
  * name: it lists and marks a member so reported, forged or not.
1339
- * Removing them clears both.
1329
+ * Removing them clears both. Then everyone the deployment names becomes
1330
+ * an Owner (`#declare`): after, so that what their grants become is what
1331
+ * `coffre migrate` says, by the same rule over the same rows
1332
+ * (`everyProjectConversion`), which cannot tell who the configuration
1333
+ * names. Until both finish, each call does again what is left, a few
1334
+ * reads once nothing is.
1340
1335
  */
1341
1336
  async #convert() {
1342
- for (const principal of await everyProjectHolders(this.#db)) {
1343
- if (this.#isRootAdmin(principal)) continue;
1344
- await this.#decideNow([principal], (d) => this.#replaceEveryProject(d, principal));
1345
- }
1346
- this.#prepared.converted = true;
1337
+ for (const principal of await everyProjectHolders(this.#db)) await this.#decideNow([principal], (d) => this.#replaceEveryProject(d, principal));
1338
+ this.#prepared.converted = await this.#declare(this.#config.owners.map((email) => `user:${email}`));
1347
1339
  }
1348
1340
  async #replaceEveryProject(d, principal) {
1349
1341
  const row = d.members.get(principal);
@@ -1354,22 +1346,7 @@ var VaultService = class {
1354
1346
  if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
1355
1347
  await lockLogHead(d.tx);
1356
1348
  const before = roleOf(principal, row);
1357
- const conversion = convertEveryProjectGrants({
1358
- person: principal.startsWith("user:"),
1359
- role: before.role,
1360
- everyProject: everyProject.filter((grant) => live(grant, d.at)).map((grant) => ({
1361
- environmentSlug: grant.environmentSlug,
1362
- role: grant.role,
1363
- expiresAt: grant.expiresAt
1364
- })),
1365
- grants: subject.stored.filter((grant) => grantKind(grant) !== null && live(grant, d.at)).map((grant) => ({
1366
- projectId: grant.projectId,
1367
- environmentId: grant.environmentId,
1368
- role: grant.role,
1369
- expiresAt: grant.expiresAt
1370
- })),
1371
- projects: await liveProjects(d.tx)
1372
- });
1349
+ const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await liveProjects(d.tx));
1373
1350
  const after = {
1374
1351
  role: conversion.role,
1375
1352
  scope: conversion.scope
@@ -1693,7 +1670,6 @@ var VaultService = class {
1693
1670
  };
1694
1671
  }
1695
1672
  async #refuseVersions(principal, code, entries) {
1696
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1697
1673
  return this.#decide([principal], async () => {
1698
1674
  throw new Refused(refusal(code, MESSAGES[code]), entries);
1699
1675
  });
@@ -1729,7 +1705,6 @@ var VaultService = class {
1729
1705
  if (wrongKek !== null) return this.#decide([], async () => {
1730
1706
  throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
1731
1707
  });
1732
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1733
1708
  const check = async (d) => {
1734
1709
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
1735
1710
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
@@ -1875,7 +1850,6 @@ var VaultService = class {
1875
1850
  })
1876
1851
  };
1877
1852
  };
1878
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1879
1853
  return this.#decide([principal], async (d) => {
1880
1854
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1881
1855
  const environments = await environmentsById(d.tx, items.flatMap((item) => {
@@ -1948,7 +1922,6 @@ var VaultService = class {
1948
1922
  ...traced(input)
1949
1923
  })
1950
1924
  });
1951
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1952
1925
  if (sealed.some((seal) => seal === null)) return this.#decide([principal], async () => {
1953
1926
  throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i], "deny", "bad_claim")));
1954
1927
  });
@@ -1996,80 +1969,129 @@ var VaultService = class {
1996
1969
  return sealedReference(entry, via.reference);
1997
1970
  });
1998
1971
  }
1999
- #isRootAdmin(principal) {
2000
- return principal.startsWith("user:") && this.#config.rootAdmins.includes(principal.slice(5));
1972
+ /** Whether the deployment names `principal` an Owner of the whole instance (`owners`). */
1973
+ #listed(principal) {
1974
+ return principal.startsWith("user:") && this.#config.owners.includes(principal.slice(5));
2001
1975
  }
2002
1976
  /**
2003
- * Give a root admin a member row the first time anyone asks about them:
2004
- * the app's sign-ins and sessions point at it, and their reads queue on
2005
- * it like anyone's. Logged, and made in a transaction of its own, under
2006
- * the log's lock: a decision that held the head and then waited for a
2007
- * member row would take the locks out of order. The row never makes
2008
- * anyone a root admin; the configuration does. Rows are never deleted, so
2009
- * one read finds it made already, as on every fresh isolate but the first,
2010
- * without queueing on the log's lock.
1977
+ * An Owner of the whole instance other than `except`, as their row says
1978
+ * and its check holds (`#integrity`), or null when there is none. A row
1979
+ * changed around the vault owns nothing, and is reported. Read under the
1980
+ * log's head, which every change to a member takes before it writes, the
1981
+ * answer stands until the reader commits.
2011
1982
  */
2012
- async #rootRow(principal) {
2013
- if (this.#prepared.rooted.has(principal) || await this.#settled() !== null) return;
2014
- if (await member(this.#db, principal) !== void 0) {
2015
- this.#prepared.rooted.add(principal);
2016
- return;
1983
+ async #anOwner(db, reports, except = null) {
1984
+ for (const row of await instanceOwners(db)) {
1985
+ if (row.principal === except) continue;
1986
+ if (await this.#integrity(db, row.principal, row, await grants(db, row.principal), reports) === null) return row.principal;
2017
1987
  }
2018
- await this.#db.transaction(async (tx) => {
2019
- await lockLogHead(tx);
2020
- if (await member(tx, principal) !== void 0) return;
2021
- const { occurredAt: at, seqStart } = await this.#append(tx, [{
2022
- actor: VAULT_ACTOR,
2023
- action: "member.add",
2024
- decision: "allow",
2025
- subjectPrincipal: principal,
2026
- metadata: JSON.stringify({
2027
- role: "member",
2028
- rootAdmin: true
2029
- })
2030
- }]);
2031
- const row = {
1988
+ return null;
1989
+ }
1990
+ /**
1991
+ * Make each of `principals`, people the deployment names (`owners`), an
1992
+ * Owner of the whole instance, whoever else is one: asked about all of
1993
+ * them at the start of each process (`#convert`), and at `access` about
1994
+ * one who owns nothing, which is a new instance's first sign-in, before
1995
+ * the vault has written to its log, and the way back for someone whose
1996
+ * row was changed around the vault since. An unlocked read of each finds
1997
+ * who is one already, the quick way out: a hint, never a report, and
1998
+ * never `#record`, which would settle the vault, and so convert, again;
1999
+ * one it cannot tell, a row changing under it as another process makes
2000
+ * them an Owner, is due. Then one decision, under the others' rows, reads
2001
+ * again (`#makeOwner`). One whose row the log never admitted stays as it is,
2002
+ * and is asked about again at each of their requests. Whether it ran.
2003
+ */
2004
+ async #declare(principals) {
2005
+ const at = await this.#now(this.#db);
2006
+ const due = [];
2007
+ for (const principal of principals) {
2008
+ const subject = await this.#standing(this.#db, principal, await member(this.#db, principal), at, []).catch((error) => {
2009
+ if (error instanceof Retry) return null;
2010
+ throw error;
2011
+ });
2012
+ if (!(subject?.status === "active" && ownsInstance(subject.live))) due.push(principal);
2013
+ }
2014
+ if (due.length === 0) return true;
2015
+ return (await this.#decideNow(due, async (d) => {
2016
+ for (const principal of due) await this.#makeOwner(d, principal, "owners");
2017
+ })).ok;
2018
+ }
2019
+ /**
2020
+ * Refuse a change that would leave `principal`, an Owner of the whole
2021
+ * instance, no longer one, unless another is. Counted under the log's
2022
+ * head: two changes at once, each demoting or removing a different
2023
+ * Owner, lock different rows, and only the head puts one after the other.
2024
+ */
2025
+ async #keepAnOwner(d, actor, principal, refused) {
2026
+ await lockLogHead(d.tx);
2027
+ if (await this.#anOwner(d.tx, d.reports, principal) !== null) return;
2028
+ throw refused("last_owner", actor === principal ? "you're the last Owner of the whole instance: make someone else an Owner first" : `${principal.slice(5)} is the last Owner of the whole instance: make someone else an Owner first`);
2029
+ }
2030
+ /**
2031
+ * Make `principal`, a person, an Owner of the whole instance, as the
2032
+ * vault, for `reason`: admitted, brought back, or given the role, the
2033
+ * grants they hold kept. One whose row fails its check is started over
2034
+ * first, as removing them would; one the log never admitted, whose row was
2035
+ * written around the vault, is left as it is. Whether it made them one.
2036
+ */
2037
+ async #makeOwner(d, principal, reason) {
2038
+ const row = d.members.get(principal);
2039
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2040
+ if (subject.status === "active" && ownsInstance(subject.live)) return false;
2041
+ const tampered = subject.status === "tampered";
2042
+ if (tampered && await this.#logged(d.tx, principal) === void 0) return false;
2043
+ const correlation = { operationId: randomUUID() };
2044
+ const owner = {
2045
+ role: "owner",
2046
+ scope: EVERYWHERE
2047
+ };
2048
+ d.grants.set(principal, [...subject.stored]);
2049
+ d.touched.add(principal);
2050
+ if (tampered) await this.#startOver(d, VAULT_ACTOR, principal, row, subject.fault, correlation, (code, message) => new Refused(refusal(code, message ?? MESSAGES[code]), []));
2051
+ if (row === void 0 && !tampered) {
2052
+ d.log.push(accessEntry(VAULT_ACTOR, "member.add", principal, "allow", correlation, {
2053
+ ...owner,
2054
+ reason
2055
+ }));
2056
+ d.writes.push((at) => insertMember(d.tx, {
2032
2057
  principal,
2033
2058
  status: "active",
2034
- role: "member",
2035
- scope: EVERYWHERE,
2059
+ ...owner,
2036
2060
  generation: 0,
2037
2061
  createdAt: at,
2038
2062
  createdBy: VAULT_ACTOR,
2039
2063
  statusChangedAt: at,
2040
2064
  statusChangedBy: VAULT_ACTOR,
2041
- accessSeq: seqStart
2042
- };
2043
- await insertMember(tx, {
2044
- ...row,
2045
- mac: memberMac(this.#prepared.rowKey, row, [])
2046
- });
2047
- });
2048
- this.#prepared.rooted.add(principal);
2065
+ ...UNSEALED
2066
+ }));
2067
+ } else if (tampered || row?.status === "removed") {
2068
+ d.log.push(accessEntry(VAULT_ACTOR, "member.restore", principal, "allow", correlation, {
2069
+ ...owner,
2070
+ reason
2071
+ }));
2072
+ d.writes.push((at) => updateMember(d.tx, principal, {
2073
+ status: "active",
2074
+ ...owner,
2075
+ statusChangedAt: at,
2076
+ statusChangedBy: VAULT_ACTOR
2077
+ }));
2078
+ } else {
2079
+ d.log.push(accessEntry(VAULT_ACTOR, "member.role", principal, "allow", correlation, {
2080
+ ...owner,
2081
+ previousRole: row.role,
2082
+ previousScope: row.scope,
2083
+ reason
2084
+ }));
2085
+ d.writes.push(() => updateMember(d.tx, principal, owner));
2086
+ }
2087
+ return true;
2049
2088
  }
2050
2089
  /**
2051
2090
  * What `principal` holds, their row checked first (`#integrity`): a row
2052
- * that fails holds nothing, and is `tampered`. A root admin's come from the
2053
- * configuration, which no row can change.
2091
+ * that fails holds nothing, and is `tampered`.
2054
2092
  */
2055
2093
  async #standing(db, principal, row, at, reports) {
2056
2094
  const none = NOTHING;
2057
- if (this.#isRootAdmin(principal)) {
2058
- const root = {
2059
- isRootAdmin: true,
2060
- role: "owner",
2061
- scope: EVERYWHERE,
2062
- grants: []
2063
- };
2064
- return {
2065
- principal,
2066
- status: "active",
2067
- live: root,
2068
- all: root,
2069
- fault: null,
2070
- stored: []
2071
- };
2072
- }
2073
2095
  const grants$1 = row === void 0 ? [] : await grants(db, principal);
2074
2096
  const fault = await this.#integrity(db, principal, row, grants$1, reports);
2075
2097
  if (fault !== null) return {
@@ -2105,13 +2127,11 @@ var VaultService = class {
2105
2127
  principal,
2106
2128
  status: "active",
2107
2129
  live: {
2108
- isRootAdmin: false,
2109
2130
  role,
2110
2131
  scope,
2111
2132
  grants: held.filter((grant) => live(grant, at))
2112
2133
  },
2113
2134
  all: {
2114
- isRootAdmin: false,
2115
2135
  role,
2116
2136
  scope,
2117
2137
  grants: held
@@ -2121,23 +2141,11 @@ var VaultService = class {
2121
2141
  };
2122
2142
  }
2123
2143
  #access(principal, row, held, at, tampered) {
2124
- if (this.#isRootAdmin(principal)) return {
2125
- principal,
2126
- status: "active",
2127
- generation: row?.generation ?? 0,
2128
- isRootAdmin: true,
2129
- role: "owner",
2130
- scope: EVERYWHERE,
2131
- grants: [],
2132
- since: null,
2133
- by: null
2134
- };
2135
2144
  const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
2136
2145
  return {
2137
2146
  principal,
2138
2147
  status: tampered ? "tampered" : !PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
2139
2148
  generation: row?.generation ?? 0,
2140
- isRootAdmin: false,
2141
2149
  ...active ? roleOf(principal, row) : {
2142
2150
  role: "member",
2143
2151
  scope: EVERYWHERE
@@ -2151,18 +2159,29 @@ var VaultService = class {
2151
2159
  * What `principal` holds now. Read without locks, the fast way; a row that
2152
2160
  * seems to fail its check is read again in one snapshot before anyone is
2153
2161
  * called tampered, since a change committed between two of the reads
2154
- * looks like one.
2162
+ * looks like one. A person the deployment names (`owners`) who owns
2163
+ * nothing becomes an Owner of the whole instance first (`#declare`). A
2164
+ * vault that holds no key the log's newest entries are under, given the
2165
+ * wrong vault key, cannot tell a row it cannot check from one changed
2166
+ * around it: it says so, an outage, rather than call the member tampered.
2167
+ * A row it can check still answers, so a forged entry that stops it
2168
+ * writing leaves the Owners able to sign in and find it.
2155
2169
  */
2156
2170
  async access(principal) {
2157
- await this.#settled();
2158
- if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
2171
+ const superseded = await this.#settled();
2172
+ const access = await this.#read(principal, superseded);
2173
+ if (superseded !== null || !this.#listed(principal) || access.status === "active" && ownsInstance(access)) return access;
2174
+ return await this.#declare([principal]) ? this.#read(principal, superseded) : access;
2175
+ }
2176
+ /** `access`, as the rows are now. */
2177
+ async #read(principal, superseded) {
2159
2178
  const read = async (db, reports) => {
2160
2179
  const [row, held, at] = await Promise.all([
2161
2180
  member(db, principal),
2162
2181
  grants(db, principal),
2163
2182
  this.#now(db)
2164
2183
  ]);
2165
- const fault = this.#isRootAdmin(principal) ? null : await this.#integrity(db, principal, row, held, reports);
2184
+ const fault = await this.#integrity(db, principal, row, held, reports);
2166
2185
  return this.#access(principal, row, held, at, fault !== null);
2167
2186
  };
2168
2187
  const found = [];
@@ -2176,6 +2195,7 @@ var VaultService = class {
2176
2195
  }
2177
2196
  const reports = [];
2178
2197
  const access = await this.#db.transaction((tx) => read(tx, reports), SNAPSHOT);
2198
+ if (access.status === "tampered" && superseded !== null) throw new Error(superseded);
2179
2199
  await this.#record(reports);
2180
2200
  return access;
2181
2201
  }
@@ -2194,7 +2214,6 @@ var VaultService = class {
2194
2214
  const held = await grants(db);
2195
2215
  const newest = await newestAccessEntries(db);
2196
2216
  for (const row of rows) {
2197
- if (this.#isRootAdmin(row.principal)) continue;
2198
2217
  const grants = held.filter((grant) => grant.principal === row.principal);
2199
2218
  const entry = newest.get(row.principal);
2200
2219
  if (!(entry !== void 0 && this.#authentic(entry) && this.#sealed(row, grants) && entry.seq === row.accessSeq)) await this.#integrity(db, row.principal, row, grants, reports);
@@ -2227,7 +2246,6 @@ var VaultService = class {
2227
2246
  return this.#decide([actor, principal], async (d) => {
2228
2247
  validateCorrelation(input);
2229
2248
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
2230
- if (this.#isRootAdmin(principal)) throw refused("root_admin");
2231
2249
  const places$1 = /* @__PURE__ */ new Set();
2232
2250
  for (const change of changes) {
2233
2251
  if (grantKind(change) === null) throw refused("invalid", change.projectId === null && change.environmentId === null ? "grants are on a project or an environment: a person reaches every project by their instance role" : "an environment is named in its project");
@@ -2255,15 +2273,19 @@ var VaultService = class {
2255
2273
  if (acting.status === "tampered") throw refused("tampered");
2256
2274
  const manages = where.every((place) => mayManageAccess(acting.live, place));
2257
2275
  if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused("not_allowed");
2258
- if (actor === principal && !acting.live.isRootAdmin && changes.some((change) => change.role !== null)) throw refused("not_allowed", "nobody grants themselves a role: ask another admin");
2276
+ if (actor === principal && changes.some((change) => change.role !== null)) throw refused("not_allowed", "nobody grants themselves a role: ask another admin");
2259
2277
  const row = d.members.get(principal);
2260
2278
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2261
2279
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2262
- if (row?.status === "removed") throw refused("removed");
2263
- if (row === void 0) throw refused("not_a_member");
2280
+ const admitted = input.admit === true && row?.status !== "active" ? await this.#admitWith(d, acting, principal, row, input) : void 0;
2281
+ if (admitted === void 0 && row?.status === "removed") throw refused("removed");
2282
+ if (admitted === void 0 && row === void 0) throw refused("not_a_member");
2264
2283
  if (!manages) {
2265
2284
  const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
2266
- const account = await this.#account(d.tx, subject, row);
2285
+ const account = admitted !== void 0 || row === void 0 ? {
2286
+ grants: [],
2287
+ admittedBy: actor
2288
+ } : await this.#account(d.tx, subject, row);
2267
2289
  if (!changes.every((change, i) => {
2268
2290
  const before = subject.live.grants.find((grant) => placeKey({
2269
2291
  ...grant,
@@ -2276,9 +2298,23 @@ var VaultService = class {
2276
2298
  d.grants.set(principal, [...held]);
2277
2299
  const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
2278
2300
  if (d.writes.length > 0) d.touched.add(principal);
2279
- return { changes: outcomes };
2301
+ return {
2302
+ changes: outcomes,
2303
+ ...admitted === void 0 ? {} : { admitted }
2304
+ };
2280
2305
  });
2281
2306
  }
2307
+ /**
2308
+ * Admit a service account that `setAccess` gives its first grants, as
2309
+ * `admit` would, its refusals logged as `member.add`'s: someone who does
2310
+ * not run the instance sets one up as they would through `admit`.
2311
+ */
2312
+ async #admitWith(d, acting, principal, row, input) {
2313
+ const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(acting.principal, "member.add", principal, "deny", input, { changes: input.changes }, code)]);
2314
+ if (!SERVICE.test(principal)) throw refused("invalid", "only a service account is admitted with its grants: a person, by an instance role");
2315
+ if (!runsInstance(acting.live)) await this.#setsUp(d, acting, true, refused);
2316
+ return this.#enlist(d, acting, principal, row, void 0, input, refused);
2317
+ }
2282
2318
  /** One place's change, logged when it changes what is live. */
2283
2319
  #apply(d, actor, principal, held, change, correlation) {
2284
2320
  const existing = held.find((grant) => placeKey(grant) === placeKey(change));
@@ -2341,15 +2377,9 @@ var VaultService = class {
2341
2377
  validateCorrelation(input);
2342
2378
  const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2343
2379
  if (acting.status === "tampered") throw refused("tampered");
2344
- if (!runsInstance(acting.live)) {
2345
- const service = SERVICE.test(principal) && (input.role ?? "member") === "member" && input.scope === void 0;
2346
- const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
2347
- if (!service || !PERSON.test(actor) || !setsUpServices(acting.live, setting, placesOf(await liveProjects(d.tx)))) 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");
2348
- const removed = d.members.get(principal);
2349
- if (removed?.status === "removed" && removed.statusChangedBy !== actor) throw refused("not_allowed", `${shownMember(removed.statusChangedBy)} removed this service account: only they, or someone who runs the whole instance, may bring it back`);
2350
- }
2380
+ const service = SERVICE.test(principal) && (input.role ?? "member") === "member" && input.scope === void 0;
2381
+ if (!runsInstance(acting.live)) await this.#setsUp(d, acting, service, refused);
2351
2382
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
2352
- if (this.#isRootAdmin(principal)) throw refused("root_admin");
2353
2383
  if (input.role !== void 0 && !isInstanceRole(input.role)) throw refused("invalid", `no such instance role: ${input.role}`);
2354
2384
  if (input.scope !== void 0 && (input.role === void 0 || !isScope(input.scope))) throw refused("invalid", "a scope is a role's: { projects, environments }, each all, only or except");
2355
2385
  const wanted = input.role === void 0 ? void 0 : {
@@ -2362,53 +2392,22 @@ var VaultService = class {
2362
2392
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2363
2393
  d.touched.add(principal);
2364
2394
  d.grants.set(principal, [...subject.stored]);
2365
- const entry = (action, detail) => d.log.push(accessEntry(actor, action, principal, "allow", input, detail));
2366
2395
  if (row === void 0 || row.status === "removed") {
2367
- const { role, scope } = wanted ?? {
2368
- role: "member",
2369
- scope: EVERYWHERE
2370
- };
2371
- entry(row === void 0 ? "member.add" : "member.restore", {
2372
- role,
2373
- scope
2374
- });
2375
- d.writes.push(async (at) => {
2376
- if (row === void 0) await insertMember(d.tx, {
2377
- principal,
2378
- status: "active",
2379
- role,
2380
- scope,
2381
- generation: 0,
2382
- createdAt: at,
2383
- createdBy: actor,
2384
- statusChangedAt: at,
2385
- statusChangedBy: actor,
2386
- ...UNSEALED
2387
- });
2388
- else await updateMember(d.tx, principal, {
2389
- status: "active",
2390
- role,
2391
- scope,
2392
- statusChangedAt: at,
2393
- statusChangedBy: actor
2394
- });
2395
- });
2396
- return {
2397
- created: true,
2398
- role,
2399
- scope,
2400
- generation: row?.generation ?? 0
2401
- };
2396
+ if (this.#listed(principal) && !(wanted !== void 0 && ownsInstance(wanted))) throw refused("not_allowed", declaredOwner(principal));
2397
+ return this.#enlist(d, acting, principal, row, wanted, input, refused);
2402
2398
  }
2403
2399
  const changed = wanted !== void 0 && JSON.stringify(standing(wanted)) !== JSON.stringify(standing(row));
2404
- if (changed && actor === principal) throw refused("not_allowed", "nobody changes their own instance role: ask another admin");
2400
+ if (changed && this.#listed(principal) && !ownsInstance(wanted)) throw refused("not_allowed", declaredOwner(principal));
2401
+ if (changed && (wanted.role === "owner" || subject.live.role === "owner") && !ownsInstance(acting.live)) throw refused("not_allowed", OWNERS_ONLY);
2402
+ if (changed && actor === principal && !ownsInstance(acting.live)) throw refused("not_allowed", "nobody changes their own instance role: ask another admin");
2403
+ if (changed && ownsInstance(subject.live) && !ownsInstance(wanted)) await this.#keepAnOwner(d, actor, principal, refused);
2405
2404
  if (changed) {
2406
- entry("member.role", {
2405
+ d.log.push(accessEntry(actor, "member.role", principal, "allow", input, {
2407
2406
  role: wanted.role,
2408
2407
  scope: wanted.scope,
2409
2408
  previousRole: row.role,
2410
2409
  previousScope: row.scope
2411
- });
2410
+ }));
2412
2411
  d.writes.push(() => updateMember(d.tx, principal, {
2413
2412
  role: wanted.role,
2414
2413
  scope: wanted.scope
@@ -2427,19 +2426,75 @@ var VaultService = class {
2427
2426
  };
2428
2427
  });
2429
2428
  }
2429
+ /**
2430
+ * Whether `acting`, who does not run the instance, may admit `principal`:
2431
+ * only a service account (`service`: one asked for with no role), as a
2432
+ * person who could give it a grant somewhere (`setsUpServices`), and one
2433
+ * removed only if they removed it themselves, never one someone else
2434
+ * took away.
2435
+ */
2436
+ async #setsUp(d, acting, service, refused) {
2437
+ const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
2438
+ if (!service || !PERSON.test(acting.principal) || !setsUpServices(acting.live, setting, placesOf(await liveProjects(d.tx)))) 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");
2439
+ }
2440
+ /**
2441
+ * Admit `principal`, who has no row or a removed one: a fresh start, as a
2442
+ * member unless given a role (`wanted`), at the generation a removal
2443
+ * moved on. The caller has checked who may, and locked the row.
2444
+ */
2445
+ #enlist(d, acting, principal, row, wanted, correlation, refused) {
2446
+ const actor = acting.principal;
2447
+ if (row?.status === "removed" && row.statusChangedBy !== actor && !runsInstance(acting.live)) throw refused("not_allowed", `${shownMember(row.statusChangedBy)} removed this service account: only they, or someone who runs the whole instance, may bring it back`);
2448
+ if (wanted?.role === "owner" && !ownsInstance(acting.live)) throw refused("not_allowed", OWNERS_ONLY);
2449
+ const { role, scope } = wanted ?? {
2450
+ role: "member",
2451
+ scope: EVERYWHERE
2452
+ };
2453
+ d.log.push(accessEntry(actor, row === void 0 ? "member.add" : "member.restore", principal, "allow", correlation, {
2454
+ role,
2455
+ scope
2456
+ }));
2457
+ d.writes.push(async (at) => {
2458
+ if (row === void 0) await insertMember(d.tx, {
2459
+ principal,
2460
+ status: "active",
2461
+ role,
2462
+ scope,
2463
+ generation: 0,
2464
+ createdAt: at,
2465
+ createdBy: actor,
2466
+ statusChangedAt: at,
2467
+ statusChangedBy: actor,
2468
+ ...UNSEALED
2469
+ });
2470
+ else await updateMember(d.tx, principal, {
2471
+ status: "active",
2472
+ role,
2473
+ scope,
2474
+ statusChangedAt: at,
2475
+ statusChangedBy: actor
2476
+ });
2477
+ });
2478
+ return {
2479
+ created: true,
2480
+ role,
2481
+ scope,
2482
+ generation: row?.generation ?? 0
2483
+ };
2484
+ }
2430
2485
  remove(input) {
2431
2486
  const { actor, principal } = input;
2432
2487
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.remove", principal, "deny", input, {}, code)]);
2433
2488
  return this.#decide([actor, principal], async (d) => {
2434
2489
  validateCorrelation(input);
2435
2490
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
2436
- if (this.#isRootAdmin(principal)) throw refused("root_admin");
2437
2491
  const row = d.members.get(principal);
2438
2492
  const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2439
2493
  if (acting.status === "tampered") throw refused("tampered");
2440
2494
  const holder = acting.live;
2441
2495
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2442
2496
  const held = subject.stored;
2497
+ if (this.#listed(principal)) throw refused("not_allowed", declaredOwner(principal));
2443
2498
  if (subject.status === "tampered") {
2444
2499
  if (!runsInstance(holder)) throw refused("not_allowed", "only admins and owners of the whole instance may remove a member whose record failed its check");
2445
2500
  return this.#startOver(d, actor, principal, row, subject.fault, input, refused);
@@ -2448,6 +2503,8 @@ var VaultService = class {
2448
2503
  if (!(SERVICE.test(principal) && PERSON.test(actor) && row?.status === "active" && managesService(holder, (await this.#settingsAt(d.tx, d.reports)).serviceAccounts, actor, await this.#account(d.tx, subject, row)))) throw refused("not_allowed", "only admins and owners of the whole instance may remove members, and a service account whoever manages it");
2449
2504
  }
2450
2505
  if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
2506
+ if (subject.live.role === "owner" && !ownsInstance(holder)) throw refused("not_allowed", OWNERS_ONLY);
2507
+ if (ownsInstance(subject.live)) await this.#keepAnOwner(d, actor, principal, refused);
2451
2508
  const revoked = held.filter((grant) => live(grant, d.at));
2452
2509
  for (const grant of revoked) {
2453
2510
  const where = located(grant);
@@ -2814,7 +2871,8 @@ var VaultService = class {
2814
2871
  publicKey,
2815
2872
  until: i === 0 ? null : until
2816
2873
  }])),
2817
- rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`)
2874
+ owners: [...this.#config.owners],
2875
+ rootAdmins: []
2818
2876
  };
2819
2877
  }
2820
2878
  async keyChecks() {