@coffre/vault 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
4
4
  "description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.4.6",
40
- "@coffre/db": "0.4.6"
39
+ "@coffre/db": "0.5.1",
40
+ "@coffre/core": "0.5.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/cloudflare.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  EndReferencesInput,
23
23
  RewrapInput,
24
24
  SetAccessInput,
25
+ SetSettingsInput,
25
26
  UnwrapInput,
26
27
  Vault,
27
28
  VerifyLogInput,
@@ -91,6 +92,8 @@ export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
91
92
  async setAccess(input: SetAccessInput) { return this.#call((vault) => vault.setAccess(input)); }
92
93
  async admit(input: AdmitInput) { return this.#call((vault) => vault.admit(input)); }
93
94
  async remove(input: RemoveInput) { return this.#call((vault) => vault.remove(input)); }
95
+ async settings() { return this.#call((vault) => vault.settings()); }
96
+ async setSettings(input: SetSettingsInput) { return this.#call((vault) => vault.setSettings(input)); }
94
97
  async checkpoint() { return this.#call((vault) => vault.checkpoint()); }
95
98
  async about() { return this.#call((vault) => vault.about()); }
96
99
  async keyChecks() { return this.#call((vault) => vault.keyChecks()); }
package/src/local.ts CHANGED
@@ -15,6 +15,8 @@ export const METHODS = [
15
15
  'setAccess',
16
16
  'admit',
17
17
  'remove',
18
+ 'settings',
19
+ 'setSettings',
18
20
  'checkpoint',
19
21
  'about',
20
22
  'keyChecks',
package/src/replay.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { everyProjectPath } from '@coffre/core/access';
1
+ import { EVERYWHERE, isInstanceRole, isScope, normalScope, type InstanceRole, type Scope } from '@coffre/core/access';
2
2
  import type { StoredEntry } from '@coffre/core/audit';
3
3
  import type { AccessFault, FaultGrant } from '@coffre/core/vault';
4
4
 
@@ -28,7 +28,8 @@ export function replayFault(state: Replayed, rows: readonly Member[], storedGran
28
28
  const [inStore, inLog] = [stored.get(principal), members.get(principal)];
29
29
  if (inLog === undefined) return { kind: 'unlogged-member', principal };
30
30
  if (inStore === undefined) return { kind: 'missing-member', principal };
31
- const fields = MEMBER_FIELDS.filter(([field]) => inStore[field] !== inLog[field]).map(([, column]) => column);
31
+ const same = (field: keyof LoggedMember) => (field === 'scope' ? JSON.stringify(inStore.scope) === JSON.stringify(inLog.scope) : inStore[field] === inLog[field]);
32
+ const fields = MEMBER_FIELDS.filter(([field]) => !same(field)).map(([, column]) => column);
32
33
  if (fields.length > 0) return { kind: 'member-differs', principal, fields };
33
34
  }
34
35
 
@@ -63,7 +64,7 @@ export function apply(state: Replayed, row: StoredEntry): AccessFault | null {
63
64
  state.members.set(principal, {
64
65
  principal,
65
66
  status: 'active',
66
- owner: detail.owner === true,
67
+ ...standingIn(detail),
67
68
  generation: before?.generation ?? 0,
68
69
  createdAt: before?.createdAt ?? row.occurredAt,
69
70
  createdBy: before?.createdBy ?? row.actor,
@@ -72,15 +73,16 @@ export function apply(state: Replayed, row: StoredEntry): AccessFault | null {
72
73
  });
73
74
  return null;
74
75
  case 'member.owner':
76
+ case 'member.role':
75
77
  if (before === undefined) return { kind: 'unadmitted-change', seq: Number(row.seq), principal };
76
- state.members.set(principal, { ...before, owner: detail.owner === true, accessSeq: row.seq });
78
+ state.members.set(principal, { ...before, ...standingIn(detail), accessSeq: row.seq });
77
79
  return null;
78
80
  case 'member.remove': {
79
81
  if (before === undefined) return { kind: 'unadmitted-change', seq: Number(row.seq), principal };
80
82
  // A removal names the generation it moved to; one before this format
81
83
  // was always the next.
82
84
  const generation = typeof detail.generation === 'number' ? detail.generation : before.generation + 1;
83
- state.members.set(principal, { ...before, status: 'removed', owner: false, generation, accessSeq: row.seq, ...changed });
85
+ state.members.set(principal, { ...before, status: 'removed', role: 'member', scope: EVERYWHERE, generation, accessSeq: row.seq, ...changed });
84
86
  grantsOf.clear();
85
87
  return null;
86
88
  }
@@ -103,10 +105,21 @@ export function apply(state: Replayed, row: StoredEntry): AccessFault | null {
103
105
  return null;
104
106
  }
105
107
 
108
+ /**
109
+ * The instance role an entry gives: its `role` and `scope`, or, in one
110
+ * 0.4 wrote, whether it made them an owner, an Admin everywhere.
111
+ */
112
+ function standingIn(detail: Record<string, unknown>): { role: InstanceRole; scope: Scope } {
113
+ if (typeof detail.role !== 'string') return { role: detail.owner === true ? 'admin' : 'member', scope: EVERYWHERE };
114
+ const role = isInstanceRole(detail.role) ? detail.role : 'member';
115
+ return { role, scope: role !== 'member' && isScope(detail.scope) ? normalScope(detail.scope) : EVERYWHERE };
116
+ }
117
+
106
118
  /** A member's fields, and the columns a fault names them by. */
107
119
  const MEMBER_FIELDS = [
108
120
  ['status', 'status'],
109
- ['owner', 'owner'],
121
+ ['role', 'role'],
122
+ ['scope', 'scope'],
110
123
  ['generation', 'generation'],
111
124
  ['createdAt', 'created_at'],
112
125
  ['createdBy', 'created_by'],
@@ -129,7 +142,7 @@ function placeOf(row: StoredEntry, detail: Record<string, unknown>): Place {
129
142
 
130
143
  /** A grant's place: its environment, or its project when it has none, or every project's path. */
131
144
  function placeKey(place: Place): string {
132
- return place.environmentId ?? place.projectId ?? everyProjectPath(place.environmentSlug);
145
+ return place.environmentId ?? place.projectId ?? (place.environmentSlug === null ? '*' : `*/${place.environmentSlug}`);
133
146
  }
134
147
 
135
148
  function grantKey(grant: GrantRow): string {
package/src/rows.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
2
2
 
3
+ import { normalScope, unscoped } from '@coffre/core/access';
4
+
3
5
  import type { GrantRow, Member } from './store.ts';
4
6
 
5
7
  /**
@@ -24,8 +26,8 @@ export function rowKey(signingKey: Uint8Array): Buffer {
24
26
  /**
25
27
  * A member's grants as the MAC covers them: each one's place, role, end and
26
28
  * grant, as a sorted set. A grant is kept by what its row names first: its
27
- * environment, its project, or neither, which is a grant on every project,
28
- * with the slug it covers, or null. A member who holds none of those has the
29
+ * environment, its project, or neither, which is a grant on every project
30
+ * of 0.4, with the slug it covers, or null, until the vault replaces it. A member who holds none of those has the
29
31
  * set, and so the MAC, they had before there were any.
30
32
  */
31
33
  export function grantSet(grants: readonly GrantRow[]): string[] {
@@ -48,13 +50,25 @@ export function sameGrants(a: readonly GrantRow[], b: readonly GrantRow[]): bool
48
50
  return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
49
51
  }
50
52
 
53
+ /**
54
+ * A member's instance role and its scope, as the MAC covers them: as 0.4
55
+ * did, whether they are an owner, for a Member or an Admin everywhere, so
56
+ * that every row 0.4 sealed holds as it is; otherwise the role and the
57
+ * scope, which no row of 0.4 can match.
58
+ */
59
+ export function standing(member: Pick<Member, 'role' | 'scope'>): boolean | [string, unknown] {
60
+ const scope = normalScope(member.scope);
61
+ if (unscoped(scope) && (member.role === 'member' || member.role === 'admin')) return member.role === 'admin';
62
+ return [member.role, scope];
63
+ }
64
+
51
65
  export function memberMac(key: Buffer, member: Omit<Member, 'mac'>, grants: readonly GrantRow[]): Buffer {
52
66
  const held = grantSet(grants);
53
67
  const tuple = [
54
68
  'coffre.vault.member.v1',
55
69
  member.principal,
56
70
  member.status,
57
- member.owner,
71
+ standing(member),
58
72
  member.generation,
59
73
  member.accessSeq.toString(),
60
74
  member.createdAt,
package/src/store.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { GrantPlace } from '@coffre/core/access';
1
+ import { EVERYWHERE, type InstanceRole, type Scope } from '@coffre/core/access';
2
2
  import type { Author, StoredEntry } from '@coffre/core/audit';
3
3
  import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
4
4
  import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
5
5
  import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
6
- import { readGrants, type GrantRow } from '@coffre/db/grants';
6
+ import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
7
+ import { entryColumns } from '@coffre/db/log';
7
8
  import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
8
9
 
9
10
  /**
@@ -20,7 +21,9 @@ import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql,
20
21
  export type Member = {
21
22
  principal: string;
22
23
  status: 'active' | 'removed';
23
- owner: boolean;
24
+ /** A person's instance role and its scope; `member` everywhere for anyone else (`memberOf`). */
25
+ role: InstanceRole;
26
+ scope: Scope;
24
27
  generation: number;
25
28
  createdAt: number;
26
29
  createdBy: string;
@@ -36,15 +39,16 @@ export type Member = {
36
39
  export { ACCESS_ACTIONS };
37
40
 
38
41
  /**
39
- * Where a grant applies: a project, one of its environments (with its
40
- * project, which its row finds through `environments`), every project, or
41
- * one environment slug in every project.
42
+ * Where a grant applies: a project, or one of its environments (with its
43
+ * project, which its row finds through `environments`); or, for a grant on
44
+ * every project of 0.4, until the vault replaces it, neither, with the one
45
+ * environment slug it covers in each, if any.
42
46
  */
43
- export type Place = GrantPlace;
47
+ export type Place = Pick<GrantRow, 'projectId' | 'environmentId' | 'environmentSlug'>;
44
48
 
45
49
  /** A grant as stored, which `@coffre/db/grants` reads and writes. */
46
50
  export type { GrantRow };
47
- export { insertGrant } from '@coffre/db/grants';
51
+ export { accessEntriesAbout, insertGrant } from '@coffre/db/grants';
48
52
 
49
53
  /**
50
54
  * On Postgres, fail a lock wait in this transaction after `ms`, rather than
@@ -69,6 +73,8 @@ function memberColumns(db: Queryable) {
69
73
  principal: vaultMembers.principal,
70
74
  status: vaultMembers.status,
71
75
  owner: vaultMembers.owner,
76
+ role: vaultMembers.role,
77
+ scope: vaultMembers.scope,
72
78
  generation: vaultMembers.generation,
73
79
  createdAt: vaultMembers.createdAt,
74
80
  createdBy: vaultMembers.createdBy,
@@ -79,15 +85,29 @@ function memberColumns(db: Queryable) {
79
85
  };
80
86
  }
81
87
 
88
+ /** A row as stored: `role` and `scope` as their columns hold them. */
89
+ type StoredMember = Omit<Member, 'role' | 'scope'> & { owner: boolean; role: string | null; scope: string | null };
90
+
91
+ /** A member as their row says (`storedRole`). */
92
+ function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
93
+ return { ...row, ...storedRole({ owner, role, scope }) };
94
+ }
95
+
96
+ function storedChange(change: Partial<Omit<Member, 'principal'>>): Record<string, unknown> {
97
+ const { role, scope, ...rest } = change;
98
+ if (role === undefined) return rest;
99
+ return { ...rest, ...roleColumns(role, scope ?? EVERYWHERE) };
100
+ }
101
+
82
102
  export async function member(db: Queryable, principal: string): Promise<Member | undefined> {
83
103
  const { vaultMembers } = tablesOf(db);
84
104
  const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
85
- return row as Member | undefined;
105
+ return row === undefined ? undefined : memberOf(row as StoredMember);
86
106
  }
87
107
 
88
108
  export async function allMembers(db: Queryable): Promise<Member[]> {
89
109
  const { vaultMembers } = tablesOf(db);
90
- return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))) as Member[];
110
+ return ((await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))) as StoredMember[]).map(memberOf);
91
111
  }
92
112
 
93
113
  /**
@@ -107,7 +127,7 @@ export async function lockMembers(tx: Transaction, principals: readonly string[]
107
127
  .orderBy(asc(vaultMembers.principal)),
108
128
  'no key update',
109
129
  );
110
- return new Map((rows as Member[]).map((row) => [row.principal, row]));
130
+ return new Map((rows as StoredMember[]).map((row) => [row.principal, memberOf(row)]));
111
131
  }
112
132
 
113
133
  /**
@@ -116,7 +136,7 @@ export async function lockMembers(tx: Transaction, principals: readonly string[]
116
136
  */
117
137
  export async function insertMember(tx: Transaction, row: Member): Promise<void> {
118
138
  const { vaultMembers } = tablesOf(tx);
119
- await tx.insert(vaultMembers).values(row);
139
+ await tx.insert(vaultMembers).values(storedChange(row) as typeof vaultMembers.$inferInsert);
120
140
  }
121
141
 
122
142
  export async function updateMember(
@@ -125,7 +145,7 @@ export async function updateMember(
125
145
  change: Partial<Omit<Member, 'principal'>>,
126
146
  ): Promise<void> {
127
147
  const { vaultMembers } = tablesOf(tx);
128
- await tx.update(vaultMembers).set(change).where(eq(vaultMembers.principal, principal));
148
+ await tx.update(vaultMembers).set(storedChange(change)).where(eq(vaultMembers.principal, principal));
129
149
  }
130
150
 
131
151
  // --- grants ---------------------------------------------------------------------
@@ -211,12 +231,12 @@ export async function wrappedUnder(db: Queryable, provider: string, keyId: strin
211
231
  return rows.map((row) => ({ ...row, bytes: Buffer.from(row.bytes) }));
212
232
  }
213
233
 
214
- /** Of these projects and environments, the ones that exist, each environment with its project. */
234
+ /** Of these projects and environments, the ones that exist, each environment with its project and slug. */
215
235
  export async function places(
216
236
  db: Queryable,
217
237
  projectIds: readonly string[],
218
238
  environmentIds: readonly string[],
219
- ): Promise<{ projects: Set<string>; environments: Map<string, string>; deleted: Set<string> }> {
239
+ ): Promise<{ projects: Set<string>; environments: Map<string, { projectId: string; slug: string }>; deleted: Set<string> }> {
220
240
  const { projects, environments } = tablesOf(db);
221
241
  const [foundProjects, foundEnvironments] = await Promise.all([
222
242
  projectIds.length === 0
@@ -225,19 +245,43 @@ export async function places(
225
245
  environmentIds.length === 0
226
246
  ? []
227
247
  : db
228
- .select({ id: environments.id, projectId: environments.projectId, deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!) })
248
+ .select({ id: environments.id, projectId: environments.projectId, slug: environments.slug, deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!) })
229
249
  .from(environments)
230
250
  .innerJoin(projects, eq(projects.id, environments.projectId))
231
251
  .where(inArray(environments.id, [...environmentIds])),
232
252
  ]);
233
253
  return {
234
254
  projects: new Set(foundProjects.map((row) => row.id)),
235
- environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId])),
255
+ environments: new Map(foundEnvironments.map((row) => [row.id, { projectId: row.projectId, slug: row.slug }])),
236
256
  // The projects and environments among them that were deleted, alone or with their project.
237
257
  deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((row) => row.id)),
238
258
  };
239
259
  }
240
260
 
261
+ /** Every project not deleted, with its environments not deleted: what a grant on every project of 0.4 reached. */
262
+ export async function liveProjects(db: Queryable): Promise<{ id: string; environments: { id: string; slug: string }[] }[]> {
263
+ const { projects, environments } = tablesOf(db);
264
+ const [found, inside] = await Promise.all([
265
+ db.select({ id: projects.id, deleted: truth(tombstone(projects.slug)) }).from(projects).orderBy(asc(projects.id)),
266
+ db.select({ id: environments.id, projectId: environments.projectId, slug: environments.slug, deleted: truth(tombstone(environments.slug)) }).from(environments).orderBy(asc(environments.id)),
267
+ ]);
268
+ return found.filter((project) => !project.deleted).map((project) => ({
269
+ id: project.id,
270
+ environments: inside.filter((environment) => environment.projectId === project.id && !environment.deleted).map(({ id, slug }) => ({ id, slug })),
271
+ }));
272
+ }
273
+
274
+ /** Who holds a grant on every project of 0.4, lapsed or not, which the vault replaces (`#convert`). */
275
+ export async function everyProjectHolders(db: Queryable): Promise<string[]> {
276
+ const { vaultGrants } = tablesOf(db);
277
+ const rows = await db
278
+ .selectDistinct({ principal: vaultGrants.principal })
279
+ .from(vaultGrants)
280
+ .where(and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId)))
281
+ .orderBy(asc(vaultGrants.principal));
282
+ return rows.map((row) => row.principal);
283
+ }
284
+
241
285
  // --- the log --------------------------------------------------------------------
242
286
 
243
287
  /** The action of an entry that releases a key, which the bulk limit counts. */
@@ -261,33 +305,6 @@ export async function releasesSince(db: Queryable, principal: string, after: num
261
305
  return Number(n);
262
306
  }
263
307
 
264
- function entryColumns(db: Queryable) {
265
- const { auditLog } = tablesOf(db);
266
- return {
267
- seq: auditLog.seq,
268
- author: auditLog.author,
269
- keyId: auditLog.keyId,
270
- occurredAt: auditLog.occurredAt,
271
- actor: auditLog.actor,
272
- action: auditLog.action,
273
- decision: auditLog.decision,
274
- code: auditLog.code,
275
- subjectPrincipal: auditLog.subjectPrincipal,
276
- projectId: auditLog.projectId,
277
- environmentId: auditLog.environmentId,
278
- secretId: auditLog.secretId,
279
- secretVersionId: auditLog.secretVersionId,
280
- operationId: auditLog.operationId,
281
- requestId: auditLog.requestId,
282
- sourceIp: auditLog.sourceIp,
283
- relatedSeq: auditLog.relatedSeq,
284
- metadata: auditLog.metadata,
285
- prevHash: auditLog.prevHash,
286
- mac: auditLog.mac,
287
- hash: auditLog.hash,
288
- };
289
- }
290
-
291
308
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
292
309
  function stored(rows: object[]): StoredEntry[] {
293
310
  return rows as StoredEntry[];
@@ -447,8 +464,8 @@ export async function latestVaultEntry(db: Queryable, actions: readonly string[]
447
464
  return row === undefined ? undefined : stored([row])[0];
448
465
  }
449
466
 
450
- /** Up to `limit` of the vault's allowed access entries about `principal`, newest first. */
451
- export async function accessEntriesAbout(db: Queryable, principal: string, limit: number): Promise<StoredEntry[]> {
467
+ /** Up to `limit` of the vault's allowed entries of `action` before `before` (the newest when undefined), newest first. */
468
+ export async function newestVaultEntries(db: Queryable, action: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
452
469
  const { auditLog } = tablesOf(db);
453
470
  const rows = await db
454
471
  .select(entryColumns(db))
@@ -456,9 +473,9 @@ export async function accessEntriesAbout(db: Queryable, principal: string, limit
456
473
  .where(
457
474
  and(
458
475
  eq(auditLog.author, 'vault'),
459
- eq(auditLog.subjectPrincipal, principal),
460
- inArray(auditLog.action, [...ACCESS_ACTIONS]),
476
+ eq(auditLog.action, action),
461
477
  eq(auditLog.decision, 'allow'),
478
+ before === undefined ? undefined : lt(auditLog.seq, before),
462
479
  ),
463
480
  )
464
481
  .orderBy(desc(auditLog.seq))