@coffre/vault 0.3.0 → 0.4.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.3.0",
3
+ "version": "0.4.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.3.0",
40
- "@coffre/db": "0.3.0"
39
+ "@coffre/core": "0.4.1",
40
+ "@coffre/db": "0.4.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
@@ -45,7 +45,7 @@
45
45
  "pg": "8.16.3",
46
46
  "tsdown": "0.23.0",
47
47
  "typescript": "5.9.3",
48
- "wrangler": "4.118.0"
48
+ "wrangler": "4.142.0"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "tsdown",
package/src/cloudflare.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  import type {
19
19
  AdmitInput,
20
20
  RemoveInput,
21
+ ReferenceInput,
22
+ EndReferencesInput,
21
23
  RewrapInput,
22
24
  SetAccessInput,
23
25
  UnwrapInput,
@@ -83,6 +85,8 @@ export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
83
85
  async unwrap(input: UnwrapInput) { return this.#call((vault) => vault.unwrap(input)); }
84
86
  async wrap(input: WrapInput) { return this.#call((vault) => vault.wrap(input)); }
85
87
  async rewrap(input: RewrapInput) { return this.#call((vault) => vault.rewrap(input)); }
88
+ async reference(input: ReferenceInput) { return this.#call((vault) => vault.reference(input)); }
89
+ async endReferences(input: EndReferencesInput) { return this.#call((vault) => vault.endReferences(input)); }
86
90
  async access(principal: string) { return this.#call((vault) => vault.access(principal)); }
87
91
  async setAccess(input: SetAccessInput) { return this.#call((vault) => vault.setAccess(input)); }
88
92
  async admit(input: AdmitInput) { return this.#call((vault) => vault.admit(input)); }
package/src/local.ts CHANGED
@@ -9,6 +9,8 @@ export const METHODS = [
9
9
  'unwrap',
10
10
  'wrap',
11
11
  'rewrap',
12
+ 'reference',
13
+ 'endReferences',
12
14
  'access',
13
15
  'setAccess',
14
16
  'admit',
package/src/node.ts CHANGED
@@ -79,6 +79,12 @@ export async function serveVault(config: NodeVaultConfig & { socket: string }):
79
79
  }
80
80
  });
81
81
 
82
+ // The server's agent keeps its connections open for its next calls, so
83
+ // the vault never ends an idle one. Node's default did, after five seconds
84
+ // and one more, racing the server's next call on it, which then failed
85
+ // with ECONNRESET: "coffre cannot check who you are right now".
86
+ server.keepAliveTimeout = 0;
87
+
82
88
  // A socket file left by a vault that did not stop cleanly would make
83
89
  // listen fail; anything else at that path is not ours to remove.
84
90
  if (existsSync(config.socket)) {
package/src/replay.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { everyProjectPath } from '@coffre/core/access';
1
2
  import type { StoredEntry } from '@coffre/core/audit';
2
3
  import type { AccessFault, FaultGrant } from '@coffre/core/vault';
3
4
 
@@ -51,7 +52,7 @@ export type Replayed = { members: Map<string, LoggedMember>; held: Map<string, M
51
52
  export function apply(state: Replayed, row: StoredEntry): AccessFault | null {
52
53
  const principal = row.subjectPrincipal!;
53
54
  const detail = JSON.parse(row.metadata) as Record<string, unknown>;
54
- const place: Place = { projectId: row.projectId!, environmentId: row.environmentId };
55
+ const place = placeOf(row, detail);
55
56
  const grantsOf = state.held.get(principal) ?? new Map<string, GrantRow>();
56
57
  state.held.set(principal, grantsOf);
57
58
  const before = state.members.get(principal);
@@ -114,9 +115,21 @@ const MEMBER_FIELDS = [
114
115
  ['accessSeq', 'access_seq'],
115
116
  ] as const satisfies readonly (readonly [keyof LoggedMember, string])[];
116
117
 
117
- /** A grant's place: its environment, or its project when it has none. */
118
+ /**
119
+ * The place an access entry is about: its project and environment, or, with
120
+ * neither, every project, which it names as a path (`*`, or `*` and a slug).
121
+ */
122
+ function placeOf(row: StoredEntry, detail: Record<string, unknown>): Place {
123
+ if (row.projectId !== null || row.environmentId !== null) {
124
+ return { projectId: row.projectId, environmentId: row.environmentId, environmentSlug: null };
125
+ }
126
+ const slug = typeof detail.place === 'string' ? detail.place.split('/')[1] : undefined;
127
+ return { projectId: null, environmentId: null, environmentSlug: slug ?? null };
128
+ }
129
+
130
+ /** A grant's place: its environment, or its project when it has none, or every project's path. */
118
131
  function placeKey(place: Place): string {
119
- return place.environmentId ?? place.projectId;
132
+ return place.environmentId ?? place.projectId ?? everyProjectPath(place.environmentSlug);
120
133
  }
121
134
 
122
135
  function grantKey(grant: GrantRow): string {
@@ -124,6 +137,7 @@ function grantKey(grant: GrantRow): string {
124
137
  grant.principal,
125
138
  grant.projectId,
126
139
  grant.environmentId,
140
+ grant.environmentSlug,
127
141
  grant.role,
128
142
  grant.expiresAt,
129
143
  grant.grantedAt,
@@ -132,6 +146,6 @@ function grantKey(grant: GrantRow): string {
132
146
  }
133
147
 
134
148
  function faultGrant(key: string): FaultGrant {
135
- const [principal, projectId, environmentId, role] = JSON.parse(key) as [string, string, string | null, string];
136
- return { principal, projectId, environmentId, role };
149
+ const [principal, projectId, environmentId, environmentSlug, role] = JSON.parse(key) as [string, string | null, string | null, string | null, string];
150
+ return { principal, projectId, environmentId, environmentSlug, role };
137
151
  }
package/src/rows.ts CHANGED
@@ -21,12 +21,18 @@ export function rowKey(signingKey: Uint8Array): Buffer {
21
21
  return Buffer.from(hkdfSync('sha256', signingKey, new Uint8Array(0), 'coffre.vault.rows.v1', 32));
22
22
  }
23
23
 
24
- /** A member's grants as the MAC covers them: each one's place, role, end and grant, as a sorted set. */
24
+ /**
25
+ * A member's grants as the MAC covers them: each one's place, role, end and
26
+ * 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
+ * set, and so the MAC, they had before there were any.
30
+ */
25
31
  export function grantSet(grants: readonly GrantRow[]): string[] {
26
32
  return grants
27
33
  .map((grant) => [
28
- grant.environmentId === null ? 'project' : 'environment',
29
- grant.environmentId ?? grant.projectId,
34
+ grant.environmentId !== null ? 'environment' : grant.projectId !== null ? 'project' : 'every-project',
35
+ grant.environmentId ?? grant.projectId ?? grant.environmentSlug,
30
36
  grant.role,
31
37
  grant.expiresAt,
32
38
  grant.grantedAt,
package/src/store.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import type { GrantPlace } from '@coffre/core/access';
1
2
  import type { Author, StoredEntry } from '@coffre/core/audit';
2
3
  import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
3
4
  import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
4
- import { clockMillis, engineOf, forUpdate } from '@coffre/db/dialect';
5
- import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, sql } from 'drizzle-orm';
5
+ import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
6
+ import { readGrants, type GrantRow } from '@coffre/db/grants';
7
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
6
8
 
7
9
  /**
8
10
  * Every query the vault makes, and the only code in it that holds them.
@@ -33,16 +35,16 @@ export type Member = {
33
35
  /** Every entry that changes who is a member or what they hold. */
34
36
  export { ACCESS_ACTIONS };
35
37
 
36
- /** Where a grant applies: a project (`environmentId` null), or one of its environments. */
37
- export type Place = { projectId: string; environmentId: string | null };
38
+ /**
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
+ */
43
+ export type Place = GrantPlace;
38
44
 
39
- export type GrantRow = Place & {
40
- principal: string;
41
- role: string;
42
- expiresAt: number | null;
43
- grantedAt: number;
44
- grantedBy: string;
45
- };
45
+ /** A grant as stored, which `@coffre/db/grants` reads and writes. */
46
+ export type { GrantRow };
47
+ export { insertGrant } from '@coffre/db/grants';
46
48
 
47
49
  /**
48
50
  * On Postgres, fail a lock wait in this transaction after `ms`, rather than
@@ -129,37 +131,48 @@ export async function updateMember(
129
131
  // --- grants ---------------------------------------------------------------------
130
132
 
131
133
  /** Grants, lapsed ones too, each with its project, which an environment's grant finds through `environments`. */
132
- export async function grants(db: Queryable, principal?: string): Promise<GrantRow[]> {
133
- const { vaultGrants, environments } = tablesOf(db);
134
- return db
135
- .select({
136
- principal: vaultGrants.principal,
137
- projectId: sql<string>`coalesce(${vaultGrants.projectId}, ${environments.projectId})`,
138
- environmentId: vaultGrants.environmentId,
139
- role: vaultGrants.role,
140
- expiresAt: vaultGrants.expiresAt,
141
- grantedAt: vaultGrants.grantedAt,
142
- grantedBy: vaultGrants.grantedBy,
143
- })
144
- .from(vaultGrants)
145
- .leftJoin(environments, eq(environments.id, vaultGrants.environmentId))
146
- .where(principal === undefined ? undefined : eq(vaultGrants.principal, principal));
134
+ export function grants(db: Queryable, principal?: string): Promise<GrantRow[]> {
135
+ return readGrants(db, principal === undefined ? {} : { principal });
147
136
  }
148
137
 
149
- /** A grant names its environment, or its project when it has none: exactly one. */
138
+ /**
139
+ * A grant's row names its environment, or its project when it has none, or
140
+ * neither on every project, with the slug it covers there, if any.
141
+ */
150
142
  function at(db: Queryable, place: Place) {
151
143
  const { vaultGrants } = tablesOf(db);
152
- return place.environmentId === null
153
- ? and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId))
154
- : eq(vaultGrants.environmentId, place.environmentId);
144
+ if (place.environmentId !== null) return eq(vaultGrants.environmentId, place.environmentId);
145
+ if (place.projectId !== null) return and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId));
146
+ return and(
147
+ isNull(vaultGrants.projectId),
148
+ isNull(vaultGrants.environmentId),
149
+ place.environmentSlug === null ? isNull(vaultGrants.environmentSlug) : eq(vaultGrants.environmentSlug, place.environmentSlug),
150
+ );
155
151
  }
156
152
 
157
- export async function insertGrant(tx: Transaction, grant: GrantRow): Promise<void> {
158
- const { vaultGrants } = tablesOf(tx);
159
- await tx.insert(vaultGrants).values({
160
- ...grant,
161
- projectId: grant.environmentId === null ? grant.projectId : null,
162
- });
153
+ /**
154
+ * Each of these environments that exists, by id, with its project, its
155
+ * slug, what grants on one slug in every project match, and whether it was
156
+ * deleted, alone or with its project: then no key under it opens, or is
157
+ * wrapped, whatever grant would cover it.
158
+ */
159
+ export async function environmentsById(
160
+ db: Queryable,
161
+ ids: readonly string[],
162
+ ): Promise<Map<string, { projectId: string; slug: string; deleted: boolean }>> {
163
+ if (ids.length === 0) return new Map();
164
+ const { environments, projects } = tablesOf(db);
165
+ const rows = await db
166
+ .select({
167
+ id: environments.id,
168
+ projectId: environments.projectId,
169
+ slug: environments.slug,
170
+ deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
171
+ })
172
+ .from(environments)
173
+ .innerJoin(projects, eq(projects.id, environments.projectId))
174
+ .where(inArray(environments.id, [...new Set(ids)]));
175
+ return new Map(rows.map(({ id, ...environment }) => [id, environment]));
163
176
  }
164
177
 
165
178
  export async function deleteGrant(tx: Transaction, principal: string, place: Place): Promise<void> {
@@ -192,7 +205,7 @@ export async function wrappedUnder(db: Queryable, provider: string, keyId: strin
192
205
  })
193
206
  .from(secretVersions)
194
207
  .innerJoin(secrets, eq(secrets.id, secretVersions.secretId))
195
- .where(and(eq(secretVersions.kekProvider, provider), eq(secretVersions.kekId, keyId)))
208
+ .where(and(eq(secretVersions.kekProvider, provider), eq(secretVersions.kekId, keyId), sealed(secretVersions.wrappedDek)))
196
209
  .orderBy(desc(secretVersions.createdAt))
197
210
  .limit(limit);
198
211
  return rows.map((row) => ({ ...row, bytes: Buffer.from(row.bytes) }));
@@ -203,20 +216,25 @@ export async function places(
203
216
  db: Queryable,
204
217
  projectIds: readonly string[],
205
218
  environmentIds: readonly string[],
206
- ): Promise<{ projects: Set<string>; environments: Map<string, string> }> {
219
+ ): Promise<{ projects: Set<string>; environments: Map<string, string>; deleted: Set<string> }> {
207
220
  const { projects, environments } = tablesOf(db);
208
221
  const [foundProjects, foundEnvironments] = await Promise.all([
209
- projectIds.length === 0 ? [] : db.select({ id: projects.id }).from(projects).where(inArray(projects.id, [...projectIds])),
222
+ projectIds.length === 0
223
+ ? []
224
+ : db.select({ id: projects.id, deleted: truth(tombstone(projects.slug)) }).from(projects).where(inArray(projects.id, [...projectIds])),
210
225
  environmentIds.length === 0
211
226
  ? []
212
227
  : db
213
- .select({ id: environments.id, projectId: environments.projectId })
228
+ .select({ id: environments.id, projectId: environments.projectId, deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!) })
214
229
  .from(environments)
230
+ .innerJoin(projects, eq(projects.id, environments.projectId))
215
231
  .where(inArray(environments.id, [...environmentIds])),
216
232
  ]);
217
233
  return {
218
234
  projects: new Set(foundProjects.map((row) => row.id)),
219
235
  environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId])),
236
+ // The projects and environments among them that were deleted, alone or with their project.
237
+ deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((row) => row.id)),
220
238
  };
221
239
  }
222
240
 
@@ -362,6 +380,61 @@ export async function firstVaultEntryUnder(db: Queryable, keyId: string): Promis
362
380
  return row?.seq;
363
381
  }
364
382
 
383
+ /** The entries at these seqs, any author, by seq: what a reference names as its seal. */
384
+ export async function entriesAt(db: Queryable, seqs: readonly bigint[]): Promise<Map<bigint, StoredEntry>> {
385
+ if (seqs.length === 0) return new Map();
386
+ const { auditLog } = tablesOf(db);
387
+ const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, [...new Set(seqs)])));
388
+ return new Map(rows.map((row) => [row.seq, row]));
389
+ }
390
+
391
+ /**
392
+ * Which of these `reference.create` entries a `reference.end` of the
393
+ * vault's names, allowed: one read through `audit_log_reference_end_idx`.
394
+ * An end that fails its MAC still ends: putting a reference back takes
395
+ * more than a forged end can undo, and refusing is the safe side.
396
+ */
397
+ export async function endedReferences(db: Queryable, seqs: readonly bigint[]): Promise<Set<bigint>> {
398
+ if (seqs.length === 0) return new Set();
399
+ const { auditLog } = tablesOf(db);
400
+ const rows = await db
401
+ .select({ relatedSeq: auditLog.relatedSeq })
402
+ .from(auditLog)
403
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'reference.end'), eq(auditLog.decision, 'allow'), inArray(auditLog.relatedSeq, [...new Set(seqs)])));
404
+ return new Set(rows.map((row) => row.relatedSeq!));
405
+ }
406
+
407
+ /** The project each environment is in: a holder's ids, checked against each other. */
408
+ export async function projectsOfEnvironments(db: Queryable, ids: readonly string[]): Promise<Map<string, string>> {
409
+ if (ids.length === 0) return new Map();
410
+ const { environments } = tablesOf(db);
411
+ const rows = await db.select({ id: environments.id, projectId: environments.projectId }).from(environments).where(inArray(environments.id, [...new Set(ids)]));
412
+ return new Map(rows.map((row) => [row.id, row.projectId]));
413
+ }
414
+
415
+ /** A secret as the vault reads it to make a reference to it: where it is, its path, and its current version. */
416
+ export type SecretPlace = { secretId: string; projectId: string; environmentId: string; path: string; currentVersionId: string | null };
417
+
418
+ export async function secretsByIds(db: Queryable, ids: readonly string[]): Promise<Map<string, SecretPlace>> {
419
+ if (ids.length === 0) return new Map();
420
+ const { secrets, environments, projects } = tablesOf(db);
421
+ const rows = await db
422
+ .select({
423
+ secretId: secrets.id,
424
+ projectId: secrets.projectId,
425
+ environmentId: secrets.environmentId,
426
+ project: projects.slug,
427
+ environment: environments.slug,
428
+ key: secrets.key,
429
+ currentVersionId: secrets.currentVersionId,
430
+ })
431
+ .from(secrets)
432
+ .innerJoin(environments, eq(environments.id, secrets.environmentId))
433
+ .innerJoin(projects, eq(projects.id, secrets.projectId))
434
+ .where(inArray(secrets.id, [...new Set(ids)]));
435
+ return new Map(rows.map(({ project, environment, key, ...row }) => [row.secretId, { ...row, path: `${project}/${environment}/${key}` }]));
436
+ }
437
+
365
438
  /** The vault's newest entry of any of these actions, allowed, or undefined. */
366
439
  export async function latestVaultEntry(db: Queryable, actions: readonly string[]): Promise<StoredEntry | undefined> {
367
440
  const { auditLog } = tablesOf(db);
@@ -431,8 +504,22 @@ export async function vaultEntriesOf(
431
504
  return stored(rows);
432
505
  }
433
506
 
434
- /** A stored version and the binding and key the vault reads for itself. */
435
- export type SecretVersion = { id: string; secret: SecretRef; wrapped: WrappedKey };
507
+ /**
508
+ * A version that still holds its wrapped key. A deleted place's versions are
509
+ * erased, their keys emptied: there is nothing left to open, or to try a
510
+ * vault key on.
511
+ */
512
+ function sealed(wrappedDek: SQLWrapper): SQL {
513
+ return sql`length(${wrappedDek}) > 0`;
514
+ }
515
+
516
+ /**
517
+ * A stored version and the binding and key the vault reads for itself;
518
+ * whether its place was deleted, and whether it is its secret's current
519
+ * one, as a read through a reference needs. An erased version outside a
520
+ * deleted place is no version.
521
+ */
522
+ export type SecretVersion = { id: string; secret: SecretRef; wrapped: WrappedKey; deleted: boolean; current: boolean };
436
523
 
437
524
  export async function versions(db: Queryable, ids: readonly string[]): Promise<SecretVersion[]> {
438
525
  if (ids.length === 0) return [];
@@ -451,14 +538,19 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
451
538
  kekId: secretVersions.kekId,
452
539
  kekVersion: secretVersions.kekVersion,
453
540
  wrappedDek: secretVersions.wrappedDek,
541
+ deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
542
+ currentVersionId: secrets.currentVersionId,
543
+ // The newest by the versions' own order: the app may write `current_version_id`, never a version.
544
+ newest: sql<number | string>`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
454
545
  })
455
546
  .from(secretVersions)
456
547
  .innerJoin(secrets, eq(secrets.id, secretVersions.secretId))
457
548
  .innerJoin(environments, eq(environments.id, secrets.environmentId))
458
549
  .innerJoin(projects, eq(projects.id, secrets.projectId))
459
- .where(inArray(secretVersions.id, [...new Set(ids)]));
550
+ .where(and(inArray(secretVersions.id, [...new Set(ids)]), or(sealed(secretVersions.wrappedDek), tombstone(environments.slug), tombstone(projects.slug))));
460
551
  return rows.map((row) => ({
461
552
  id: row.id,
553
+ deleted: row.deleted,
462
554
  secret: {
463
555
  projectId: row.projectId,
464
556
  environmentId: row.environmentId,
@@ -472,5 +564,6 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
472
564
  kekVersion: row.kekVersion,
473
565
  bytes: row.wrappedDek.toString('base64'),
474
566
  },
567
+ current: row.currentVersionId === row.id && Number(row.newest) === row.version,
475
568
  }));
476
569
  }