@coffre/vault 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { allows, assignableToEnvironment, everyProjectPath, grantKind, isRole, mayManageAccess } from "@coffre/core/access";
5
+ import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, convertEveryProjectGrants, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, 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 { appendEntries, lockLogHead } from "@coffre/db/log";
11
- import { insertGrant, readGrants } from "@coffre/db/grants";
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";
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([
@@ -188,6 +188,8 @@ function memberColumns(db) {
188
188
  principal: vaultMembers.principal,
189
189
  status: vaultMembers.status,
190
190
  owner: vaultMembers.owner,
191
+ role: vaultMembers.role,
192
+ scope: vaultMembers.scope,
191
193
  generation: vaultMembers.generation,
192
194
  createdAt: vaultMembers.createdAt,
193
195
  createdBy: vaultMembers.createdBy,
@@ -197,14 +199,33 @@ function memberColumns(db) {
197
199
  mac: vaultMembers.mac
198
200
  };
199
201
  }
202
+ /** A member as their row says (`storedRole`). */
203
+ function memberOf({ owner, role, scope, ...row }) {
204
+ return {
205
+ ...row,
206
+ ...storedRole({
207
+ owner,
208
+ role,
209
+ scope
210
+ })
211
+ };
212
+ }
213
+ function storedChange(change) {
214
+ const { role, scope, ...rest } = change;
215
+ if (role === void 0) return rest;
216
+ return {
217
+ ...rest,
218
+ ...roleColumns(role, scope ?? EVERYWHERE)
219
+ };
220
+ }
200
221
  async function member(db, principal) {
201
222
  const { vaultMembers } = tablesOf(db);
202
223
  const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
203
- return row;
224
+ return row === void 0 ? void 0 : memberOf(row);
204
225
  }
205
226
  async function allMembers(db) {
206
227
  const { vaultMembers } = tablesOf(db);
207
- return await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal));
228
+ return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
208
229
  }
209
230
  /**
210
231
  * Lock these members' rows for the rest of the transaction, in one order
@@ -215,7 +236,7 @@ async function allMembers(db) {
215
236
  async function lockMembers(tx, principals) {
216
237
  const { vaultMembers } = tablesOf(tx);
217
238
  const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
218
- return new Map(rows.map((row) => [row.principal, row]));
239
+ return new Map(rows.map((row) => [row.principal, memberOf(row)]));
219
240
  }
220
241
  /**
221
242
  * Add `row`. A principal has one row: when two decisions admit the same
@@ -223,11 +244,11 @@ async function lockMembers(tx, principals) {
223
244
  */
224
245
  async function insertMember(tx, row) {
225
246
  const { vaultMembers } = tablesOf(tx);
226
- await tx.insert(vaultMembers).values(row);
247
+ await tx.insert(vaultMembers).values(storedChange(row));
227
248
  }
228
249
  async function updateMember(tx, principal, change) {
229
250
  const { vaultMembers } = tablesOf(tx);
230
- await tx.update(vaultMembers).set(change).where(eq(vaultMembers.principal, principal));
251
+ await tx.update(vaultMembers).set(storedChange(change)).where(eq(vaultMembers.principal, principal));
231
252
  }
232
253
  /** Grants, lapsed ones too, each with its project, which an environment's grant finds through `environments`. */
233
254
  function grants(db, principal) {
@@ -289,7 +310,7 @@ async function wrappedUnder(db, provider, keyId, limit) {
289
310
  bytes: Buffer.from(row.bytes)
290
311
  }));
291
312
  }
292
- /** Of these projects and environments, the ones that exist, each environment with its project. */
313
+ /** Of these projects and environments, the ones that exist, each environment with its project and slug. */
293
314
  async function places(db, projectIds, environmentIds) {
294
315
  const { projects, environments } = tablesOf(db);
295
316
  const [foundProjects, foundEnvironments] = await Promise.all([projectIds.length === 0 ? [] : db.select({
@@ -298,14 +319,43 @@ async function places(db, projectIds, environmentIds) {
298
319
  }).from(projects).where(inArray(projects.id, [...projectIds])), environmentIds.length === 0 ? [] : db.select({
299
320
  id: environments.id,
300
321
  projectId: environments.projectId,
322
+ slug: environments.slug,
301
323
  deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug)))
302
324
  }).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(inArray(environments.id, [...environmentIds]))]);
303
325
  return {
304
326
  projects: new Set(foundProjects.map((row) => row.id)),
305
- environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId])),
327
+ environments: new Map(foundEnvironments.map((row) => [row.id, {
328
+ projectId: row.projectId,
329
+ slug: row.slug
330
+ }])),
306
331
  deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((row) => row.id))
307
332
  };
308
333
  }
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
+ /** Who holds a grant on every project of 0.4, lapsed or not, which the vault replaces (`#convert`). */
355
+ async function everyProjectHolders(db) {
356
+ const { vaultGrants } = tablesOf(db);
357
+ return (await db.selectDistinct({ principal: vaultGrants.principal }).from(vaultGrants).where(and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId))).orderBy(asc(vaultGrants.principal))).map((row) => row.principal);
358
+ }
309
359
  /** The action of an entry that releases a key, which the bulk limit counts. */
310
360
  const RELEASE = "secret.read";
311
361
  /** How many keys the vault has released to `principal` since `after`. */
@@ -314,32 +364,6 @@ async function releasesSince(db, principal, after) {
314
364
  const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
315
365
  return Number(n);
316
366
  }
317
- function entryColumns(db) {
318
- const { auditLog } = tablesOf(db);
319
- return {
320
- seq: auditLog.seq,
321
- author: auditLog.author,
322
- keyId: auditLog.keyId,
323
- occurredAt: auditLog.occurredAt,
324
- actor: auditLog.actor,
325
- action: auditLog.action,
326
- decision: auditLog.decision,
327
- code: auditLog.code,
328
- subjectPrincipal: auditLog.subjectPrincipal,
329
- projectId: auditLog.projectId,
330
- environmentId: auditLog.environmentId,
331
- secretId: auditLog.secretId,
332
- secretVersionId: auditLog.secretVersionId,
333
- operationId: auditLog.operationId,
334
- requestId: auditLog.requestId,
335
- sourceIp: auditLog.sourceIp,
336
- relatedSeq: auditLog.relatedSeq,
337
- metadata: auditLog.metadata,
338
- prevHash: auditLog.prevHash,
339
- mac: auditLog.mac,
340
- hash: auditLog.hash
341
- };
342
- }
343
367
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
344
368
  function stored(rows) {
345
369
  return rows;
@@ -452,10 +476,10 @@ async function latestVaultEntry(db, actions) {
452
476
  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);
453
477
  return row === void 0 ? void 0 : stored([row])[0];
454
478
  }
455
- /** Up to `limit` of the vault's allowed access entries about `principal`, newest first. */
456
- async function accessEntriesAbout(db, principal, limit) {
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) {
457
481
  const { auditLog } = tablesOf(db);
458
- return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.subjectPrincipal, principal), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(limit));
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));
459
483
  }
460
484
  /** The vault's newest allowed access entry about each member who has one. */
461
485
  async function newestAccessEntries(db) {
@@ -681,7 +705,8 @@ function replayFault(state, rows, storedGrants, at) {
681
705
  kind: "missing-member",
682
706
  principal
683
707
  };
684
- const fields = MEMBER_FIELDS.filter(([field]) => inStore[field] !== inLog[field]).map(([, column]) => column);
708
+ const same = (field) => field === "scope" ? JSON.stringify(inStore.scope) === JSON.stringify(inLog.scope) : inStore[field] === inLog[field];
709
+ const fields = MEMBER_FIELDS.filter(([field]) => !same(field)).map(([, column]) => column);
685
710
  if (fields.length > 0) return {
686
711
  kind: "member-differs",
687
712
  principal,
@@ -721,7 +746,7 @@ function apply(state, row) {
721
746
  state.members.set(principal, {
722
747
  principal,
723
748
  status: "active",
724
- owner: detail.owner === true,
749
+ ...standingIn(detail),
725
750
  generation: before?.generation ?? 0,
726
751
  createdAt: before?.createdAt ?? row.occurredAt,
727
752
  createdBy: before?.createdBy ?? row.actor,
@@ -730,6 +755,7 @@ function apply(state, row) {
730
755
  });
731
756
  return null;
732
757
  case "member.owner":
758
+ case "member.role":
733
759
  if (before === void 0) return {
734
760
  kind: "unadmitted-change",
735
761
  seq: Number(row.seq),
@@ -737,7 +763,7 @@ function apply(state, row) {
737
763
  };
738
764
  state.members.set(principal, {
739
765
  ...before,
740
- owner: detail.owner === true,
766
+ ...standingIn(detail),
741
767
  accessSeq: row.seq
742
768
  });
743
769
  return null;
@@ -751,7 +777,8 @@ function apply(state, row) {
751
777
  state.members.set(principal, {
752
778
  ...before,
753
779
  status: "removed",
754
- owner: false,
780
+ role: "member",
781
+ scope: EVERYWHERE,
755
782
  generation,
756
783
  accessSeq: row.seq,
757
784
  ...changed
@@ -783,10 +810,26 @@ function apply(state, row) {
783
810
  }
784
811
  return null;
785
812
  }
813
+ /**
814
+ * The instance role an entry gives: its `role` and `scope`, or, in one
815
+ * 0.4 wrote, whether it made them an owner, an Admin everywhere.
816
+ */
817
+ function standingIn(detail) {
818
+ if (typeof detail.role !== "string") return {
819
+ role: detail.owner === true ? "admin" : "member",
820
+ scope: EVERYWHERE
821
+ };
822
+ const role = isInstanceRole(detail.role) ? detail.role : "member";
823
+ return {
824
+ role,
825
+ scope: role !== "member" && isScope(detail.scope) ? normalScope(detail.scope) : EVERYWHERE
826
+ };
827
+ }
786
828
  /** A member's fields, and the columns a fault names them by. */
787
829
  const MEMBER_FIELDS = [
788
830
  ["status", "status"],
789
- ["owner", "owner"],
831
+ ["role", "role"],
832
+ ["scope", "scope"],
790
833
  ["generation", "generation"],
791
834
  ["createdAt", "created_at"],
792
835
  ["createdBy", "created_by"],
@@ -812,7 +855,7 @@ function placeOf(row, detail) {
812
855
  }
813
856
  /** A grant's place: its environment, or its project when it has none, or every project's path. */
814
857
  function placeKey$1(place) {
815
- return place.environmentId ?? place.projectId ?? everyProjectPath(place.environmentSlug);
858
+ return place.environmentId ?? place.projectId ?? (place.environmentSlug === null ? "*" : `*/${place.environmentSlug}`);
816
859
  }
817
860
  function grantKey(grant) {
818
861
  return JSON.stringify([
@@ -858,8 +901,8 @@ function rowKey(signingKey) {
858
901
  /**
859
902
  * A member's grants as the MAC covers them: each one's place, role, end and
860
903
  * grant, as a sorted set. A grant is kept by what its row names first: its
861
- * environment, its project, or neither, which is a grant on every project,
862
- * with the slug it covers, or null. A member who holds none of those has the
904
+ * environment, its project, or neither, which is a grant on every project
905
+ * of 0.4, with the slug it covers, or null, until the vault replaces it. A member who holds none of those has the
863
906
  * set, and so the MAC, they had before there were any.
864
907
  */
865
908
  function grantSet(grants) {
@@ -877,13 +920,24 @@ function sameGrants(a, b) {
877
920
  const [x, y] = [grantSet(a), grantSet(b)];
878
921
  return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
879
922
  }
923
+ /**
924
+ * A member's instance role and its scope, as the MAC covers them: as 0.4
925
+ * did, whether they are an owner, for a Member or an Admin everywhere, so
926
+ * that every row 0.4 sealed holds as it is; otherwise the role and the
927
+ * scope, which no row of 0.4 can match.
928
+ */
929
+ function standing(member) {
930
+ const scope = normalScope(member.scope);
931
+ if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
932
+ return [member.role, scope];
933
+ }
880
934
  function memberMac(key, member, grants) {
881
935
  const held = grantSet(grants);
882
936
  const tuple = [
883
937
  "coffre.vault.member.v1",
884
938
  member.principal,
885
939
  member.status,
886
- member.owner,
940
+ standing(member),
887
941
  member.generation,
888
942
  member.accessSeq.toString(),
889
943
  member.createdAt,
@@ -915,7 +969,8 @@ async function prepareVault(config, options = {}) {
915
969
  since: null,
916
970
  superseded: null,
917
971
  kekChecked: false,
918
- wrongKek: null
972
+ wrongKek: null,
973
+ converted: false
919
974
  };
920
975
  }
921
976
  /** The vault's own keys, from each of its signing keys, the one it signs with first. */
@@ -942,6 +997,9 @@ const KEY_BUDGET_MS = 5e3;
942
997
  const LOCK_TIMEOUT_MS = 15e3;
943
998
  /** A member: `user:<email>` or `token:<name>`. */
944
999
  const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
1000
+ /** A person, and a service account: only a person sets one up (`givesService`). */
1001
+ const PERSON = /^user:/;
1002
+ const SERVICE = /^token:/;
945
1003
  /** Who acts for the vault itself, as when it gives a root admin a member row. */
946
1004
  const VAULT_ACTOR = "system:vault";
947
1005
  /** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
@@ -957,6 +1015,17 @@ const UNSEALED = {
957
1015
  const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
958
1016
  /** The action of the vault's entry that signs a prefix of the log. */
959
1017
  const CHECKPOINT = "audit.checkpoint";
1018
+ /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
1019
+ const SETTINGS = "settings.change";
1020
+ /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
1021
+ const DEFAULT_SETTINGS = { serviceAccounts: EVERYWHERE };
1022
+ /** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
1023
+ const CLOSED_SETTINGS = { serviceAccounts: {
1024
+ projects: { only: [] },
1025
+ environments: "all"
1026
+ } };
1027
+ /** Why a person's change to a service account is refused. */
1028
+ 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";
960
1029
  /** Who asks for checkpoints: the app's scheduled job. */
961
1030
  const SCHEDULER = "system:coffre-scheduler";
962
1031
  /**
@@ -1009,6 +1078,26 @@ const MESSAGES = {
1009
1078
  wrong_kek: "this vault's key does not open the data it holds",
1010
1079
  tampered: "this member's record failed the vault's integrity check"
1011
1080
  };
1081
+ /**
1082
+ * What the one asking holds through the credential their request came in
1083
+ * on: no more than its scope (`allows`). A scope that is not one is a bug
1084
+ * of the app's, which fails the call.
1085
+ */
1086
+ function through(standing, { credentialScope }) {
1087
+ if (credentialScope == null) return standing;
1088
+ if (!isCredentialScope(credentialScope)) throw new Error("credentialScope must be a list of grants, one a place");
1089
+ return {
1090
+ ...standing,
1091
+ live: {
1092
+ ...standing.live,
1093
+ credentialScope
1094
+ },
1095
+ all: {
1096
+ ...standing.all,
1097
+ credentialScope
1098
+ }
1099
+ };
1100
+ }
1012
1101
  /** The credential a CI run's request came in on, as its entries record it: copied, never decided on. */
1013
1102
  function traced(correlation) {
1014
1103
  return correlation.credentialId == null ? {} : { credentialId: correlation.credentialId };
@@ -1053,6 +1142,10 @@ var VaultService = class {
1053
1142
  ok: false,
1054
1143
  refusal: refusal("wrong_kek", superseded)
1055
1144
  };
1145
+ return this.#decideNow(principals, decide);
1146
+ }
1147
+ /** `#decide`, once the vault is settled. */
1148
+ async #decideNow(principals, decide) {
1056
1149
  const reports = [];
1057
1150
  for (let attempt = 1;; attempt += 1) try {
1058
1151
  return await this.#decideOnce(principals, decide, reports);
@@ -1152,19 +1245,23 @@ var VaultService = class {
1152
1245
  this.#report(reports, principal, "mac", row.mac.toString("hex"));
1153
1246
  return "mac";
1154
1247
  }
1155
- const newest = await this.#newestAccessSeq(db, principal, reports);
1156
- if (row === void 0 ? newest === null : newest === row.accessSeq) return null;
1248
+ const newest = await this.#newestAccess(db, principal, reports);
1249
+ const seq = typeof newest !== "string" ? newest.seq : newest === "unreached" ? void 0 : null;
1250
+ if (row === void 0 ? seq === null : seq === row.accessSeq) return null;
1157
1251
  if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
1158
- this.#report(reports, principal, "stale", `${row?.accessSeq ?? "none"}<${newest}`);
1252
+ this.#report(reports, principal, "stale", `${row?.accessSeq ?? "none"}<${seq ?? newest}`);
1159
1253
  return "stale";
1160
1254
  }
1161
- /** The seq of the newest access entry about `principal` that carries the vault's MAC, or null. */
1162
- async #newestAccessSeq(db, principal, reports) {
1163
- for (const entry of await accessEntriesAbout(db, principal, 32)) {
1164
- if (this.#authentic(entry)) return entry.seq;
1165
- this.#report(reports, principal, "forged_entry", String(entry.seq), entry.seq);
1166
- }
1167
- return null;
1255
+ /** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
1256
+ #newestAccess(db, principal, reports) {
1257
+ return newestAccess(db, principal, this.#check(reports, principal));
1258
+ }
1259
+ /** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
1260
+ #check(reports, principal) {
1261
+ return {
1262
+ authentic: (entry) => this.#authentic(entry),
1263
+ forged: (entry) => this.#report(reports, principal, "forged_entry", String(entry.seq), entry.seq)
1264
+ };
1168
1265
  }
1169
1266
  /** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
1170
1267
  #authentic(entry) {
@@ -1222,9 +1319,127 @@ var VaultService = class {
1222
1319
  async #settled() {
1223
1320
  const prepared = this.#prepared;
1224
1321
  if (prepared.since === null && prepared.superseded === null) await this.#settle();
1322
+ if (prepared.superseded === null && !prepared.converted) await this.#convert();
1225
1323
  return prepared.superseded;
1226
1324
  }
1227
1325
  /**
1326
+ * Replace every grant on every project of 0.4 with what 0.5 has instead
1327
+ * (`convertEveryProjectGrants` in @coffre/core/access): a person's
1328
+ * instance role and its scope, and project grants on the projects there
1329
+ * are now. Once per process, before its first call does anything else:
1330
+ * one decision per member who holds any, as `system:vault`, each grant it
1331
+ * replaces an `access.revoke` that says what became of it. Another
1332
+ * process that converts the same member at once finds nothing left under
1333
+ * their lock. A member whose row fails its check is left as they are, and
1334
+ * so is one the vault has reported changed around it since the last
1335
+ * change to what they hold, even if their row checks out again now (a
1336
+ * genuine row put back). `reportStands` is the rule, which `coffre
1337
+ * migrate` applies too, holding no key, to every entry in the vault's
1338
+ * name: it lists and marks a member so reported, forged or not.
1339
+ * Removing them clears both.
1340
+ */
1341
+ 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;
1347
+ }
1348
+ async #replaceEveryProject(d, principal) {
1349
+ const row = d.members.get(principal);
1350
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1351
+ if (row === void 0 || subject.status !== "active") return;
1352
+ const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
1353
+ if (everyProject.length === 0) return;
1354
+ if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
1355
+ await lockLogHead(d.tx);
1356
+ 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
+ });
1373
+ const after = {
1374
+ role: conversion.role,
1375
+ scope: conversion.scope
1376
+ };
1377
+ const replacedBy = {
1378
+ role: after.role,
1379
+ scope: after.scope,
1380
+ grants: conversion.grants.length
1381
+ };
1382
+ d.grants.set(principal, [...subject.stored]);
1383
+ d.touched.add(principal);
1384
+ const correlation = { operationId: randomUUID() };
1385
+ const drop = (grant) => d.writes.push(async () => {
1386
+ await deleteGrant(d.tx, principal, grant);
1387
+ d.grants.set(principal, d.grants.get(principal).filter((held) => held !== grant));
1388
+ });
1389
+ for (const grant of everyProject) {
1390
+ drop(grant);
1391
+ if (!live(grant, d.at)) continue;
1392
+ const narrowed = conversion.narrowed.filter((loss) => loss.grant.environmentSlug === grant.environmentSlug);
1393
+ d.log.push(accessEntry(VAULT_ACTOR, "access.revoke", principal, "allow", correlation, {
1394
+ role: null,
1395
+ expiresAt: null,
1396
+ previousRole: grant.role,
1397
+ ...located(grant).detail,
1398
+ replacedBy,
1399
+ ...narrowed.length === 0 ? {} : { narrowed: narrowed.map(({ grant: _, ...loss }) => loss) }
1400
+ }));
1401
+ }
1402
+ if (JSON.stringify(standing(after)) !== JSON.stringify(standing(before))) {
1403
+ d.log.push(accessEntry(VAULT_ACTOR, "member.role", principal, "allow", correlation, {
1404
+ ...after,
1405
+ previousRole: before.role,
1406
+ previousScope: before.scope,
1407
+ reason: "every-project"
1408
+ }));
1409
+ d.writes.push(() => updateMember(d.tx, principal, after));
1410
+ }
1411
+ for (const given of conversion.grants) {
1412
+ const place = {
1413
+ projectId: given.projectId,
1414
+ environmentId: given.environmentId,
1415
+ environmentSlug: null
1416
+ };
1417
+ const there = subject.stored.find((grant) => grantKind(grant) !== null && placeKey(grant) === placeKey(place));
1418
+ if (there !== void 0) drop(there);
1419
+ d.writes.push(async (at) => {
1420
+ const grant = {
1421
+ principal,
1422
+ ...place,
1423
+ role: given.role,
1424
+ expiresAt: given.expiresAt,
1425
+ grantedAt: at,
1426
+ grantedBy: VAULT_ACTOR
1427
+ };
1428
+ await insertGrant(d.tx, grant);
1429
+ d.grants.get(principal).push(grant);
1430
+ });
1431
+ d.log.push({
1432
+ ...accessEntry(VAULT_ACTOR, "access.grant", principal, "allow", correlation, {
1433
+ role: given.role,
1434
+ expiresAt: given.expiresAt === null ? null : iso(given.expiresAt),
1435
+ previousRole: given.replaces,
1436
+ reason: "every-project"
1437
+ }),
1438
+ ...located(place).ids
1439
+ });
1440
+ }
1441
+ }
1442
+ /**
1228
1443
  * Which of its keys count where. Those it replaced, from the KEKs in
1229
1444
  * previousKeks, verify only what came before its first entry under its
1230
1445
  * current key, `since`, and vouch for no member row after it. The first
@@ -1516,7 +1731,7 @@ var VaultService = class {
1516
1731
  });
1517
1732
  if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1518
1733
  const check = async (d) => {
1519
- const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1734
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
1520
1735
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1521
1736
  const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
1522
1737
  const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
@@ -1662,7 +1877,7 @@ var VaultService = class {
1662
1877
  };
1663
1878
  if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1664
1879
  return this.#decide([principal], async (d) => {
1665
- const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1880
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1666
1881
  const environments = await environmentsById(d.tx, items.flatMap((item) => {
1667
1882
  const source = sources.get(item.source.secretId);
1668
1883
  return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
@@ -1743,7 +1958,7 @@ var VaultService = class {
1743
1958
  return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
1744
1959
  };
1745
1960
  return this.#decide([principal], async (d) => {
1746
- const actor = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1961
+ const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1747
1962
  const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
1748
1963
  const codes = seals.map((seal) => {
1749
1964
  const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
@@ -1809,14 +2024,15 @@ var VaultService = class {
1809
2024
  decision: "allow",
1810
2025
  subjectPrincipal: principal,
1811
2026
  metadata: JSON.stringify({
1812
- owner: false,
2027
+ role: "member",
1813
2028
  rootAdmin: true
1814
2029
  })
1815
2030
  }]);
1816
2031
  const row = {
1817
2032
  principal,
1818
2033
  status: "active",
1819
- owner: false,
2034
+ role: "member",
2035
+ scope: EVERYWHERE,
1820
2036
  generation: 0,
1821
2037
  createdAt: at,
1822
2038
  createdBy: VAULT_ACTOR,
@@ -1837,15 +2053,12 @@ var VaultService = class {
1837
2053
  * configuration, which no row can change.
1838
2054
  */
1839
2055
  async #standing(db, principal, row, at, reports) {
1840
- const none = {
1841
- isRootAdmin: false,
1842
- isOwner: false,
1843
- grants: []
1844
- };
2056
+ const none = NOTHING;
1845
2057
  if (this.#isRootAdmin(principal)) {
1846
2058
  const root = {
1847
2059
  isRootAdmin: true,
1848
- isOwner: true,
2060
+ role: "owner",
2061
+ scope: EVERYWHERE,
1849
2062
  grants: []
1850
2063
  };
1851
2064
  return {
@@ -1887,18 +2100,20 @@ var VaultService = class {
1887
2100
  ...grant,
1888
2101
  role: grant.role
1889
2102
  }));
1890
- const isOwner = row.owner && principal.startsWith("user:");
2103
+ const { role, scope } = roleOf(principal, row);
1891
2104
  return {
1892
2105
  principal,
1893
2106
  status: "active",
1894
2107
  live: {
1895
2108
  isRootAdmin: false,
1896
- isOwner,
2109
+ role,
2110
+ scope,
1897
2111
  grants: held.filter((grant) => live(grant, at))
1898
2112
  },
1899
2113
  all: {
1900
2114
  isRootAdmin: false,
1901
- isOwner,
2115
+ role,
2116
+ scope,
1902
2117
  grants: held
1903
2118
  },
1904
2119
  fault,
@@ -1911,7 +2126,8 @@ var VaultService = class {
1911
2126
  status: "active",
1912
2127
  generation: row?.generation ?? 0,
1913
2128
  isRootAdmin: true,
1914
- isOwner: true,
2129
+ role: "owner",
2130
+ scope: EVERYWHERE,
1915
2131
  grants: [],
1916
2132
  since: null,
1917
2133
  by: null
@@ -1922,8 +2138,11 @@ var VaultService = class {
1922
2138
  status: tampered ? "tampered" : !PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
1923
2139
  generation: row?.generation ?? 0,
1924
2140
  isRootAdmin: false,
1925
- isOwner: active && row.owner && principal.startsWith("user:"),
1926
- grants: active ? held.filter((grant) => live(grant, at)).map(view) : [],
2141
+ ...active ? roleOf(principal, row) : {
2142
+ role: "member",
2143
+ scope: EVERYWHERE
2144
+ },
2145
+ grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
1927
2146
  since: row ? iso(row.statusChangedAt) : null,
1928
2147
  by: row?.statusChangedBy ?? null
1929
2148
  };
@@ -1983,9 +2202,12 @@ var VaultService = class {
1983
2202
  }
1984
2203
  setAccess(input) {
1985
2204
  const { actor, principal } = input;
1986
- const changes = input.changes.map((change) => ({
1987
- ...change,
1988
- environmentSlug: change.environmentSlug ?? null
2205
+ const changes = input.changes.map(({ projectId, environmentId, role, expiresAt }) => ({
2206
+ projectId,
2207
+ environmentId,
2208
+ environmentSlug: null,
2209
+ role,
2210
+ expiresAt
1989
2211
  }));
1990
2212
  const action = changes.every((change) => change.role === null) ? "access.revoke" : "access.grant";
1991
2213
  const [only] = changes.length === 1 ? changes : [];
@@ -2002,41 +2224,54 @@ var VaultService = class {
2002
2224
  ...where.ids
2003
2225
  }]);
2004
2226
  };
2005
- const onProjects = changes.flatMap(({ projectId, environmentId, role }) => projectId === null ? [] : [{
2006
- projectId,
2007
- environmentId,
2008
- role
2009
- }]);
2010
2227
  return this.#decide([actor, principal], async (d) => {
2011
2228
  validateCorrelation(input);
2012
2229
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
2013
2230
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
2014
2231
  const places$1 = /* @__PURE__ */ new Set();
2015
2232
  for (const change of changes) {
2016
- const shape = placeShape(change);
2017
- if (shape !== null) throw refused("invalid", shape);
2233
+ 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");
2018
2234
  const key = placeKey(change);
2019
2235
  if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
2020
2236
  places$1.add(key);
2021
2237
  if (change.role !== null && !isRole(change.role)) throw refused("invalid", `no such role: ${change.role}`);
2022
- if (change.role !== null && (change.environmentId !== null || change.environmentSlug !== null) && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project, or on every project`);
2238
+ if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
2023
2239
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
2024
2240
  if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
2025
2241
  }
2026
- if (onProjects.length > 0) await lockLogHead(d.tx);
2027
- const known = await places(d.tx, onProjects.map((change) => change.projectId), onProjects.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
2028
- for (const { projectId, environmentId, role } of onProjects) {
2029
- if (!known.projects.has(projectId) || environmentId !== null && known.environments.get(environmentId) !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
2242
+ await lockLogHead(d.tx);
2243
+ const known = await places(d.tx, changes.map((change) => change.projectId), changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
2244
+ const where = changes.map(({ projectId, environmentId, role }) => {
2245
+ const environment = environmentId === null ? void 0 : known.environments.get(environmentId);
2246
+ if (!known.projects.has(projectId) || environmentId !== null && environment?.projectId !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
2030
2247
  if (role !== null && (known.deleted.has(projectId) || environmentId !== null && known.deleted.has(environmentId))) throw refused("deleted");
2031
- }
2032
- const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
2248
+ return environmentId === null ? { projectId } : {
2249
+ projectId,
2250
+ environmentId,
2251
+ environmentSlug: environment.slug
2252
+ };
2253
+ });
2254
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2033
2255
  if (acting.status === "tampered") throw refused("tampered");
2034
- if (!changes.every((change) => mayManageAccess(acting.live, change))) throw refused("not_allowed");
2256
+ const manages = where.every((place) => mayManageAccess(acting.live, place));
2257
+ 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");
2035
2259
  const row = d.members.get(principal);
2036
2260
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2037
2261
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2038
2262
  if (row?.status === "removed") throw refused("removed");
2039
2263
  if (row === void 0) throw refused("not_a_member");
2264
+ if (!manages) {
2265
+ const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
2266
+ const account = await this.#account(d.tx, subject, row);
2267
+ if (!changes.every((change, i) => {
2268
+ const before = subject.live.grants.find((grant) => placeKey({
2269
+ ...grant,
2270
+ environmentSlug: null
2271
+ }) === placeKey(change));
2272
+ return [change.role, before?.role].every((role) => role == null || givesService(acting.live, setting, role, where[i]));
2273
+ }) || !managesService(acting.live, setting, actor, account)) throw refused("not_allowed", NOT_THEIRS);
2274
+ }
2040
2275
  const held = subject.stored;
2041
2276
  d.grants.set(principal, [...held]);
2042
2277
  const outcomes = changes.map((change) => this.#apply(d, actor, principal, held, change, input));
@@ -2097,29 +2332,52 @@ var VaultService = class {
2097
2332
  }
2098
2333
  admit(input) {
2099
2334
  const { actor, principal } = input;
2100
- const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, { owner: input.owner ?? null }, code)]);
2335
+ const asked = input.role === void 0 ? {} : {
2336
+ role: input.role,
2337
+ scope: input.scope ?? EVERYWHERE
2338
+ };
2339
+ const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, asked, code)]);
2101
2340
  return this.#decide([actor, principal], async (d) => {
2102
2341
  validateCorrelation(input);
2103
- const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
2342
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2104
2343
  if (acting.status === "tampered") throw refused("tampered");
2105
- if (!acting.live.isOwner) throw refused("not_allowed", "only owners may add or restore members");
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
+ }
2106
2351
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
2107
2352
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
2108
- if (input.owner === true && !principal.startsWith("user:")) throw refused("invalid", "service accounts cannot be owners");
2353
+ if (input.role !== void 0 && !isInstanceRole(input.role)) throw refused("invalid", `no such instance role: ${input.role}`);
2354
+ 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
+ const wanted = input.role === void 0 ? void 0 : {
2356
+ role: input.role,
2357
+ scope: input.role === "member" ? EVERYWHERE : normalScope(input.scope ?? EVERYWHERE)
2358
+ };
2359
+ if (wanted !== void 0 && wanted.role !== "member" && !principal.startsWith("user:")) throw refused("invalid", "service accounts hold project grants only, never an instance role");
2109
2360
  const row = d.members.get(principal);
2110
2361
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2111
2362
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2112
2363
  d.touched.add(principal);
2113
2364
  d.grants.set(principal, [...subject.stored]);
2114
- const entry = (action, owner) => d.log.push(accessEntry(actor, action, principal, "allow", input, { owner }));
2365
+ const entry = (action, detail) => d.log.push(accessEntry(actor, action, principal, "allow", input, detail));
2115
2366
  if (row === void 0 || row.status === "removed") {
2116
- const owner = input.owner ?? false;
2117
- entry(row === void 0 ? "member.add" : "member.restore", owner);
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
+ });
2118
2375
  d.writes.push(async (at) => {
2119
2376
  if (row === void 0) await insertMember(d.tx, {
2120
2377
  principal,
2121
2378
  status: "active",
2122
- owner,
2379
+ role,
2380
+ scope,
2123
2381
  generation: 0,
2124
2382
  createdAt: at,
2125
2383
  createdBy: actor,
@@ -2129,25 +2387,42 @@ var VaultService = class {
2129
2387
  });
2130
2388
  else await updateMember(d.tx, principal, {
2131
2389
  status: "active",
2132
- owner,
2390
+ role,
2391
+ scope,
2133
2392
  statusChangedAt: at,
2134
2393
  statusChangedBy: actor
2135
2394
  });
2136
2395
  });
2137
2396
  return {
2138
2397
  created: true,
2139
- owner,
2398
+ role,
2399
+ scope,
2140
2400
  generation: row?.generation ?? 0
2141
2401
  };
2142
2402
  }
2143
- const owner = input.owner ?? row.owner;
2144
- if (owner !== row.owner) {
2145
- entry("member.owner", owner);
2146
- d.writes.push(() => updateMember(d.tx, principal, { owner }));
2403
+ 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");
2405
+ if (changed) {
2406
+ entry("member.role", {
2407
+ role: wanted.role,
2408
+ scope: wanted.scope,
2409
+ previousRole: row.role,
2410
+ previousScope: row.scope
2411
+ });
2412
+ d.writes.push(() => updateMember(d.tx, principal, {
2413
+ role: wanted.role,
2414
+ scope: wanted.scope
2415
+ }));
2416
+ return {
2417
+ created: false,
2418
+ ...wanted,
2419
+ generation: row.generation
2420
+ };
2147
2421
  }
2148
2422
  return {
2149
2423
  created: false,
2150
- owner,
2424
+ role: row.role,
2425
+ scope: row.scope,
2151
2426
  generation: row.generation
2152
2427
  };
2153
2428
  });
@@ -2160,16 +2435,18 @@ var VaultService = class {
2160
2435
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
2161
2436
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
2162
2437
  const row = d.members.get(principal);
2163
- const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
2438
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2164
2439
  if (acting.status === "tampered") throw refused("tampered");
2165
2440
  const holder = acting.live;
2166
2441
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2167
2442
  const held = subject.stored;
2168
2443
  if (subject.status === "tampered") {
2169
- if (!holder.isOwner) throw refused("not_allowed", "only owners may remove a member whose record failed its check");
2444
+ if (!runsInstance(holder)) throw refused("not_allowed", "only admins and owners of the whole instance may remove a member whose record failed its check");
2170
2445
  return this.#startOver(d, actor, principal, row, subject.fault, input, refused);
2171
2446
  }
2172
- if (!holder.isOwner) throw refused("not_allowed", "only owners may remove members");
2447
+ if (!runsInstance(holder)) {
2448
+ 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
+ }
2173
2450
  if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
2174
2451
  const revoked = held.filter((grant) => live(grant, d.at));
2175
2452
  for (const grant of revoked) {
@@ -2195,7 +2472,8 @@ var VaultService = class {
2195
2472
  d.grants.set(principal, []);
2196
2473
  await updateMember(d.tx, principal, {
2197
2474
  status: "removed",
2198
- owner: false,
2475
+ role: "member",
2476
+ scope: EVERYWHERE,
2199
2477
  generation,
2200
2478
  statusChangedAt: at,
2201
2479
  statusChangedBy: actor
@@ -2208,6 +2486,76 @@ var VaultService = class {
2208
2486
  });
2209
2487
  }
2210
2488
  /**
2489
+ * A service account as `managesService` weighs it: its live grants, each
2490
+ * environment by its slug, which the setting's scope matches, and who
2491
+ * admitted it, as its row says.
2492
+ */
2493
+ async #account(db, subject, row) {
2494
+ const slugs = await environmentsById(db, subject.live.grants.flatMap((grant) => grant.environmentId === null ? [] : [grant.environmentId]));
2495
+ return {
2496
+ grants: subject.live.grants.map((grant) => ({
2497
+ role: grant.role,
2498
+ place: grant.environmentId === null ? { projectId: grant.projectId } : {
2499
+ projectId: grant.projectId,
2500
+ environmentId: grant.environmentId,
2501
+ environmentSlug: slugs.get(grant.environmentId)?.slug ?? null
2502
+ }
2503
+ })),
2504
+ admittedBy: row.statusChangedBy
2505
+ };
2506
+ }
2507
+ async settings() {
2508
+ await this.#settled();
2509
+ const reports = [];
2510
+ const settings = await this.#settingsAt(this.#db, reports);
2511
+ await this.#record(reports);
2512
+ return settings;
2513
+ }
2514
+ /**
2515
+ * The settings the vault's newest `settings.change` says, by its MAC: one
2516
+ * in its name it did not write is passed over, and reported. With such
2517
+ * entries and none of its own within reach, the setting is closed, never
2518
+ * the default: otherwise whoever can insert rows could open it again.
2519
+ */
2520
+ async #settingsAt(db, reports) {
2521
+ const entry = await newestAuthentic((before) => newestVaultEntries(db, SETTINGS, LOOK_BACK.page, before), this.#check(reports, null));
2522
+ if (entry === "none") return DEFAULT_SETTINGS;
2523
+ if (typeof entry === "string") return CLOSED_SETTINGS;
2524
+ const { serviceAccounts } = JSON.parse(entry.metadata);
2525
+ return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
2526
+ }
2527
+ setSettings(input) {
2528
+ const { actor } = input;
2529
+ const entry = (decision, detail, code = null) => ({
2530
+ actor,
2531
+ action: SETTINGS,
2532
+ decision,
2533
+ code,
2534
+ operationId: input.operationId ?? null,
2535
+ requestId: input.requestId ?? null,
2536
+ metadata: JSON.stringify({
2537
+ ...detail,
2538
+ ...traced(input)
2539
+ })
2540
+ });
2541
+ const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
2542
+ return this.#decide([actor], async (d) => {
2543
+ validateCorrelation(input);
2544
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2545
+ if (acting.status === "tampered") throw refused("tampered");
2546
+ if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
2547
+ if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
2548
+ await lockLogHead(d.tx);
2549
+ const previous = await this.#settingsAt(d.tx, d.reports);
2550
+ const settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
2551
+ if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry("allow", {
2552
+ ...settings,
2553
+ previous
2554
+ }));
2555
+ return { settings };
2556
+ });
2557
+ }
2558
+ /**
2211
2559
  * Remove a member whose row failed its check, from what the log says of
2212
2560
  * them rather than what the row does: their grants go, whatever they were,
2213
2561
  * and their generation moves past both the row's and the log's, so no
@@ -2229,7 +2577,8 @@ var VaultService = class {
2229
2577
  d.grants.set(principal, []);
2230
2578
  const fresh = {
2231
2579
  status: "removed",
2232
- owner: false,
2580
+ role: "member",
2581
+ scope: EVERYWHERE,
2233
2582
  generation,
2234
2583
  createdAt: logged.createdAt,
2235
2584
  createdBy: logged.createdBy,
@@ -2908,13 +3257,13 @@ function live(grant, at) {
2908
3257
  }
2909
3258
  /**
2910
3259
  * Where an entry about a grant says it is: its project and environment; or,
2911
- * on every project, neither, and the place as a path in its payload. Only a
2912
- * grant that names no place at all is on every project (`grantKind`).
3260
+ * for a grant on every project of 0.4, neither, and the place as a path in
3261
+ * its payload, `*`, or `*` and the slug, as 0.4 wrote it.
2913
3262
  */
2914
3263
  function located(place) {
2915
- return grantKind(place) === "every-project" ? {
3264
+ return place.projectId === null && place.environmentId === null ? {
2916
3265
  ids: {},
2917
- detail: { place: everyProjectPath(place.environmentSlug) }
3266
+ detail: { place: place.environmentSlug === null ? "*" : `*/${place.environmentSlug}` }
2918
3267
  } : {
2919
3268
  ids: {
2920
3269
  projectId: place.projectId,
@@ -2923,25 +3272,33 @@ function located(place) {
2923
3272
  detail: {}
2924
3273
  };
2925
3274
  }
2926
- /** One key per place a member can hold a grant at, by what the grant names first: its environment, its project, or neither. */
3275
+ /** 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. */
3276
+ /** Every project there is now and each of its environments, as a permission check names them. */
3277
+ function placesOf(projects) {
3278
+ return projects.flatMap((project) => [{ projectId: project.id }, ...project.environments.map((environment) => ({
3279
+ projectId: project.id,
3280
+ environmentId: environment.id,
3281
+ environmentSlug: environment.slug
3282
+ }))]);
3283
+ }
2927
3284
  function placeKey(place) {
2928
3285
  if (place.environmentId !== null) return `environment:${place.environmentId}`;
2929
- return place.projectId !== null ? `project:${place.projectId}` : everyProjectPath(place.environmentSlug);
3286
+ return place.projectId !== null ? `project:${place.projectId}` : `every-project:${place.environmentSlug ?? ""}`;
2930
3287
  }
2931
- /** Why a change's place is not one a grant can be at, or null when it is. */
2932
- function placeShape(place) {
2933
- const kind = grantKind(place);
2934
- if (kind === null) return place.projectId === null ? "an environment is named in its project" : "a slug names environments on every project, not in one";
2935
- if (kind === "every-project" && place.environmentSlug !== null && !SLUG.test(place.environmentSlug)) return `not an environment slug: ${place.environmentSlug}`;
2936
- return null;
3288
+ /** A member's instance role, as their row has it: service accounts are members, whatever a row says. */
3289
+ function roleOf(principal, row) {
3290
+ return principal.startsWith("user:") ? {
3291
+ role: row.role,
3292
+ scope: row.scope
3293
+ } : {
3294
+ role: "member",
3295
+ scope: EVERYWHERE
3296
+ };
2937
3297
  }
2938
- /** An environment's slug, as the schema has it. */
2939
- const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
2940
3298
  function view(grant) {
2941
3299
  return {
2942
3300
  projectId: grant.projectId,
2943
3301
  environmentId: grant.environmentId,
2944
- environmentSlug: grant.environmentSlug,
2945
3302
  role: grant.role,
2946
3303
  expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
2947
3304
  grantedAt: iso(grant.grantedAt),