@coffre/vault 0.10.1 → 0.10.2

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.10.1",
3
+ "version": "0.10.2",
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.10.1",
40
- "@coffre/db": "0.10.1"
39
+ "@coffre/core": "0.10.2",
40
+ "@coffre/db": "0.10.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/config.ts CHANGED
@@ -2,13 +2,14 @@ import { hkdfSync } from 'node:crypto';
2
2
 
3
3
  import { KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
4
4
 
5
- /** At most `count` data keys unwrapped per principal in any `windowMs`. */
5
+ /** At most `count` distinct data keys unwrapped per principal in any `windowMs`. */
6
6
  export type BulkLimit = { count: number; windowMs: number };
7
7
 
8
8
  /**
9
- * 1000 keys in 15 minutes: twenty `coffre run`s of a 50-key environment
10
- * back to back, which no person or deploy pipeline does, while a script
11
- * pulling every secret it can reach stops within a few seconds.
9
+ * 1000 different keys in 15 minutes, which no person or deploy pipeline
10
+ * reads, while a script pulling every secret it can reach stops within a
11
+ * few seconds. A key read again counts once: a 50-key `coffre run` is 50
12
+ * however often it runs.
12
13
  */
13
14
  export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
14
15
 
@@ -62,7 +63,7 @@ export type VaultConfig = {
62
63
  * service holds the KEK, as AWS KMS does: the vault never sees that key.
63
64
  */
64
65
  signingKey?: string;
65
- /** At most `count` data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
66
+ /** At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
66
67
  bulkLimit?: { count: number; windowMinutes: number };
67
68
  /**
68
69
  * @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
@@ -0,0 +1,97 @@
1
+ import { TOMBSTONE } from '@coffre/core/schemas';
2
+ import { ACCESS_ACTIONS } from '@coffre/core/vault';
3
+ import { sql, type SQL } from 'drizzle-orm';
4
+
5
+ import type { Asked } from './snapshot.ts';
6
+
7
+ /** An entry of `audit_log` aliased `e`, as JSON (`RawEntry`): whole numbers past 2^53 as text, bytes as hex. */
8
+ const ENTRY = sql.raw(`json_build_object(
9
+ 'seq', e.seq::text, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
10
+ 'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
11
+ 'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
12
+ 'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq::text,
13
+ 'metadata', e.metadata, 'prevHash', encode(e.prev_hash, 'hex'), 'mac', encode(e.mac, 'hex'), 'hash', encode(e.hash, 'hex'))`);
14
+
15
+ const NOW = sql.raw('(extract(epoch from clock_timestamp()) * 1000)::bigint');
16
+
17
+ /** `snapshot`'s statement on Postgres: one row, one JSON column. Each part reads one index range. */
18
+ export function snapshotQuery(asked: Asked): SQL {
19
+ const principals = sql`${sql.param([...asked.principals])}::text[]`;
20
+ const environments = sql`${sql.param([...(asked.environments ?? [])])}::uuid[]`;
21
+ const references = sql`${sql.param((asked.references ?? []).map(String))}::bigint[]`;
22
+ const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
23
+ const tomb = `%${TOMBSTONE}%`;
24
+ const released = asked.released;
25
+ // The head only if nobody holds it: a statement never waits for it, so all it reads is as of when it took it (`snapshot`).
26
+ const head = asked.head === true
27
+ ? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)`
28
+ : sql``;
29
+ return sql`
30
+ WITH policy AS (
31
+ SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
32
+ )${head}
33
+ SELECT json_build_object(
34
+ 'at', ${NOW},
35
+ 'head', ${asked.head === true ? sql`(SELECT json_build_object('nextSeq', next_seq::text, 'hash', encode(head_hash, 'hex')) FROM head)` : sql`NULL`},
36
+ 'last', ${asked.head === true ? sql`(SELECT json_build_object('seq', seq::text, 'hash', encode(hash, 'hex')) FROM audit_log ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
37
+ 'rotation', ${asked.head === true
38
+ ? sql`(SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)`
39
+ : sql`NULL`},
40
+ 'members', ${asked.members === true
41
+ ? sql`(SELECT coalesce(json_agg(json_build_object(
42
+ 'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
43
+ 'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
44
+ 'accessSeq', access_seq::text, 'mac', encode(mac, 'hex'))), '[]') FROM vault_members WHERE principal = ANY(${principals}))`
45
+ : sql`'[]'::json`},
46
+ 'grants', (
47
+ SELECT coalesce(json_agg(json_build_object(
48
+ 'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
49
+ 'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by)), '[]')
50
+ FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
51
+ WHERE g.principal = ANY(${principals})
52
+ ),
53
+ 'access', (
54
+ SELECT coalesce(json_agg(${ENTRY}), '[]')
55
+ FROM unnest(${principals}) AS asked (principal)
56
+ CROSS JOIN LATERAL (
57
+ SELECT * FROM audit_log
58
+ WHERE author = 'vault' AND subject_principal = asked.principal AND action = ANY(${actions}) AND decision = 'allow'
59
+ ORDER BY seq DESC LIMIT 1
60
+ ) e
61
+ ),
62
+ 'policy', (SELECT ${ENTRY} FROM policy e),
63
+ 'environments', (
64
+ SELECT coalesce(json_agg(json_build_object(
65
+ 'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
66
+ 'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
67
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL)), '[]')
68
+ FROM environments env JOIN projects project ON project.id = env.project_id
69
+ -- One list of ids, each found by the primary key.
70
+ WHERE env.id = ANY(ARRAY(
71
+ SELECT unnest(${environments})
72
+ UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
73
+ -- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
74
+ UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
75
+ WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
76
+ ))
77
+ ),
78
+ 'ended', (
79
+ SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
80
+ WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
81
+ ),
82
+ 'released', ${released === undefined
83
+ ? sql`0`
84
+ : sql`(
85
+ SELECT count(DISTINCT key) FROM (
86
+ -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
87
+ -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
88
+ SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
89
+ -- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
90
+ LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
91
+ WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
92
+ AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
93
+ UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
94
+ ) AS released (key)
95
+ )`}
96
+ ) AS snapshot`;
97
+ }
@@ -0,0 +1,103 @@
1
+ import { TOMBSTONE } from '@coffre/core/schemas';
2
+ import { ACCESS_ACTIONS } from '@coffre/core/vault';
3
+ import { sql, type SQL } from 'drizzle-orm';
4
+
5
+ import type { Asked } from './snapshot.ts';
6
+
7
+ /**
8
+ * An entry of `audit_log` aliased `e`, as JSON (`RawEntry`), bytes as hex.
9
+ * A JSON value loses its type leaving a subquery, so whoever takes one
10
+ * from a subquery wraps it in `json(…)`.
11
+ */
12
+ const ENTRY = sql.raw(`json_object(
13
+ 'seq', e.seq, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
14
+ 'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
15
+ 'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
16
+ 'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq,
17
+ 'metadata', e.metadata, 'prevHash', hex(e.prev_hash), 'mac', hex(e.mac), 'hash', hex(e.hash))`);
18
+
19
+ const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
20
+
21
+ /**
22
+ * `snapshot`'s statement on SQLite, the same as on Postgres. Nothing is
23
+ * locked: one transaction runs at a time (connect.ts), so the head is the
24
+ * last entry's.
25
+ */
26
+ export function snapshotQuery(asked: Asked): SQL {
27
+ const principals = JSON.stringify(asked.principals);
28
+ const environments = JSON.stringify(asked.environments ?? []);
29
+ const references = JSON.stringify((asked.references ?? []).map(Number));
30
+ const actions = JSON.stringify(ACCESS_ACTIONS);
31
+ const tomb = `%${TOMBSTONE}%`;
32
+ const released = asked.released;
33
+ const head = asked.head === true;
34
+ return sql`
35
+ WITH policy AS (
36
+ SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
37
+ )
38
+ SELECT json_object(
39
+ 'at', ${NOW},
40
+ 'head', ${head ? sql`json((SELECT json_object('nextSeq', next_seq, 'hash', hex(head_hash)) FROM audit_chain_head WHERE only_row))` : sql`NULL`},
41
+ 'last', ${head ? sql`json((SELECT json_object('seq', seq, 'hash', hex(hash)) FROM audit_log ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
42
+ 'rotation', ${head
43
+ ? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))`
44
+ : sql`NULL`},
45
+ 'members', json(${asked.members === true
46
+ ? sql`(SELECT json_group_array(json_object(
47
+ 'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
48
+ 'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
49
+ 'accessSeq', access_seq, 'mac', hex(mac))) FROM vault_members WHERE principal IN (SELECT value FROM json_each(${principals})))`
50
+ : sql`'[]'`}),
51
+ 'grants', json((
52
+ SELECT json_group_array(json_object(
53
+ 'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
54
+ 'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by))
55
+ FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
56
+ WHERE g.principal IN (SELECT value FROM json_each(${principals}))
57
+ )),
58
+ 'access', json((
59
+ SELECT json_group_array(json(newest)) FROM (
60
+ SELECT (
61
+ SELECT ${ENTRY} FROM audit_log e
62
+ WHERE author = 'vault' AND subject_principal = asked.value AND action IN (SELECT value FROM json_each(${actions})) AND decision = 'allow'
63
+ ORDER BY seq DESC LIMIT 1
64
+ ) AS newest
65
+ FROM json_each(${principals}) AS asked
66
+ ) WHERE newest IS NOT NULL
67
+ )),
68
+ 'policy', json((SELECT ${ENTRY} FROM policy e)),
69
+ 'environments', json((
70
+ SELECT json_group_array(json_object(
71
+ 'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
72
+ 'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
73
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL))
74
+ FROM environments env JOIN projects project ON project.id = env.project_id
75
+ WHERE env.id IN (SELECT value FROM json_each(${environments}))
76
+ OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
77
+ OR env.id IN (
78
+ SELECT json_extract(place.value, '$.environmentId')
79
+ FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
80
+ -- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
81
+ WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
82
+ )
83
+ )),
84
+ 'ended', json((
85
+ SELECT json_group_array(related_seq) FROM audit_log
86
+ WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
87
+ )),
88
+ 'released', ${released === undefined
89
+ ? sql`0`
90
+ : sql`(
91
+ SELECT count(DISTINCT key) FROM (
92
+ -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
93
+ -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
94
+ SELECT coalesce(e.secret_id, CASE WHEN one.type = 'object' AND json_type(one.value, '$.secretId') = 'text' THEN json_extract(one.value, '$.secretId') END, 'entry:' || e.seq) AS key
95
+ FROM audit_log e
96
+ LEFT JOIN json_each(CASE WHEN e.secret_id IS NULL AND json_type(e.metadata, '$.secrets') = 'array' THEN e.metadata END, '$.secrets') AS one
97
+ WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
98
+ AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
99
+ UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
100
+ )
101
+ )`}
102
+ ) AS snapshot`;
103
+ }
@@ -0,0 +1,191 @@
1
+ import type { StoredEntry } from '@coffre/core/audit';
2
+ import type { Queryable, Transaction } from '@coffre/db';
3
+ import { LogHeadMismatch, lockLogHead } from '@coffre/db/log';
4
+ import { engineOf, rowsOf } from '@coffre/db/dialect';
5
+ import type { GrantRow } from '@coffre/db/grants';
6
+
7
+ import { snapshotQuery as postgres } from './snapshot.postgres.ts';
8
+ import { snapshotQuery as sqlite } from './snapshot.sqlite.ts';
9
+ import * as store from './store.ts';
10
+ import type { EnvironmentRow, Member } from './store.ts';
11
+
12
+ /**
13
+ * What a decision reads, or `access`, in one statement: one round trip, and
14
+ * one snapshot of the database, so its parts agree with each other.
15
+ *
16
+ * In a decision, the same statement locks the log's head, but only if it
17
+ * is free (`SKIP LOCKED`). Postgres takes a statement's snapshot when it
18
+ * starts, so one that waited for the lock would have read everything but
19
+ * the head as it was before the wait: a policy changed, a place deleted, a
20
+ * grant written around the vault while it waited would be missed. A
21
+ * statement that never waits reads all of it as of the lock. When another
22
+ * transaction holds the head, the decision waits for it in a statement of
23
+ * its own, then reads again, holding it. The read checks that the head names
24
+ * the last entry it saw, too (`current`): one taken between the snapshot and
25
+ * the lock, by an append that committed in that instant, is read again. That
26
+ * check sees a commit only through the head it moved, which holds because
27
+ * every change a decision reads commits with an entry under the head: the
28
+ * vault's own decisions, and the app's writes to places, each `audited`
29
+ * with its entry.
30
+ */
31
+ export type Snapshot = {
32
+ /** The database's clock, milliseconds since the epoch. */
33
+ at: number;
34
+ /** The rows of `asked.members`, by principal; those with none are not in the map. */
35
+ members: Map<string, Member>;
36
+ /** Every grant of each asked principal, lapsed ones too. */
37
+ grants: Map<string, GrantRow[]>;
38
+ /** The newest allowed access entry in the vault's name about each asked principal, unchecked. */
39
+ newestAccess: Map<string, StoredEntry>;
40
+ /** The newest allowed `policy.change` in the vault's name, unchecked. */
41
+ newestPolicy: StoredEntry | null;
42
+ /**
43
+ * The asked environments, the environments each personal config among
44
+ * them is a config of, and those the newest `policy.change` names, by id.
45
+ */
46
+ environments: Map<string, EnvironmentRow>;
47
+ /** Of the asked `reference.create` entries, those a `reference.end` of the vault's names, allowed. */
48
+ ended: Set<bigint>;
49
+ /**
50
+ * How many distinct keys `asked.released.principal` holds released within
51
+ * its window once it is given `asked.released.keys` too: a key released
52
+ * again, or asked for again, counts once.
53
+ */
54
+ released: number;
55
+ /** The log's head, locked, with the last entry and the newest `key.rotate`; null unless asked, or held by another. */
56
+ head: LockedHead | null;
57
+ };
58
+
59
+ export type LockedHead = {
60
+ nextSeq: bigint;
61
+ headHash: Buffer;
62
+ /** The log's last entry, as this statement's snapshot has it. */
63
+ last: { seq: bigint; hash: Buffer } | null;
64
+ /** The vault's newest `key.rotate`, allowed, unchecked. */
65
+ rotation: StoredEntry | null;
66
+ };
67
+
68
+ /** What to read. */
69
+ export type Asked = {
70
+ /** Whose grants and newest access entry; with `members`, their rows too, unlocked. */
71
+ principals: readonly string[];
72
+ members?: boolean;
73
+ environments?: readonly string[];
74
+ references?: readonly bigint[];
75
+ /**
76
+ * One principal's releases within the last `windowMs` of the clock, moved
77
+ * on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides.
78
+ */
79
+ released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number };
80
+ /** Lock the log's head first. */
81
+ head?: boolean;
82
+ };
83
+
84
+ /** One statement's read, as `Snapshot` says; with the head, two more when another holds it. */
85
+ export async function snapshot(tx: Transaction, asked: Asked): Promise<Snapshot>;
86
+ export async function snapshot(db: Queryable, asked: Asked & { head?: false }): Promise<Snapshot>;
87
+ export async function snapshot(db: Queryable, asked: Asked): Promise<Snapshot> {
88
+ const read = () => readOnce(db, asked);
89
+ const first = await read();
90
+ if (asked.head !== true || (first.head !== null && current(first.head))) return first;
91
+ // Held by another, or moved in the instant before the lock: everything again, once the head is this transaction's.
92
+ // Asked for the head, `db` is a transaction (the overloads above).
93
+ if (first.head === null) await lockLogHead(db as Transaction);
94
+ const again = await read();
95
+ if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
96
+ // Held, nothing appends: a head that names another last entry was moved, or the log written, around it (`appendEntries`).
97
+ if (!current(again.head)) {
98
+ throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? 'empty' : `at entry ${again.head.last.seq}`}, or with another hash`);
99
+ }
100
+ return again;
101
+ }
102
+
103
+ /** Whether the head names the last entry the same statement saw: nothing was appended between its snapshot and its lock. */
104
+ export function current(head: LockedHead): boolean {
105
+ if (head.last === null) return head.nextSeq === 0n;
106
+ return head.nextSeq === head.last.seq + 1n && head.headHash.equals(head.last.hash);
107
+ }
108
+
109
+ async function readOnce(db: Queryable, asked: Asked): Promise<Snapshot> {
110
+ const query = (engineOf(db) === 'postgres' ? postgres : sqlite)(asked);
111
+ const [row] = await rowsOf<{ snapshot: string | Raw }>(db, query);
112
+ const raw: Raw = typeof row!.snapshot === 'string' ? (JSON.parse(row!.snapshot) as Raw) : row!.snapshot;
113
+ return {
114
+ at: Number(raw.at),
115
+ members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
116
+ grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
117
+ newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal!, entryOf(entry)])),
118
+ newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
119
+ environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
120
+ ...environment,
121
+ deleted: Boolean(environment.deleted),
122
+ archived: Boolean(environment.archived),
123
+ }])),
124
+ ended: new Set(raw.ended.map((seq) => BigInt(seq))),
125
+ released: Number(raw.released ?? 0),
126
+ head: raw.head === null ? null : {
127
+ nextSeq: BigInt(raw.head.nextSeq),
128
+ headHash: Buffer.from(raw.head.hash, 'hex'),
129
+ last: raw.last === null ? null : { seq: BigInt(raw.last.seq), hash: Buffer.from(raw.last.hash, 'hex') },
130
+ rotation: raw.rotation === null ? null : entryOf(raw.rotation),
131
+ },
132
+ };
133
+ }
134
+
135
+ /** Whole numbers come as JSON numbers or text, bytes as hex, as each dialect's query writes them. */
136
+ type Int = number | string;
137
+
138
+ /** An entry's columns, as both dialects' queries write them (`entryJson`). */
139
+ export type RawEntry = {
140
+ seq: Int; author: string; keyId: string; occurredAt: Int; actor: string; action: string; decision: string; code: string | null;
141
+ subjectPrincipal: string | null; projectId: string | null; environmentId: string | null; secretId: string | null;
142
+ secretVersionId: string | null; operationId: string | null; requestId: string | null; sourceIp: string | null;
143
+ relatedSeq: Int | null; metadata: string; prevHash: string; mac: string; hash: string;
144
+ };
145
+
146
+ type Raw = {
147
+ at: Int;
148
+ head: { nextSeq: Int; hash: string } | null;
149
+ last: { seq: Int; hash: string } | null;
150
+ rotation: RawEntry | null;
151
+ members: {
152
+ principal: string; status: 'active' | 'removed'; owner: boolean | number; role: string | null; scope: string | null; generation: Int;
153
+ createdAt: Int; createdBy: string; statusChangedAt: Int; statusChangedBy: string; accessSeq: Int; mac: string;
154
+ }[];
155
+ grants: { principal: string; projectId: string | null; environmentId: string | null; environmentSlug: string | null; role: string; expiresAt: Int | null; grantedAt: Int; grantedBy: string }[];
156
+ access: RawEntry[];
157
+ policy: RawEntry | null;
158
+ environments: { id: string; projectId: string; slug: string; owner: string | null; parentId: string | null; deleted: boolean | number; archived: boolean | number }[];
159
+ ended: Int[];
160
+ released: Int | null;
161
+ };
162
+
163
+ /** An entry as the chain's codec reads it, from what `entryJson` wrote. */
164
+ export function entryOf(raw: RawEntry): StoredEntry {
165
+ return {
166
+ ...raw,
167
+ seq: BigInt(raw.seq),
168
+ author: raw.author as StoredEntry['author'],
169
+ occurredAt: Number(raw.occurredAt),
170
+ relatedSeq: raw.relatedSeq === null ? null : BigInt(raw.relatedSeq),
171
+ prevHash: Buffer.from(raw.prevHash, 'hex'),
172
+ mac: Buffer.from(raw.mac, 'hex'),
173
+ hash: Buffer.from(raw.hash, 'hex'),
174
+ };
175
+ }
176
+
177
+ function grantOf(raw: Raw['grants'][number]): GrantRow {
178
+ return { ...raw, expiresAt: raw.expiresAt === null ? null : Number(raw.expiresAt), grantedAt: Number(raw.grantedAt) };
179
+ }
180
+
181
+ function memberOf({ owner, ...raw }: Raw['members'][number]): Member {
182
+ return store.memberOf({
183
+ ...raw,
184
+ owner: owner === true || owner === 1,
185
+ generation: Number(raw.generation),
186
+ createdAt: Number(raw.createdAt),
187
+ statusChangedAt: Number(raw.statusChangedAt),
188
+ accessSeq: BigInt(raw.accessSeq),
189
+ mac: Buffer.from(raw.mac, 'hex'),
190
+ });
191
+ }
package/src/store.ts CHANGED
@@ -88,10 +88,10 @@ function memberColumns(db: Queryable) {
88
88
  }
89
89
 
90
90
  /** A row as stored: `role` and `scope` as their columns hold them. */
91
- type StoredMember = Omit<Member, 'role' | 'scope'> & { owner: boolean; role: string | null; scope: string | null };
91
+ export type StoredMember = Omit<Member, 'role' | 'scope'> & { owner: boolean; role: string | null; scope: string | null };
92
92
 
93
93
  /** A member as their row says (`storedRole`). */
94
- function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
94
+ export function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
95
95
  return { ...row, ...storedRole({ owner, role, scope }) };
96
96
  }
97
97
 
@@ -308,38 +308,6 @@ export async function everyProjectHolders(db: Queryable): Promise<string[]> {
308
308
 
309
309
  // --- the log --------------------------------------------------------------------
310
310
 
311
- /** The action of an entry that releases a key, which the bulk limit counts. */
312
- export const RELEASE = 'secret.read';
313
-
314
- /**
315
- * How many keys the vault has released to `principal` since `after`: one
316
- * for an entry about one secret, and as many as it lists for one about
317
- * several (`secrets`), a read logged once per place.
318
- */
319
- export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
320
- const [{ n }] = await released(db, { principal, after });
321
- return Number(n ?? 0);
322
- }
323
-
324
- const released = prepared((db) => {
325
- const { auditLog } = tablesOf(db);
326
- const listed = engineOf(db) === 'postgres'
327
- ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
328
- : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
329
- return db
330
- .select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
331
- .from(auditLog)
332
- .where(
333
- and(
334
- eq(auditLog.author, 'vault'),
335
- eq(auditLog.actor, sql.placeholder('principal')),
336
- eq(auditLog.action, RELEASE),
337
- eq(auditLog.decision, 'allow'),
338
- gt(auditLog.occurredAt, sql.placeholder('after')),
339
- ),
340
- );
341
- });
342
-
343
311
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
344
312
  function stored(rows: object[]): StoredEntry[] {
345
313
  return rows as StoredEntry[];