@gnolith/taproot 0.2.0 → 0.3.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/CHANGELOG.md +53 -0
- package/COMPATIBILITY.md +5 -3
- package/README.md +106 -33
- package/SECURITY.md +6 -3
- package/dist/authorization-maintenance.d.ts +55 -0
- package/dist/authorization-maintenance.d.ts.map +1 -0
- package/dist/authorization-maintenance.js +686 -0
- package/dist/authorization-maintenance.js.map +1 -0
- package/dist/authorization.d.ts +98 -0
- package/dist/authorization.d.ts.map +1 -0
- package/dist/authorization.js +1137 -0
- package/dist/authorization.js.map +1 -0
- package/dist/canonical.d.ts +2 -1
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +9 -1
- package/dist/canonical.js.map +1 -1
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +125 -49
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +351 -44
- package/dist/index.js.map +1 -1
- package/dist/migrations.d.ts +9 -3
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +77 -5
- package/dist/migrations.js.map +1 -1
- package/dist/repository.d.ts +20 -9
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +593 -48
- package/dist/repository.js.map +1 -1
- package/dist/schema.d.ts +14 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +461 -18
- package/dist/schema.js.map +1 -1
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/api.md +56 -18
- package/docs/architecture.md +19 -7
- package/docs/authorization.md +82 -0
- package/docs/completion-audit.md +15 -15
- package/docs/operations.md +28 -5
- package/docs/product-scope.md +5 -3
- package/docs/testing.md +18 -1
- package/docs/threat-model.md +30 -3
- package/examples/d1-diamond-interop/README.md +2 -1
- package/examples/d1-diamond-interop/demo.ts +150 -21
- package/migrations/0003_canonical_statement_text.sql +31 -0
- package/migrations/0004_canonical_authorization_policy.sql +213 -0
- package/package.json +4 -1
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
import { AuthorizationDeniedError, InvalidAuthorizationError, InvalidCursorError, } from './errors.js';
|
|
2
|
+
import { exportEntityJson } from './canonical.js';
|
|
3
|
+
import { TaprootRepository } from './repository.js';
|
|
4
|
+
export const SEARCH_ADMIN_CAPABILITY = 'search:admin';
|
|
5
|
+
export const KNOWLEDGE_WRITE_CAPABILITY = 'knowledge:write';
|
|
6
|
+
export const KNOWLEDGE_POLICY_CAPABILITY = 'knowledge:policy';
|
|
7
|
+
const cursorCodecs = new WeakMap();
|
|
8
|
+
export function createAuthorizationCursorCodec(key) {
|
|
9
|
+
if (key.type !== 'secret' ||
|
|
10
|
+
key.extractable ||
|
|
11
|
+
key.algorithm.name !== 'AES-GCM' ||
|
|
12
|
+
!key.usages.includes('encrypt') ||
|
|
13
|
+
!key.usages.includes('decrypt'))
|
|
14
|
+
invalid('cursor key must be a non-extractable AES-GCM key with encrypt/decrypt usages');
|
|
15
|
+
const codec = Object.freeze({ kind: 'taproot-aes-gcm-v1' });
|
|
16
|
+
cursorCodecs.set(codec, key);
|
|
17
|
+
return codec;
|
|
18
|
+
}
|
|
19
|
+
const MAX_IDENTIFIER_LENGTH = 256;
|
|
20
|
+
const MAX_CLAUSES = 64;
|
|
21
|
+
const MAX_ATOMS_PER_CLAUSE = 64;
|
|
22
|
+
const CURSOR_AAD = new TextEncoder().encode('taproot-authorized-cursor-v1').buffer;
|
|
23
|
+
const CURSOR_PLAINTEXT_BYTES = 1024;
|
|
24
|
+
export function normalizeAuthorizationContext(value) {
|
|
25
|
+
if (!isRecord(value))
|
|
26
|
+
invalid('authorization context must be an object');
|
|
27
|
+
exactKeys(value, [
|
|
28
|
+
'installationId',
|
|
29
|
+
'principalId',
|
|
30
|
+
'activeWorkspaceId',
|
|
31
|
+
'workspaceIds',
|
|
32
|
+
'capabilities',
|
|
33
|
+
'authorizationRevision',
|
|
34
|
+
]);
|
|
35
|
+
const installationId = identifier(value.installationId, 'installationId');
|
|
36
|
+
const principalId = identifier(value.principalId, 'principalId');
|
|
37
|
+
const activeWorkspaceId = value.activeWorkspaceId === null
|
|
38
|
+
? null
|
|
39
|
+
: identifier(value.activeWorkspaceId, 'activeWorkspaceId');
|
|
40
|
+
const workspaceIds = stringSet(value.workspaceIds, 'workspaceIds');
|
|
41
|
+
const capabilities = stringSet(value.capabilities, 'capabilities');
|
|
42
|
+
const authorizationRevision = revision(value.authorizationRevision, 'authorizationRevision');
|
|
43
|
+
if (activeWorkspaceId !== null && !workspaceIds.includes(activeWorkspaceId))
|
|
44
|
+
invalid('activeWorkspaceId must be present in workspaceIds');
|
|
45
|
+
return {
|
|
46
|
+
installationId,
|
|
47
|
+
principalId,
|
|
48
|
+
activeWorkspaceId,
|
|
49
|
+
workspaceIds,
|
|
50
|
+
capabilities,
|
|
51
|
+
authorizationRevision,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function normalizeVisibilityScope(value) {
|
|
55
|
+
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.clauses))
|
|
56
|
+
invalid('visibility scope must be a version 1 CNF object');
|
|
57
|
+
exactKeys(value, ['version', 'clauses']);
|
|
58
|
+
if (value.clauses.length > MAX_CLAUSES)
|
|
59
|
+
invalid(`visibility scope exceeds ${MAX_CLAUSES} clauses`);
|
|
60
|
+
const clauses = new Map();
|
|
61
|
+
for (const rawClause of value.clauses) {
|
|
62
|
+
if (!Array.isArray(rawClause) || rawClause.length === 0)
|
|
63
|
+
invalid('visibility clauses must contain at least one atom');
|
|
64
|
+
if (rawClause.length > MAX_ATOMS_PER_CLAUSE)
|
|
65
|
+
invalid(`visibility clause exceeds ${MAX_ATOMS_PER_CLAUSE} atoms`);
|
|
66
|
+
const atoms = new Map();
|
|
67
|
+
let publicClause = false;
|
|
68
|
+
for (const rawAtom of rawClause) {
|
|
69
|
+
const atom = normalizeAtom(rawAtom);
|
|
70
|
+
if (atom.kind === 'public') {
|
|
71
|
+
publicClause = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
atoms.set(serializeAtom(atom), atom);
|
|
75
|
+
}
|
|
76
|
+
// A public OR-clause is true and therefore has no effect in an AND scope.
|
|
77
|
+
if (publicClause)
|
|
78
|
+
continue;
|
|
79
|
+
const sorted = [...atoms.entries()]
|
|
80
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
81
|
+
.map(([, atom]) => atom);
|
|
82
|
+
if (sorted.length === 0)
|
|
83
|
+
invalid('visibility clause is empty after normalization');
|
|
84
|
+
clauses.set(JSON.stringify(sorted), sorted);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
version: 1,
|
|
88
|
+
clauses: [...clauses.entries()]
|
|
89
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
90
|
+
.map(([, clause]) => clause),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function serializeVisibilityScope(scope) {
|
|
94
|
+
return JSON.stringify(normalizeVisibilityScope(scope));
|
|
95
|
+
}
|
|
96
|
+
export async function visibilityScopeFingerprint(scope) {
|
|
97
|
+
const bytes = new TextEncoder().encode(serializeVisibilityScope(scope));
|
|
98
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
99
|
+
return [...new Uint8Array(digest)]
|
|
100
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
101
|
+
.join('');
|
|
102
|
+
}
|
|
103
|
+
/** CNF intersection is lossless clause concatenation followed by normalization. */
|
|
104
|
+
export function intersectVisibilityScopes(...scopes) {
|
|
105
|
+
return normalizeVisibilityScope({
|
|
106
|
+
version: 1,
|
|
107
|
+
clauses: scopes.flatMap((scope) => normalizeVisibilityScope(scope).clauses),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export function normalizeCanonicalAuthorizationPolicy(value) {
|
|
111
|
+
if (!isRecord(value))
|
|
112
|
+
invalid('authorization policy must be an object');
|
|
113
|
+
exactKeys(value, [
|
|
114
|
+
'installationId',
|
|
115
|
+
'workspaceId',
|
|
116
|
+
'ownerPrincipalId',
|
|
117
|
+
'visibility',
|
|
118
|
+
'statementRestrictions',
|
|
119
|
+
'expectedAuthorizationRevision',
|
|
120
|
+
]);
|
|
121
|
+
if (!isRecord(value.statementRestrictions))
|
|
122
|
+
invalid('statementRestrictions must be an object');
|
|
123
|
+
const statementRestrictions = {};
|
|
124
|
+
for (const [statementId, scopes] of Object.entries(value.statementRestrictions).sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
125
|
+
identifier(statementId, 'statementId');
|
|
126
|
+
if (!Array.isArray(scopes))
|
|
127
|
+
invalid(`statementRestrictions.${statementId} must be an array`);
|
|
128
|
+
const normalizedScopes = [];
|
|
129
|
+
for (let index = 0; index < scopes.length; index += 1) {
|
|
130
|
+
if (!Object.hasOwn(scopes, index))
|
|
131
|
+
invalid(`statementRestrictions.${statementId} must be dense`);
|
|
132
|
+
normalizedScopes.push(normalizeVisibilityScope(scopes[index]));
|
|
133
|
+
}
|
|
134
|
+
statementRestrictions[statementId] = normalizedScopes;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
installationId: identifier(value.installationId, 'installationId'),
|
|
138
|
+
workspaceId: value.workspaceId === null
|
|
139
|
+
? null
|
|
140
|
+
: identifier(value.workspaceId, 'workspaceId'),
|
|
141
|
+
ownerPrincipalId: identifier(value.ownerPrincipalId, 'ownerPrincipalId'),
|
|
142
|
+
visibility: normalizeVisibilityScope(value.visibility),
|
|
143
|
+
statementRestrictions,
|
|
144
|
+
expectedAuthorizationRevision: revision(value.expectedAuthorizationRevision, 'expectedAuthorizationRevision'),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export function isVisibleTo(rawScope, rawContext) {
|
|
148
|
+
const scope = normalizeVisibilityScope(rawScope);
|
|
149
|
+
const context = normalizeAuthorizationContext(rawContext);
|
|
150
|
+
const workspaces = new Set(context.workspaceIds);
|
|
151
|
+
const capabilities = new Set(context.capabilities);
|
|
152
|
+
return scope.clauses.every((clause) => clause.some((atom) => {
|
|
153
|
+
switch (atom.kind) {
|
|
154
|
+
case 'public':
|
|
155
|
+
return true;
|
|
156
|
+
case 'principal':
|
|
157
|
+
return atom.principalId === context.principalId;
|
|
158
|
+
case 'workspace':
|
|
159
|
+
return workspaces.has(atom.workspaceId);
|
|
160
|
+
case 'capability':
|
|
161
|
+
return capabilities.has(atom.capability);
|
|
162
|
+
}
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
export function hasSearchAdministration(context) {
|
|
166
|
+
return normalizeAuthorizationContext(context).capabilities.includes(SEARCH_ADMIN_CAPABILITY);
|
|
167
|
+
}
|
|
168
|
+
export function requireSearchAdministration(context) {
|
|
169
|
+
if (!hasSearchAdministration(context))
|
|
170
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Authorization-enforcing canonical read boundary. The context and policy
|
|
174
|
+
* source are mandatory, and policy is checked both before and after hydration.
|
|
175
|
+
*/
|
|
176
|
+
export class AuthorizedTaprootReader {
|
|
177
|
+
#db;
|
|
178
|
+
#repository;
|
|
179
|
+
#context;
|
|
180
|
+
#authorization;
|
|
181
|
+
#cursorCodec;
|
|
182
|
+
constructor(db, options, context, authorizedOptions) {
|
|
183
|
+
this.#db = db;
|
|
184
|
+
this.#repository = new TaprootRepository(db, options);
|
|
185
|
+
this.#context = normalizeAuthorizationContext(context);
|
|
186
|
+
this.#authorization = new PersistedEntityAuthorizationSource(db);
|
|
187
|
+
if (!isRecord(authorizedOptions))
|
|
188
|
+
invalid('authorized options are required');
|
|
189
|
+
exactKeys(authorizedOptions, ['cursorCodec']);
|
|
190
|
+
if (!cursorCodecs.has(authorizedOptions.cursorCodec))
|
|
191
|
+
invalid('host-issued cursor codec is required');
|
|
192
|
+
this.#cursorCodec = authorizedOptions.cursorCodec;
|
|
193
|
+
}
|
|
194
|
+
async getEntity(id) {
|
|
195
|
+
return this.#authorizedHydration(id, undefined, () => this.#repository.getEntity(id));
|
|
196
|
+
}
|
|
197
|
+
async getEntityRevision(id, revisionNumber) {
|
|
198
|
+
return this.#authorizedHydration(id, revisionNumber, () => this.#repository.getEntityRevision(id, revisionNumber));
|
|
199
|
+
}
|
|
200
|
+
async resolveEntity(id, maxDepth = 100) {
|
|
201
|
+
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > 1000)
|
|
202
|
+
throw new RangeError('maxDepth must be an integer from 0 through 1000');
|
|
203
|
+
const redirects = [];
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
let current = id;
|
|
206
|
+
for (;;) {
|
|
207
|
+
if (seen.has(current))
|
|
208
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
209
|
+
seen.add(current);
|
|
210
|
+
const stored = await this.getEntity(current);
|
|
211
|
+
if (!stored.redirectTo)
|
|
212
|
+
return { ...stored, requestedId: id, resolvedId: current, redirects };
|
|
213
|
+
if (redirects.length >= maxDepth)
|
|
214
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
215
|
+
redirects.push(stored.redirectTo);
|
|
216
|
+
current = stored.redirectTo;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async listEntityRevisions(id, options = {}) {
|
|
220
|
+
const limit = authorizedLimit(options.limit, 50);
|
|
221
|
+
const binding = await this.#binding('entity-revisions', { id });
|
|
222
|
+
const generation = await this.#generation();
|
|
223
|
+
let before = Number.MAX_SAFE_INTEGER;
|
|
224
|
+
if (options.cursor) {
|
|
225
|
+
const cursor = await this.#decodeCursor(options.cursor, binding, generation);
|
|
226
|
+
before = cursorNumber(cursor.position, 'revision');
|
|
227
|
+
}
|
|
228
|
+
await this.#assertContextCurrent();
|
|
229
|
+
const items = [];
|
|
230
|
+
let exhausted = false;
|
|
231
|
+
while (items.length < limit && !exhausted) {
|
|
232
|
+
const batch = await this.#db
|
|
233
|
+
.prepare(`SELECT revision FROM taproot_entity_revisions
|
|
234
|
+
WHERE entity_id = ? AND revision < ?
|
|
235
|
+
ORDER BY revision DESC LIMIT ?`)
|
|
236
|
+
.bind(id, before, scanLimit(limit - items.length))
|
|
237
|
+
.all();
|
|
238
|
+
exhausted = batch.results.length < scanLimit(limit - items.length);
|
|
239
|
+
for (let index = 0; index < batch.results.length; index += 1) {
|
|
240
|
+
const candidate = batch.results[index];
|
|
241
|
+
before = candidate.revision;
|
|
242
|
+
try {
|
|
243
|
+
items.push(await this.#authorizedHydration(id, candidate.revision, () => this.#repository.getEntityRevision(id, candidate.revision)));
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (!(error instanceof AuthorizationDeniedError))
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
if (items.length === limit) {
|
|
250
|
+
if (index < batch.results.length - 1)
|
|
251
|
+
exhausted = false;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!batch.results.length)
|
|
256
|
+
exhausted = true;
|
|
257
|
+
}
|
|
258
|
+
await this.#reauthorize(items.map(() => id));
|
|
259
|
+
await this.#assertGeneration(generation);
|
|
260
|
+
return {
|
|
261
|
+
items,
|
|
262
|
+
cursor: !exhausted && items.length
|
|
263
|
+
? await this.#encodeCursor(binding, generation, { revision: before })
|
|
264
|
+
: null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async listEntities(options = {}) {
|
|
268
|
+
const limit = authorizedLimit(options.limit, 50);
|
|
269
|
+
const filter = {
|
|
270
|
+
type: options.type ?? null,
|
|
271
|
+
includeDeleted: options.includeDeleted ?? false,
|
|
272
|
+
};
|
|
273
|
+
const binding = await this.#binding('entities', filter);
|
|
274
|
+
const generation = await this.#generation();
|
|
275
|
+
let position = null;
|
|
276
|
+
if (options.cursor) {
|
|
277
|
+
const cursor = await this.#decodeCursor(options.cursor, binding, generation);
|
|
278
|
+
position = entityPosition(cursor.position);
|
|
279
|
+
}
|
|
280
|
+
await this.#assertContextCurrent();
|
|
281
|
+
const items = [];
|
|
282
|
+
let exhausted = false;
|
|
283
|
+
while (items.length < limit && !exhausted) {
|
|
284
|
+
const amount = scanLimit(limit - items.length);
|
|
285
|
+
const result = await this.#db
|
|
286
|
+
.prepare(`SELECT entity_id, entity_type FROM taproot_entities
|
|
287
|
+
WHERE (? IS NULL OR entity_type > ? OR
|
|
288
|
+
(entity_type = ? AND CAST(substr(entity_id, 2) AS INTEGER) > ?))
|
|
289
|
+
AND (? IS NULL OR entity_type = ?)
|
|
290
|
+
AND (? = 1 OR deleted_at IS NULL)
|
|
291
|
+
ORDER BY entity_type, CAST(substr(entity_id, 2) AS INTEGER) LIMIT ?`)
|
|
292
|
+
.bind(position?.entityType ?? null, position?.entityType ?? null, position?.entityType ?? null, position?.numericId ?? 0, filter.type, filter.type, filter.includeDeleted ? 1 : 0, amount)
|
|
293
|
+
.all();
|
|
294
|
+
exhausted = result.results.length < amount;
|
|
295
|
+
for (let index = 0; index < result.results.length; index += 1) {
|
|
296
|
+
const candidate = result.results[index];
|
|
297
|
+
position = {
|
|
298
|
+
entityType: candidate.entity_type,
|
|
299
|
+
numericId: Number(candidate.entity_id.slice(1)),
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
const stored = await this.#authorizedHydration(candidate.entity_id, undefined, () => this.#repository.getEntity(candidate.entity_id));
|
|
303
|
+
items.push({ entityId: candidate.entity_id, ...stored });
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (!(error instanceof AuthorizationDeniedError))
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (items.length === limit) {
|
|
310
|
+
if (index < result.results.length - 1)
|
|
311
|
+
exhausted = false;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!result.results.length)
|
|
316
|
+
exhausted = true;
|
|
317
|
+
}
|
|
318
|
+
await this.#reauthorize(items.map((item) => item.entityId));
|
|
319
|
+
await this.#assertGeneration(generation);
|
|
320
|
+
return {
|
|
321
|
+
items,
|
|
322
|
+
cursor: !exhausted && position
|
|
323
|
+
? await this.#encodeCursor(binding, generation, position)
|
|
324
|
+
: null,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async searchEntities(query, options = {}) {
|
|
328
|
+
const limit = authorizedLimit(options.limit, 20);
|
|
329
|
+
const filter = {
|
|
330
|
+
query,
|
|
331
|
+
language: options.language ?? null,
|
|
332
|
+
includeDeleted: options.includeDeleted ?? false,
|
|
333
|
+
};
|
|
334
|
+
const binding = await this.#binding('term-search', filter);
|
|
335
|
+
const generation = await this.#generation();
|
|
336
|
+
let offset = 0;
|
|
337
|
+
if (options.cursor) {
|
|
338
|
+
const cursor = await this.#decodeCursor(options.cursor, binding, generation);
|
|
339
|
+
offset = cursorNumber(cursor.position, 'offset');
|
|
340
|
+
}
|
|
341
|
+
await this.#assertContextCurrent();
|
|
342
|
+
const escaped = query.replace(/[\\%_]/gu, '\\$&');
|
|
343
|
+
const items = [];
|
|
344
|
+
let exhausted = false;
|
|
345
|
+
while (items.length < limit && !exhausted) {
|
|
346
|
+
const amount = scanLimit(limit - items.length);
|
|
347
|
+
const candidates = await this.#db
|
|
348
|
+
.prepare(`SELECT t.entity_id
|
|
349
|
+
FROM taproot_terms t JOIN taproot_entities e ON e.entity_id = t.entity_id
|
|
350
|
+
WHERE t.value LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
351
|
+
AND (? IS NULL OR t.language = ?)
|
|
352
|
+
AND (? = 1 OR e.deleted_at IS NULL)
|
|
353
|
+
ORDER BY CASE WHEN t.value = ? COLLATE NOCASE THEN 0 ELSE 1 END,
|
|
354
|
+
t.value COLLATE NOCASE, t.entity_id, t.language, t.term_type, t.ordinal
|
|
355
|
+
LIMIT ? OFFSET ?`)
|
|
356
|
+
.bind(`%${escaped}%`, filter.language, filter.language, filter.includeDeleted ? 1 : 0, query, amount, offset)
|
|
357
|
+
.all();
|
|
358
|
+
exhausted = candidates.results.length < amount;
|
|
359
|
+
const batchOffset = offset;
|
|
360
|
+
for (let index = 0; index < candidates.results.length; index += 1) {
|
|
361
|
+
const candidate = candidates.results[index];
|
|
362
|
+
const candidateOffset = batchOffset + index;
|
|
363
|
+
try {
|
|
364
|
+
const before = await this.#readAndAuthorize(candidate.entity_id);
|
|
365
|
+
const hydrated = await this.#db
|
|
366
|
+
.prepare(`SELECT t.entity_id, e.entity_type, t.language, t.term_type, t.value
|
|
367
|
+
FROM taproot_terms t JOIN taproot_entities e ON e.entity_id = t.entity_id
|
|
368
|
+
WHERE t.value LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
369
|
+
AND (? IS NULL OR t.language = ?)
|
|
370
|
+
AND (? = 1 OR e.deleted_at IS NULL)
|
|
371
|
+
ORDER BY CASE WHEN t.value = ? COLLATE NOCASE THEN 0 ELSE 1 END,
|
|
372
|
+
t.value COLLATE NOCASE, t.entity_id, t.language, t.term_type, t.ordinal
|
|
373
|
+
LIMIT 1 OFFSET ?`)
|
|
374
|
+
.bind(`%${escaped}%`, filter.language, filter.language, filter.includeDeleted ? 1 : 0, query, candidateOffset)
|
|
375
|
+
.all();
|
|
376
|
+
const row = hydrated.results[0];
|
|
377
|
+
if (!row || row.entity_id !== candidate.entity_id)
|
|
378
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
379
|
+
const after = await this.#readAndAuthorize(candidate.entity_id);
|
|
380
|
+
if (before.state.authorizationRevision !==
|
|
381
|
+
after.state.authorizationRevision ||
|
|
382
|
+
before.record.authorizationRevision !==
|
|
383
|
+
after.record.authorizationRevision ||
|
|
384
|
+
serializeVisibilityScope(before.record.visibility) !==
|
|
385
|
+
serializeVisibilityScope(after.record.visibility))
|
|
386
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
387
|
+
items.push({
|
|
388
|
+
entityId: row.entity_id,
|
|
389
|
+
entityType: row.entity_type,
|
|
390
|
+
language: row.language,
|
|
391
|
+
termType: row.term_type,
|
|
392
|
+
value: row.value,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (!(error instanceof AuthorizationDeniedError))
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
offset = candidateOffset + 1;
|
|
400
|
+
if (items.length === limit) {
|
|
401
|
+
if (index < candidates.results.length - 1)
|
|
402
|
+
exhausted = false;
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (!candidates.results.length)
|
|
407
|
+
exhausted = true;
|
|
408
|
+
}
|
|
409
|
+
await this.#reauthorize(items.map((item) => item.entityId));
|
|
410
|
+
await this.#assertGeneration(generation);
|
|
411
|
+
return {
|
|
412
|
+
items,
|
|
413
|
+
cursor: !exhausted && items.length
|
|
414
|
+
? await this.#encodeCursor(binding, generation, { offset })
|
|
415
|
+
: null,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
async getAuditEvent(eventId) {
|
|
419
|
+
const candidate = await this.#db
|
|
420
|
+
.prepare(`SELECT entity_id, revision FROM taproot_audit_events WHERE event_id = ?`)
|
|
421
|
+
.bind(eventId)
|
|
422
|
+
.all();
|
|
423
|
+
const row = candidate.results[0];
|
|
424
|
+
if (!row)
|
|
425
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
426
|
+
return this.#authorizedHydration(row.entity_id, row.revision, () => this.#repository.getAuditEvent(eventId));
|
|
427
|
+
}
|
|
428
|
+
async listAuditEvents(options = {}) {
|
|
429
|
+
const limit = authorizedLimit(options.limit, 50);
|
|
430
|
+
const filter = {
|
|
431
|
+
entityId: options.entityId ?? null,
|
|
432
|
+
requestId: options.requestId ?? null,
|
|
433
|
+
type: options.type ?? null,
|
|
434
|
+
attributionId: options.attributionId ?? null,
|
|
435
|
+
tag: options.tag ?? null,
|
|
436
|
+
};
|
|
437
|
+
const binding = await this.#binding('audit-events', filter);
|
|
438
|
+
const generation = await this.#generation();
|
|
439
|
+
let before = Number.MAX_SAFE_INTEGER;
|
|
440
|
+
if (options.cursor) {
|
|
441
|
+
const cursor = await this.#decodeCursor(options.cursor, binding, generation);
|
|
442
|
+
before = cursorNumber(cursor.position, 'sequence');
|
|
443
|
+
}
|
|
444
|
+
await this.#assertContextCurrent();
|
|
445
|
+
const items = [];
|
|
446
|
+
let exhausted = false;
|
|
447
|
+
while (items.length < limit && !exhausted) {
|
|
448
|
+
const amount = scanLimit(limit - items.length);
|
|
449
|
+
const candidates = await this.#db
|
|
450
|
+
.prepare(`SELECT rowid AS sequence, event_id, entity_id, revision FROM taproot_audit_events
|
|
451
|
+
WHERE (? IS NULL OR entity_id = ?) AND (? IS NULL OR request_id = ?)
|
|
452
|
+
AND (? IS NULL OR event_type = ?)
|
|
453
|
+
AND (? IS NULL OR json_extract(attribution_json, '$.id') = ?)
|
|
454
|
+
AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?))
|
|
455
|
+
AND rowid < ? ORDER BY rowid DESC LIMIT ?`)
|
|
456
|
+
.bind(filter.entityId, filter.entityId, filter.requestId, filter.requestId, filter.type, filter.type, filter.attributionId, filter.attributionId, filter.tag, filter.tag, before, amount)
|
|
457
|
+
.all();
|
|
458
|
+
exhausted = candidates.results.length < amount;
|
|
459
|
+
for (let index = 0; index < candidates.results.length; index += 1) {
|
|
460
|
+
const candidate = candidates.results[index];
|
|
461
|
+
before = candidate.sequence;
|
|
462
|
+
try {
|
|
463
|
+
items.push(await this.#authorizedHydration(candidate.entity_id, candidate.revision, () => this.#repository.getAuditEvent(candidate.event_id)));
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
if (!(error instanceof AuthorizationDeniedError))
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
if (items.length === limit) {
|
|
470
|
+
if (index < candidates.results.length - 1)
|
|
471
|
+
exhausted = false;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (!candidates.results.length)
|
|
476
|
+
exhausted = true;
|
|
477
|
+
}
|
|
478
|
+
await this.#reauthorize(items.map((item) => item.entityId));
|
|
479
|
+
await this.#assertGeneration(generation);
|
|
480
|
+
return {
|
|
481
|
+
items,
|
|
482
|
+
cursor: !exhausted && items.length
|
|
483
|
+
? await this.#encodeCursor(binding, generation, { sequence: before })
|
|
484
|
+
: null,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async exportEntities(options = {}) {
|
|
488
|
+
const lines = [];
|
|
489
|
+
let cursor;
|
|
490
|
+
do {
|
|
491
|
+
const page = await this.listEntities({
|
|
492
|
+
...options,
|
|
493
|
+
limit: 100,
|
|
494
|
+
...(cursor ? { cursor } : {}),
|
|
495
|
+
});
|
|
496
|
+
for (const item of page.items)
|
|
497
|
+
lines.push(exportEntityJson(item.entity));
|
|
498
|
+
cursor = page.cursor ?? undefined;
|
|
499
|
+
} while (cursor);
|
|
500
|
+
return lines.length ? `${lines.join('\n')}\n` : '';
|
|
501
|
+
}
|
|
502
|
+
async inspectEntityIntegrity(id) {
|
|
503
|
+
requireSearchAdministration(this.#context);
|
|
504
|
+
return this.#authorizedHydration(id, undefined, () => this.#repository.inspectEntityIntegrity(id));
|
|
505
|
+
}
|
|
506
|
+
async inspectTaprootIntegrity(options = {}) {
|
|
507
|
+
requireSearchAdministration(this.#context);
|
|
508
|
+
const entities = await this.listEntities({
|
|
509
|
+
...options,
|
|
510
|
+
includeDeleted: true,
|
|
511
|
+
});
|
|
512
|
+
const items = [];
|
|
513
|
+
for (const entity of entities.items)
|
|
514
|
+
items.push(await this.inspectEntityIntegrity(entity.entityId));
|
|
515
|
+
return { items, cursor: entities.cursor };
|
|
516
|
+
}
|
|
517
|
+
async verifyAuditChain(id) {
|
|
518
|
+
requireSearchAdministration(this.#context);
|
|
519
|
+
return this.#authorizedHydration(id, undefined, async () => {
|
|
520
|
+
const generation = await this.#generation();
|
|
521
|
+
const revisions = await this.#db
|
|
522
|
+
.prepare(`SELECT revision FROM taproot_entity_revisions
|
|
523
|
+
WHERE entity_id = ? ORDER BY revision`)
|
|
524
|
+
.bind(id)
|
|
525
|
+
.all();
|
|
526
|
+
const before = [];
|
|
527
|
+
for (const { revision } of revisions.results)
|
|
528
|
+
before.push(await this.#readAndAuthorize(id, revision));
|
|
529
|
+
const report = await this.#repository.verifyAuditChain(id);
|
|
530
|
+
for (let index = 0; index < revisions.results.length; index += 1) {
|
|
531
|
+
const after = await this.#readAndAuthorize(id, revisions.results[index].revision);
|
|
532
|
+
const prior = before[index];
|
|
533
|
+
if (prior.state.authorizationRevision !==
|
|
534
|
+
after.state.authorizationRevision ||
|
|
535
|
+
prior.record.authorizationRevision !==
|
|
536
|
+
after.record.authorizationRevision ||
|
|
537
|
+
serializeVisibilityScope(prior.record.visibility) !==
|
|
538
|
+
serializeVisibilityScope(after.record.visibility))
|
|
539
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
540
|
+
}
|
|
541
|
+
await this.#assertGeneration(generation);
|
|
542
|
+
return report;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
async repairEntityProjection(id, metadata = {}) {
|
|
546
|
+
requireSearchAdministration(this.#context);
|
|
547
|
+
return this.#authorizedHydration(id, undefined, () => this.#repository.repairEntityProjection(id, metadata));
|
|
548
|
+
}
|
|
549
|
+
async #binding(operation, filter) {
|
|
550
|
+
return fingerprintValue({
|
|
551
|
+
operation,
|
|
552
|
+
filter,
|
|
553
|
+
installationId: this.#context.installationId,
|
|
554
|
+
principalId: this.#context.principalId,
|
|
555
|
+
activeWorkspaceId: this.#context.activeWorkspaceId,
|
|
556
|
+
workspaceIds: this.#context.workspaceIds,
|
|
557
|
+
capabilities: this.#context.capabilities,
|
|
558
|
+
authorizationRevision: this.#context.authorizationRevision,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
async #generation() {
|
|
562
|
+
const result = await this.#db
|
|
563
|
+
.prepare(`SELECT
|
|
564
|
+
COALESCE((SELECT MAX(rowid) FROM taproot_entity_revisions), 0) AS revision_generation,
|
|
565
|
+
COALESCE((SELECT MAX(rowid) FROM taproot_audit_events), 0) AS audit_generation,
|
|
566
|
+
COALESCE((SELECT search_generation FROM taproot_installation_authorization
|
|
567
|
+
WHERE singleton = 1), 0) AS search_generation`)
|
|
568
|
+
.all();
|
|
569
|
+
const revisionGeneration = Number(result.results[0]?.revision_generation ?? 0);
|
|
570
|
+
const auditGeneration = Number(result.results[0]?.audit_generation ?? 0);
|
|
571
|
+
const searchGeneration = Number(result.results[0]?.search_generation ?? 0);
|
|
572
|
+
if (!Number.isSafeInteger(revisionGeneration) ||
|
|
573
|
+
revisionGeneration < 0 ||
|
|
574
|
+
!Number.isSafeInteger(auditGeneration) ||
|
|
575
|
+
auditGeneration < 0 ||
|
|
576
|
+
!Number.isSafeInteger(searchGeneration) ||
|
|
577
|
+
searchGeneration < 1)
|
|
578
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
579
|
+
return `${revisionGeneration}:${auditGeneration}:${searchGeneration}`;
|
|
580
|
+
}
|
|
581
|
+
async #assertGeneration(expected) {
|
|
582
|
+
if ((await this.#generation()) !== expected)
|
|
583
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
584
|
+
}
|
|
585
|
+
async #assertContextCurrent() {
|
|
586
|
+
try {
|
|
587
|
+
const state = normalizeInstallationState(await this.#authorization.getInstallationAuthorizationState());
|
|
588
|
+
if (state.installationId !== this.#context.installationId ||
|
|
589
|
+
state.authorizationRevision !== this.#context.authorizationRevision)
|
|
590
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async #reauthorize(entityIds) {
|
|
597
|
+
await this.#assertContextCurrent();
|
|
598
|
+
for (const entityId of new Set(entityIds))
|
|
599
|
+
await this.#readAndAuthorize(entityId);
|
|
600
|
+
}
|
|
601
|
+
async #encodeCursor(binding, generation, position) {
|
|
602
|
+
const key = cursorCodecs.get(this.#cursorCodec);
|
|
603
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
604
|
+
const serialized = new TextEncoder().encode(JSON.stringify({ version: 1, binding, generation, position }));
|
|
605
|
+
if (serialized.length > CURSOR_PLAINTEXT_BYTES)
|
|
606
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
607
|
+
const plaintext = new Uint8Array(CURSOR_PLAINTEXT_BYTES);
|
|
608
|
+
plaintext.fill(0x20);
|
|
609
|
+
plaintext.set(serialized);
|
|
610
|
+
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: CURSOR_AAD }, key, plaintext);
|
|
611
|
+
return `${base64Url(iv)}.${base64Url(new Uint8Array(encrypted))}`;
|
|
612
|
+
}
|
|
613
|
+
async #decodeCursor(encoded, binding, generation) {
|
|
614
|
+
try {
|
|
615
|
+
if (encoded.length > 4096)
|
|
616
|
+
throw new Error('invalid cursor');
|
|
617
|
+
const parts = encoded.split('.');
|
|
618
|
+
if (parts.length !== 2)
|
|
619
|
+
throw new Error('invalid cursor');
|
|
620
|
+
const key = cursorCodecs.get(this.#cursorCodec);
|
|
621
|
+
const decrypted = await crypto.subtle.decrypt({
|
|
622
|
+
name: 'AES-GCM',
|
|
623
|
+
iv: fromBase64Url(parts[0]).buffer,
|
|
624
|
+
additionalData: CURSOR_AAD,
|
|
625
|
+
}, key, fromBase64Url(parts[1]).buffer);
|
|
626
|
+
const payload = JSON.parse(new TextDecoder().decode(decrypted));
|
|
627
|
+
if (!isRecord(payload) ||
|
|
628
|
+
payload.version !== 1 ||
|
|
629
|
+
payload.binding !== binding ||
|
|
630
|
+
payload.generation !== generation ||
|
|
631
|
+
!Object.hasOwn(payload, 'position') ||
|
|
632
|
+
Object.keys(payload).length !== 4)
|
|
633
|
+
throw new Error('invalid cursor');
|
|
634
|
+
return payload;
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
throw new InvalidCursorError('Cursor is invalid');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
async #authorizedHydration(entityId, revisionNumber, hydrate) {
|
|
641
|
+
const before = await this.#readAndAuthorize(entityId, revisionNumber);
|
|
642
|
+
const hydrated = await hydrate();
|
|
643
|
+
const after = await this.#readAndAuthorize(entityId, revisionNumber);
|
|
644
|
+
if (before.state.authorizationRevision !==
|
|
645
|
+
after.state.authorizationRevision ||
|
|
646
|
+
before.record.authorizationRevision !==
|
|
647
|
+
after.record.authorizationRevision ||
|
|
648
|
+
serializeVisibilityScope(before.record.visibility) !==
|
|
649
|
+
serializeVisibilityScope(after.record.visibility))
|
|
650
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
651
|
+
return hydrated;
|
|
652
|
+
}
|
|
653
|
+
async #readAndAuthorize(entityId, revisionNumber) {
|
|
654
|
+
let state;
|
|
655
|
+
let record;
|
|
656
|
+
try {
|
|
657
|
+
[state, record] = await Promise.all([
|
|
658
|
+
this.#authorization.getInstallationAuthorizationState(),
|
|
659
|
+
this.#authorization.getEntityAuthorization(entityId),
|
|
660
|
+
]);
|
|
661
|
+
const normalizedState = normalizeInstallationState(state);
|
|
662
|
+
const current = normalizeAuthorizationRecord(record);
|
|
663
|
+
if (normalizedState.installationId !== this.#context.installationId ||
|
|
664
|
+
current.installationId !== this.#context.installationId ||
|
|
665
|
+
normalizedState.authorizationRevision !==
|
|
666
|
+
this.#context.authorizationRevision ||
|
|
667
|
+
current.authorizationRevision > normalizedState.authorizationRevision ||
|
|
668
|
+
!isVisibleTo(current.visibility, this.#context))
|
|
669
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
670
|
+
let effective = current;
|
|
671
|
+
if (revisionNumber !== undefined) {
|
|
672
|
+
const historical = normalizeAuthorizationRecord(await this.#authorization.getEntityRevisionAuthorization(entityId, revisionNumber));
|
|
673
|
+
if (historical.installationId !== current.installationId)
|
|
674
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
675
|
+
effective = {
|
|
676
|
+
...current,
|
|
677
|
+
authorizationRevision: Math.max(current.authorizationRevision, historical.authorizationRevision),
|
|
678
|
+
visibility: intersectVisibilityScopes(current.visibility, historical.visibility),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (effective.authorizationRevision >
|
|
682
|
+
normalizedState.authorizationRevision ||
|
|
683
|
+
!isVisibleTo(effective.visibility, this.#context))
|
|
684
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
685
|
+
return { state: normalizedState, record: effective };
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/** Taproot-owned, fail-closed canonical policy source. */
|
|
693
|
+
export class PersistedEntityAuthorizationSource {
|
|
694
|
+
#db;
|
|
695
|
+
constructor(db) {
|
|
696
|
+
this.#db = db;
|
|
697
|
+
}
|
|
698
|
+
async getInstallationAuthorizationState() {
|
|
699
|
+
const result = await this.#db
|
|
700
|
+
.prepare(`SELECT installation_id, authorization_revision, search_generation
|
|
701
|
+
FROM taproot_installation_authorization WHERE singleton = 1`)
|
|
702
|
+
.all();
|
|
703
|
+
const row = result.results[0];
|
|
704
|
+
if (!row)
|
|
705
|
+
throw new AuthorizationDeniedError('Authorization denied');
|
|
706
|
+
return {
|
|
707
|
+
installationId: row.installation_id,
|
|
708
|
+
authorizationRevision: Number(row.authorization_revision),
|
|
709
|
+
searchGeneration: Number(row.search_generation),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
async getEntityAuthorization(entityId) {
|
|
713
|
+
const result = await this.#db
|
|
714
|
+
.prepare(`SELECT p.installation_id, p.authorization_revision,
|
|
715
|
+
p.effective_visibility_json AS visibility_json,
|
|
716
|
+
p.visibility_json AS declared_visibility_json,
|
|
717
|
+
p.workspace_id, p.owner_principal_id, p.source_revision
|
|
718
|
+
FROM taproot_entity_authorization p
|
|
719
|
+
JOIN taproot_entity_authorization_revisions history
|
|
720
|
+
ON history.entity_id = p.entity_id
|
|
721
|
+
AND history.source_revision = p.source_revision
|
|
722
|
+
AND history.installation_id IS p.installation_id
|
|
723
|
+
AND history.workspace_id IS p.workspace_id
|
|
724
|
+
AND history.owner_principal_id IS p.owner_principal_id
|
|
725
|
+
AND history.visibility_json IS p.visibility_json
|
|
726
|
+
AND history.effective_visibility_json IS p.effective_visibility_json
|
|
727
|
+
AND history.authorization_revision IS p.authorization_revision
|
|
728
|
+
AND history.deleted_at IS p.deleted_at
|
|
729
|
+
AND history.event_id IS p.event_id
|
|
730
|
+
AND history.created_at IS p.updated_at
|
|
731
|
+
JOIN taproot_entities e ON e.entity_id = p.entity_id
|
|
732
|
+
AND e.revision = p.source_revision
|
|
733
|
+
JOIN taproot_installation_authorization state ON state.singleton = 1
|
|
734
|
+
AND state.installation_id = p.installation_id
|
|
735
|
+
AND p.authorization_revision BETWEEN 1 AND state.authorization_revision
|
|
736
|
+
WHERE p.entity_id = ? AND p.deleted_at IS NULL
|
|
737
|
+
AND e.deleted_at IS NULL`)
|
|
738
|
+
.bind(entityId)
|
|
739
|
+
.all();
|
|
740
|
+
const row = result.results[0];
|
|
741
|
+
if (!row)
|
|
742
|
+
return null;
|
|
743
|
+
const declared = parseCanonicalVisibilityScope(row.declared_visibility_json);
|
|
744
|
+
const effective = parseCanonicalVisibilityScope(row.visibility_json);
|
|
745
|
+
if (!declared || !effective)
|
|
746
|
+
return null;
|
|
747
|
+
const statements = await this.#db
|
|
748
|
+
.prepare(`SELECT taproot_statement_authorization.restrictions_json,
|
|
749
|
+
taproot_statement_authorization.effective_visibility_json,
|
|
750
|
+
taproot_statement_authorization.authorization_revision
|
|
751
|
+
FROM taproot_statement_authorization
|
|
752
|
+
JOIN taproot_statement_authorization_revisions history
|
|
753
|
+
ON history.entity_id = taproot_statement_authorization.entity_id
|
|
754
|
+
AND history.source_revision = taproot_statement_authorization.source_revision
|
|
755
|
+
AND history.statement_id = taproot_statement_authorization.statement_id
|
|
756
|
+
AND history.restrictions_json IS taproot_statement_authorization.restrictions_json
|
|
757
|
+
AND history.effective_visibility_json IS taproot_statement_authorization.effective_visibility_json
|
|
758
|
+
AND history.authorization_revision IS taproot_statement_authorization.authorization_revision
|
|
759
|
+
WHERE taproot_statement_authorization.entity_id = ?
|
|
760
|
+
AND taproot_statement_authorization.source_revision = ?
|
|
761
|
+
ORDER BY taproot_statement_authorization.statement_id`)
|
|
762
|
+
.bind(entityId, row.source_revision)
|
|
763
|
+
.all();
|
|
764
|
+
let expectedEffective = declared;
|
|
765
|
+
for (const statement of statements.results) {
|
|
766
|
+
const restrictions = parseCanonicalVisibilityRestrictions(statement.restrictions_json);
|
|
767
|
+
const statementEffective = parseCanonicalVisibilityScope(statement.effective_visibility_json);
|
|
768
|
+
if (!restrictions ||
|
|
769
|
+
!statementEffective ||
|
|
770
|
+
Number(statement.authorization_revision) !==
|
|
771
|
+
Number(row.authorization_revision))
|
|
772
|
+
return null;
|
|
773
|
+
const expectedStatement = intersectVisibilityScopes(declared, ...restrictions);
|
|
774
|
+
if (serializeVisibilityScope(statementEffective) !==
|
|
775
|
+
serializeVisibilityScope(expectedStatement))
|
|
776
|
+
return null;
|
|
777
|
+
expectedEffective = intersectVisibilityScopes(expectedEffective, expectedStatement);
|
|
778
|
+
}
|
|
779
|
+
if (serializeVisibilityScope(effective) !==
|
|
780
|
+
serializeVisibilityScope(expectedEffective))
|
|
781
|
+
return null;
|
|
782
|
+
return authorizationRecordFromRow(row);
|
|
783
|
+
}
|
|
784
|
+
async getEntityRevisionAuthorization(entityId, revisionNumber) {
|
|
785
|
+
const current = await this.getEntityAuthorization(entityId);
|
|
786
|
+
if (!current)
|
|
787
|
+
return null;
|
|
788
|
+
const result = await this.#db
|
|
789
|
+
.prepare(`SELECT history.installation_id, history.authorization_revision,
|
|
790
|
+
history.effective_visibility_json AS historical_visibility_json,
|
|
791
|
+
history.visibility_json AS historical_declared_visibility_json,
|
|
792
|
+
history.workspace_id, history.owner_principal_id,
|
|
793
|
+
history.source_revision
|
|
794
|
+
FROM taproot_entity_authorization_revisions history
|
|
795
|
+
JOIN taproot_installation_authorization state ON state.singleton = 1
|
|
796
|
+
AND state.installation_id = history.installation_id
|
|
797
|
+
AND history.authorization_revision BETWEEN 1 AND state.authorization_revision
|
|
798
|
+
WHERE history.entity_id = ? AND history.source_revision = ?
|
|
799
|
+
AND history.deleted_at IS NULL`)
|
|
800
|
+
.bind(entityId, revisionNumber)
|
|
801
|
+
.all();
|
|
802
|
+
const row = result.results[0];
|
|
803
|
+
if (!row || row.installation_id !== current.installationId)
|
|
804
|
+
return null;
|
|
805
|
+
const declared = parseCanonicalVisibilityScope(row.historical_declared_visibility_json);
|
|
806
|
+
const effective = parseCanonicalVisibilityScope(row.historical_visibility_json);
|
|
807
|
+
if (!declared || !effective)
|
|
808
|
+
return null;
|
|
809
|
+
const coverage = await this.#db
|
|
810
|
+
.prepare(`WITH expected(statement_id) AS (
|
|
811
|
+
SELECT json_extract(statement.value, '$.id')
|
|
812
|
+
FROM taproot_entity_revisions revision,
|
|
813
|
+
json_each(revision.entity_json, '$.claims') claim,
|
|
814
|
+
json_each(claim.value) statement
|
|
815
|
+
WHERE revision.entity_id = ? AND revision.revision = ?
|
|
816
|
+
)
|
|
817
|
+
SELECT
|
|
818
|
+
(SELECT COUNT(*) FROM expected) AS expected_count,
|
|
819
|
+
(SELECT COUNT(*) FROM taproot_statement_authorization_revisions policy
|
|
820
|
+
WHERE policy.entity_id = ? AND policy.source_revision = ?) AS actual_count,
|
|
821
|
+
(SELECT COUNT(*) FROM expected WHERE NOT EXISTS (
|
|
822
|
+
SELECT 1 FROM taproot_statement_authorization_revisions policy
|
|
823
|
+
WHERE policy.entity_id = ? AND policy.source_revision = ?
|
|
824
|
+
AND policy.statement_id = expected.statement_id
|
|
825
|
+
)) AS missing_count`)
|
|
826
|
+
.bind(entityId, revisionNumber, entityId, revisionNumber, entityId, revisionNumber)
|
|
827
|
+
.all();
|
|
828
|
+
const coverageRow = coverage.results[0];
|
|
829
|
+
if (!coverageRow ||
|
|
830
|
+
Number(coverageRow.expected_count) !== Number(coverageRow.actual_count) ||
|
|
831
|
+
Number(coverageRow.missing_count) !== 0)
|
|
832
|
+
return null;
|
|
833
|
+
const statements = await this.#db
|
|
834
|
+
.prepare(`SELECT restrictions_json, effective_visibility_json,
|
|
835
|
+
authorization_revision
|
|
836
|
+
FROM taproot_statement_authorization_revisions
|
|
837
|
+
WHERE entity_id = ? AND source_revision = ?
|
|
838
|
+
ORDER BY statement_id`)
|
|
839
|
+
.bind(entityId, revisionNumber)
|
|
840
|
+
.all();
|
|
841
|
+
let expectedEffective = declared;
|
|
842
|
+
for (const statement of statements.results) {
|
|
843
|
+
const restrictions = parseCanonicalVisibilityRestrictions(statement.restrictions_json);
|
|
844
|
+
const statementEffective = parseCanonicalVisibilityScope(statement.effective_visibility_json);
|
|
845
|
+
if (!restrictions ||
|
|
846
|
+
!statementEffective ||
|
|
847
|
+
Number(statement.authorization_revision) !==
|
|
848
|
+
Number(row.authorization_revision))
|
|
849
|
+
return null;
|
|
850
|
+
const expectedStatement = intersectVisibilityScopes(declared, ...restrictions);
|
|
851
|
+
if (serializeVisibilityScope(statementEffective) !==
|
|
852
|
+
serializeVisibilityScope(expectedStatement))
|
|
853
|
+
return null;
|
|
854
|
+
expectedEffective = intersectVisibilityScopes(expectedEffective, expectedStatement);
|
|
855
|
+
}
|
|
856
|
+
if (serializeVisibilityScope(effective) !==
|
|
857
|
+
serializeVisibilityScope(expectedEffective))
|
|
858
|
+
return null;
|
|
859
|
+
return {
|
|
860
|
+
installationId: row.installation_id,
|
|
861
|
+
authorizationRevision: Math.max(current.authorizationRevision, Number(row.authorization_revision)),
|
|
862
|
+
visibility: intersectVisibilityScopes(current.visibility, effective),
|
|
863
|
+
workspaceId: row.workspace_id,
|
|
864
|
+
ownerPrincipalId: row.owner_principal_id,
|
|
865
|
+
sourceRevision: Number(row.source_revision),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
async getStatementAuthorization(entityId, statementId) {
|
|
869
|
+
const result = await this.#db
|
|
870
|
+
.prepare(`SELECT p.installation_id, s.authorization_revision,
|
|
871
|
+
s.effective_visibility_json AS visibility_json,
|
|
872
|
+
s.restrictions_json, p.visibility_json AS parent_visibility_json,
|
|
873
|
+
p.effective_visibility_json AS parent_effective_visibility_json,
|
|
874
|
+
p.authorization_revision AS parent_authorization_revision,
|
|
875
|
+
p.workspace_id, p.owner_principal_id, s.source_revision
|
|
876
|
+
FROM taproot_statement_authorization s
|
|
877
|
+
JOIN taproot_statement_authorization_revisions history
|
|
878
|
+
ON history.entity_id = s.entity_id
|
|
879
|
+
AND history.source_revision = s.source_revision
|
|
880
|
+
AND history.statement_id = s.statement_id
|
|
881
|
+
AND history.restrictions_json IS s.restrictions_json
|
|
882
|
+
AND history.effective_visibility_json IS s.effective_visibility_json
|
|
883
|
+
AND history.authorization_revision IS s.authorization_revision
|
|
884
|
+
JOIN taproot_entity_authorization p ON p.entity_id = s.entity_id
|
|
885
|
+
AND p.source_revision = s.source_revision
|
|
886
|
+
JOIN taproot_entity_authorization_revisions parent_history
|
|
887
|
+
ON parent_history.entity_id = p.entity_id
|
|
888
|
+
AND parent_history.source_revision = p.source_revision
|
|
889
|
+
AND parent_history.installation_id IS p.installation_id
|
|
890
|
+
AND parent_history.workspace_id IS p.workspace_id
|
|
891
|
+
AND parent_history.owner_principal_id IS p.owner_principal_id
|
|
892
|
+
AND parent_history.visibility_json IS p.visibility_json
|
|
893
|
+
AND parent_history.effective_visibility_json IS p.effective_visibility_json
|
|
894
|
+
AND parent_history.authorization_revision IS p.authorization_revision
|
|
895
|
+
AND parent_history.deleted_at IS p.deleted_at
|
|
896
|
+
AND parent_history.event_id IS p.event_id
|
|
897
|
+
AND parent_history.created_at IS p.updated_at
|
|
898
|
+
JOIN taproot_entities e ON e.entity_id = s.entity_id
|
|
899
|
+
AND e.revision = s.source_revision
|
|
900
|
+
JOIN taproot_installation_authorization state ON state.singleton = 1
|
|
901
|
+
AND state.installation_id = p.installation_id
|
|
902
|
+
AND p.authorization_revision BETWEEN 1 AND state.authorization_revision
|
|
903
|
+
AND s.authorization_revision BETWEEN 1 AND state.authorization_revision
|
|
904
|
+
WHERE s.entity_id = ? AND s.statement_id = ?
|
|
905
|
+
AND p.deleted_at IS NULL AND e.deleted_at IS NULL`)
|
|
906
|
+
.bind(entityId, statementId)
|
|
907
|
+
.all();
|
|
908
|
+
const row = result.results[0];
|
|
909
|
+
if (!row ||
|
|
910
|
+
Number(row.authorization_revision) !==
|
|
911
|
+
Number(row.parent_authorization_revision))
|
|
912
|
+
return null;
|
|
913
|
+
const declaredParent = parseCanonicalVisibilityScope(row.parent_visibility_json);
|
|
914
|
+
const restrictions = parseCanonicalVisibilityRestrictions(row.restrictions_json);
|
|
915
|
+
const effective = parseCanonicalVisibilityScope(row.visibility_json);
|
|
916
|
+
if (!declaredParent ||
|
|
917
|
+
!restrictions ||
|
|
918
|
+
!effective ||
|
|
919
|
+
serializeVisibilityScope(effective) !==
|
|
920
|
+
serializeVisibilityScope(intersectVisibilityScopes(declaredParent, ...restrictions)))
|
|
921
|
+
return null;
|
|
922
|
+
return authorizationRecordFromRow(row);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function parseCanonicalVisibilityRestrictions(raw) {
|
|
926
|
+
try {
|
|
927
|
+
const value = JSON.parse(raw);
|
|
928
|
+
if (!Array.isArray(value))
|
|
929
|
+
return null;
|
|
930
|
+
const restrictions = value.map((scope) => normalizeVisibilityScope(scope));
|
|
931
|
+
return JSON.stringify(restrictions) === raw ? restrictions : null;
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function parseCanonicalVisibilityScope(raw) {
|
|
938
|
+
try {
|
|
939
|
+
const scope = normalizeVisibilityScope(JSON.parse(raw));
|
|
940
|
+
return serializeVisibilityScope(scope) === raw ? scope : null;
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function authorizationRecordFromRow(row) {
|
|
947
|
+
if (!row)
|
|
948
|
+
return null;
|
|
949
|
+
return {
|
|
950
|
+
installationId: row.installation_id,
|
|
951
|
+
authorizationRevision: Number(row.authorization_revision),
|
|
952
|
+
visibility: JSON.parse(row.visibility_json),
|
|
953
|
+
workspaceId: row.workspace_id,
|
|
954
|
+
ownerPrincipalId: row.owner_principal_id,
|
|
955
|
+
sourceRevision: Number(row.source_revision),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
export function createAuthorizedTaproot(db, options, context, authorizedOptions) {
|
|
959
|
+
return new AuthorizedTaprootReader(db, options, context, authorizedOptions);
|
|
960
|
+
}
|
|
961
|
+
function normalizeInstallationState(value) {
|
|
962
|
+
if (!isRecord(value))
|
|
963
|
+
invalid('authorization state must be an object');
|
|
964
|
+
exactKeys(value, [
|
|
965
|
+
'installationId',
|
|
966
|
+
'authorizationRevision',
|
|
967
|
+
'searchGeneration',
|
|
968
|
+
]);
|
|
969
|
+
return {
|
|
970
|
+
installationId: identifier(value.installationId, 'installationId'),
|
|
971
|
+
authorizationRevision: revision(value.authorizationRevision, 'authorizationRevision'),
|
|
972
|
+
searchGeneration: revision(value.searchGeneration, 'searchGeneration'),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
function normalizeAuthorizationRecord(value) {
|
|
976
|
+
if (!isRecord(value))
|
|
977
|
+
invalid('authorization record must be an object');
|
|
978
|
+
exactKeys(value, [
|
|
979
|
+
'installationId',
|
|
980
|
+
'authorizationRevision',
|
|
981
|
+
'visibility',
|
|
982
|
+
'workspaceId',
|
|
983
|
+
'ownerPrincipalId',
|
|
984
|
+
'sourceRevision',
|
|
985
|
+
]);
|
|
986
|
+
return {
|
|
987
|
+
installationId: identifier(value.installationId, 'installationId'),
|
|
988
|
+
authorizationRevision: revision(value.authorizationRevision, 'authorizationRevision'),
|
|
989
|
+
visibility: normalizeVisibilityScope(value.visibility),
|
|
990
|
+
workspaceId: value.workspaceId === null
|
|
991
|
+
? null
|
|
992
|
+
: identifier(value.workspaceId, 'workspaceId'),
|
|
993
|
+
ownerPrincipalId: identifier(value.ownerPrincipalId, 'ownerPrincipalId'),
|
|
994
|
+
sourceRevision: revision(value.sourceRevision, 'sourceRevision'),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function normalizeAtom(value) {
|
|
998
|
+
if (!isRecord(value) || typeof value.kind !== 'string')
|
|
999
|
+
invalid('visibility atom must be an object');
|
|
1000
|
+
switch (value.kind) {
|
|
1001
|
+
case 'public':
|
|
1002
|
+
if (Object.keys(value).length !== 1)
|
|
1003
|
+
invalid('public atom has unknown fields');
|
|
1004
|
+
return { kind: 'public' };
|
|
1005
|
+
case 'principal':
|
|
1006
|
+
exactKeys(value, ['kind', 'principalId']);
|
|
1007
|
+
return {
|
|
1008
|
+
kind: 'principal',
|
|
1009
|
+
principalId: identifier(value.principalId, 'principalId'),
|
|
1010
|
+
};
|
|
1011
|
+
case 'workspace':
|
|
1012
|
+
exactKeys(value, ['kind', 'workspaceId']);
|
|
1013
|
+
return {
|
|
1014
|
+
kind: 'workspace',
|
|
1015
|
+
workspaceId: identifier(value.workspaceId, 'workspaceId'),
|
|
1016
|
+
};
|
|
1017
|
+
case 'capability':
|
|
1018
|
+
exactKeys(value, ['kind', 'capability']);
|
|
1019
|
+
return {
|
|
1020
|
+
kind: 'capability',
|
|
1021
|
+
capability: identifier(value.capability, 'capability'),
|
|
1022
|
+
};
|
|
1023
|
+
default:
|
|
1024
|
+
invalid('visibility atom kind is not supported');
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
function serializeAtom(atom) {
|
|
1028
|
+
return JSON.stringify(atom);
|
|
1029
|
+
}
|
|
1030
|
+
function stringSet(value, field) {
|
|
1031
|
+
if (!Array.isArray(value) || value.length > 256)
|
|
1032
|
+
invalid(`${field} must be an array with at most 256 entries`);
|
|
1033
|
+
const normalized = [];
|
|
1034
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1035
|
+
if (!Object.hasOwn(value, index))
|
|
1036
|
+
invalid(`${field} must be dense`);
|
|
1037
|
+
normalized.push(identifier(value[index], field));
|
|
1038
|
+
}
|
|
1039
|
+
return [...new Set(normalized)].sort(compareCodeUnits);
|
|
1040
|
+
}
|
|
1041
|
+
function identifier(value, field) {
|
|
1042
|
+
if (typeof value !== 'string')
|
|
1043
|
+
invalid(`${field} must be a string`);
|
|
1044
|
+
const normalized = value.normalize('NFC');
|
|
1045
|
+
if (normalized.length === 0 ||
|
|
1046
|
+
normalized.length > MAX_IDENTIFIER_LENGTH ||
|
|
1047
|
+
normalized.trim() !== normalized ||
|
|
1048
|
+
hasControlCharacter(normalized))
|
|
1049
|
+
invalid(`${field} is invalid`);
|
|
1050
|
+
return normalized;
|
|
1051
|
+
}
|
|
1052
|
+
function revision(value, field) {
|
|
1053
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
1054
|
+
invalid(`${field} must be a non-negative safe integer`);
|
|
1055
|
+
return value;
|
|
1056
|
+
}
|
|
1057
|
+
function exactKeys(value, expected) {
|
|
1058
|
+
const actual = Object.keys(value).sort();
|
|
1059
|
+
const wanted = [...expected].sort();
|
|
1060
|
+
if (actual.length !== wanted.length ||
|
|
1061
|
+
actual.some((key, index) => key !== wanted[index]))
|
|
1062
|
+
invalid('visibility atom has unknown or missing fields');
|
|
1063
|
+
}
|
|
1064
|
+
function isRecord(value) {
|
|
1065
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1066
|
+
}
|
|
1067
|
+
function compareCodeUnits(left, right) {
|
|
1068
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1069
|
+
}
|
|
1070
|
+
function hasControlCharacter(value) {
|
|
1071
|
+
for (const character of value) {
|
|
1072
|
+
const codePoint = character.codePointAt(0);
|
|
1073
|
+
if (codePoint <= 0x1f || codePoint === 0x7f)
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
function authorizedLimit(value, fallback) {
|
|
1079
|
+
const limit = value ?? fallback;
|
|
1080
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)
|
|
1081
|
+
throw new RangeError('limit must be an integer from 1 through 500');
|
|
1082
|
+
return limit;
|
|
1083
|
+
}
|
|
1084
|
+
function scanLimit(remaining) {
|
|
1085
|
+
return Math.min(500, Math.max(32, remaining * 4));
|
|
1086
|
+
}
|
|
1087
|
+
function cursorNumber(position, key) {
|
|
1088
|
+
if (!isRecord(position) ||
|
|
1089
|
+
Object.keys(position).length !== 1 ||
|
|
1090
|
+
!Object.hasOwn(position, key))
|
|
1091
|
+
throw new InvalidCursorError('Cursor is invalid');
|
|
1092
|
+
const value = position[key];
|
|
1093
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
1094
|
+
throw new InvalidCursorError('Cursor is invalid');
|
|
1095
|
+
return value;
|
|
1096
|
+
}
|
|
1097
|
+
function entityPosition(position) {
|
|
1098
|
+
if (!isRecord(position))
|
|
1099
|
+
throw new InvalidCursorError('Cursor is invalid');
|
|
1100
|
+
exactKeys(position, ['entityType', 'numericId']);
|
|
1101
|
+
if ((position.entityType !== 'item' && position.entityType !== 'property') ||
|
|
1102
|
+
!Number.isSafeInteger(position.numericId) ||
|
|
1103
|
+
position.numericId < 0)
|
|
1104
|
+
throw new InvalidCursorError('Cursor is invalid');
|
|
1105
|
+
return {
|
|
1106
|
+
entityType: position.entityType,
|
|
1107
|
+
numericId: position.numericId,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
async function fingerprintValue(value) {
|
|
1111
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
1112
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
1113
|
+
return base64Url(new Uint8Array(digest));
|
|
1114
|
+
}
|
|
1115
|
+
function base64Url(bytes) {
|
|
1116
|
+
let binary = '';
|
|
1117
|
+
for (const byte of bytes)
|
|
1118
|
+
binary += String.fromCharCode(byte);
|
|
1119
|
+
return btoa(binary)
|
|
1120
|
+
.replaceAll('+', '-')
|
|
1121
|
+
.replaceAll('/', '_')
|
|
1122
|
+
.replace(/=+$/u, '');
|
|
1123
|
+
}
|
|
1124
|
+
function fromBase64Url(value) {
|
|
1125
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(value))
|
|
1126
|
+
throw new Error('invalid base64url');
|
|
1127
|
+
const padding = '='.repeat((4 - (value.length % 4)) % 4);
|
|
1128
|
+
const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/') + padding);
|
|
1129
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
1130
|
+
if (base64Url(bytes) !== value)
|
|
1131
|
+
throw new Error('invalid base64url');
|
|
1132
|
+
return bytes;
|
|
1133
|
+
}
|
|
1134
|
+
function invalid(message) {
|
|
1135
|
+
throw new InvalidAuthorizationError(message);
|
|
1136
|
+
}
|
|
1137
|
+
//# sourceMappingURL=authorization.js.map
|