@coffre/vault 0.14.5 → 0.15.0
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/README.md +6 -4
- package/dist/cloudflare.d.ts +341 -4
- package/dist/cloudflare.js +802 -6
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +181 -6
- package/dist/node.js +358 -16
- package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +3 -2
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +64 -10
- package/src/store.ts +832 -36
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1366 -220
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-Cbi45kLB.js +0 -5197
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
|
|
2
|
+
import { ACCESS_ACTIONS } from '@coffre/core/vault';
|
|
3
|
+
import type { Database, Queryable } from '@coffre/db';
|
|
4
|
+
import { rowsOf } from '@coffre/db/dialect';
|
|
5
|
+
import { sql, type SQL } from 'drizzle-orm';
|
|
6
|
+
|
|
7
|
+
import { periodOf, refreshMs, USE_AHEAD_MS, USE_DEADLINE_MS } from './bulk.ts';
|
|
8
|
+
import { namedStanding } from './decide.ts';
|
|
9
|
+
import { noEdits, objectKey, type Kind, type RolesInput } from './inputs.ts';
|
|
10
|
+
import { purposeOf, WAYS } from './log-replay.ts';
|
|
11
|
+
import { configObject, credentialObject, environmentObject, memberStanding, principalObject } from './objects.ts';
|
|
12
|
+
import type { Cover, Resolved, UseCall, UseRow } from './use.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `use_credential` (0024's, as 0046 has it) on SQLite, for tests and local development
|
|
16
|
+
* (docs/design/0.15-plan.md, "Postgres serves; SQLite is for tests"): the
|
|
17
|
+
* same reads and writes, the same row, in one transaction. SQLite runs one
|
|
18
|
+
* writer at a time (`BEGIN IMMEDIATE`, @coffre/db/connect), so the fence and
|
|
19
|
+
* the principal's lock are implicit, and no number is taken out of order.
|
|
20
|
+
* It has no sequence: an entry's number is one past the log's highest.
|
|
21
|
+
*/
|
|
22
|
+
export async function callUse(db: Queryable, call: UseCall): Promise<UseRow> {
|
|
23
|
+
return (db as Database).transaction((tx) => useOnce(tx, call, direct(tx)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `use_batch` (0048) on SQLite: each key's use as `callUse` makes it, in order, in one transaction. Its one writer at a
|
|
28
|
+
* time makes the principal's lock, the batch's `lock`, implicit too.
|
|
29
|
+
*
|
|
30
|
+
* Each key is decided as `useOnce` decides it, on rows read for all the keys at once (`Batched`): what every key reads
|
|
31
|
+
* alike once, each key's own rows in a few statements of all their ids, and the principal's cap rows once, counted in
|
|
32
|
+
* memory as the keys before it enter them. Its entries and cap rows are written together, last. A thousand keys are
|
|
33
|
+
* then some 30 statements where they were 30,000 (W281), and none counts the cap's set again.
|
|
34
|
+
*/
|
|
35
|
+
export async function callBatch(db: Queryable, calls: readonly UseCall[], _lock: string | null): Promise<UseRow[]> {
|
|
36
|
+
return (db as Database).transaction(async (tx) => {
|
|
37
|
+
const batched = await Batched.read(tx, calls);
|
|
38
|
+
const rows: UseRow[] = [];
|
|
39
|
+
for (const call of calls) rows.push(await useOnce(tx, call, batched));
|
|
40
|
+
await batched.write();
|
|
41
|
+
// Past the deadline once written, as each key's use is past it at its end: the whole batch rolls back.
|
|
42
|
+
if (rows.some((row) => row.state === null) && Date.now() > calls[0]!.deadline) throw Object.assign(new Error('past its deadline'), { code: '57014' });
|
|
43
|
+
return rows;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A change's number as text: SQLite's integers are 64 bits, a JavaScript number's exact ones 53. */
|
|
48
|
+
type Changed = { number: string; mac: Buffer; seal: Buffer | null; payload: string };
|
|
49
|
+
|
|
50
|
+
type SecretRow = { id: string; project_id: string; environment_id: string; current_version_id: string | null; seal: Buffer | null; key: string };
|
|
51
|
+
type ReferenceRow = { source_secret_id: string | null; id: string | null };
|
|
52
|
+
type VersionRow = {
|
|
53
|
+
envelope_version: number; ciphertext: Buffer; iv: Buffer; auth_tag: Buffer; wrapped_dek: Buffer; kek_provider: string; kek_id: string; kek_version: string; sensitive: number; plaintext: string | null;
|
|
54
|
+
};
|
|
55
|
+
type EnvironmentRow = { project_id: string; slug: string; owner: string | null; parent_id: string | null; protected: number | null; archived: number; project_archived: number; seal: Buffer | null };
|
|
56
|
+
|
|
57
|
+
// The columns a key's rows are read with, alone (`useOnce`) or a batch's at once (`Batched`): the same rows either way.
|
|
58
|
+
const SECRET = sql.raw('id, project_id, environment_id, current_version_id, seal, key');
|
|
59
|
+
const REFERENCE = sql.raw('CASE WHEN ended_at IS NULL THEN source_secret_id END AS source_secret_id, CASE WHEN ended_at IS NULL THEN id END AS id');
|
|
60
|
+
const VERSION = sql.raw('envelope_version, ciphertext, iv, auth_tag, wrapped_dek, kek_provider, kek_id, kek_version, sensitive, plaintext');
|
|
61
|
+
const ENVIRONMENT = sql.raw('e.project_id, e.slug, e.owner, e.parent_id, e.protected, e.archived_at IS NOT NULL AS archived, p.archived_at IS NOT NULL AS project_archived, e.seal');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What a use reads and writes beside the named call's own reads: `read`, a row no use writes, by a key naming what the
|
|
65
|
+
* query reads; the principal's cap rows; its entry, numbered; and its key's cap row entered or moved on.
|
|
66
|
+
*/
|
|
67
|
+
type Rows = {
|
|
68
|
+
read<T>(key: string, load: () => Promise<T>): Promise<T>;
|
|
69
|
+
/**
|
|
70
|
+
* The principal's row for `secret`. `since`: the bound `others` counts from, before which a row decides nothing (it
|
|
71
|
+
* is in no set, is never fresh and holds no cover), so that a batch may read none from before it.
|
|
72
|
+
*/
|
|
73
|
+
bulk(principal: string, secret: string, since: number): Promise<{ last: number } | undefined>;
|
|
74
|
+
/** How many of the principal's keys but `secret` count since `since`, up to `count`. */
|
|
75
|
+
others(principal: string, secret: string | null, since: number, count: number): Promise<number>;
|
|
76
|
+
/** Whether the entry the call's cover names is its request's: the same key, way in, purpose and place. */
|
|
77
|
+
cover(call: UseCall & { cover: Cover }): Promise<boolean>;
|
|
78
|
+
/** The use's entry, written; its number. */
|
|
79
|
+
use(call: UseCall): Promise<bigint>;
|
|
80
|
+
enter(principal: string, secret: string | null, period: number): Promise<void>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A lone use's: each read and write a statement, made as it is asked. */
|
|
84
|
+
function direct(tx: Queryable): Rows {
|
|
85
|
+
const one = async <T>(query: SQL): Promise<T | undefined> => (await rowsOf<T>(tx, query))[0];
|
|
86
|
+
return {
|
|
87
|
+
read: (_key, load) => load(),
|
|
88
|
+
bulk: (principal, secret) => one<{ last: number }>(sql`SELECT last FROM log_bulk WHERE principal = ${principal} AND secret = ${secret}`),
|
|
89
|
+
others: async (principal, secret, since, count) => Number((await one<{ n: number }>(sql`SELECT count(*) AS n FROM (
|
|
90
|
+
SELECT 1 FROM log_bulk WHERE principal = ${principal} AND secret <> ${secret} AND last > ${since} LIMIT ${count})`))!.n),
|
|
91
|
+
cover: async ({ cover, ...call }) => (await one(sql`SELECT 1 FROM log_uses WHERE at = ${cover.at} AND request_id = ${BigInt(cover.requestId)} AND number = ${BigInt(cover.number)}
|
|
92
|
+
AND credential = ${call.credential} AND secret = ${call.secretId} AND purpose = ${call.purpose} AND place IS ${call.place}`)) !== undefined,
|
|
93
|
+
use: async (call) => {
|
|
94
|
+
const next = BigInt((await one<{ n: string }>(NEXT))!.n);
|
|
95
|
+
await insertUses(tx, [{ number: next, call }]);
|
|
96
|
+
return next;
|
|
97
|
+
},
|
|
98
|
+
enter: async (principal, secret, period) => {
|
|
99
|
+
await rowsOf(tx, sql`INSERT INTO log_bulk (principal, secret, last) VALUES (${principal}, ${secret}, ${period})
|
|
100
|
+
ON CONFLICT (principal, secret) DO UPDATE SET last = max(last, excluded.last)`);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The next entry's number: one past the log's highest. */
|
|
106
|
+
const NEXT = sql`SELECT CAST(max(coalesce((SELECT max(number) FROM log_changes), 0), coalesce((SELECT max(number) FROM log_uses), 0)) + 1 AS TEXT) AS n`;
|
|
107
|
+
|
|
108
|
+
async function insertUses(tx: Queryable, uses: readonly { number: bigint; call: UseCall }[]): Promise<void> {
|
|
109
|
+
for (let start = 0; start < uses.length; start += CHUNK) {
|
|
110
|
+
const values = uses.slice(start, start + CHUNK).map(({ number, call }) =>
|
|
111
|
+
sql`(${number}, ${call.at}, ${BigInt(call.requestId)}, ${call.credential}, ${call.secretId}, ${call.purpose}, ${call.mac}, ${call.place})`);
|
|
112
|
+
try {
|
|
113
|
+
await rowsOf(tx, sql`INSERT INTO log_uses (number, at, request_id, credential, secret, purpose, mac, place) VALUES ${sql.join(values, sql`, `)}`);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// The unique (at, request_id), as Postgres says it.
|
|
116
|
+
if (/UNIQUE constraint failed: log_uses/.test(`${String(error)} ${String((error as { cause?: unknown }).cause)}`)) throw Object.assign(new Error('a replayed use'), { code: '23505' });
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Rows or ids a statement lists at most: 4,000 parameters at most, well under SQLite's bound of 32,766. */
|
|
123
|
+
const CHUNK = 500;
|
|
124
|
+
|
|
125
|
+
/** A principal's cap rows in a batch: each key's `last`, every one since `since`; those it entered; how many count since each bound asked. */
|
|
126
|
+
type Bulk = { since: number; rows: Map<string, number>; entered: Set<string>; above: Map<number, number> };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A batch's rows (`callBatch`): every key's own rows read up front, a statement for all the keys' ids at a time, and
|
|
130
|
+
* any other read once for the batch, by its key; a key whose row was not read up front (a personal config's parent, a
|
|
131
|
+
* reference's source's source) reads it as alone, once. No use writes what these read but its entry and its cap row:
|
|
132
|
+
* the entries are numbered on from the log's highest, and the principal's cap rows are kept here as each key enters
|
|
133
|
+
* or moves on, counted in memory; both are written once every key is decided (`write`).
|
|
134
|
+
*/
|
|
135
|
+
class Batched implements Rows {
|
|
136
|
+
readonly #tx: Queryable;
|
|
137
|
+
readonly #read = new Map<string, Promise<unknown>>();
|
|
138
|
+
/** Per principal, the cap rows read (`#bulkOf`) and those the batch's keys entered. */
|
|
139
|
+
readonly #bulk = new Map<string, Bulk>();
|
|
140
|
+
readonly #uses: { number: bigint; call: UseCall }[] = [];
|
|
141
|
+
/** The same, by number. */
|
|
142
|
+
readonly #numbered = new Map<string, UseCall>();
|
|
143
|
+
/** The calls whose cover names an entry the log held when the batch began. */
|
|
144
|
+
readonly #covered = new Set<UseCall>();
|
|
145
|
+
#next: bigint | undefined;
|
|
146
|
+
|
|
147
|
+
private constructor(tx: Queryable) {
|
|
148
|
+
this.#tx = tx;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The batch's keys' rows, each kind read for all their ids at once: secrets, their references and sources, versions, places, newest changes, covers. */
|
|
152
|
+
static async read(tx: Queryable, calls: readonly UseCall[]): Promise<Batched> {
|
|
153
|
+
const batch = new Batched(tx);
|
|
154
|
+
const ids = (list: Iterable<string | null | undefined>) => [...new Set([...list].filter((id): id is string => typeof id === 'string'))];
|
|
155
|
+
const secrets = new Map<string, SecretRow>();
|
|
156
|
+
const readSecrets = async (wanted: string[]) => {
|
|
157
|
+
for (const row of await batch.#in<SecretRow>(wanted, (list) => sql`SELECT ${SECRET} FROM secrets WHERE id IN (${list}) AND archived_at IS NULL`)) secrets.set(row.id, row);
|
|
158
|
+
for (const id of wanted) batch.#set(`secret\n${id}`, secrets.get(id));
|
|
159
|
+
// The newest reference row of each, which a key without a version of its own reads.
|
|
160
|
+
const held = wanted.filter((id) => secrets.get(id)?.current_version_id === null);
|
|
161
|
+
const references = new Map<string, ReferenceRow>();
|
|
162
|
+
for (const { of, ...row } of await batch.#in<ReferenceRow & { of: string }>(held, (list) => sql`SELECT r.secret_id AS of, ${REFERENCE} FROM secret_references r
|
|
163
|
+
WHERE r.secret_id IN (${list}) AND r.created_seq = (SELECT max(created_seq) FROM secret_references WHERE secret_id = r.secret_id)`)) references.set(of, row);
|
|
164
|
+
for (const id of held) batch.#set(`reference\n${id}`, references.get(id));
|
|
165
|
+
return [...references.values()].map((row) => row.source_secret_id);
|
|
166
|
+
};
|
|
167
|
+
const asked = new Set(ids(calls.map((call) => call.secretId)));
|
|
168
|
+
const sources = ids(await readSecrets([...asked]));
|
|
169
|
+
await readSecrets(sources.filter((id) => !secrets.has(id) && !asked.has(id)));
|
|
170
|
+
const versions = ids([...secrets.values()].map((row) => row.current_version_id));
|
|
171
|
+
const rows = await batch.#in<VersionRow & { of: string }>(versions, (list) => sql`SELECT id AS of, ${VERSION} FROM secret_versions WHERE id IN (${list})`);
|
|
172
|
+
batch.#all('version', versions, rows);
|
|
173
|
+
const places = ids([...calls.map((call) => call.place), ...[...secrets.values()].map((row) => row.environment_id)]);
|
|
174
|
+
batch.#all('environment', places, await batch.#in<EnvironmentRow & { of: string }>(places, (list) => sql`SELECT e.id AS of, ${ENVIRONMENT}
|
|
175
|
+
FROM environments e LEFT JOIN projects p ON p.id = e.project_id WHERE e.id IN (${list})`));
|
|
176
|
+
const objects = [...ids([...asked, ...sources]).map((id) => objectKey({ kind: 'secret', id })), ...places.map((id) => objectKey({ kind: 'environment', id }))];
|
|
177
|
+
batch.#all('newest', objects, await batch.#in<Changed & { of: string }>(objects, (list) => sql`SELECT c.object AS of, CAST(c.number AS TEXT) AS number, c.seal, c.payload
|
|
178
|
+
FROM log_changes c WHERE c.object IN (${list}) AND c.number = (SELECT max(number) FROM log_changes WHERE object = c.object)`));
|
|
179
|
+
const covered = calls.filter((call) => call.cover !== undefined);
|
|
180
|
+
for (let start = 0; start < covered.length; start += CHUNK) {
|
|
181
|
+
const some = covered.slice(start, start + CHUNK);
|
|
182
|
+
const values = some.map(({ cover, ...call }, k) => sql`(${k}, ${cover!.at}, ${BigInt(cover!.requestId)}, ${BigInt(cover!.number)}, ${call.credential}, ${call.secretId}, ${call.purpose}, ${call.place})`);
|
|
183
|
+
const found = new Set((await rowsOf<{ k: number }>(tx, sql`WITH v (k, at, request_id, number, credential, secret, purpose, place) AS (VALUES ${sql.join(values, sql`, `)})
|
|
184
|
+
SELECT v.k FROM v JOIN log_uses u ON u.at = v.at AND u.request_id = v.request_id AND u.number = v.number AND u.credential = v.credential
|
|
185
|
+
AND u.secret = v.secret AND u.purpose = v.purpose AND u.place IS v.place`)).map((row) => Number(row.k)));
|
|
186
|
+
for (const [k, call] of some.entries()) if (found.has(k)) batch.#covered.add(call);
|
|
187
|
+
}
|
|
188
|
+
return batch;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
read<T>(key: string, load: () => Promise<T>): Promise<T> {
|
|
192
|
+
if (!this.#read.has(key)) this.#read.set(key, load());
|
|
193
|
+
return this.#read.get(key) as Promise<T>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async bulk(principal: string, secret: string, since: number): Promise<{ last: number } | undefined> {
|
|
197
|
+
const last = (await this.#bulkOf(principal, since)).rows.get(secret);
|
|
198
|
+
return last === undefined ? undefined : { last };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async others(principal: string, secret: string | null, since: number, count: number): Promise<number> {
|
|
202
|
+
// As SQL counts them: `secret <> NULL` holds for no row, and a negative LIMIT is none.
|
|
203
|
+
if (secret === null) return 0;
|
|
204
|
+
const bulk = await this.#bulkOf(principal, since);
|
|
205
|
+
let above = bulk.above.get(since);
|
|
206
|
+
if (above === undefined) {
|
|
207
|
+
above = 0;
|
|
208
|
+
for (const last of bulk.rows.values()) if (last > since) above += 1;
|
|
209
|
+
bulk.above.set(since, above);
|
|
210
|
+
}
|
|
211
|
+
const n = above - ((bulk.rows.get(secret) ?? -Infinity) > since ? 1 : 0);
|
|
212
|
+
return count < 0 ? n : Math.min(n, count);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async cover(call: UseCall & { cover: Cover }): Promise<boolean> {
|
|
216
|
+
if (this.#covered.has(call)) return true;
|
|
217
|
+
// Or an entry of the batch's own, made before it, as alone the use would find it written (SQL's `=` holds for no null).
|
|
218
|
+
const { cover } = call;
|
|
219
|
+
const made = this.#numbered.get(String(BigInt(cover.number)));
|
|
220
|
+
return made !== undefined && made.at === cover.at && BigInt(made.requestId) === BigInt(cover.requestId)
|
|
221
|
+
&& call.credential !== null && made.credential === call.credential && call.secretId !== null && made.secretId === call.secretId
|
|
222
|
+
&& made.purpose === call.purpose && made.place === call.place;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async use(call: UseCall): Promise<bigint> {
|
|
226
|
+
this.#next ??= BigInt((await rowsOf<{ n: string }>(this.#tx, NEXT))[0]!.n);
|
|
227
|
+
const number = this.#next++;
|
|
228
|
+
this.#uses.push({ number, call });
|
|
229
|
+
this.#numbered.set(String(number), call);
|
|
230
|
+
return number;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async enter(principal: string, secret: string | null, period: number): Promise<void> {
|
|
234
|
+
// A use with no key has no row to enter: its entry, queued before, fails to write first (its key NOT NULL), as alone.
|
|
235
|
+
if (secret === null) return;
|
|
236
|
+
// A key enters once its row was read (`bulk`).
|
|
237
|
+
const bulk = this.#bulk.get(principal);
|
|
238
|
+
if (bulk === undefined) throw new Error('a cap row entered unread');
|
|
239
|
+
const old = bulk.rows.get(secret) ?? -Infinity;
|
|
240
|
+
const last = Math.max(old, period);
|
|
241
|
+
bulk.rows.set(secret, last);
|
|
242
|
+
bulk.entered.add(secret);
|
|
243
|
+
// Each count kept moves with it.
|
|
244
|
+
for (const [since, n] of bulk.above) if (old <= since && last > since) bulk.above.set(since, n + 1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The batch's entries and cap rows, each as a lone use writes it: a statement for many at a time. */
|
|
248
|
+
async write(): Promise<void> {
|
|
249
|
+
await insertUses(this.#tx, this.#uses);
|
|
250
|
+
for (const [principal, { rows, entered }] of this.#bulk) {
|
|
251
|
+
const all = [...entered];
|
|
252
|
+
for (let start = 0; start < all.length; start += CHUNK) {
|
|
253
|
+
const values = all.slice(start, start + CHUNK).map((secret) => sql`(${principal}, ${secret}, ${rows.get(secret)!})`);
|
|
254
|
+
await rowsOf(this.#tx, sql`INSERT INTO log_bulk (principal, secret, last) VALUES ${sql.join(values, sql`, `)}
|
|
255
|
+
ON CONFLICT (principal, secret) DO UPDATE SET last = max(last, excluded.last)`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The principal's cap rows since `since`, read once, and again from further back should a key count from there. */
|
|
261
|
+
async #bulkOf(principal: string, since: number): Promise<Bulk> {
|
|
262
|
+
const known = this.#bulk.get(principal);
|
|
263
|
+
if (known !== undefined && known.since <= since) return known;
|
|
264
|
+
const bulk: Bulk = known ?? { since, rows: new Map(), entered: new Set(), above: new Map() };
|
|
265
|
+
const read = await rowsOf<{ secret: string; last: number }>(this.#tx, sql`SELECT secret, last FROM log_bulk WHERE principal = ${principal} AND last > ${since}`);
|
|
266
|
+
// A row the batch entered keeps the greater `last`, as its write will.
|
|
267
|
+
for (const row of read) bulk.rows.set(row.secret, Math.max(bulk.rows.get(row.secret) ?? -Infinity, Number(row.last)));
|
|
268
|
+
bulk.since = since;
|
|
269
|
+
bulk.above.clear();
|
|
270
|
+
this.#bulk.set(principal, bulk);
|
|
271
|
+
return bulk;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#set(key: string, value: unknown): void {
|
|
275
|
+
this.#read.set(key, Promise.resolve(value));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Each of `ids`' row, keyed `<kind>\n<id>`, by the `of` column it was read with; none for an id with no row. */
|
|
279
|
+
#all<T extends { of: string }>(kind: string, ids: readonly string[], rows: readonly T[]): void {
|
|
280
|
+
const by = new Map(rows.map(({ of, ...row }) => [of, row]));
|
|
281
|
+
for (const id of ids) this.#set(`${kind}\n${id}`, by.get(id));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A query over `ids`, `CHUNK` of them a statement. */
|
|
285
|
+
async #in<T>(ids: readonly string[], query: (list: SQL) => SQL): Promise<T[]> {
|
|
286
|
+
const rows: T[] = [];
|
|
287
|
+
for (let start = 0; start < ids.length; start += CHUNK) {
|
|
288
|
+
rows.push(...await rowsOf<T>(this.#tx, query(sql.join(ids.slice(start, start + CHUNK).map((id) => sql`${id}`), sql`, `))));
|
|
289
|
+
}
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function useOnce(tx: Queryable, call: UseCall, rows: Rows): Promise<UseRow> {
|
|
295
|
+
const one = async <T>(query: SQL): Promise<T | undefined> => (await rowsOf<T>(tx, query))[0];
|
|
296
|
+
const once = <T>(key: string, query: SQL) => rows.read(key, () => one<T>(query));
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
const nothing = (state: string): UseRow => ({ state } as UseRow);
|
|
299
|
+
if (call.deadline > call.at + USE_DEADLINE_MS) return nothing('late');
|
|
300
|
+
if (call.at > now + 60_000) return nothing('ahead');
|
|
301
|
+
// A named call's credential first, held to what the app's config accepts (0045, reviews r6 and r7 of D153): one the
|
|
302
|
+
// app would refuse is answered as a token no credential has, before the place, the key or anything else is read.
|
|
303
|
+
const names = call.names;
|
|
304
|
+
const credential = names === undefined ? undefined : await one<{ id: string; places: string | null; kind: string; provider: string | null; issuer_hash: string | null }>(sql`SELECT
|
|
305
|
+
c.id, c.places, c.kind, x.provider, x.issuer_hash FROM credentials c LEFT JOIN identities x ON x.id = c.identity_id WHERE c.token_hash = ${names.tokenHash}`);
|
|
306
|
+
if (names !== undefined && (credential === undefined || !names.accepts.signin
|
|
307
|
+
|| (credential.kind === 'browser' && !names.accepts.issuers.includes(`${credential.provider}:${credential.issuer_hash}`)))) return nothing('unknown');
|
|
308
|
+
const newestOf = (kind: Kind, id: string | null) =>
|
|
309
|
+
id === null ? Promise.resolve(undefined) : once<Changed>(`newest\n${objectKey({ kind, id })}`, sql`SELECT CAST(c.number AS TEXT) AS number, c.seal, c.payload FROM log_changes c WHERE c.object = ${objectKey({ kind, id })} ORDER BY c.number DESC LIMIT 1`);
|
|
310
|
+
const n = await once<{ number: string; mac: Buffer }>('head', sql`SELECT CAST(c.number AS TEXT) AS number, c.mac FROM log_changes c ORDER BY c.number DESC LIMIT 1`);
|
|
311
|
+
const known = call.known;
|
|
312
|
+
let cached = n !== undefined && known !== undefined && n.number === known.newest && Buffer.from(n.mac).equals(known.newestMac);
|
|
313
|
+
const floor = await once<{ at_floor: number }>('floor', sql`SELECT at_floor FROM log_fence WHERE only_row`);
|
|
314
|
+
if (floor === undefined || call.at < floor.at_floor || Date.now() > call.deadline) return nothing('late');
|
|
315
|
+
const policy = await newestOf('policy', 'instance');
|
|
316
|
+
const logged = policy === undefined ? {} : (JSON.parse(policy.payload) as { value?: { bulk?: { count?: number; windowMs?: number } } }).value?.bulk ?? {};
|
|
317
|
+
const cap = cached ? known!.cap : { count: logged.count, windowMs: logged.windowMs };
|
|
318
|
+
if (typeof cap.count !== 'number' || typeof cap.windowMs !== 'number') return nothing('unconfigured');
|
|
319
|
+
const limit = { count: cap.count, windowMs: cap.windowMs };
|
|
320
|
+
|
|
321
|
+
// A named call reads what its names name, as 0045 has it (D153): the credential of its token's hash, the place of its
|
|
322
|
+
// slugs or a handle's one place, as `use_resolve` reads it, and the key of its name there. Every row it reads is one
|
|
323
|
+
// of those; the ids the vault passed only let it write, when they are those (`right`).
|
|
324
|
+
let read: { credential: string | null; secretId: string | null; place: string | null } = { credential: call.credential, secretId: call.secretId, place: call.place };
|
|
325
|
+
let resolved: { state: 'nowhere' | null; right: boolean; place: string | null; project: string | null; environment: string | null } | null = null;
|
|
326
|
+
let stands: boolean | null = null;
|
|
327
|
+
if (names !== undefined && credential !== undefined) {
|
|
328
|
+
type Place = { id: string; project_id: string; project_slug: string; slug: string; live: number };
|
|
329
|
+
const placed = sql`SELECT e.id, e.project_id, p.slug AS project_slug, e.slug,
|
|
330
|
+
e.archived_at IS NULL AND p.archived_at IS NULL AND e.parent_id IS NULL AND e.owner IS NULL AS live
|
|
331
|
+
FROM environments e JOIN projects p ON p.id = e.project_id WHERE e.slug NOT LIKE '%~%' AND p.slug NOT LIKE '%~%'`;
|
|
332
|
+
const bound = names.handle ? boundOf(credential.places) : null;
|
|
333
|
+
const place = names.handle
|
|
334
|
+
? (bound === null ? undefined : await one<Place>(sql`${placed} AND e.id = ${bound}`))
|
|
335
|
+
: await one<Place>(sql`${placed} AND p.slug = ${names.project} AND e.slug = ${names.environment}`);
|
|
336
|
+
// A handle's place the stable URL does not mean, by the slugs read here: the app's, as a place that is none.
|
|
337
|
+
const state = place === undefined || !place.live || (names.handle && names.unless.includes(`${place.project_slug}/${place.slug}`)) ? 'nowhere' : null;
|
|
338
|
+
// The caller's standing there first, from rows no key decides (0045's `use_stands`, W268): the key only for one who stands.
|
|
339
|
+
stands = state === null && place !== undefined && await standsAt(tx, credential.id, place.id, call);
|
|
340
|
+
const key = !stands || names.key === null ? undefined : await one<{ id: string }>(sql`SELECT id FROM secrets
|
|
341
|
+
WHERE project_id = ${place!.project_id} AND environment_id = ${place!.id} AND key = ${names.key} AND archived_at IS NULL LIMIT 1`);
|
|
342
|
+
read = { credential: credential.id, secretId: key?.id ?? null, place: key === undefined ? place?.id ?? null : null };
|
|
343
|
+
// No standing: on the place alone, whatever seals the isolate passed (0045's `probe`).
|
|
344
|
+
if (!stands) cached = false;
|
|
345
|
+
resolved = {
|
|
346
|
+
state,
|
|
347
|
+
right: read.credential === call.credential && read.secretId === call.secretId && read.place === call.place,
|
|
348
|
+
place: place?.id ?? null, project: place?.project_slug ?? null, environment: place?.slug ?? null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The way in (0046, T147): a credential's row, an MCP connection's or a Cloudflare binding's, each read as the
|
|
353
|
+
// credential's columns, a binding's whole row beside; behind Access, the person alone.
|
|
354
|
+
const way = WAYS[call.way];
|
|
355
|
+
type WayRow = { principal: string; kind: string | null; scope: string | null; revoked_at: number | null; expires_at: number | null; not_after: number | null; seal: Buffer | null; generation: number | null; token_hash: Buffer | null };
|
|
356
|
+
const c: WayRow | undefined = way === 'access'
|
|
357
|
+
? { principal: call.principal!, kind: null, scope: null, revoked_at: null, expires_at: null, not_after: null, seal: null, generation: null, token_hash: null }
|
|
358
|
+
: way === 'connection'
|
|
359
|
+
? await once<WayRow>(`way\n${way}\n${read.credential}`, sql`SELECT principal, NULL AS kind, NULL AS scope, revoked_at, expires_at, NULL AS not_after, seal, generation, NULL AS token_hash FROM mcp_connections WHERE id = ${read.credential}`)
|
|
360
|
+
: way === 'binding'
|
|
361
|
+
? await once<WayRow>(`way\n${way}\n${read.credential}`, sql`SELECT principal, NULL AS kind, scope, revoked_at, NULL AS expires_at, NULL AS not_after, seal, generation, NULL AS token_hash FROM service_bindings WHERE id = ${read.credential}`)
|
|
362
|
+
: await once<WayRow>(`way\n${way}\n${read.credential}`, sql`SELECT principal, kind, scope, revoked_at, expires_at, not_after, seal, generation, token_hash FROM credentials WHERE id = ${read.credential}`);
|
|
363
|
+
const wRow = way !== 'binding' || c === undefined ? null : (await once<Record<string, unknown>>(`binding\n${read.credential}`, sql`SELECT principal, generation, profile, issuer, origin, jwks_uri, claims, scope, policy, revoked_at FROM service_bindings WHERE id = ${read.credential}`)) ?? null;
|
|
364
|
+
const m = c === undefined ? undefined : await once<{ status: string; owner: number; role: string | null; scope: string | null; seal: Buffer | null; generation: number }>(
|
|
365
|
+
`member\n${c.principal}`, sql`SELECT status, owner, role, scope, seal, generation FROM vault_members WHERE principal = ${c.principal}`);
|
|
366
|
+
const grants = m === undefined ? [] : await rows.read(`grants\n${c!.principal}`, () => rowsOf<{ project_id: string | null; environment_id: string | null; environment_slug: string | null; role: string; expires_at: number | null }>(tx,
|
|
367
|
+
sql`SELECT coalesce(g.project_id, ge.project_id) AS project_id, g.environment_id, g.environment_slug, g.role, g.expires_at
|
|
368
|
+
FROM vault_grants g LEFT JOIN environments ge ON ge.id = g.environment_id WHERE g.principal = ${c!.principal}`));
|
|
369
|
+
const config = await rows.read('config', () => rowsOf<{ name: string; value: string; seal: Buffer | null }>(tx, sql`SELECT name, value, seal FROM log_config`));
|
|
370
|
+
const roles = config.find((row) => row.name === 'roles');
|
|
371
|
+
const policyRow = config.find((row) => row.name === 'policy');
|
|
372
|
+
const secretOf = (id: string | null) => id === null
|
|
373
|
+
? Promise.resolve(undefined)
|
|
374
|
+
: once<SecretRow>(`secret\n${id}`, sql`SELECT ${SECRET} FROM secrets WHERE id = ${id} AND archived_at IS NULL`);
|
|
375
|
+
// Its newest reference row's source and id, unless that row has ended.
|
|
376
|
+
const referenceOf = async (secret: SecretRow | undefined) => secret === undefined || secret.current_version_id !== null
|
|
377
|
+
? undefined
|
|
378
|
+
: once<ReferenceRow>(`reference\n${secret.id}`, sql`SELECT ${REFERENCE} FROM secret_references WHERE secret_id = ${secret.id} ORDER BY created_seq DESC LIMIT 1`);
|
|
379
|
+
const s = await secretOf(read.secretId);
|
|
380
|
+
const sReference = await referenceOf(s);
|
|
381
|
+
const sSource = sReference?.source_secret_id ?? null;
|
|
382
|
+
const x = sSource === null ? undefined : await secretOf(sSource);
|
|
383
|
+
const xReference = await referenceOf(x);
|
|
384
|
+
const xSource = xReference?.source_secret_id ?? null;
|
|
385
|
+
const versionId = sSource === null ? s?.current_version_id ?? null : x?.current_version_id ?? null;
|
|
386
|
+
const v = versionId === null ? undefined : await once<VersionRow>(`version\n${versionId}`, sql`SELECT ${VERSION} FROM secret_versions WHERE id = ${versionId}`);
|
|
387
|
+
// Its own archival and its project's, apart.
|
|
388
|
+
const environmentOf = (id: string | null) => id === null
|
|
389
|
+
? Promise.resolve(undefined)
|
|
390
|
+
: once<EnvironmentRow>(`environment\n${id}`, sql`SELECT ${ENVIRONMENT} FROM environments e LEFT JOIN projects p ON p.id = e.project_id WHERE e.id = ${id}`);
|
|
391
|
+
// As 0024 names them: the place read through, the key's own, its value's elsewhere, a personal config's parent.
|
|
392
|
+
const ids: (string | null)[] = [
|
|
393
|
+
read.place !== null && read.place !== s?.environment_id ? read.place : null,
|
|
394
|
+
s?.environment_id ?? null,
|
|
395
|
+
x !== undefined && x.environment_id !== s?.environment_id ? x.environment_id : null,
|
|
396
|
+
];
|
|
397
|
+
const e = [await environmentOf(ids[0]!), await environmentOf(ids[1]!), await environmentOf(ids[2]!)];
|
|
398
|
+
const own = sSource === null || (x !== undefined && ids[2] === null) ? e[1] : e[2];
|
|
399
|
+
ids.push(own?.owner != null ? own.parent_id : null);
|
|
400
|
+
e.push(await environmentOf(ids[3]!));
|
|
401
|
+
// What the names name, if not a live place; ids the vault passed that are not the names' (`moved`), nothing written;
|
|
402
|
+
// a place it reads archived, the key's or a reference's source's, which the vault refuses by name (0045's `archived`).
|
|
403
|
+
let namedState: 'unknown' | 'nowhere' | 'moved' | 'archived' | null = null;
|
|
404
|
+
if (resolved !== null) {
|
|
405
|
+
namedState = resolved.state ?? (!resolved.right ? 'moved'
|
|
406
|
+
: e.slice(0, 3).some((row) => row !== undefined && (Boolean(row.archived) || Boolean(row.project_archived))) ? 'archived' : null);
|
|
407
|
+
if (namedState !== null) cached = false;
|
|
408
|
+
}
|
|
409
|
+
// Named, a key the place does not hold, and a plain value: decided, never used, so neither an entry nor a cap's row
|
|
410
|
+
// (0044). A use by ids logs its request whatever it finds, as before.
|
|
411
|
+
const uses = namedState === null && (names === undefined || (s !== undefined && (v === undefined || v.plaintext === null)));
|
|
412
|
+
|
|
413
|
+
const last = (kind: Kind, id: string | null) => (cached ? Promise.resolve(undefined) : newestOf(kind, id));
|
|
414
|
+
const [lc, lm, lr, ls, lx, ...le] = await Promise.all([
|
|
415
|
+
last(way === 'access' ? 'credential' : way, way === 'access' ? null : read.credential), last('principal', c?.principal ?? null), last('roles', 'instance'), last('secret', read.secretId), last('secret', sSource),
|
|
416
|
+
...ids.map((id) => last('environment', id)),
|
|
417
|
+
]);
|
|
418
|
+
const numbers = [lc, lm, lr, cached ? undefined : policy, ls, lx, ...le].flatMap((change) => (change === undefined ? [] : [BigInt(change.number)]));
|
|
419
|
+
const inputs = numbers.length === 0 ? null : numbers.reduce((a, b) => (b > a ? b : a));
|
|
420
|
+
const access = c === undefined ? undefined : await once<{ seq: string }>(`access\n${c.principal}`, sql`SELECT CAST(a.seq AS TEXT) AS seq FROM audit_log a WHERE a.author = 'vault' AND a.subject_principal = ${c.principal}
|
|
421
|
+
AND a.decision = 'allow' AND a.action IN (${sql.join(ACCESS_ACTIONS.map((action) => sql`${action}`), sql`, `)}) ORDER BY a.seq DESC LIMIT 1`);
|
|
422
|
+
|
|
423
|
+
// The cap's rule (bulk.ts, as 0022 and 0024 write it): the key's row, and the others only for a key not in the set.
|
|
424
|
+
const refresh = refreshMs(limit);
|
|
425
|
+
const period = periodOf(call.at, refresh);
|
|
426
|
+
const since = call.at - limit.windowMs - refresh - USE_DEADLINE_MS - USE_AHEAD_MS;
|
|
427
|
+
const b = c === undefined || read.secretId === null ? undefined : await rows.bulk(c.principal, read.secretId, since);
|
|
428
|
+
const inSet = b !== undefined && b.last > call.at - limit.windowMs - refresh;
|
|
429
|
+
const stale = b === undefined || b.last < period;
|
|
430
|
+
const others = c === undefined || inSet ? 0 : await rows.others(c.principal, read.secretId, since, limit.count);
|
|
431
|
+
const cover = call.cover;
|
|
432
|
+
const covered = cover !== undefined && b !== undefined && b.last >= period && (cached || BigInt(cover.number) > (inputs ?? 0n))
|
|
433
|
+
&& await rows.cover(call as UseCall & { cover: Cover });
|
|
434
|
+
// A use whose key would take its principal's lock, to enter the cap's set or move on in it, named or by ids, takes it
|
|
435
|
+
// only in the call the vault makes once it has decided the use (0045, T158, D153): else `decide`, nothing written, its
|
|
436
|
+
// rows for the vault to decide on. SQLite's one writer at a time makes the lock implicit; the call is the same, so
|
|
437
|
+
// that both engines answer alike.
|
|
438
|
+
const decide = c !== undefined && uses && stale && call.decided !== true;
|
|
439
|
+
let number: string | null = null;
|
|
440
|
+
if (!covered && uses && !decide) number = String(await rows.use(call));
|
|
441
|
+
if (c !== undefined && uses && !decide && !covered && stale && (inSet || others < limit.count)) await rows.enter(c.principal, call.secretId, period);
|
|
442
|
+
// Past its deadline by the end, the call rolls back and writes nothing (DeadlineChecked), as Postgres's does.
|
|
443
|
+
if (!decide && namedState === null && Date.now() > call.deadline) throw Object.assign(new Error('past its deadline'), { code: '57014' });
|
|
444
|
+
const blob = (value: Buffer | Uint8Array | null | undefined) => (value == null ? null : Buffer.from(value));
|
|
445
|
+
const slot = (i: number) => {
|
|
446
|
+
const row = e[i];
|
|
447
|
+
const at = `e${i + 1}`;
|
|
448
|
+
return {
|
|
449
|
+
[`${at}_id`]: ids[i] ?? null, [`${at}_project`]: row?.project_id ?? null, [`${at}_slug`]: row?.slug ?? null, [`${at}_owner`]: row?.owner ?? null,
|
|
450
|
+
[`${at}_parent`]: row?.parent_id ?? null, [`${at}_protected`]: row?.protected == null ? null : Boolean(row.protected),
|
|
451
|
+
[`${at}_archived`]: row === undefined ? null : Boolean(row.archived), [`${at}_project_archived`]: row === undefined ? null : Boolean(row.project_archived),
|
|
452
|
+
[`${at}_seal`]: blob(row?.seal), [`${at}_last`]: blob(le[i]?.seal),
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
const expires = c === undefined || c.expires_at === null ? null : Math.min(c.expires_at, c.not_after ?? Infinity);
|
|
456
|
+
return {
|
|
457
|
+
state: namedState ?? (decide ? 'decide' : null), number, covered: covered ? cover!.number : null, newest: n === undefined ? null : n.number, newest_mac: blob(n?.mac), inputs: cached || inputs === null ? null : String(inputs),
|
|
458
|
+
probed: !cached,
|
|
459
|
+
c_principal: c?.principal ?? null, c_kind: c?.kind ?? null, c_scope: c?.scope ?? null, c_revoked: c === undefined ? null : c.revoked_at !== null,
|
|
460
|
+
c_expires: expires === null ? null : String(expires), c_seal: blob(c?.seal), c_last: blob(lc?.seal),
|
|
461
|
+
m_status: m?.status ?? null, m_owner: m === undefined ? null : Boolean(m.owner), m_role: m?.role ?? null, m_scope: m?.scope ?? null, m_seal: blob(m?.seal), m_last: blob(lm?.seal),
|
|
462
|
+
m_old: lm === undefined ? null : oldOf(lm.payload), m_access: access === undefined ? null : access.seq,
|
|
463
|
+
c_generation: c?.generation ?? null, m_generation: m?.generation ?? null,
|
|
464
|
+
g_project: m === undefined ? null : grants.map((g) => g.project_id), g_environment: m === undefined ? null : grants.map((g) => g.environment_id),
|
|
465
|
+
g_slug: m === undefined ? null : grants.map((g) => g.environment_slug), g_role: m === undefined ? null : grants.map((g) => g.role),
|
|
466
|
+
g_expires: m === undefined ? null : grants.map((g) => (g.expires_at === null ? null : String(g.expires_at))),
|
|
467
|
+
r_value: roles?.value ?? null, r_seal: blob(roles?.seal), r_last: blob(lr?.seal),
|
|
468
|
+
p_value: policyRow?.value ?? null, p_seal: blob(policyRow?.seal), p_last: cached ? null : blob(policy?.seal), cap_count: limit.count, cap_window: String(limit.windowMs),
|
|
469
|
+
s_project: s?.project_id ?? null, s_environment: s?.environment_id ?? null, s_version: s?.current_version_id ?? null, s_source: sSource,
|
|
470
|
+
s_reference: sSource === null ? null : sReference?.id ?? null, s_seal: blob(s?.seal), s_last: blob(ls?.seal),
|
|
471
|
+
x_project: x?.project_id ?? null, x_environment: x?.environment_id ?? null, x_version: x?.current_version_id ?? null, x_source: xSource,
|
|
472
|
+
x_reference: xSource === null ? null : xReference?.id ?? null, x_seal: blob(x?.seal), x_last: blob(lx?.seal),
|
|
473
|
+
v_envelope: v?.envelope_version ?? null, v_ciphertext: blob(v?.ciphertext), v_iv: blob(v?.iv), v_auth_tag: blob(v?.auth_tag), v_wrapped_dek: blob(v?.wrapped_dek),
|
|
474
|
+
v_kek_provider: v?.kek_provider ?? null, v_kek_id: v?.kek_id ?? null, v_kek_version: v?.kek_version ?? null,
|
|
475
|
+
v_sensitive: v === undefined ? null : Boolean(v.sensitive), v_plain: v === undefined ? null : v.plaintext !== null, v_plaintext: v?.plaintext ?? null,
|
|
476
|
+
...slot(0), ...slot(1), ...slot(2), ...slot(3),
|
|
477
|
+
in_set: inSet, others,
|
|
478
|
+
// SQLite's app reads its own rows, as it always has (`app_read` is Postgres's).
|
|
479
|
+
c_token_hash: blob(c?.token_hash), app: null,
|
|
480
|
+
n_credential: resolved === null ? null : read.credential, n_secret: resolved === null ? null : read.secretId, n_place: resolved?.place ?? null,
|
|
481
|
+
n_project: resolved?.project ?? null, n_environment: resolved?.environment ?? null, n_standing: stands,
|
|
482
|
+
w_row: wRow,
|
|
483
|
+
} as UseRow;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Whether a named call's caller stands at `place` for its purpose, as 0045's `use_stands` decides it before the key
|
|
488
|
+
* is read (W268): the vault's own rule on the rows as read (`namedStanding`), whose seals the vault checks after.
|
|
489
|
+
*/
|
|
490
|
+
async function standsAt(tx: Queryable, credentialId: string, place: string, call: UseCall): Promise<boolean> {
|
|
491
|
+
const one = async <T>(query: SQL): Promise<T | undefined> => (await rowsOf<T>(tx, query))[0];
|
|
492
|
+
const c = await one<{ principal: string; kind: string; scope: string | null; revoked_at: number | null; expires_at: number; not_after: number | null; generation: number }>(
|
|
493
|
+
sql`SELECT principal, kind, scope, revoked_at, expires_at, not_after, generation FROM credentials WHERE id = ${credentialId}`);
|
|
494
|
+
const e = await one<{ project_id: string; slug: string; owner: string | null; parent_id: string | null; protected: number | null; archived: number; project_archived: number }>(
|
|
495
|
+
sql`SELECT e.project_id, e.slug, e.owner, e.parent_id, e.protected, e.archived_at IS NOT NULL AS archived, p.archived_at IS NOT NULL AS project_archived
|
|
496
|
+
FROM environments e LEFT JOIN projects p ON p.id = e.project_id WHERE e.id = ${place}`);
|
|
497
|
+
if (c === undefined || e === undefined) return false;
|
|
498
|
+
const m = await one<{ status: string; owner: number; role: string | null; scope: string | null; generation: number }>(
|
|
499
|
+
sql`SELECT status, owner, role, scope, generation FROM vault_members WHERE principal = ${c.principal}`);
|
|
500
|
+
const grants = m === undefined ? [] : await rowsOf<{ project_id: string | null; environment_id: string | null; environment_slug: string | null; role: string; expires_at: number | null }>(tx,
|
|
501
|
+
sql`SELECT coalesce(g.project_id, ge.project_id) AS project_id, g.environment_id, g.environment_slug, g.role, g.expires_at
|
|
502
|
+
FROM vault_grants g LEFT JOIN environments ge ON ge.id = g.environment_id WHERE g.principal = ${c.principal}`);
|
|
503
|
+
const roles = await one<{ value: string }>(sql`SELECT value FROM log_config WHERE name = 'roles'`);
|
|
504
|
+
const credential = credentialObject({
|
|
505
|
+
id: credentialId, principal: c.principal, kind: c.kind, scope: c.scope, revoked: c.revoked_at !== null,
|
|
506
|
+
expiresAt: Math.min(c.expires_at, c.not_after ?? Infinity), generation: c.generation,
|
|
507
|
+
});
|
|
508
|
+
const principal = m === undefined ? null : principalObject({ principal: c.principal, status: m.status, generation: m.generation, ...memberStanding({ owner: Boolean(m.owner), role: m.role, scope: m.scope }) },
|
|
509
|
+
grants.map((g) => ({ projectId: g.project_id, environmentId: g.environment_id, environmentSlug: g.environment_slug, role: g.role, expiresAt: g.expires_at })));
|
|
510
|
+
const environment = environmentObject({
|
|
511
|
+
id: place, projectId: e.project_id, slug: e.slug, owner: e.owner, parentId: e.parent_id, protected: e.protected === null ? null : Boolean(e.protected),
|
|
512
|
+
archived: Boolean(e.archived), projectArchived: Boolean(e.project_archived),
|
|
513
|
+
});
|
|
514
|
+
const request = { at: call.at, purpose: purposeOf(call.purpose)!.purpose };
|
|
515
|
+
return namedStanding(principal, credential, roles === undefined ? noEdits() : configObject(roles.value) as RolesInput, environment, request) === null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* A handle's one place, as 0045 reads it from its credential's `places`: none for several, a personal config, or
|
|
520
|
+
* anything that is no list of one place, read before the credential is checked, so without throwing.
|
|
521
|
+
*/
|
|
522
|
+
function boundOf(places: string | null): string | null {
|
|
523
|
+
let parsed: unknown;
|
|
524
|
+
try {
|
|
525
|
+
parsed = places === null ? null : JSON.parse(places);
|
|
526
|
+
} catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
if (!Array.isArray(parsed) || parsed.length !== 1) return null;
|
|
530
|
+
const [place] = parsed as unknown[];
|
|
531
|
+
if (typeof place !== 'object' || place === null || Array.isArray(place)) return null;
|
|
532
|
+
const { environmentId, personal } = place as { environmentId?: unknown; personal?: unknown };
|
|
533
|
+
if (personal !== undefined && personal !== null && personal !== false) return null;
|
|
534
|
+
return typeof environmentId === 'string' && UUID.test(environmentId) ? environmentId : null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
538
|
+
|
|
539
|
+
function oldOf(payload: string): string | null {
|
|
540
|
+
const old = (JSON.parse(payload) as { old?: unknown }).old;
|
|
541
|
+
return old === undefined || old === null ? null : String(old);
|
|
542
|
+
}
|