@coffre/vault 0.10.0 → 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/dist/cloudflare.d.ts +2 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CByXzyi-.d.ts → index-zkW6DEm9.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +12 -1
- package/dist/node.js +2 -2
- package/dist/{src-D82jyprb.js → src-B0d-W-SI.js} +4 -3
- package/dist/{vault-COqZ12ny.js → vault-66Xf0Hw1.js} +1474 -543
- package/package.json +3 -3
- package/src/accounting.ts +37 -8
- package/src/config.ts +6 -5
- package/src/log.ts +91 -60
- package/src/replay.ts +1 -1
- package/src/snapshot.postgres.ts +97 -0
- package/src/snapshot.sqlite.ts +103 -0
- package/src/snapshot.ts +191 -0
- package/src/store.ts +88 -46
- package/src/tally.ts +241 -0
- package/src/vault.ts +580 -273
package/src/snapshot.ts
ADDED
|
@@ -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EVERYWHERE, ownsInstance, 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
|
-
import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
|
|
4
|
+
import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
|
|
5
5
|
import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
|
|
6
6
|
import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
|
|
7
7
|
import { entryColumns, withinBytes } from '@coffre/db/log';
|
|
@@ -61,11 +61,12 @@ export async function boundLockWaits(tx: Transaction, ms: number): Promise<void>
|
|
|
61
61
|
|
|
62
62
|
/** The database's clock, now. */
|
|
63
63
|
export async function now(db: Queryable): Promise<number> {
|
|
64
|
-
const {
|
|
65
|
-
const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
|
|
64
|
+
const [{ at }] = await clock(db);
|
|
66
65
|
return at;
|
|
67
66
|
}
|
|
68
67
|
|
|
68
|
+
const clock = prepared((db) => db.select({ at: clockMillis(db) }).from(tablesOf(db).auditChainHead));
|
|
69
|
+
|
|
69
70
|
// --- members --------------------------------------------------------------------
|
|
70
71
|
|
|
71
72
|
function memberColumns(db: Queryable) {
|
|
@@ -87,10 +88,10 @@ function memberColumns(db: Queryable) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/** A row as stored: `role` and `scope` as their columns hold them. */
|
|
90
|
-
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 };
|
|
91
92
|
|
|
92
93
|
/** A member as their row says (`storedRole`). */
|
|
93
|
-
function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
|
|
94
|
+
export function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
|
|
94
95
|
return { ...row, ...storedRole({ owner, role, scope }) };
|
|
95
96
|
}
|
|
96
97
|
|
|
@@ -101,11 +102,15 @@ function storedChange(change: Partial<Omit<Member, 'principal'>>): Record<string
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
export async function member(db: Queryable, principal: string): Promise<Member | undefined> {
|
|
104
|
-
const
|
|
105
|
-
const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
|
|
105
|
+
const [row] = await memberRow(db, { principal });
|
|
106
106
|
return row === undefined ? undefined : memberOf(row as StoredMember);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const memberRow = prepared((db) => {
|
|
110
|
+
const { vaultMembers } = tablesOf(db);
|
|
111
|
+
return db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, sql.placeholder('principal')));
|
|
112
|
+
});
|
|
113
|
+
|
|
109
114
|
export async function allMembers(db: Queryable): Promise<Member[]> {
|
|
110
115
|
const { vaultMembers } = tablesOf(db);
|
|
111
116
|
return ((await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))) as StoredMember[]).map(memberOf);
|
|
@@ -303,34 +308,6 @@ export async function everyProjectHolders(db: Queryable): Promise<string[]> {
|
|
|
303
308
|
|
|
304
309
|
// --- the log --------------------------------------------------------------------
|
|
305
310
|
|
|
306
|
-
/** The action of an entry that releases a key, which the bulk limit counts. */
|
|
307
|
-
export const RELEASE = 'secret.read';
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* How many keys the vault has released to `principal` since `after`: one
|
|
311
|
-
* for an entry about one secret, and as many as it lists for one about
|
|
312
|
-
* several (`secrets`), a read logged once per place.
|
|
313
|
-
*/
|
|
314
|
-
export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
|
|
315
|
-
const { auditLog } = tablesOf(db);
|
|
316
|
-
const listed = engineOf(db) === 'postgres'
|
|
317
|
-
? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
|
|
318
|
-
: sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
|
|
319
|
-
const [{ n }] = await db
|
|
320
|
-
.select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
|
|
321
|
-
.from(auditLog)
|
|
322
|
-
.where(
|
|
323
|
-
and(
|
|
324
|
-
eq(auditLog.author, 'vault'),
|
|
325
|
-
eq(auditLog.actor, principal),
|
|
326
|
-
eq(auditLog.action, RELEASE),
|
|
327
|
-
eq(auditLog.decision, 'allow'),
|
|
328
|
-
gt(auditLog.occurredAt, after),
|
|
329
|
-
),
|
|
330
|
-
);
|
|
331
|
-
return Number(n ?? 0);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
311
|
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
335
312
|
function stored(rows: object[]): StoredEntry[] {
|
|
336
313
|
return rows as StoredEntry[];
|
|
@@ -380,17 +357,6 @@ export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<
|
|
|
380
357
|
/** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
|
|
381
358
|
const HASHES_AT_ONCE = 5000;
|
|
382
359
|
|
|
383
|
-
/**
|
|
384
|
-
* Whether every entry from the first through `seq` is there. Seqs are the
|
|
385
|
-
* primary key, so the count is `seq + 1` exactly when none is missing: one
|
|
386
|
-
* pass over the key's index, never the entries.
|
|
387
|
-
*/
|
|
388
|
-
export async function complete(db: Queryable, seq: bigint): Promise<boolean> {
|
|
389
|
-
const { auditLog } = tablesOf(db);
|
|
390
|
-
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
|
|
391
|
-
return BigInt(n) === seq + 1n;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
360
|
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
395
361
|
export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
|
|
396
362
|
const { auditLog } = tablesOf(db);
|
|
@@ -537,6 +503,82 @@ export async function latestVaultEntry(db: Queryable, actions: readonly string[]
|
|
|
537
503
|
return row === undefined ? undefined : stored([row])[0];
|
|
538
504
|
}
|
|
539
505
|
|
|
506
|
+
/**
|
|
507
|
+
* The vault's newest checkpoint that keeps a tally, allowed, or undefined:
|
|
508
|
+
* one written since 0.10.1, which names the last entry of the prefix it
|
|
509
|
+
* signs as related (`related_seq`); those before name none.
|
|
510
|
+
*/
|
|
511
|
+
export async function latestTallied(db: Queryable): Promise<StoredEntry | undefined> {
|
|
512
|
+
const { auditLog } = tablesOf(db);
|
|
513
|
+
const [row] = await db
|
|
514
|
+
.select(entryColumns(db))
|
|
515
|
+
.from(auditLog)
|
|
516
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq)))
|
|
517
|
+
.orderBy(desc(auditLog.seq))
|
|
518
|
+
.limit(1);
|
|
519
|
+
return row === undefined ? undefined : stored([row])[0];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
|
|
523
|
+
export async function newestCheckpointEntry(db: Queryable): Promise<StoredEntry | undefined> {
|
|
524
|
+
const { auditLog } = tablesOf(db);
|
|
525
|
+
const [row] = await db
|
|
526
|
+
.select(entryColumns(db))
|
|
527
|
+
.from(auditLog)
|
|
528
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint')))
|
|
529
|
+
.orderBy(desc(auditLog.seq))
|
|
530
|
+
.limit(1);
|
|
531
|
+
return row === undefined ? undefined : stored([row])[0];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The vault's checkpoints that keep a tally after entry `after` and through
|
|
536
|
+
* `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
|
|
537
|
+
* holds, but always one. A reader asks again after the last.
|
|
538
|
+
*/
|
|
539
|
+
export async function talliedWithin(db: Queryable, after: bigint, through: bigint, limit: number): Promise<StoredEntry[]> {
|
|
540
|
+
const { auditLog } = tablesOf(db);
|
|
541
|
+
const tallied = and(
|
|
542
|
+
eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq),
|
|
543
|
+
gt(auditLog.seq, after), lte(auditLog.seq, through),
|
|
544
|
+
);
|
|
545
|
+
const batch = await withinBytes(db, tallied, limit);
|
|
546
|
+
if (batch === null) return [];
|
|
547
|
+
const rows = await db.select(entryColumns(db)).from(auditLog)
|
|
548
|
+
.where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq));
|
|
549
|
+
return stored(rows);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
|
|
553
|
+
export async function newestRefusal(db: Queryable, action: string, code: string, after: bigint): Promise<StoredEntry | undefined> {
|
|
554
|
+
const { auditLog } = tablesOf(db);
|
|
555
|
+
const [row] = await db
|
|
556
|
+
.select(entryColumns(db))
|
|
557
|
+
.from(auditLog)
|
|
558
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, 'deny'), eq(auditLog.code, code)))
|
|
559
|
+
.orderBy(desc(auditLog.seq))
|
|
560
|
+
.limit(1);
|
|
561
|
+
return row === undefined ? undefined : stored([row])[0];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Up to `limit` of the vault's checkpoints that keep a tally after entry `after`, oldest first: each one's entry, and the last entry it signs. */
|
|
565
|
+
export async function talliedAfter(db: Queryable, after: bigint, limit: number): Promise<{ seq: bigint; signs: bigint }[]> {
|
|
566
|
+
const { auditLog } = tablesOf(db);
|
|
567
|
+
const rows = await db
|
|
568
|
+
.select({ seq: auditLog.seq, signs: auditLog.relatedSeq })
|
|
569
|
+
.from(auditLog)
|
|
570
|
+
.where(and(
|
|
571
|
+
eq(auditLog.author, 'vault'),
|
|
572
|
+
eq(auditLog.action, 'audit.checkpoint'),
|
|
573
|
+
eq(auditLog.decision, 'allow'),
|
|
574
|
+
isNotNull(auditLog.relatedSeq),
|
|
575
|
+
gt(auditLog.seq, after),
|
|
576
|
+
))
|
|
577
|
+
.orderBy(asc(auditLog.seq))
|
|
578
|
+
.limit(limit);
|
|
579
|
+
return rows.map(({ seq, signs }) => ({ seq, signs: signs! }));
|
|
580
|
+
}
|
|
581
|
+
|
|
540
582
|
/** The vault's newest allowed access entry about each member who has one. */
|
|
541
583
|
export async function newestAccessEntries(db: Queryable): Promise<Map<string, StoredEntry>> {
|
|
542
584
|
const { auditLog } = tablesOf(db);
|
package/src/tally.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { StoredEntry } from '@coffre/core/audit';
|
|
4
|
+
import type { AccessFault, Checkpoint, LogHead } from '@coffre/core/vault';
|
|
5
|
+
|
|
6
|
+
import { KeyAccounting, type AccountingState } from './accounting.ts';
|
|
7
|
+
import { forward } from './log.ts';
|
|
8
|
+
import { apply, placeKey, type LoggedMember, type Replayed } from './replay.ts';
|
|
9
|
+
import { ACCESS_ACTIONS, type GrantRow } from './store.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a walk of the vault's entries from the first has gathered by some
|
|
13
|
+
* entry: all a walk from there needs to go on as if it had started at the
|
|
14
|
+
* first. A checkpoint keeps the tally of the prefix it signs, so that the
|
|
15
|
+
* next checkpoint, and every verification, start from it and read only the
|
|
16
|
+
* entries since (docs/architecture.md, "The tally").
|
|
17
|
+
*
|
|
18
|
+
* - `entries`: the vault's entries so far;
|
|
19
|
+
* - `keys`: the vault's log keys, in the order its entries moved through
|
|
20
|
+
* them, which only moves forward (`forward` in log.ts);
|
|
21
|
+
* - `accounting`: the key batches still under way, and the first fault in
|
|
22
|
+
* their entries;
|
|
23
|
+
* - `access`: who the log says is a member, what each holds, and each one's
|
|
24
|
+
* newest access entry, which their row must name;
|
|
25
|
+
* - `replayFault`: the first access entry about someone never admitted.
|
|
26
|
+
*
|
|
27
|
+
* The faults are kept, once found, as a walk from the first entry would
|
|
28
|
+
* find them again each time.
|
|
29
|
+
*/
|
|
30
|
+
export type Tally = {
|
|
31
|
+
entries: number;
|
|
32
|
+
keys: string[];
|
|
33
|
+
accounting: AccountingState;
|
|
34
|
+
access: AccessTally;
|
|
35
|
+
replayFault: AccessFault | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Members and grants as the log says they are, and each member's newest access entry, as plain JSON. */
|
|
39
|
+
export type AccessTally = {
|
|
40
|
+
members: (Omit<LoggedMember, 'accessSeq'> & { accessSeq: number })[];
|
|
41
|
+
grants: GrantRow[];
|
|
42
|
+
newest: [principal: string, seq: number][];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A tally as a checkpoint's entry holds it. Members and grants change far
|
|
47
|
+
* less often than every five minutes, so `access` is written whole only
|
|
48
|
+
* when it changed since the tally resumed from; otherwise the entry names
|
|
49
|
+
* the checkpoint entry that holds it (`at`). Either way with its digest.
|
|
50
|
+
*/
|
|
51
|
+
export type StoredTally = Omit<Tally, 'access'> & { v: typeof TALLY_VERSION; access: { digest: string } & ({ at: number } | AccessTally) };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What a tally keeps, as 0.10.1 wrote it. A release that keeps more, or
|
|
55
|
+
* less, writes another version, and compares a tally of this one on what
|
|
56
|
+
* this one kept: a walk from the first entry passes every tally ever
|
|
57
|
+
* written, and an honest one must still match. A release rolled back to
|
|
58
|
+
* reads no tally newer than its own, and refuses the checkpoints that
|
|
59
|
+
* keep one: going back across a version is not a way back.
|
|
60
|
+
*/
|
|
61
|
+
export const TALLY_VERSION = 1;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A checkpoint as the vault's entry holds it: the signed prefix, its
|
|
65
|
+
* tally, and the lap of the rehash of the past under way (`scrub`): the
|
|
66
|
+
* prefix it rehashes to, signed when the lap began, and the last entry it
|
|
67
|
+
* rehashed. Null between laps.
|
|
68
|
+
*/
|
|
69
|
+
export type TalliedCheckpoint = Checkpoint & { tally: StoredTally; scrub: { at: LogHead; end: LogHead } | null };
|
|
70
|
+
|
|
71
|
+
/** Before the first entry. */
|
|
72
|
+
export const EMPTY_TALLY: Tally = {
|
|
73
|
+
entries: 0,
|
|
74
|
+
keys: [],
|
|
75
|
+
accounting: { intents: [], fault: null },
|
|
76
|
+
access: { members: [], grants: [], newest: [] },
|
|
77
|
+
replayFault: null,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The digest of what `access` says, over its canonical form. */
|
|
81
|
+
export function accessDigest(access: AccessTally): string {
|
|
82
|
+
return createHash('sha256').update(canonical(access)).digest('hex');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What two tallies are compared by, so that they match whatever order
|
|
87
|
+
* their maps were built in: the members and grants by their digest, which
|
|
88
|
+
* a stretch passing many checkpoints computes once a change; and a fault by
|
|
89
|
+
* where it was found, since the words for it may change from one release
|
|
90
|
+
* to the next.
|
|
91
|
+
*/
|
|
92
|
+
function tallyKey({ accounting, entries, keys, replayFault }: Omit<Tally, 'access'>, digest: string): string {
|
|
93
|
+
return canonical({ entries, keys, replayFault, accounting: { ...accounting, fault: accounting.fault?.failedAtSeq ?? null }, access: digest });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A tally a walk must come to: the one a checkpoint keeps, `entry`, of the
|
|
98
|
+
* prefix through `through`; or why that checkpoint's tally cannot be read.
|
|
99
|
+
*/
|
|
100
|
+
export type Expected = { entry: bigint; through: bigint; key: string } | { entry: bigint; through: bigint; unreadable: string };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What a walk must come to at a checkpoint, from the tally it keeps: its
|
|
104
|
+
* members and grants by the digest it keeps of them, never read.
|
|
105
|
+
*/
|
|
106
|
+
export function expectedKey(stored: StoredTally): string {
|
|
107
|
+
return tallyKey(stored, stored.access.digest);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The vault's entries walked, oldest first, each after its MAC was checked,
|
|
112
|
+
* gathered into what the checks after the walk need: the tally it started
|
|
113
|
+
* from, carried on.
|
|
114
|
+
*/
|
|
115
|
+
export class Walk {
|
|
116
|
+
readonly accounting: KeyAccounting;
|
|
117
|
+
readonly replayed: Replayed;
|
|
118
|
+
/** Each member's newest allowed access entry, by seq. */
|
|
119
|
+
readonly newestAccess: Map<string, bigint>;
|
|
120
|
+
/** The first access entry the replay could not apply. */
|
|
121
|
+
replayFault: AccessFault | null;
|
|
122
|
+
/** The vault's entries so far, those before the walk included. */
|
|
123
|
+
entries: number;
|
|
124
|
+
/** The vault's log keys, as the log moved through them. */
|
|
125
|
+
keys: readonly string[];
|
|
126
|
+
/** The checkpoints walked, each to be checked against its signature and the prefix it signed once the walk ends: what it signed, and nothing it keeps beside. */
|
|
127
|
+
readonly checkpoints: { entry: { seq: bigint; keyId: string; relatedSeq: bigint | null }; checkpoint: Checkpoint }[] = [];
|
|
128
|
+
|
|
129
|
+
/** The tallies the walk must come to as it passes them, oldest first (`expect`). */
|
|
130
|
+
#expected: Expected[] = [];
|
|
131
|
+
/** The first it did not come to, and why. */
|
|
132
|
+
mismatch: { entry: bigint; reason: string } | null = null;
|
|
133
|
+
/** The digest of the members and grants as the walk has them, until an access entry changes them. */
|
|
134
|
+
#digest: string | null = null;
|
|
135
|
+
|
|
136
|
+
/** The digest of the members and grants as the walk has them: the tally's it started from, until an access entry. */
|
|
137
|
+
digest(): string {
|
|
138
|
+
return (this.#digest ??= accessDigest(this.tally().access));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The tally but for its members and grants, which `digest` stands for. */
|
|
142
|
+
#unaccessed(): Omit<Tally, 'access'> {
|
|
143
|
+
return { entries: this.entries, keys: [...this.keys], accounting: this.accounting.state(), replayFault: this.replayFault };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `digest` is that of `from`'s members and grants, when the caller has it: a beat with no access entry hashes none. */
|
|
147
|
+
constructor(from: Tally, digest?: string) {
|
|
148
|
+
this.#digest = digest ?? null;
|
|
149
|
+
this.accounting = new KeyAccounting(from.accounting);
|
|
150
|
+
this.replayed = {
|
|
151
|
+
members: new Map(from.access.members.map((member) => [member.principal, { ...member, accessSeq: BigInt(member.accessSeq) }])),
|
|
152
|
+
held: new Map(),
|
|
153
|
+
};
|
|
154
|
+
for (const grant of from.access.grants) {
|
|
155
|
+
const held = this.replayed.held.get(grant.principal) ?? new Map<string, GrantRow>();
|
|
156
|
+
this.replayed.held.set(grant.principal, held);
|
|
157
|
+
held.set(placeKey(grant), { ...grant });
|
|
158
|
+
}
|
|
159
|
+
this.newestAccess = new Map(from.access.newest.map(([principal, seq]) => [principal, BigInt(seq)]));
|
|
160
|
+
this.replayFault = from.replayFault;
|
|
161
|
+
this.entries = from.entries;
|
|
162
|
+
this.keys = from.keys;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Hold the walk to these tallies, each as the walk stands once it has
|
|
167
|
+
* read every entry through the prefix's end: before the vault's next
|
|
168
|
+
* entry, since only the vault's change a tally, or at `finish`.
|
|
169
|
+
*/
|
|
170
|
+
expect(expected: readonly Expected[]): void {
|
|
171
|
+
this.#expected = [...expected].sort((a, b) => (a.through < b.through ? -1 : 1));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The tallies expected through `through`, held to the walk. */
|
|
175
|
+
finish(through: bigint): void {
|
|
176
|
+
this.#reach(through + 1n);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#reach(before: bigint): void {
|
|
180
|
+
while (this.#expected.length > 0 && this.#expected[0]!.through < before) {
|
|
181
|
+
const expected = this.#expected.shift()!;
|
|
182
|
+
if (this.mismatch !== null) continue;
|
|
183
|
+
if ('unreadable' in expected) this.mismatch = { entry: expected.entry, reason: expected.unreadable };
|
|
184
|
+
else if (tallyKey(this.#unaccessed(), this.digest()) !== expected.key) {
|
|
185
|
+
this.mismatch = { entry: expected.entry, reason: 'the tally this checkpoint keeps does not follow from the log before it' };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* One of the vault's entries, its MAC checked, in the log's order: a
|
|
192
|
+
* fault when it is under a key the vault had moved past (`forward`).
|
|
193
|
+
*/
|
|
194
|
+
add(entry: StoredEntry, current: string): { failedAtSeq: bigint; reason: string } | null {
|
|
195
|
+
this.#reach(entry.seq);
|
|
196
|
+
const moved = forward([entry], this.keys, current);
|
|
197
|
+
if ('failedAtSeq' in moved) return moved;
|
|
198
|
+
this.keys = moved.keys;
|
|
199
|
+
this.entries += 1;
|
|
200
|
+
this.accounting.add(entry);
|
|
201
|
+
if (entry.decision !== 'allow') return null;
|
|
202
|
+
if (ACCESS.has(entry.action)) {
|
|
203
|
+
this.#digest = null;
|
|
204
|
+
this.replayFault ??= apply(this.replayed, entry);
|
|
205
|
+
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
206
|
+
} else if (entry.action === CHECKPOINT) {
|
|
207
|
+
const { seq, hash, signedAt, keyId, signature } = JSON.parse(entry.metadata) as Checkpoint;
|
|
208
|
+
this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId, relatedSeq: entry.relatedSeq }, checkpoint: { seq, hash, signedAt, keyId, signature } });
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Where the walk stands, to resume from. */
|
|
214
|
+
tally(): Tally {
|
|
215
|
+
const byPrincipal = <T>(entries: Iterable<[string, T]>) => [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
216
|
+
return {
|
|
217
|
+
entries: this.entries,
|
|
218
|
+
keys: [...this.keys],
|
|
219
|
+
accounting: this.accounting.state(),
|
|
220
|
+
access: {
|
|
221
|
+
members: byPrincipal(this.replayed.members).map(([, member]) => ({ ...member, accessSeq: Number(member.accessSeq) })),
|
|
222
|
+
grants: byPrincipal(this.replayed.held).flatMap(([, held]) => byPrincipal(held).map(([, grant]) => grant)),
|
|
223
|
+
newest: byPrincipal(this.newestAccess).map(([principal, seq]) => [principal, Number(seq)]),
|
|
224
|
+
},
|
|
225
|
+
replayFault: this.replayFault,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The action of the vault's entry that signs a prefix of the log. */
|
|
231
|
+
export const CHECKPOINT = 'audit.checkpoint';
|
|
232
|
+
|
|
233
|
+
const ACCESS = new Set<string>(ACCESS_ACTIONS);
|
|
234
|
+
|
|
235
|
+
/** JSON with every object's keys sorted: one text for one value, however it was built. */
|
|
236
|
+
function canonical(value: unknown): string {
|
|
237
|
+
return JSON.stringify(value, (_, inner: unknown) =>
|
|
238
|
+
inner !== null && typeof inner === 'object' && !Array.isArray(inner)
|
|
239
|
+
? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
240
|
+
: inner);
|
|
241
|
+
}
|