@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
package/src/use.ts
ADDED
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
import { NOTHING } from '@coffre/core/access';
|
|
2
|
+
import { ownerKey, type SecretContext } from '@coffre/core/envelope';
|
|
3
|
+
import { KekBadClaimError, KekUnavailableError, type KekRegistry } from '@coffre/core/kek';
|
|
4
|
+
import { type Access, type CredentialAcceptance, type NamedUseInput, type NamedUseOutcome, type UseBusy, type UseInput, type UseManyInput, type UseManyOutcome, type UseOutcome, type UsedValue } from '@coffre/core/vault';
|
|
5
|
+
import type { Queryable } from '@coffre/db';
|
|
6
|
+
import { engineOf } from '@coffre/db/dialect';
|
|
7
|
+
|
|
8
|
+
import { USE_DEADLINE_MS } from './bulk.ts';
|
|
9
|
+
import type { BulkLimit } from './config.ts';
|
|
10
|
+
import { checkSeals, credentialEnded, decideChecked, readerOf, standingRefusal, useEnvironments, type CheckedView, type Read, type UseCode, type UseRequest, type UseView, type Way, type WayInput } from './decide.ts';
|
|
11
|
+
import { noEdits, objectKey, sealedOnce, type EnvironmentInput, type Input, type InputGrant, type PolicyInput, type RolesInput, type SealKeys, type sealed } from './inputs.ts';
|
|
12
|
+
import { entryMac, MAC_BYTES, TAG_BYTES, type LogMacKey } from './log-entry.ts';
|
|
13
|
+
import { bindingObject, configObject, connectionObject, credentialObject, environmentObject, memberStanding, principalObject, secretObject } from './objects.ts';
|
|
14
|
+
import { PURPOSES, purposeCode, WAYS } from './log-replay.ts';
|
|
15
|
+
import { callBatch as batchPostgres, callUse as callPostgres } from './use.postgres.ts';
|
|
16
|
+
import { callBatch as batchSqlite, callUse as callSqlite } from './use.sqlite.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 0.15's use (docs/design/0.15-plan.md, PR 5; hot-path-one.md, "A use";
|
|
20
|
+
* formal/HotPathOne.tla): one call, `use_credential`, then the decision on
|
|
21
|
+
* what it read, then the key, opened only once the call has returned,
|
|
22
|
+
* which is once its entry has committed (ReleaseAfterCommit). The log
|
|
23
|
+
* records the request; the outcome is `decideChecked`'s on what the
|
|
24
|
+
* statement read, which the replay of the log recomputes (R53).
|
|
25
|
+
*
|
|
26
|
+
* Around the call, what is per isolate (each a settled value, never work in
|
|
27
|
+
* flight: vault.ts, `PreparedVault`):
|
|
28
|
+
* - **admission:** at most `limit` calls in flight; the next is refused at
|
|
29
|
+
* once, `overloaded`, holding no connection (R44). A call's place is
|
|
30
|
+
* given back when it settles; on Workers, where a call may never settle,
|
|
31
|
+
* at the latest `HELD_PAST_DEADLINE_MS` past its deadline (`UseCache`);
|
|
32
|
+
* - **the deadline:** at most 3 s from the request's time (R53, R54). The
|
|
33
|
+
* call is raced against it and answered `late` when it loses; Postgres
|
|
34
|
+
* holds it too (DeadlineChecked), and a value whose call returns after
|
|
35
|
+
* it never leaves (WorkerHonoursDeadline);
|
|
36
|
+
* - **the seal cache:** each key's inputs' newest seals and the cap, as of
|
|
37
|
+
* the log's newest change (its number and MAC), which the next call
|
|
38
|
+
* names so that its statement probes nothing while that change is still
|
|
39
|
+
* the newest (CacheChecked, CacheSameChange);
|
|
40
|
+
* - **covers:** a repeat of an allowed use rides its entry, writing none,
|
|
41
|
+
* while no input changed since and the key's bulk row is fresh;
|
|
42
|
+
* - **no decision** for a use to take its principal's lock on (T158, D153):
|
|
43
|
+
* a named use that would take it makes a call that locks and writes
|
|
44
|
+
* nothing, decides the use on its rows (`decide`), and only then makes it,
|
|
45
|
+
* in a second call (DecideBeforeLock, DecideEachLock).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export type { UseInput, UseManyInput, UseManyOutcome, UseOutcome, UsedValue };
|
|
49
|
+
|
|
50
|
+
/** A use's request id (`newUseRequestId`, core's: the app makes them too). */
|
|
51
|
+
export { newUseRequestId as newRequestId } from '@coffre/core/vault';
|
|
52
|
+
|
|
53
|
+
/** What the vault holds per isolate for its uses (`UseCache`): only settled values. */
|
|
54
|
+
export type CachedSeals = {
|
|
55
|
+
/** The log's newest change when these were read: its number and MAC. */
|
|
56
|
+
newest: string;
|
|
57
|
+
newestMac: Buffer;
|
|
58
|
+
/** The cap its policy's newest change said then (CapSealed). */
|
|
59
|
+
cap: BulkLimit;
|
|
60
|
+
/** The newest change of any input then: a cover after it holds. */
|
|
61
|
+
inputs: string | null;
|
|
62
|
+
/** Which object each seal is of, as the rows named them: an input whose row names another now reads nothing from here. */
|
|
63
|
+
ids: Identities;
|
|
64
|
+
last: Lasts;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Which object each input's row is of (the credential, its principal, the key, its source, each place): the seals' keys. */
|
|
68
|
+
type Identities = Record<'c' | 'm' | 's' | 'x' | (typeof SLOTS)[number], string | null>;
|
|
69
|
+
|
|
70
|
+
/** Each input's newest change's seal, and the 0.14 entry the principal's accounts for. */
|
|
71
|
+
type Lasts = {
|
|
72
|
+
c: Buffer | null; m: Buffer | null; mOld: string | null; r: Buffer | null; p: Buffer | null; s: Buffer | null; x: Buffer | null;
|
|
73
|
+
e1: Buffer | null; e2: Buffer | null; e3: Buffer | null; e4: Buffer | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type Cover = { number: string; at: number; requestId: string; until: number };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The settings of an isolate's uses: how many calls in flight at once, how long a cover holds, and how many requests'
|
|
80
|
+
* names it maps to ids (`UseCache.names`; 0 maps none, each request then reads its ids: the bench's worst case).
|
|
81
|
+
*/
|
|
82
|
+
export type UseLimits = {
|
|
83
|
+
inFlight: number; coverMs: number; names: number;
|
|
84
|
+
/**
|
|
85
|
+
* The most keys a statement of a batch makes uses of (`useMany`): each statement holds the fence for its keys' uses,
|
|
86
|
+
* about 0.4 ms a key, and a change or the checkpointer's barrier waits behind it, every use queued behind that (R44).
|
|
87
|
+
* A batch of more is made in statements of this many, in turn (review r2 of #433): 100 hold the fence about 40 ms.
|
|
88
|
+
*/
|
|
89
|
+
batch: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How many uses an isolate has in flight at most, unless set: a pool of
|
|
94
|
+
* `connections` serves that many at once, and a call past it would only
|
|
95
|
+
* queue for a connection, holding its deadline. T112 measures the number
|
|
96
|
+
* for Workers; here it is the pool's.
|
|
97
|
+
*/
|
|
98
|
+
export function useLimits(connections: number, set: Partial<UseLimits> = {}): UseLimits {
|
|
99
|
+
return { inFlight: set.inFlight ?? Math.max(1, connections), coverMs: set.coverMs ?? 60_000, names: set.names ?? 10_000, batch: Math.max(1, set.batch ?? 100) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How long past its deadline a call keeps its place at most where places lapse (`UseCache`, Workers): the hot login's
|
|
104
|
+
* `statement_timeout` (`coffre setup`'s HOT_SETTINGS, `migrate`). On Workers a call's connection is its own, closed
|
|
105
|
+
* at its deadline at the latest (cloudflare.ts, `HyperdrivePool.end`), or with its request if that ends first: what
|
|
106
|
+
* the call asked and Hyperdrive had not sent goes with it, and what reached Postgres by then Postgres ends within its
|
|
107
|
+
* `statement_timeout`. So by this long past the deadline nothing of the call's holds a connection, whether its
|
|
108
|
+
* promise ever settles or not, which on Workers it may never do: its I/O is gone with its request (AGENTS.md, "What
|
|
109
|
+
* an isolate keeps"). Counted until it settled, each such call kept its place until the isolate was recycled (T170).
|
|
110
|
+
*/
|
|
111
|
+
export const HELD_PAST_DEADLINE_MS = 2_000;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* An isolate's admission, seal cache and covers. Bounded: past `max` entries, a map starts over.
|
|
115
|
+
*
|
|
116
|
+
* A call holds its place in admission until it settles. With `callsEndWithRequest` (Workers), at most until `HELD_PAST_DEADLINE_MS`
|
|
117
|
+
* past its deadline, settled or not; without (Node), until it settles, as it always does there, its I/O never torn
|
|
118
|
+
* down under it: a call stuck queued for the hot pool or reading past its deadline (`oldWrite`) holds the pool too,
|
|
119
|
+
* and counts until it lets go, so that no call abandoned piles more on the pool (review r1 of #438).
|
|
120
|
+
*/
|
|
121
|
+
export class UseCache {
|
|
122
|
+
/** Each call admitted and not settled, by when its place lapses (`admit`): a time, never work in flight. */
|
|
123
|
+
readonly #admitted = new Set<{ until: number }>();
|
|
124
|
+
readonly #lapses: boolean;
|
|
125
|
+
readonly seals = new Map<string, CachedSeals>();
|
|
126
|
+
readonly covers = new Map<string, Cover>();
|
|
127
|
+
/**
|
|
128
|
+
* What a request names, by its token's hash and place, mapped to ids (`useNamed`): ids only, never an answer, and
|
|
129
|
+
* no key's (`keyIds`). The least recently used goes first past `limits.names` (`named`, `name`). They only let a
|
|
130
|
+
* named use's first call make the use: what it reads, it reads by the request's names (D153).
|
|
131
|
+
*/
|
|
132
|
+
readonly names = new Map<string, Resolved>();
|
|
133
|
+
/**
|
|
134
|
+
* Each key's id by its environment and name, whoever asks (`keyed`, `key`), or null for a name the environment held
|
|
135
|
+
* no key of when read: bounded on its own, so that names asked and dropped leave none behind (review r3 of #412).
|
|
136
|
+
*/
|
|
137
|
+
readonly keyIds = new Map<string, string | null>();
|
|
138
|
+
readonly limits: UseLimits;
|
|
139
|
+
readonly max: number;
|
|
140
|
+
|
|
141
|
+
constructor(limits: UseLimits, { max = 10_000, callsEndWithRequest = false }: { max?: number; callsEndWithRequest?: boolean } = {}) {
|
|
142
|
+
this.limits = limits;
|
|
143
|
+
this.max = max;
|
|
144
|
+
this.#lapses = callsEndWithRequest;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** How many calls hold a place now: admitted, not settled, and not lapsed. */
|
|
148
|
+
get inFlight(): number {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
for (const slot of this.#admitted) if (slot.until <= now) this.#admitted.delete(slot);
|
|
151
|
+
return this.#admitted.size;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether the next call is refused, `overloaded`. */
|
|
155
|
+
full(): boolean {
|
|
156
|
+
return this.inFlight >= this.limits.inFlight;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A place for a call due by `deadline`, given back once it settles (what this answers), unless it lapsed first. */
|
|
160
|
+
admit(deadline: number): () => void {
|
|
161
|
+
const slot = { until: this.#lapses ? deadline + HELD_PAST_DEADLINE_MS : Infinity };
|
|
162
|
+
this.#admitted.add(slot);
|
|
163
|
+
return () => this.#admitted.delete(slot);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
keep<T>(map: Map<string, T>, key: string, value: T): void {
|
|
167
|
+
if (map.size >= this.max && !map.has(key)) map.clear();
|
|
168
|
+
map.set(key, value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The ids `key` names, if mapped, made the most recently used. */
|
|
172
|
+
named(key: string): Resolved | undefined {
|
|
173
|
+
return recall(this.names, key);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Maps `key` to its ids, the least recently used dropped past the cap. */
|
|
177
|
+
name(key: string, ids: Resolved): void {
|
|
178
|
+
remember(this.names, key, ids, this.limits.names);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The id of the key named `key` in `environment`, or null for none, if mapped, made the most recently used. */
|
|
182
|
+
keyed(environment: string, key: string): string | null | undefined {
|
|
183
|
+
return recall(this.keyIds, `${environment}\n${key}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Maps a key's name in `environment` to its id, or null for none, the least recently used dropped past the cap. */
|
|
187
|
+
key(environment: string, key: string, id: string | null): void {
|
|
188
|
+
remember(this.keyIds, `${environment}\n${key}`, id, this.limits.names);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Forgets the key named `key` in `environment`. */
|
|
192
|
+
unkey(environment: string, key: string): void {
|
|
193
|
+
this.keyIds.delete(`${environment}\n${key}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A Map as an LRU: `key`'s value, moved to the end, the most recent; a Map iterates in the order of insertion. */
|
|
198
|
+
function recall<T>(map: Map<string, T>, key: string): T | undefined {
|
|
199
|
+
const found = map.get(key);
|
|
200
|
+
if (found !== undefined) {
|
|
201
|
+
map.delete(key);
|
|
202
|
+
map.set(key, found);
|
|
203
|
+
}
|
|
204
|
+
return found;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** `key` mapped to `value`, the most recent, the least recent dropped past `cap` (0: none kept). */
|
|
208
|
+
function remember<T>(map: Map<string, T>, key: string, value: T, cap: number): void {
|
|
209
|
+
map.delete(key);
|
|
210
|
+
if (cap <= 0) return;
|
|
211
|
+
if (map.size >= cap) map.delete(map.keys().next().value!);
|
|
212
|
+
map.set(key, value);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* One call of `use_credential` as the vault makes it: the request, its MAC, a cover and the cache, if any; `names`,
|
|
217
|
+
* what the request named, which a named call reads by. Its credential none: the isolate's map holds no ids for the
|
|
218
|
+
* names, and the call makes no use, only reads them (D153).
|
|
219
|
+
*/
|
|
220
|
+
export type UseCall = {
|
|
221
|
+
credential: string | null; secretId: string | null; requestId: string; at: number; purpose: number; place: string | null; mac: Buffer; deadline: number;
|
|
222
|
+
/** Its way in (`WAYS`'s index: a credential, 0), and behind Access the person, `credential` their object's key (T147). */
|
|
223
|
+
way: number; principal: string | null;
|
|
224
|
+
cover?: Cover;
|
|
225
|
+
known?: Pick<CachedSeals, 'newest' | 'newestMac' | 'cap'>;
|
|
226
|
+
names?: Names;
|
|
227
|
+
/** The call a named use makes once the vault has decided the use on the rows of one that answered `decide` or `moved`. */
|
|
228
|
+
decided?: true;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* What a request named, which a named call reads by (0045, D153), and answers the app's rows for (0044). `handle`: it
|
|
233
|
+
* named no place, and means its handle's own.
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* What a named call names (0045): its token's hash, its place by slugs, or the handle's own (`handle`), and its key.
|
|
237
|
+
* `unless`, a handle's: the places, `project/environment`, its request does not mean, which the call judges against the
|
|
238
|
+
* slugs it reads (never the map's), answering them as none, the app's.
|
|
239
|
+
*/
|
|
240
|
+
export type Names = {
|
|
241
|
+
tokenHash: Buffer; project: string | null; environment: string | null; key: string | null; handle: boolean; unless: readonly string[];
|
|
242
|
+
/** What the app's config accepts of a credential (`NamedUseInput.accepts`), which the call holds the token's to first. */
|
|
243
|
+
accepts: CredentialAcceptance;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The ids of what a request names, as a named call read them: its credential and its place, and the place's slugs.
|
|
248
|
+
* Kept per token and place without its keys, which `UseCache.keyIds` keeps by environment and name, each map's least
|
|
249
|
+
* recently used dropped past `UseLimits.names`: about 200 and 120 bytes an entry, some 3 MB with both full at 10,000.
|
|
250
|
+
*/
|
|
251
|
+
export type Resolved = { credential: string; environment: string; slugs: { project: string; environment: string } };
|
|
252
|
+
|
|
253
|
+
/** What `use_credential` answers (schema 0024's `use_row`), as node-postgres reads it; the SQLite twin answers the same. */
|
|
254
|
+
export type UseRow = {
|
|
255
|
+
state: string | null; number: string | null; covered: string | null; newest: string | null; newest_mac: Buffer | null; inputs: string | null; probed: boolean;
|
|
256
|
+
c_principal: string | null; c_kind: string | null; c_scope: string | null; c_revoked: boolean | null; c_expires: string | null; c_seal: Buffer | null; c_last: Buffer | null;
|
|
257
|
+
m_status: string | null; m_owner: boolean | null; m_role: string | null; m_scope: string | null; m_seal: Buffer | null; m_last: Buffer | null; m_old: string | null; m_access: string | null;
|
|
258
|
+
c_generation: number | null; m_generation: number | null;
|
|
259
|
+
g_project: (string | null)[] | null; g_environment: (string | null)[] | null; g_slug: (string | null)[] | null; g_role: string[] | null; g_expires: (string | null)[] | null;
|
|
260
|
+
r_value: string | null; r_seal: Buffer | null; r_last: Buffer | null;
|
|
261
|
+
p_value: string | null; p_seal: Buffer | null; p_last: Buffer | null; cap_count: number | null; cap_window: string | null;
|
|
262
|
+
s_project: string | null; s_environment: string | null; s_version: string | null; s_source: string | null; s_reference: string | null; s_seal: Buffer | null; s_last: Buffer | null;
|
|
263
|
+
x_project: string | null; x_environment: string | null; x_version: string | null; x_source: string | null; x_reference: string | null; x_seal: Buffer | null; x_last: Buffer | null;
|
|
264
|
+
v_envelope: number | null; v_ciphertext: Buffer | null; v_iv: Buffer | null; v_auth_tag: Buffer | null; v_wrapped_dek: Buffer | null;
|
|
265
|
+
v_kek_provider: string | null; v_kek_id: string | null; v_kek_version: string | null; v_sensitive: boolean | null; v_plain: boolean | null; v_plaintext: string | null;
|
|
266
|
+
in_set: boolean | null; others: number | null;
|
|
267
|
+
/** The credential's token hash, and the rows the app checks, when the call named them (0044). */
|
|
268
|
+
c_token_hash: Buffer | null; app: unknown;
|
|
269
|
+
/** What a named call's names name now: its credential, key and place, by id, and the place's slugs (0045, D153). */
|
|
270
|
+
n_credential: string | null; n_secret: string | null; n_place: string | null; n_project: string | null; n_environment: string | null;
|
|
271
|
+
/**
|
|
272
|
+
* Whether a named call found its caller standing at the place, for its purpose, and so read its key (`use_stands`,
|
|
273
|
+
* W268): false, it read no key and answered on the place alone, as for a key the place does not hold.
|
|
274
|
+
*/
|
|
275
|
+
n_standing: boolean | null;
|
|
276
|
+
/** A Cloudflare binding's row, which a use through it read whole, its object (T147): as `to_json` writes it. */
|
|
277
|
+
w_row: Record<string, unknown> | null;
|
|
278
|
+
} & Slot<'e1'> & Slot<'e2'> & Slot<'e3'> & Slot<'e4'>;
|
|
279
|
+
|
|
280
|
+
type Slot<N extends string> = {
|
|
281
|
+
[K in `${N}_id` | `${N}_project` | `${N}_slug` | `${N}_owner` | `${N}_parent`]: string | null;
|
|
282
|
+
} & { [K in `${N}_protected` | `${N}_archived` | `${N}_project_archived`]: boolean | null } & { [K in `${N}_seal` | `${N}_last`]: Buffer | null };
|
|
283
|
+
|
|
284
|
+
const SLOTS = ['e1', 'e2', 'e3', 'e4'] as const;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* What a use needs of its vault: its database, the keys it checks seals
|
|
288
|
+
* with, the log's MAC key (`logMacKey`, the change path's and the
|
|
289
|
+
* checkpointer's: one key, the tree under the MAC, KindInMac), its key
|
|
290
|
+
* registry, and its isolate's cache.
|
|
291
|
+
*/
|
|
292
|
+
export type UsePath = {
|
|
293
|
+
db: Queryable;
|
|
294
|
+
sealKeys: SealKeys;
|
|
295
|
+
macKey: LogMacKey;
|
|
296
|
+
keks: KekRegistry;
|
|
297
|
+
cache: UseCache;
|
|
298
|
+
/**
|
|
299
|
+
* What a call waits for first, within its admission: an isolate's first, the log's cutover (vault.ts, `#cutOverYet`).
|
|
300
|
+
* Within it, as on Workers it reads on the call's one connection; however long it waits, its place lapses as any does.
|
|
301
|
+
*/
|
|
302
|
+
ready?: () => Promise<void>;
|
|
303
|
+
/**
|
|
304
|
+
* Whether `principal`'s newest 0.14 access entry that carries the vault's
|
|
305
|
+
* MAC, at or below `through`, is past `old`, the newest its newest change
|
|
306
|
+
* accounts for (null: none): asked only when the statement's probe, which
|
|
307
|
+
* reads any entry in the vault's name, found one past it, `through`
|
|
308
|
+
* (UsesSeeOldWrites). An entry forged there is passed over (vault.ts,
|
|
309
|
+
* `#oldWrite`), which writes nothing. Never past `through`: the use's
|
|
310
|
+
* statement committed before this reads, and an entry since is no input it
|
|
311
|
+
* decided on. Absent, the probe's answer stands.
|
|
312
|
+
*/
|
|
313
|
+
oldWrite?: (principal: string, old: bigint | null, through: bigint) => Promise<boolean>;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
317
|
+
const REQUEST_ID = /^(0|[1-9][0-9]{0,18})$/;
|
|
318
|
+
|
|
319
|
+
/** Why a use's input is not one: a bug of the app's, which fails the call. */
|
|
320
|
+
function checkInput(input: Pick<UseInput, 'requestId' | 'at' | 'deadline' | 'keepsSensitive' | 'purpose'>): void {
|
|
321
|
+
if (!REQUEST_ID.test(input.requestId) || BigInt(input.requestId) >= 2n ** 63n) throw new Error('a use request id is a whole number below 2^63, as text');
|
|
322
|
+
if (!Number.isSafeInteger(input.at) || !Number.isSafeInteger(input.deadline)) throw new Error("a use's time and deadline are whole ms since the epoch");
|
|
323
|
+
if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
|
|
324
|
+
if (!(PURPOSES as readonly string[]).includes(input.purpose)) throw new Error(`no purpose ${String(input.purpose)}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function checkIds(input: { credential: string; secretId: string | null; place: string | null; way?: unknown }): void {
|
|
328
|
+
if (input.way !== undefined && !(WAYS as readonly unknown[]).includes(input.way)) throw new Error(`no way in ${String(input.way)}`);
|
|
329
|
+
// Behind Access, the account: a `user:` or a `token:`, as `vault_members` holds them.
|
|
330
|
+
const named = input.way === 'access' ? /^(user|token):[^\s]{1,320}$/.test(input.credential) : UUID.test(input.credential);
|
|
331
|
+
if (!named || (input.secretId !== null && !UUID.test(input.secretId)) || (input.place !== null && !UUID.test(input.place))) {
|
|
332
|
+
throw new Error('a use names its way in, its key and its place, if any, by lowercase UUID; behind Access, its account');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A use's way in as its entry names it (T147): a credential's, a connection's or a binding's id; behind Access, the
|
|
338
|
+
* person's object's key, and the person beside it, which the call reads their row by.
|
|
339
|
+
*/
|
|
340
|
+
function wayOf(input: Pick<UseInput, 'credential' | 'way'>): { credential: string; way: Way; principal: string | null } {
|
|
341
|
+
const way = input.way ?? 'credential';
|
|
342
|
+
return way === 'access'
|
|
343
|
+
? { credential: objectKey({ kind: 'principal', id: input.credential }), way, principal: input.credential }
|
|
344
|
+
: { credential: input.credential, way, principal: null };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Busy answers: nothing was decided, ask again shortly. */
|
|
348
|
+
const busy = (why: UseBusy, retryAfter = 1): Extract<UseOutcome, { busy: UseBusy }> => ({ ok: false, busy: why, retryAfter });
|
|
349
|
+
|
|
350
|
+
/** One use, end to end (the module's comment says how). */
|
|
351
|
+
export async function use(path: UsePath, input: UseInput): Promise<UseOutcome> {
|
|
352
|
+
checkInput(input);
|
|
353
|
+
checkIds(input);
|
|
354
|
+
const asked = { ...input, ...wayOf(input) };
|
|
355
|
+
let used = await useOnce(path, asked);
|
|
356
|
+
// Its key would take its principal's lock: decided first, as a named use is, and made by a second call once allowed;
|
|
357
|
+
// refused, nothing written, which the app logs (`number` null; review r2 of D153, DecideBeforeLock).
|
|
358
|
+
if ('again' in used) used = await useOnce(path, asked, undefined, true);
|
|
359
|
+
if (!('outcome' in used)) throw new Error('use_credential answered a named state to a use that named nothing');
|
|
360
|
+
// A plain value is the app's to hand back on a use by ids, as 0.14's vault never decides one: `decideChecked` refuses
|
|
361
|
+
// it, `bad_claim`, live and replayed alike; only a named use hands one back.
|
|
362
|
+
if ('plain' in used.outcome) throw new Error('a use by ids was handed a plain value');
|
|
363
|
+
return used.outcome;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A request's several keys, one call (T167, B2; formal/Batching.tla; migration 0048, `use_batch`): each key's use made
|
|
368
|
+
* as `use` makes it, by ids, in the request's order, in one statement (of at most `limits.batch` keys: more are made
|
|
369
|
+
* in statements of that many, in turn), and decided on what it read (`settle`), under one admission and one deadline. Then, only once every key is allowed on those rows (AllDecidedFirst), one more call
|
|
370
|
+
* for the keys that would take the principal's lock, which answered `decide` having written and locked nothing: it
|
|
371
|
+
* takes the lock exclusive before its first key (StrongestFirst), and each key is decided again on what it reads then.
|
|
372
|
+
* Values leave only once every key is allowed, all opened then (AnswerWhole); otherwise the first key in the request's
|
|
373
|
+
* order not allowed is the answer, refused or busy, and no key is opened.
|
|
374
|
+
*
|
|
375
|
+
* a CI run's `coffre export` of 30 keys 1 call, 30 entries (2, its keys entering its cap's set) the 30 values
|
|
376
|
+
* the same, a reference among them ended 1 call, the other keys' entries that key's refusal
|
|
377
|
+
*
|
|
378
|
+
* A refusal on a cover, which a lone use by ids makes again as an entry of its own, is answered as it is (its `number`
|
|
379
|
+
* null, the app logs it): the batch is refused, and makes no other call.
|
|
380
|
+
*/
|
|
381
|
+
export async function useMany(path: UsePath, input: UseManyInput): Promise<UseManyOutcome> {
|
|
382
|
+
const { items, ...asked } = input;
|
|
383
|
+
if (!Array.isArray(items) || items.length === 0) throw new Error('a batch of uses names a key at least');
|
|
384
|
+
for (const item of items) {
|
|
385
|
+
checkInput({ ...asked, requestId: item.requestId });
|
|
386
|
+
checkIds({ credential: asked.credential, secretId: item.secretId, place: item.place, way: asked.way });
|
|
387
|
+
}
|
|
388
|
+
if (new Set(items.map((item) => item.requestId)).size !== items.length) throw new Error("a batch's uses each have a request id of their own");
|
|
389
|
+
const way = wayOf(asked);
|
|
390
|
+
const each: Asked[] = items.map((item) => ({ ...asked, ...way, secretId: item.secretId, place: item.place, requestId: item.requestId }));
|
|
391
|
+
const settled: (Used | Allowed | Uncovered | undefined)[] = [];
|
|
392
|
+
// The objects every key shares, their seals checked once for the batch.
|
|
393
|
+
const seals = sealedOnce();
|
|
394
|
+
let pending = each.map((_, index) => index);
|
|
395
|
+
for (let round = 1; pending.length > 0; round++) {
|
|
396
|
+
const decided = round > 1;
|
|
397
|
+
if (round > 2) throw new Error('use_batch answered `decide` to a call of keys it was decided for');
|
|
398
|
+
if (input.deadline > input.at + USE_DEADLINE_MS || Date.now() >= input.deadline) return busy('late');
|
|
399
|
+
if (path.cache.full()) return busy('overloaded');
|
|
400
|
+
const made = pending.map((index) => ready(path, each[index]!, requestOf(each[index]!, undefined), undefined, decided, seals));
|
|
401
|
+
// Round 2's keys, each decided on round 1's rows, take their principal's lock: exclusive, first. Rows that name two
|
|
402
|
+
// principals for one credential were written around the log: no lock first, and Postgres ends a wait cycle (40P01).
|
|
403
|
+
const principals = new Set(decided ? pending.map((index) => (settled[index] as Extract<Used, { again: Ids }>).row.c_principal) : []);
|
|
404
|
+
const lock = principals.size === 1 ? [...principals][0] ?? null : null;
|
|
405
|
+
const answered = await raced(path, input.deadline, (sends) => inStatements(path, made.map((one) => one.call), lock, sends), async (rows) => {
|
|
406
|
+
if (rows.length !== made.length) throw new Error(`use_batch answered ${rows.length} rows for ${made.length} keys`);
|
|
407
|
+
const forged = new Map<string, Promise<boolean>>();
|
|
408
|
+
// One check a principal and entry, as every key's row reads the same (`oldWrite`), in turn on the call's connection.
|
|
409
|
+
const forgedOf = async (row: UseRow, known: CachedSeals | undefined) => {
|
|
410
|
+
const at = `${row.c_principal}\n${row.m_access}\n${(known?.last ?? lastsOf(row)).mOld}`;
|
|
411
|
+
if (!forged.has(at)) forged.set(at, forgedPast(path, row, known));
|
|
412
|
+
return forged.get(at)!;
|
|
413
|
+
};
|
|
414
|
+
const out: { row: UseRow; forged: boolean }[] = [];
|
|
415
|
+
for (const [i, row] of rows.entries()) out.push({ row, forged: await forgedOf(row, row.probed ? undefined : made[i]!.known) });
|
|
416
|
+
return out;
|
|
417
|
+
});
|
|
418
|
+
if ('busy' in answered) return answered;
|
|
419
|
+
for (const [i, index] of pending.entries()) settled[index] = settle(path, each[index]!, undefined, decided, made[i]!, answered[i]!.row, answered[i]!.forged);
|
|
420
|
+
// The first key not allowed, in the request's order, answers for the batch, and no other call is made.
|
|
421
|
+
const first = settled.findIndex((one) => one !== undefined && !('again' in one) && !('allowed' in one));
|
|
422
|
+
if (first >= 0) return refusedAt(first, settled[first]!);
|
|
423
|
+
pending = settled.flatMap((one, index) => (one !== undefined && 'again' in one ? [index] : []));
|
|
424
|
+
}
|
|
425
|
+
// Every key allowed: each opened, within the deadline.
|
|
426
|
+
const opened = await Promise.all(settled.map((one) => opening(path, one as Allowed, input.deadline)));
|
|
427
|
+
const values: Extract<UseManyOutcome, { ok: true }>['values'] = [];
|
|
428
|
+
for (const [index, one] of opened.entries()) {
|
|
429
|
+
const outcome = 'outcome' in one ? one.outcome : null;
|
|
430
|
+
if (outcome === null || !outcome.ok || !('value' in outcome)) return refusedAt(index, one);
|
|
431
|
+
values.push({ number: outcome.number, covered: outcome.covered, value: outcome.value });
|
|
432
|
+
}
|
|
433
|
+
return { ok: true, values };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A batch's answer for the key at `index` that was not allowed. */
|
|
437
|
+
function refusedAt(index: number, used: Used | Allowed | Uncovered): Extract<UseManyOutcome, { ok: false }> {
|
|
438
|
+
if (!('outcome' in used)) throw new Error('a use by ids answered a named state');
|
|
439
|
+
const { outcome } = used;
|
|
440
|
+
if ('busy' in outcome) return outcome;
|
|
441
|
+
if (outcome.ok) throw new Error('a use allowed is no refusal');
|
|
442
|
+
if (!('code' in outcome)) throw new Error('a use by ids was handed a plain value');
|
|
443
|
+
return { ok: false, index, code: outcome.code, number: outcome.number };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A round's calls, `limits.batch` keys a statement, in turn, under the round's one admission and deadline: each its own
|
|
448
|
+
* transaction and fence span, a locked round's each taking the principal's lock exclusive before its first key.
|
|
449
|
+
*/
|
|
450
|
+
async function inStatements(path: UsePath, calls: readonly UseCall[], lock: string | null, sends: () => void): Promise<UseRow[]> {
|
|
451
|
+
const rows: UseRow[] = [];
|
|
452
|
+
const size = path.cache.limits.batch;
|
|
453
|
+
for (let start = 0; start < calls.length; start += size) {
|
|
454
|
+
// Past the deadline, the round is answered `late` (`raced`): no statement more is sent, and the call lets go of its
|
|
455
|
+
// admission and connection with the one in flight (review r3 of #433). As Postgres's own past-deadline refusal.
|
|
456
|
+
if (start > 0) sends();
|
|
457
|
+
rows.push(...await batchOf(path.db)(path.db, calls.slice(start, start + size), lock));
|
|
458
|
+
}
|
|
459
|
+
return rows;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The engine's `use_batch`. */
|
|
463
|
+
function batchOf(db: Queryable): (db: Queryable, calls: readonly UseCall[], lock: string | null) => Promise<UseRow[]> {
|
|
464
|
+
return engineOf(db) === 'postgres' ? batchPostgres : batchSqlite;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** A use's request as the vault makes it: by its ids, and its way in's (`wayOf`). */
|
|
468
|
+
type Ids = { credential: string; secretId: string | null; place: string | null; way?: Way; principal?: string | null };
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A use's answer inside the vault: its outcome, its row, the view it decided on, if it got that far. A named one may
|
|
472
|
+
* also answer, having written and locked nothing: `unknown`, a token no credential has; `nowhere`, a place that is
|
|
473
|
+
* none, archived, a fork's or a personal config's, which the app answers itself, decided on the place alone; or, named
|
|
474
|
+
* or by ids, `again`, the use the vault allowed on the rows the call read, to be made by their ids, decided (D153).
|
|
475
|
+
*/
|
|
476
|
+
type Used =
|
|
477
|
+
| { unknown: true } | { nowhere: true } | { again: Ids; row: UseRow }
|
|
478
|
+
| { outcome: UseOutcome | { ok: true; plain: string; versionId: string }; row: UseRow | null; checked: CheckedView | null; request: UseRequest };
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* One call of `use_credential`, and the decision on its row. `secretId` null with `place`: a key the place does not
|
|
482
|
+
* hold, decided on the caller's standing there (Probing.tla, MissingByStanding); a plain value, decided as any and
|
|
483
|
+
* handed back as its text, which its object's digest binds (PlainByStanding). Neither leaves an entry, nor a cover.
|
|
484
|
+
*
|
|
485
|
+
* `names`: what the request named, which the statement reads by (0045, D153); its ids, the isolate's map's, only let it
|
|
486
|
+
* make the use. A named call whose ids are not the names' answers `moved`, and one whose key would take its principal's
|
|
487
|
+
* lock with no use of it kept answers `decide`, each having written and locked nothing: the vault decides the use on
|
|
488
|
+
* the rows the names read, key and all, and refuses it there, whatever the reason, as for a key never made; only a use
|
|
489
|
+
* it allows is made, in a second call (DecideBeforeLock). A named call whose caller stands nowhere there, for what
|
|
490
|
+
* they ask, reads no key at all (`use_stands`, W268; StandingFirst): its row is a key the place does not hold, which
|
|
491
|
+
* the vault refuses on that standing.
|
|
492
|
+
*/
|
|
493
|
+
async function useOnce(path: UsePath, input: Asked, names?: Names, decided = false): Promise<Used> {
|
|
494
|
+
const request = requestOf(input, names);
|
|
495
|
+
// A deadline the database would refuse (deadline_max) is refused here at no cost; one already past, too.
|
|
496
|
+
if (input.deadline > input.at + USE_DEADLINE_MS || Date.now() >= input.deadline) return { outcome: busy('late'), row: null, checked: null, request };
|
|
497
|
+
if (path.cache.full()) return { outcome: busy('overloaded'), row: null, checked: null, request };
|
|
498
|
+
const made = ready(path, input, request, names, decided);
|
|
499
|
+
// The statement, then, within the same admission and deadline, the MAC check of an access entry its probe found that
|
|
500
|
+
// the new log does not account for (`oldWrite`), never past that entry: one forged in the vault's name is passed over.
|
|
501
|
+
const answered = await raced(path, input.deadline, () => callOf(path.db)(path.db, made.call), async (row) => ({ row, forged: await forgedPast(path, row, row.probed ? undefined : made.known) }));
|
|
502
|
+
if ('busy' in answered) return { outcome: answered, row: null, checked: null, request };
|
|
503
|
+
const used = settle(path, input, names, decided, made, answered.row, answered.forged);
|
|
504
|
+
// Refused on a cover, by ids: the request is logged as an entry of its own.
|
|
505
|
+
if ('uncovered' in used) return useOnce(path, input);
|
|
506
|
+
return 'allowed' in used ? opening(path, used, input.deadline) : used;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** What a use asks the vault, by ids: the request's, or a named call's ids as its isolate's map holds them (none: null). */
|
|
510
|
+
type Asked = Pick<UseInput, 'place' | 'purpose' | 'keepsSensitive' | 'requestId' | 'at' | 'deadline'> & {
|
|
511
|
+
credential: string | null; secretId: string | null; way?: Way; principal?: string | null;
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
/** The request a use decides, as the log's replay reads it back. */
|
|
515
|
+
function requestOf(input: Asked, names: Names | undefined): UseRequest {
|
|
516
|
+
const way = input.way ?? 'credential';
|
|
517
|
+
return {
|
|
518
|
+
credential: input.credential ?? '', ...(way === 'credential' ? {} : { way }), secretId: input.secretId ?? '', place: input.place, purpose: input.purpose,
|
|
519
|
+
keepsSensitive: input.keepsSensitive === true, at: input.at, ...(names === undefined ? {} : { plain: true as const }),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** A use's call made ready, with what deciding on its row takes: its cache's keys, and the seals the call passed. */
|
|
524
|
+
type Ready = { call: UseCall; request: UseRequest; key: string; coverKey: string; known: CachedSeals | undefined; seals?: typeof sealed };
|
|
525
|
+
|
|
526
|
+
/** A use's call, its cover and its cached seals as the isolate holds them (`useOnce`, before the statement). */
|
|
527
|
+
function ready(path: UsePath, input: Asked, request: UseRequest, names: Names | undefined, decided: boolean, seals?: typeof sealed): Ready {
|
|
528
|
+
const { cache } = path;
|
|
529
|
+
const way = input.way ?? 'credential';
|
|
530
|
+
const purpose = purposeCode(request);
|
|
531
|
+
const key = `${input.credential ?? ''}\n${input.secretId ?? ''}\n${input.place ?? ''}`;
|
|
532
|
+
// The seals a probe of these ids read, for the cached form: a use by ids only. A named call always probes, so that
|
|
533
|
+
// its work is the same whatever the key and whatever this isolate kept of earlier uses (D153; review r12).
|
|
534
|
+
const known = input.credential === null || names !== undefined ? undefined : cache.seals.get(key);
|
|
535
|
+
// A cover is of the same request but its id and time: the same credential, key, place and purpose.
|
|
536
|
+
const coverKey = `${key}\n${purpose}`;
|
|
537
|
+
const held = input.secretId === null ? undefined : cache.covers.get(coverKey);
|
|
538
|
+
// A cover a change this isolate knows of came after is no cover; one it cannot judge, the statement judges.
|
|
539
|
+
const cover = held !== undefined && input.at < held.until && input.at >= held.at && (known?.inputs == null || BigInt(held.number) > BigInt(known.inputs)) ? held : undefined;
|
|
540
|
+
const call: UseCall = {
|
|
541
|
+
credential: input.credential, secretId: input.secretId, requestId: input.requestId, at: input.at, purpose, place: input.place,
|
|
542
|
+
way: WAYS.indexOf(way), principal: input.principal ?? null,
|
|
543
|
+
// No ids, no use: a call that only reads by names writes nothing, whatever its MAC (0045, `v_right`). Its key's tag
|
|
544
|
+
// all the same, so that every MAC the statement takes names the key the isolate holds (D162; T168 refuses by it).
|
|
545
|
+
mac: input.credential === null
|
|
546
|
+
? Buffer.concat([path.macKey.tag, Buffer.alloc(MAC_BYTES - TAG_BYTES)])
|
|
547
|
+
: entryMac(path.macKey, { tree: 'uses', row: { at: input.at, requestId: BigInt(input.requestId), credential: input.credential, secret: input.secretId ?? '', purpose, place: input.place } }),
|
|
548
|
+
deadline: input.deadline,
|
|
549
|
+
...(cover === undefined ? {} : { cover }),
|
|
550
|
+
...(known === undefined ? {} : { known }),
|
|
551
|
+
...(names === undefined ? {} : { names }),
|
|
552
|
+
...(decided ? { decided: true as const } : {}),
|
|
553
|
+
};
|
|
554
|
+
return { call, request, key, coverKey, known, ...(seals === undefined ? {} : { seals }) };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A use by ids refused on its cover, nothing written of it: made again as an entry of its own, or answered as it is (a batch's). */
|
|
558
|
+
type Uncovered = { uncovered: true; outcome: Extract<UseOutcome, { code: UseCode }> };
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The decision on a call's row (`useOnce`, after the statement): a use allowed with its key yet to open (`Allowed`), or
|
|
562
|
+
* refused on its cover by ids (`Uncovered`), or any other answer.
|
|
563
|
+
*/
|
|
564
|
+
function settle(path: UsePath, input: Asked, names: Names | undefined, decided: boolean, made: Ready, row: UseRow, forged: boolean): Used | Allowed | Uncovered {
|
|
565
|
+
const { cache } = path;
|
|
566
|
+
const { request, key, coverKey, known } = made;
|
|
567
|
+
const way = input.way ?? 'credential';
|
|
568
|
+
if (row.state === 'late' || row.state === 'ahead' || row.state === 'unconfigured') return { outcome: busy(row.state, row.state === 'unconfigured' ? 5 : 1), row, checked: null, request };
|
|
569
|
+
if (names !== undefined && row.state === 'unknown') return { unknown: true };
|
|
570
|
+
if (names !== undefined && row.state === 'nowhere') return { nowhere: true };
|
|
571
|
+
if (row.state !== null && !((names !== undefined && (row.state === 'moved' || row.state === 'archived')) || (row.state === 'decide' && !decided))) {
|
|
572
|
+
throw new Error(`use_credential answered ${row.state}, which never leaves it`);
|
|
573
|
+
}
|
|
574
|
+
if (row.state !== null) {
|
|
575
|
+
// The rows the call read, by the names' ids or the use's own (D153): the use decided on them, key and all but the
|
|
576
|
+
// cap, which a use the vault allows weighs under its principal's lock. Refused, as for a key never made, whatever
|
|
577
|
+
// the reason, nothing written; a key that reads an archived place (a reference's source's) too, by name (review r4);
|
|
578
|
+
// allowed, made by those ids, in a second call. A moved call read every seal anew (0045), so the map's never judge
|
|
579
|
+
// the names' rows.
|
|
580
|
+
const ids: Ids = names === undefined
|
|
581
|
+
? { credential: input.credential!, secretId: input.secretId, place: input.place, way, principal: input.principal ?? null }
|
|
582
|
+
: { credential: row.n_credential!, secretId: row.n_secret, place: row.n_secret === null ? row.n_place : null, way, principal: null };
|
|
583
|
+
const named: UseRequest = { ...request, credential: ids.credential, secretId: ids.secretId ?? '', place: ids.place };
|
|
584
|
+
const view = viewOf(row, named, row.probed ? undefined : known);
|
|
585
|
+
if (view.changed) cache.seals.delete(key);
|
|
586
|
+
const checked = checkedOf(path.sealKeys, view.view, named, view.accounted || forged, made.seals);
|
|
587
|
+
const code = decidedOn(checked, named, row) ?? (names !== undefined && readsArchived(row) ? 'archived' : null);
|
|
588
|
+
if (code !== null) return { outcome: { ok: false, code, number: null }, row, checked, request: named };
|
|
589
|
+
return { again: ids, row };
|
|
590
|
+
}
|
|
591
|
+
const view = viewOf(row, request, row.probed ? undefined : known);
|
|
592
|
+
// An input whose row names another object than the cache's seal is of: `stale`, and the next use probes.
|
|
593
|
+
if (view.changed) cache.seals.delete(key);
|
|
594
|
+
const checked = checkedOf(path.sealKeys, view.view, request, view.accounted || forged, made.seals);
|
|
595
|
+
const code = decidedOn(checked, request, row);
|
|
596
|
+
// The seals a probe read, kept for the next use's cached form: by ids alone. A named call never runs the cached form
|
|
597
|
+
// (above), which an earlier use's seals would choose for a key held and never for one not (Fable's review, r12).
|
|
598
|
+
if (row.probed && row.newest !== null && !view.changed && names === undefined) {
|
|
599
|
+
cache.keep(cache.seals, key, {
|
|
600
|
+
newest: row.newest, newestMac: row.newest_mac!, inputs: row.inputs, ids: view.ids,
|
|
601
|
+
cap: { count: Number(row.cap_count), windowMs: Number(row.cap_window) }, last: lastsOf(row),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
if (row.covered !== null && code !== null) {
|
|
605
|
+
// Refused on a cover (its credential's end, say): by ids, the request is logged as an entry of its own. By name, it
|
|
606
|
+
// is answered as it is, in its one call, as a key the place does not hold is, and the app's entry records it: a
|
|
607
|
+
// second call would tell a key held from one never made (T158; Probing.tla, CoveredRefusalAnswered).
|
|
608
|
+
cache.covers.delete(coverKey);
|
|
609
|
+
if (names === undefined) return { uncovered: true, outcome: { ok: false, code, number: null } };
|
|
610
|
+
}
|
|
611
|
+
if (code !== null) {
|
|
612
|
+
cache.covers.delete(coverKey);
|
|
613
|
+
return { outcome: { ok: false, code, number: row.number }, row, checked, request };
|
|
614
|
+
}
|
|
615
|
+
const v = view.view;
|
|
616
|
+
const secret = v.secret.row!;
|
|
617
|
+
const value = secret.source === null ? secret : v.source!.row!;
|
|
618
|
+
// A plain value, decided: its text, which its object's digest binds; no entry, no cover (it counts toward nothing).
|
|
619
|
+
// Past its deadline, it never leaves, as a value whose key opened late (`open`).
|
|
620
|
+
if (value.key === null) {
|
|
621
|
+
if (Date.now() >= input.deadline) return { outcome: busy('late'), row, checked, request };
|
|
622
|
+
return { outcome: { ok: true, plain: row.v_plaintext!, versionId: value.version }, row, checked, request };
|
|
623
|
+
}
|
|
624
|
+
if (row.covered === null) {
|
|
625
|
+
const expires = checked.credential.row!.expiresAt ?? Infinity;
|
|
626
|
+
cache.keep(cache.covers, coverKey, { number: row.number!, at: input.at, requestId: input.requestId, until: Math.min(input.at + cache.limits.coverMs, expires) });
|
|
627
|
+
}
|
|
628
|
+
return { allowed: { view: v, number: row.covered ?? row.number!, covered: row.covered !== null }, row, checked, request };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** A use allowed, its key yet to open: what `settle` answers of a sealed value, which its caller opens (`opening`). */
|
|
632
|
+
type Allowed = { allowed: { view: UseView; number: string; covered: boolean }; row: UseRow; checked: CheckedView; request: UseRequest };
|
|
633
|
+
|
|
634
|
+
/** A use's answer once its key is opened: the value, or `late` past its deadline, or `bad_claim` for a key not this value's. */
|
|
635
|
+
async function opening(path: UsePath, { allowed: { view, number, covered }, row, checked, request }: Allowed, deadline: number): Promise<Used> {
|
|
636
|
+
const opened = await open(path, view, row, deadline);
|
|
637
|
+
if (opened === 'late') return { outcome: busy('late'), row, checked, request };
|
|
638
|
+
if (opened === 'bad_claim') return { outcome: { ok: false, code: 'bad_claim', number }, row, checked, request };
|
|
639
|
+
return { outcome: { ok: true, number, covered, value: opened }, row, checked, request };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* `decideChecked` on what a call read. A named call that found its caller standing nowhere read no key (`n_standing`,
|
|
644
|
+
* W268): refused as for a key the place does not hold, which the vault's rule refuses too, on the caller's standing
|
|
645
|
+
* (`standingRefusal`). Where that rule finds them standing, a row changed around the log between the gate's statement
|
|
646
|
+
* and the one that read the rest, each with its own snapshot (the fence holds back changes, not such writes): `stale`,
|
|
647
|
+
* which is what happened, never an answer of its own.
|
|
648
|
+
*/
|
|
649
|
+
function decidedOn(checked: CheckedView, request: UseRequest, row: UseRow): UseCode | null {
|
|
650
|
+
if (request.plain === true && row.n_standing === false && standingRefusal(checked, request) === null) return 'stale';
|
|
651
|
+
return decideChecked(checked, request);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Whether a call read its key through an archived place: the place, the key's own, or a reference's source's (0045). */
|
|
655
|
+
function readsArchived(row: UseRow): boolean {
|
|
656
|
+
return (['e1', 'e2', 'e3'] as const).some((slot) => row[`${slot}_archived`] === true || row[`${slot}_project_archived`] === true);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** The ids and slugs a named call read, which the isolate's map keeps for the request's names. */
|
|
660
|
+
function mapped(row: UseRow): Resolved | null {
|
|
661
|
+
if (row.n_credential === null || row.n_place === null || row.n_project === null || row.n_environment === null) return null;
|
|
662
|
+
return { credential: row.n_credential, environment: row.n_place, slugs: { project: row.n_project, environment: row.n_environment } };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* A use as the request names it (T145, PR B; D153; formal/Probing.tla, Credentials.tla Revoking015Caller): one call,
|
|
667
|
+
* which reads what the request names by its names, whatever this isolate's map holds; the ids the map holds, if any,
|
|
668
|
+
* let it make the use in that call, as by ids. A use the vault refuses, whatever the reason, is refused in that one
|
|
669
|
+
* call, whatever the map held (Probing.tla, ResolveByName, NoIsFixed); one it allows on rows the map had no ids for
|
|
670
|
+
* is made by the ids the call read, in a second, and the map keeps them. One admission, checked before anything is
|
|
671
|
+
* read, so that a busy answer never hangs on whether a key is held. The request's route is the credential's and its
|
|
672
|
+
* account's alone (`routeOf`, Probing.tla, OneRoute): the key and the map decide the use, never how the app answers it.
|
|
673
|
+
*/
|
|
674
|
+
export async function useNamed(path: UsePath, input: NamedUseInput): Promise<NamedUseOutcome> {
|
|
675
|
+
checkInput(input);
|
|
676
|
+
if ((input.key !== null && (typeof input.key !== 'string' || input.key === '')) || typeof input.tokenHash !== 'string') throw new Error('a named use names its token, by hash, and its key, if any');
|
|
677
|
+
const { accepts } = input;
|
|
678
|
+
if (typeof accepts?.signin !== 'boolean' || !Array.isArray(accepts.issuers) || accepts.issuers.some((issuer) => typeof issuer !== 'string')) {
|
|
679
|
+
throw new Error('a named use says what credentials the app accepts: whether it signs in, and its providers\' issuers');
|
|
680
|
+
}
|
|
681
|
+
const { cache } = path;
|
|
682
|
+
const tokenHash = Buffer.from(input.tokenHash, 'base64');
|
|
683
|
+
const busyFor = (outcome: Extract<UseOutcome, { busy: UseBusy }>) => ({ served: true as const, route: 'busy' as const, outcome });
|
|
684
|
+
if (input.deadline > input.at + USE_DEADLINE_MS || Date.now() >= input.deadline) return busyFor(busy('late'));
|
|
685
|
+
if (cache.full()) return busyFor(busy('overloaded'));
|
|
686
|
+
const at = `${input.tokenHash}\n${input.place?.project ?? ''}\n${input.place?.environment ?? ''}`;
|
|
687
|
+
const found = cache.named(at);
|
|
688
|
+
const keyed = found === undefined || input.key === null ? undefined : cache.keyed(found.environment, input.key);
|
|
689
|
+
// The ids the map holds, if it holds them all; else none, and the first call only reads.
|
|
690
|
+
const held: Ids | null = found === undefined || (input.key !== null && keyed === undefined) ? null
|
|
691
|
+
: { credential: found.credential, secretId: keyed ?? null, place: keyed == null ? found.environment : null };
|
|
692
|
+
const names: Names = {
|
|
693
|
+
tokenHash, project: input.place?.project ?? null, environment: input.place?.environment ?? null, key: input.key, handle: input.place === null,
|
|
694
|
+
// The places a stable URL does not mean, judged by the call against the slugs it reads, never the map's.
|
|
695
|
+
unless: input.place !== null ? [] : (input.unless ?? []).map((other) => `${other.project}/${other.environment}`),
|
|
696
|
+
accepts: { signin: accepts.signin, issuers: [...accepts.issuers] },
|
|
697
|
+
};
|
|
698
|
+
const asked = { purpose: input.purpose, keepsSensitive: input.keepsSensitive, requestId: input.requestId, at: input.at, deadline: input.deadline };
|
|
699
|
+
let used = await useOnce(path, { ...asked, ...(held ?? { credential: null, secretId: null, place: null }) }, names);
|
|
700
|
+
if ('again' in used) {
|
|
701
|
+
// Allowed on the rows its names read: the map keeps their ids, and the use is made by them, decided.
|
|
702
|
+
keep(cache, at, input.key, used.row);
|
|
703
|
+
used = await useOnce(path, { ...asked, ...used.again }, names, true);
|
|
704
|
+
// Out of date again, between its two calls: nothing decided, ask again.
|
|
705
|
+
if ('again' in used) return busyFor(busy('late'));
|
|
706
|
+
}
|
|
707
|
+
if ('unknown' in used) {
|
|
708
|
+
cache.names.delete(at);
|
|
709
|
+
// An unknown token: the credential's own check answers it, whatever it names.
|
|
710
|
+
return { served: true, route: 'credential', outcome: { ok: false, code: 'credential', number: null } };
|
|
711
|
+
}
|
|
712
|
+
// A place the vault serves not by name (none, archived, a fork's, a personal config's, one the URL does not mean):
|
|
713
|
+
// decided on the place, never the key.
|
|
714
|
+
if ('nowhere' in used) return { served: false };
|
|
715
|
+
if ('busy' in used.outcome) return busyFor(used.outcome);
|
|
716
|
+
const row = used.row!;
|
|
717
|
+
const checked = used.checked!;
|
|
718
|
+
keep(cache, at, input.key, row);
|
|
719
|
+
if (routeOf(checked, input.at) === 'credential') {
|
|
720
|
+
const outcome = used.outcome.ok ? null : used.outcome;
|
|
721
|
+
// The credential's seals bear out, ended or not, before any use is allowed (`decideChecked`, the credential first).
|
|
722
|
+
if (outcome === null || !('code' in outcome)) throw new Error('a use was allowed on a credential its seals do not bear out');
|
|
723
|
+
return { served: true, route: 'credential', outcome };
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
served: true,
|
|
727
|
+
route: 'name',
|
|
728
|
+
secretId: used.request.secretId === '' ? null : used.request.secretId,
|
|
729
|
+
outcome: used.outcome,
|
|
730
|
+
app: row.app == null ? null : JSON.stringify(row.app),
|
|
731
|
+
standing: standingOf(checked, used.request),
|
|
732
|
+
place: input.place ?? mapped(row)?.slugs ?? null,
|
|
733
|
+
at: placeOf(checked, row),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* The ids and slugs a named call read, kept in the isolate's map for the request's names and its key: never a key it
|
|
739
|
+
* did not read, its caller standing nowhere there (W268), which would map the name to none for whoever asks next.
|
|
740
|
+
*/
|
|
741
|
+
function keep(cache: UseCache, at: string, key: string | null, row: UseRow): void {
|
|
742
|
+
const read = mapped(row);
|
|
743
|
+
if (read === null) return;
|
|
744
|
+
cache.name(at, read);
|
|
745
|
+
if (key !== null && row.n_standing !== false) cache.key(read.environment, key, row.n_secret);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** The place a named call read its names at, by the ids of its row as the call read it: none for a row its seals do not bear out (T159). */
|
|
749
|
+
function placeOf(checked: CheckedView, row: UseRow): { projectId: string; environmentId: string } | null {
|
|
750
|
+
const read = row.n_place === null ? undefined : checked.environments.get(row.n_place);
|
|
751
|
+
const place = read !== undefined && read.valid && read.current ? read.row : null;
|
|
752
|
+
return place === null ? null : { projectId: place.projectId, environmentId: place.id };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** What a named request's route hangs on: its credential and its account, as the use read and checked them. */
|
|
756
|
+
type CallerSeals = Pick<CheckedView, 'credential' | 'principal'>;
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* How the app answers a named request (D153, review r2): `name`, on the vault's decision, every refusal of the key
|
|
760
|
+
* alike; or `credential`, by the credential's own check (401, a token of 0.10's sealed first). On the credential and
|
|
761
|
+
* its account alone, as their seals bear them out, and whether the credential has ended: never the roles, a place, the
|
|
762
|
+
* key's rows or the isolate's map, which it cannot see, so that no key, however it is held, and no state of the map,
|
|
763
|
+
* moves a request from one path to the other (formal/Probing.tla, OneRoute).
|
|
764
|
+
*/
|
|
765
|
+
export function routeOf(caller: CallerSeals, at: number): 'name' | 'credential' {
|
|
766
|
+
const { credential, principal } = caller;
|
|
767
|
+
if (credential.row === null || !credential.valid || !credential.current) return 'credential';
|
|
768
|
+
if (principal === null || !principal.valid || !principal.current) return 'credential';
|
|
769
|
+
return credentialEnded(credential.row, principal.row, at) ? 'credential' : 'name';
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* What the use decided the caller holds, as `access` answers it, from the sealed rows it read (`readerOf`): their
|
|
774
|
+
* standing, role, scope and live grants, and the policy as far as the environments it weighed, which is all the app
|
|
775
|
+
* then checks (the place it reads). Its credential and account bear out, as its route says. Roles, the policy or the
|
|
776
|
+
* place where the key is held or sought not as their seals say (put back, or written around the log): the use was
|
|
777
|
+
* refused on them (`decideChecked`, the seals first), and the caller holds nothing, so that nothing the app checks
|
|
778
|
+
* after reads those rows. Never the key's own rows, which a refusal of it alone says (D153).
|
|
779
|
+
*/
|
|
780
|
+
function standingOf(checked: CheckedView, request: UseRequest): Access {
|
|
781
|
+
const credential = checked.credential.row!;
|
|
782
|
+
const principal = checked.principal!.row;
|
|
783
|
+
// Every place the use weighed (where its key is sought or held, read through, its value's, a personal config's parent):
|
|
784
|
+
// one a writer changed refused the use, and tells the app nothing either (T159, Fable on #431).
|
|
785
|
+
const weighed = useEnvironments(checked, request).flatMap((id) => {
|
|
786
|
+
const read = checked.environments.get(id);
|
|
787
|
+
return read === undefined ? [] : [read];
|
|
788
|
+
});
|
|
789
|
+
const sealed = [checked.roles, checked.policy, ...weighed].every((input) => input.valid && input.current);
|
|
790
|
+
// Only the rows their seals bear out: another a writer changed protects or names nothing the app is told (T159).
|
|
791
|
+
const rows = [...checked.environments.values()].flatMap((read) => (read.row === null || !read.valid || !read.current ? [] : [read.row]));
|
|
792
|
+
const reader = sealed
|
|
793
|
+
? readerOf(principal, credential, checked.roles.row ?? noEdits(), rows, request.at)
|
|
794
|
+
: { status: readerOf(principal, credential, noEdits(), [], request.at).status, live: NOTHING };
|
|
795
|
+
const live = reader.live;
|
|
796
|
+
return {
|
|
797
|
+
principal: credential.principal,
|
|
798
|
+
status: reader.status,
|
|
799
|
+
generation: principal?.generation ?? 0,
|
|
800
|
+
role: live.role,
|
|
801
|
+
scope: live.scope,
|
|
802
|
+
grants: !sealed || reader.status !== 'active' || principal === null
|
|
803
|
+
? []
|
|
804
|
+
: principal.grants.filter((grant) => grant.expiresAt === null || grant.expiresAt > request.at).map(({ projectId, environmentId, role, expiresAt }) => ({ projectId, environmentId, role, expiresAt })),
|
|
805
|
+
since: null,
|
|
806
|
+
by: null,
|
|
807
|
+
...(live.policy === undefined ? {} : { policy: live.policy }),
|
|
808
|
+
} as Access;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* The call, then `then` on its row (or a batch's rows), raced against the
|
|
813
|
+
* deadline (WorkerHonoursDeadline): lost, the use is answered `late`, and
|
|
814
|
+
* the call's row, if it ever comes, is dropped. Both hold one place in
|
|
815
|
+
* admission, as they hold the call's connection: what the vault reads
|
|
816
|
+
* after the statement (`oldWrite`) is within the same place and deadline.
|
|
817
|
+
* Past the deadline, or the race lost, the call starts nothing more, neither the statement
|
|
818
|
+
* after `ready` (nor a batch's next, `sends`) nor, the race lost, the reads after it: its
|
|
819
|
+
* answer has gone. One it had started may still reach Postgres (on Node, its bytes leave on
|
|
820
|
+
* the next turn of the loop), which refuses it past the deadline, and its row is dropped.
|
|
821
|
+
* A round of a batch is one call, its statements made in turn within it.
|
|
822
|
+
*/
|
|
823
|
+
async function raced<R, T>(path: UsePath, deadline: number, call: (sends: () => void) => Promise<R>, then: (row: R) => Promise<T>): Promise<T | Extract<UseOutcome, { busy: UseBusy }>> {
|
|
824
|
+
const release = path.cache.admit(deadline);
|
|
825
|
+
// Whether the race is lost: once it is, `then` starts nothing. The timer alone says so, never the clock, so that a
|
|
826
|
+
// statement that answered first is decided on, its entry written (Fable's review of #438).
|
|
827
|
+
let lost = false;
|
|
828
|
+
// Before each statement: none past the deadline, nor once the race is lost. Both, as they disagree: a timer can fire
|
|
829
|
+
// a ms or two before `Date.now()` reaches the deadline (Node measures it from the loop's cached time), and a call
|
|
830
|
+
// answered `late` would still send its statement and write its use.
|
|
831
|
+
const sends = () => {
|
|
832
|
+
if (lost || Date.now() >= deadline) throw Object.assign(new Error('a use past its deadline sends nothing more'), { code: '57014' });
|
|
833
|
+
};
|
|
834
|
+
const running = (async () => {
|
|
835
|
+
await path.ready?.();
|
|
836
|
+
sends();
|
|
837
|
+
const row = await call(sends);
|
|
838
|
+
if (lost) throw Object.assign(new Error('a use that lost its race starts nothing more'), { code: '57014' });
|
|
839
|
+
return then(row);
|
|
840
|
+
})().finally(release);
|
|
841
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
842
|
+
const late = new Promise<'late'>((resolve) => {
|
|
843
|
+
timer = setTimeout(() => {
|
|
844
|
+
lost = true;
|
|
845
|
+
resolve('late');
|
|
846
|
+
}, Math.max(0, deadline - Date.now()));
|
|
847
|
+
});
|
|
848
|
+
try {
|
|
849
|
+
const first = await Promise.race([running, late]);
|
|
850
|
+
if (first === 'late') {
|
|
851
|
+
running.catch(() => {});
|
|
852
|
+
return busy('late');
|
|
853
|
+
}
|
|
854
|
+
return first;
|
|
855
|
+
} catch (error) {
|
|
856
|
+
// node-postgres's code, on the error drizzle wraps it in.
|
|
857
|
+
const code = (error as { code?: string }).code ?? (error as { cause?: { code?: string } }).cause?.code;
|
|
858
|
+
// A replay of a call that wrote: its entry stands, and its value never left. Not decided again.
|
|
859
|
+
if (code === '23505') return busy('replayed');
|
|
860
|
+
// Past its deadline by Postgres's clock, or a lock not had within the hot login's lock_timeout: nothing written. A
|
|
861
|
+
// batch that waited on another for the principal's lock, which Postgres ended (40P01), the same: it never does when
|
|
862
|
+
// each takes the lock exclusive first (`use_batch`, StrongestFirst), unless rows written around the log name two
|
|
863
|
+
// principals for one credential.
|
|
864
|
+
if (code === '57014' || code === '55P03' || code === '40P01') return busy('late');
|
|
865
|
+
throw error;
|
|
866
|
+
} finally {
|
|
867
|
+
clearTimeout(timer);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** The engine's `use_credential`. */
|
|
872
|
+
function callOf(db: Queryable): (db: Queryable, call: UseCall) => Promise<UseRow> {
|
|
873
|
+
return engineOf(db) === 'postgres' ? callPostgres : callSqlite;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Whether the access entry the statement's probe found past what the
|
|
878
|
+
* principal's newest change accounts for (`viewOf`'s `accounted` false) is
|
|
879
|
+
* one forged in the vault's name, as is every one past it up to the probe's:
|
|
880
|
+
* then it is passed over, as Migration015.tla's UsesSeeOldWrites reads only
|
|
881
|
+
* authentic writes, and the use is decided as its replay decides it.
|
|
882
|
+
*/
|
|
883
|
+
async function forgedPast(path: UsePath, row: UseRow, known?: CachedSeals): Promise<boolean> {
|
|
884
|
+
// A row that answers a state (`moved`, `unknown`, `unconfigured`) may name none of these: nothing to check.
|
|
885
|
+
if (path.oldWrite === undefined || row.m_access == null || row.c_principal == null) return false;
|
|
886
|
+
const old = (known?.last ?? lastsOf(row)).mOld ?? null;
|
|
887
|
+
if (old !== null && BigInt(row.m_access) <= BigInt(old)) return false;
|
|
888
|
+
return !(await path.oldWrite(row.c_principal, old === null ? null : BigInt(old), BigInt(row.m_access)));
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function lastsOf(row: UseRow): Lasts {
|
|
892
|
+
return {
|
|
893
|
+
c: row.c_last, m: row.m_last, mOld: row.m_old, r: row.r_last, p: row.p_last, s: row.s_last, x: row.x_last,
|
|
894
|
+
e1: row.e1_last, e2: row.e2_last, e3: row.e3_last, e4: row.e4_last,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* What the statement read, as PR 3's `UseView` (decide.ts): each input's
|
|
900
|
+
* object, made from its row's columns (objects.ts), its seal, and its
|
|
901
|
+
* newest change's, probed or the cache's. An input whose row names another
|
|
902
|
+
* object than the cache's seal is of (a reference's source repointed, a
|
|
903
|
+
* credential's principal, written around the log) has no newest seal the
|
|
904
|
+
* vault knows: it is not current, and decides `stale` (review r2 of D153).
|
|
905
|
+
* Each input alone: the credential's and its account's judgement never
|
|
906
|
+
* hangs on the key's rows, which a request's route needs (`routeOf`).
|
|
907
|
+
* `changed`: some input's did, and the next use probes. `accounted`: the
|
|
908
|
+
* principal's newest 0.14 access entry is one the new log accounts for
|
|
909
|
+
* (UsesSeeOldWrites).
|
|
910
|
+
*/
|
|
911
|
+
export function viewOf(row: UseRow, request: UseRequest, known?: CachedSeals): { view: UseView; ids: Identities; changed: boolean; accounted: boolean } {
|
|
912
|
+
const ids: Identities = {
|
|
913
|
+
c: request.credential, m: row.c_principal, s: request.secretId, x: row.s_source,
|
|
914
|
+
e1: row.e1_id, e2: row.e2_id, e3: row.e3_id, e4: row.e4_id,
|
|
915
|
+
};
|
|
916
|
+
const moved = (Object.keys(ids) as (keyof Identities)[]).filter((input) => known !== undefined && known.ids[input] !== ids[input]);
|
|
917
|
+
const cached = known?.last ?? lastsOf(row);
|
|
918
|
+
// No seal is equal to none: an input moved is current to nothing.
|
|
919
|
+
const last = (input: keyof Identities): Buffer | null => (moved.includes(input) ? UNKNOWN : cached[input]);
|
|
920
|
+
const read = <T extends Input>(object: T | null, seal: Buffer | null, newest: Buffer | null): Read<T> => ({ row: object, seal, newest });
|
|
921
|
+
const credential: Read<WayInput> = read(wayObject(row, request), row.c_seal, last('c'));
|
|
922
|
+
const principal = row.c_principal === null ? null : read(row.m_status === null ? null : principalObject({
|
|
923
|
+
principal: row.c_principal, status: row.m_status, generation: row.m_generation!, ...memberStanding({ owner: row.m_owner === true, role: row.m_role, scope: row.m_scope }),
|
|
924
|
+
}, grantsOf(row)), row.m_seal, last('m'));
|
|
925
|
+
// A plain value's text, which its object's digest binds.
|
|
926
|
+
const version = row.v_wrapped_dek === null ? null : {
|
|
927
|
+
sensitive: row.v_sensitive === true, plaintext: row.v_plaintext,
|
|
928
|
+
kekProvider: row.v_kek_provider!, kekId: row.v_kek_id!, kekVersion: row.v_kek_version!, wrappedDek: row.v_wrapped_dek.toString('base64'),
|
|
929
|
+
};
|
|
930
|
+
const secret = read(row.s_environment === null ? null : secretObject({
|
|
931
|
+
id: request.secretId, projectId: row.s_project!, environmentId: row.s_environment, source: row.s_source, reference: row.s_reference,
|
|
932
|
+
version: row.s_source === null && version !== null ? { id: row.s_version!, ...version } : null,
|
|
933
|
+
}), row.s_seal, last('s'));
|
|
934
|
+
const source = row.s_source === null ? null : read(row.x_environment === null ? null : secretObject({
|
|
935
|
+
id: row.s_source, projectId: row.x_project!, environmentId: row.x_environment, source: row.x_source, reference: row.x_reference,
|
|
936
|
+
version: row.x_source === null && version !== null ? { id: row.x_version!, ...version } : null,
|
|
937
|
+
}), row.x_seal, last('x'));
|
|
938
|
+
const environments = new Map<string, Read<EnvironmentInput>>();
|
|
939
|
+
for (const slot of SLOTS) {
|
|
940
|
+
const id = row[`${slot}_id`];
|
|
941
|
+
if (id === null || environments.has(id)) continue;
|
|
942
|
+
const project = row[`${slot}_project`];
|
|
943
|
+
environments.set(id, read(project === null ? null : environmentObject({
|
|
944
|
+
id, projectId: project, slug: row[`${slot}_slug`]!, owner: row[`${slot}_owner`], parentId: row[`${slot}_parent`],
|
|
945
|
+
protected: row[`${slot}_protected`], archived: row[`${slot}_archived`] === true, projectArchived: row[`${slot}_project_archived`] === true,
|
|
946
|
+
}), row[`${slot}_seal`], last(slot)));
|
|
947
|
+
}
|
|
948
|
+
const view: UseView = {
|
|
949
|
+
credential,
|
|
950
|
+
principal,
|
|
951
|
+
roles: read(row.r_value === null ? null : configObject(row.r_value) as RolesInput, row.r_seal, cached.r),
|
|
952
|
+
policy: read(row.p_value === null ? null : configObject(row.p_value) as PolicyInput, row.p_seal, cached.p),
|
|
953
|
+
secret,
|
|
954
|
+
source,
|
|
955
|
+
environments,
|
|
956
|
+
// Behind Access, the account is the way in: its cap is read with it.
|
|
957
|
+
bulk: (request.way === 'access' ? principal?.row ?? null : credential.row) === null ? null : { inSet: row.in_set === true, others: Number(row.others ?? 0) },
|
|
958
|
+
};
|
|
959
|
+
// UsesSeeOldWrites: 0.14's newest access entry about the principal, at or below the one its newest change accounts for.
|
|
960
|
+
const old = cached.mOld;
|
|
961
|
+
const accounted = row.m_access === null || (old !== null && BigInt(row.m_access) <= BigInt(old));
|
|
962
|
+
return { view, ids, changed: moved.length > 0, accounted };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const UNKNOWN = Buffer.alloc(0);
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* The way in's object, from its row's columns as the call read them (T147): a credential's or a connection's, its
|
|
969
|
+
* columns; a binding's, its whole row (`w_row`); behind Access none, the account's row being all of it.
|
|
970
|
+
*/
|
|
971
|
+
function wayObject(row: UseRow, request: UseRequest): WayInput | null {
|
|
972
|
+
if (row.c_principal === null) return null;
|
|
973
|
+
const expiresAt = row.c_expires === null ? null : Number(row.c_expires);
|
|
974
|
+
switch (request.way ?? 'credential') {
|
|
975
|
+
case 'credential':
|
|
976
|
+
return credentialObject({ id: request.credential, principal: row.c_principal, kind: row.c_kind!, scope: row.c_scope, revoked: row.c_revoked === true, expiresAt, generation: row.c_generation! });
|
|
977
|
+
case 'connection':
|
|
978
|
+
return connectionObject({ id: request.credential, principal: row.c_principal, generation: row.c_generation!, expiresAt: expiresAt!, revoked: row.c_revoked === true });
|
|
979
|
+
case 'binding': {
|
|
980
|
+
const b = row.w_row;
|
|
981
|
+
if (b === null) return null;
|
|
982
|
+
const text = (value: unknown) => (value == null ? null : String(value));
|
|
983
|
+
return bindingObject({
|
|
984
|
+
id: request.credential, principal: String(b.principal), generation: Number(b.generation), profile: String(b.profile), issuer: String(b.issuer),
|
|
985
|
+
// A sealed binding names the instance it was made on (0040): one with none bears out no seal.
|
|
986
|
+
origin: text(b.origin) ?? '', jwksUri: String(b.jwks_uri), claims: String(b.claims), scope: text(b.scope), policy: text(b.policy), revoked: b.revoked_at != null,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
case 'access':
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function grantsOf(row: UseRow): InputGrant[] {
|
|
995
|
+
return (row.g_role ?? []).map((role, i) => ({
|
|
996
|
+
projectId: row.g_project![i] ?? null,
|
|
997
|
+
environmentId: row.g_environment![i] ?? null,
|
|
998
|
+
environmentSlug: row.g_slug![i] ?? null,
|
|
999
|
+
role,
|
|
1000
|
+
expiresAt: row.g_expires![i] == null ? null : Number(row.g_expires![i]),
|
|
1001
|
+
}));
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* The view checked as PR 3's decision takes it, with the principal `stale`
|
|
1006
|
+
* too while 0.14 holds an access entry about it that the new log does not
|
|
1007
|
+
* account for (Migration015.tla, UsesSeeOldWrites): a seal the principal's
|
|
1008
|
+
* row passes could still be one a late 0.14 revoke moved past. Checked with
|
|
1009
|
+
* the seals, before any row (StaleFirst).
|
|
1010
|
+
*/
|
|
1011
|
+
function checkedOf(keys: SealKeys, view: UseView, request: UseRequest, accounted: boolean, seals?: typeof sealed): CheckedView {
|
|
1012
|
+
const checked = checkSeals(keys, view, request, seals);
|
|
1013
|
+
const principal = checked.principal === null || accounted ? checked.principal : { ...checked.principal, current: false };
|
|
1014
|
+
return { ...checked, principal };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The value's data key, opened as its version's context binds it: its ids,
|
|
1019
|
+
* a personal config's owner, whether it is sensitive. Within the use's
|
|
1020
|
+
* deadline, which the key operation is bound by too: past it, nothing
|
|
1021
|
+
* leaves (`late`). A key that does not open as this value's is a claim the
|
|
1022
|
+
* rows do not bear out (`bad_claim`).
|
|
1023
|
+
*/
|
|
1024
|
+
async function open(path: UsePath, view: UseView, row: UseRow, deadline: number): Promise<UsedValue | 'late' | 'bad_claim'> {
|
|
1025
|
+
const secret = view.secret.row!;
|
|
1026
|
+
const value = secret.source === null ? secret : view.source!.row!;
|
|
1027
|
+
const owner = view.environments.get(value.environmentId)?.row?.owner ?? null;
|
|
1028
|
+
const context: SecretContext = {
|
|
1029
|
+
projectId: value.projectId, environmentId: value.environmentId, secretId: value.id,
|
|
1030
|
+
...(owner === null ? {} : { owner: ownerKey(owner) }),
|
|
1031
|
+
...(value.nature === 'sensitive' ? { sensitive: true as const } : {}),
|
|
1032
|
+
};
|
|
1033
|
+
const left = deadline - Date.now();
|
|
1034
|
+
if (left <= 0) return 'late';
|
|
1035
|
+
const controller = new AbortController();
|
|
1036
|
+
const timer = setTimeout(() => controller.abort(), left);
|
|
1037
|
+
let dek: Buffer;
|
|
1038
|
+
try {
|
|
1039
|
+
const wrapped = value.key!;
|
|
1040
|
+
dek = await path.keks.unwrap({ ...wrapped, bytes: Buffer.from(wrapped.bytes, 'base64') }, context, { deadline, signal: controller.signal });
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
if (error instanceof KekBadClaimError) return 'bad_claim';
|
|
1043
|
+
if (error instanceof KekUnavailableError && controller.signal.aborted) return 'late';
|
|
1044
|
+
throw error;
|
|
1045
|
+
} finally {
|
|
1046
|
+
clearTimeout(timer);
|
|
1047
|
+
}
|
|
1048
|
+
try {
|
|
1049
|
+
// Opened after its deadline: it never leaves.
|
|
1050
|
+
if (Date.now() >= deadline) return 'late';
|
|
1051
|
+
return {
|
|
1052
|
+
key: dek.toString('base64'),
|
|
1053
|
+
versionId: value.version,
|
|
1054
|
+
context,
|
|
1055
|
+
envelope: { version: row.v_envelope!, ciphertext: row.v_ciphertext!.toString('base64'), iv: row.v_iv!.toString('base64'), authTag: row.v_auth_tag!.toString('base64') },
|
|
1056
|
+
};
|
|
1057
|
+
} finally {
|
|
1058
|
+
dek.fill(0);
|
|
1059
|
+
}
|
|
1060
|
+
}
|