@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/COMPATIBILITY.md +5 -3
  3. package/README.md +106 -33
  4. package/SECURITY.md +6 -3
  5. package/dist/authorization-maintenance.d.ts +55 -0
  6. package/dist/authorization-maintenance.d.ts.map +1 -0
  7. package/dist/authorization-maintenance.js +686 -0
  8. package/dist/authorization-maintenance.js.map +1 -0
  9. package/dist/authorization.d.ts +98 -0
  10. package/dist/authorization.d.ts.map +1 -0
  11. package/dist/authorization.js +1137 -0
  12. package/dist/authorization.js.map +1 -0
  13. package/dist/canonical.d.ts +2 -1
  14. package/dist/canonical.d.ts.map +1 -1
  15. package/dist/canonical.js +9 -1
  16. package/dist/canonical.js.map +1 -1
  17. package/dist/errors.d.ts +4 -0
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +4 -0
  20. package/dist/errors.js.map +1 -1
  21. package/dist/index.d.ts +125 -49
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +351 -44
  24. package/dist/index.js.map +1 -1
  25. package/dist/migrations.d.ts +9 -3
  26. package/dist/migrations.d.ts.map +1 -1
  27. package/dist/migrations.js +77 -5
  28. package/dist/migrations.js.map +1 -1
  29. package/dist/repository.d.ts +20 -9
  30. package/dist/repository.d.ts.map +1 -1
  31. package/dist/repository.js +593 -48
  32. package/dist/repository.js.map +1 -1
  33. package/dist/schema.d.ts +14 -5
  34. package/dist/schema.d.ts.map +1 -1
  35. package/dist/schema.js +461 -18
  36. package/dist/schema.js.map +1 -1
  37. package/dist/types.d.ts +59 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/docs/api.md +56 -18
  40. package/docs/architecture.md +19 -7
  41. package/docs/authorization.md +82 -0
  42. package/docs/completion-audit.md +15 -15
  43. package/docs/operations.md +28 -5
  44. package/docs/product-scope.md +5 -3
  45. package/docs/testing.md +18 -1
  46. package/docs/threat-model.md +30 -3
  47. package/examples/d1-diamond-interop/README.md +2 -1
  48. package/examples/d1-diamond-interop/demo.ts +150 -21
  49. package/migrations/0003_canonical_statement_text.sql +31 -0
  50. package/migrations/0004_canonical_authorization_policy.sql +213 -0
  51. package/package.json +4 -1
@@ -0,0 +1,686 @@
1
+ import { intersectVisibilityScopes, normalizeAuthorizationContext, normalizeCanonicalAuthorizationPolicy, normalizeVisibilityScope, requireSearchAdministration, serializeVisibilityScope, } from './authorization.js';
2
+ import { AuthorizationDeniedError, InvalidAuthorizationError, RevisionConflictError, } from './errors.js';
3
+ import { parseEntityJson } from './canonical.js';
4
+ const MAX_READINESS_PAGE = 100;
5
+ const MAX_BACKFILL_ENTITIES = 100;
6
+ const MAX_BACKFILL_REVISIONS = 2_000;
7
+ export async function inspectAuthorizationReadiness(db, rawContext, options = {}) {
8
+ const { context, state } = await currentAdmin(db, rawContext);
9
+ const limit = options.limit ?? 50;
10
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_READINESS_PAGE)
11
+ throw new RangeError(`limit must be from 1 through ${MAX_READINESS_PAGE}`);
12
+ const cursor = options.cursor ?? '';
13
+ if (typeof cursor !== 'string' || cursor.length > 32)
14
+ throw new InvalidAuthorizationError('Readiness cursor is invalid');
15
+ const counts = await db
16
+ .prepare(`SELECT
17
+ (SELECT COUNT(*) FROM taproot_entities) AS canonical_entities,
18
+ (SELECT COUNT(*) FROM taproot_entity_revisions) AS canonical_revisions,
19
+ (SELECT COUNT(*) FROM taproot_entity_authorization) AS current_policies,
20
+ (SELECT COUNT(*) FROM taproot_entity_authorization_revisions) AS revision_policies,
21
+ (SELECT COUNT(*) FROM taproot_entities e
22
+ WHERE NOT EXISTS (
23
+ SELECT 1 FROM taproot_entity_authorization p
24
+ WHERE p.entity_id = e.entity_id AND p.source_revision = e.revision
25
+ AND p.installation_id = ?
26
+ AND p.authorization_revision BETWEEN 1 AND ?
27
+ )) AS quarantined_entities`)
28
+ .bind(context.installationId, context.authorizationRevision)
29
+ .all();
30
+ const count = counts.results[0];
31
+ const integrity = await db
32
+ .prepare(`WITH
33
+ expected_current(entity_id, revision, statement_id) AS (
34
+ SELECT e.entity_id, e.revision, json_extract(statement.value, '$.id')
35
+ FROM taproot_entities e,
36
+ json_each(e.entity_json, '$.claims') claim,
37
+ json_each(claim.value) statement
38
+ ),
39
+ expected_history(entity_id, revision, statement_id) AS (
40
+ SELECT r.entity_id, r.revision, json_extract(statement.value, '$.id')
41
+ FROM taproot_entity_revisions r,
42
+ json_each(r.entity_json, '$.claims') claim,
43
+ json_each(claim.value) statement
44
+ )
45
+ SELECT
46
+ (SELECT COUNT(*) FROM taproot_entity_revisions r WHERE NOT EXISTS (
47
+ SELECT 1 FROM taproot_entity_authorization_revisions p
48
+ WHERE p.entity_id = r.entity_id AND p.source_revision = r.revision
49
+ AND p.installation_id = ?
50
+ AND p.authorization_revision BETWEEN 1 AND ?
51
+ )) +
52
+ (SELECT COUNT(*) FROM taproot_entity_authorization_revisions p
53
+ WHERE NOT EXISTS (
54
+ SELECT 1 FROM taproot_entity_revisions r
55
+ WHERE r.entity_id = p.entity_id AND r.revision = p.source_revision
56
+ )) AS revision_policy_mismatches,
57
+ (SELECT COUNT(*) FROM expected_current e WHERE NOT EXISTS (
58
+ SELECT 1 FROM taproot_statement_authorization p
59
+ WHERE p.entity_id = e.entity_id AND p.source_revision = e.revision
60
+ AND p.statement_id = e.statement_id
61
+ )) +
62
+ (SELECT COUNT(*) FROM taproot_statement_authorization p WHERE NOT EXISTS (
63
+ SELECT 1 FROM expected_current e
64
+ WHERE e.entity_id = p.entity_id AND e.revision = p.source_revision
65
+ AND e.statement_id = p.statement_id
66
+ )) +
67
+ (SELECT COUNT(*) FROM expected_history e WHERE NOT EXISTS (
68
+ SELECT 1 FROM taproot_statement_authorization_revisions p
69
+ WHERE p.entity_id = e.entity_id AND p.source_revision = e.revision
70
+ AND p.statement_id = e.statement_id
71
+ )) +
72
+ (SELECT COUNT(*) FROM taproot_statement_authorization_revisions p WHERE NOT EXISTS (
73
+ SELECT 1 FROM expected_history e
74
+ WHERE e.entity_id = p.entity_id AND e.revision = p.source_revision
75
+ AND e.statement_id = p.statement_id
76
+ )) AS statement_policy_mismatches,
77
+ (SELECT COUNT(*) FROM taproot_entity_authorization current
78
+ WHERE NOT EXISTS (
79
+ SELECT 1 FROM taproot_entity_authorization_revisions history
80
+ WHERE history.entity_id = current.entity_id
81
+ AND history.source_revision = current.source_revision
82
+ AND history.installation_id IS current.installation_id
83
+ AND history.workspace_id IS current.workspace_id
84
+ AND history.owner_principal_id IS current.owner_principal_id
85
+ AND history.visibility_json IS current.visibility_json
86
+ AND history.effective_visibility_json IS current.effective_visibility_json
87
+ AND history.authorization_revision IS current.authorization_revision
88
+ AND history.deleted_at IS current.deleted_at
89
+ AND history.event_id IS current.event_id
90
+ AND history.created_at IS current.updated_at
91
+ )) +
92
+ (SELECT COUNT(*) FROM taproot_statement_authorization current
93
+ WHERE NOT EXISTS (
94
+ SELECT 1 FROM taproot_statement_authorization_revisions history
95
+ WHERE history.entity_id = current.entity_id
96
+ AND history.source_revision = current.source_revision
97
+ AND history.statement_id = current.statement_id
98
+ AND history.restrictions_json IS current.restrictions_json
99
+ AND history.effective_visibility_json IS current.effective_visibility_json
100
+ AND history.authorization_revision IS current.authorization_revision
101
+ )) AS current_history_parity_mismatches`)
102
+ .bind(context.installationId, context.authorizationRevision)
103
+ .all();
104
+ const integrityCount = integrity.results[0];
105
+ const payloadMismatches = await policyPayloadMismatchCounts(db, state);
106
+ const page = await db
107
+ .prepare(`SELECT entity_id, revision FROM taproot_entities
108
+ WHERE entity_id > ? ORDER BY entity_id LIMIT ?`)
109
+ .bind(cursor, limit + 1)
110
+ .all();
111
+ const issues = [];
112
+ for (const row of page.results.slice(0, limit)) {
113
+ const codes = await readinessCodes(db, row.entity_id, row.revision, state);
114
+ if (codes.length)
115
+ issues.push({
116
+ entityId: row.entity_id,
117
+ currentRevision: Number(row.revision),
118
+ codes,
119
+ });
120
+ }
121
+ return {
122
+ installationId: state.installation_id,
123
+ authorizationRevision: Number(state.authorization_revision),
124
+ searchGeneration: Number(state.search_generation),
125
+ counts: {
126
+ canonicalEntities: Number(count.canonical_entities),
127
+ canonicalRevisions: Number(count.canonical_revisions),
128
+ currentPolicies: Number(count.current_policies),
129
+ revisionPolicies: Number(count.revision_policies),
130
+ quarantinedEntities: Number(count.quarantined_entities),
131
+ revisionPolicyMismatches: Number(integrityCount.revision_policy_mismatches),
132
+ entityPolicyMismatches: payloadMismatches.entity,
133
+ statementPolicyMismatches: Number(integrityCount.statement_policy_mismatches) +
134
+ payloadMismatches.statement,
135
+ currentHistoryParityMismatches: Number(integrityCount.current_history_parity_mismatches),
136
+ },
137
+ ready: Number(count.quarantined_entities) === 0 &&
138
+ Number(integrityCount.revision_policy_mismatches) === 0 &&
139
+ payloadMismatches.entity === 0 &&
140
+ Number(integrityCount.statement_policy_mismatches) === 0 &&
141
+ payloadMismatches.statement === 0 &&
142
+ Number(integrityCount.current_history_parity_mismatches) === 0,
143
+ issues,
144
+ cursor: page.results.length > limit
145
+ ? (page.results[limit - 1]?.entity_id ?? null)
146
+ : null,
147
+ };
148
+ }
149
+ async function policyPayloadMismatchCounts(db, state, onlyEntityId) {
150
+ const entityRows = await db
151
+ .prepare(`SELECT 'current' AS row_kind, entity_id, source_revision,
152
+ installation_id, authorization_revision,
153
+ visibility_json, effective_visibility_json
154
+ FROM taproot_entity_authorization
155
+ UNION ALL
156
+ SELECT 'history' AS row_kind, entity_id, source_revision,
157
+ installation_id, authorization_revision,
158
+ visibility_json, effective_visibility_json
159
+ FROM taproot_entity_authorization_revisions`)
160
+ .all();
161
+ const entities = new Map();
162
+ for (const row of entityRows.results) {
163
+ if (onlyEntityId !== undefined && row.entity_id !== onlyEntityId)
164
+ continue;
165
+ const key = `${row.row_kind}:${row.entity_id}:${row.source_revision}`;
166
+ try {
167
+ const declared = normalizeVisibilityScope(JSON.parse(row.visibility_json));
168
+ const effective = normalizeVisibilityScope(JSON.parse(row.effective_visibility_json));
169
+ const invalid = row.installation_id !== state.installation_id ||
170
+ Number(row.authorization_revision) < 1 ||
171
+ Number(row.authorization_revision) >
172
+ Number(state.authorization_revision) ||
173
+ serializeVisibilityScope(declared) !== row.visibility_json ||
174
+ serializeVisibilityScope(effective) !== row.effective_visibility_json;
175
+ entities.set(key, {
176
+ declared,
177
+ effective,
178
+ expected: declared,
179
+ invalid,
180
+ });
181
+ }
182
+ catch {
183
+ entities.set(key, {
184
+ declared: null,
185
+ effective: null,
186
+ expected: null,
187
+ invalid: true,
188
+ });
189
+ }
190
+ }
191
+ const statementRows = await db
192
+ .prepare(`SELECT 'current' AS row_kind, s.entity_id, s.source_revision,
193
+ s.authorization_revision, s.restrictions_json,
194
+ s.effective_visibility_json,
195
+ p.authorization_revision AS parent_authorization_revision,
196
+ p.visibility_json AS parent_visibility_json
197
+ FROM taproot_statement_authorization s
198
+ JOIN taproot_entity_authorization p
199
+ ON p.entity_id = s.entity_id AND p.source_revision = s.source_revision
200
+ UNION ALL
201
+ SELECT 'history' AS row_kind, s.entity_id, s.source_revision,
202
+ s.authorization_revision, s.restrictions_json,
203
+ s.effective_visibility_json,
204
+ p.authorization_revision AS parent_authorization_revision,
205
+ p.visibility_json AS parent_visibility_json
206
+ FROM taproot_statement_authorization_revisions s
207
+ JOIN taproot_entity_authorization_revisions p
208
+ ON p.entity_id = s.entity_id AND p.source_revision = s.source_revision`)
209
+ .all();
210
+ let statement = 0;
211
+ for (const row of statementRows.results) {
212
+ if (onlyEntityId !== undefined && row.entity_id !== onlyEntityId)
213
+ continue;
214
+ try {
215
+ const parent = normalizeVisibilityScope(JSON.parse(row.parent_visibility_json));
216
+ const rawRestrictions = JSON.parse(row.restrictions_json);
217
+ if (!Array.isArray(rawRestrictions))
218
+ throw new Error('invalid');
219
+ const restrictions = rawRestrictions.map((scope) => normalizeVisibilityScope(scope));
220
+ const effective = normalizeVisibilityScope(JSON.parse(row.effective_visibility_json));
221
+ const expectedStatement = intersectVisibilityScopes(parent, ...restrictions);
222
+ const invalid = Number(row.authorization_revision) < 1 ||
223
+ Number(row.authorization_revision) >
224
+ Number(state.authorization_revision) ||
225
+ Number(row.authorization_revision) !==
226
+ Number(row.parent_authorization_revision) ||
227
+ JSON.stringify(restrictions) !== row.restrictions_json ||
228
+ serializeVisibilityScope(effective) !== row.effective_visibility_json ||
229
+ serializeVisibilityScope(effective) !==
230
+ serializeVisibilityScope(expectedStatement);
231
+ if (invalid)
232
+ statement += 1;
233
+ const entityPolicy = entities.get(`${row.row_kind}:${row.entity_id}:${row.source_revision}`);
234
+ if (entityPolicy?.expected)
235
+ entityPolicy.expected = intersectVisibilityScopes(entityPolicy.expected, expectedStatement);
236
+ }
237
+ catch {
238
+ statement += 1;
239
+ }
240
+ }
241
+ let entity = 0;
242
+ for (const policy of entities.values()) {
243
+ if (policy.invalid ||
244
+ !policy.effective ||
245
+ !policy.expected ||
246
+ serializeVisibilityScope(policy.effective) !==
247
+ serializeVisibilityScope(policy.expected))
248
+ entity += 1;
249
+ }
250
+ return { entity, statement };
251
+ }
252
+ export async function planAuthorizationBackfill(db, rawContext, inputs, now = new Date().toISOString()) {
253
+ const { context } = await currentAdmin(db, rawContext);
254
+ if (!isArrayValue(inputs) ||
255
+ inputs.length < 1 ||
256
+ inputs.length > MAX_BACKFILL_ENTITIES)
257
+ throw new InvalidAuthorizationError(`A backfill plan must contain 1 through ${MAX_BACKFILL_ENTITIES} entities`);
258
+ const ids = inputs.map(({ entityId }) => entityId);
259
+ if (new Set(ids).size !== ids.length)
260
+ throw new InvalidAuthorizationError('Backfill entity IDs must be unique');
261
+ const manifestEntities = [];
262
+ let revisionCount = 0;
263
+ for (const input of [...inputs].sort((a, b) => compareCodeUnits(a.entityId, b.entityId))) {
264
+ const existing = await db
265
+ .prepare(`SELECT 1 AS found FROM taproot_entity_authorization WHERE entity_id = ?`)
266
+ .bind(input.entityId)
267
+ .all();
268
+ if (existing.results.length)
269
+ throw new RevisionConflictError(`Entity ${input.entityId} already has canonical policy`);
270
+ const canonical = await db
271
+ .prepare(`SELECT revision, entity_json, content_hash, event_id, deleted_at
272
+ FROM taproot_entity_revisions WHERE entity_id = ? ORDER BY revision`)
273
+ .bind(input.entityId)
274
+ .all();
275
+ const supplied = [...input.revisions].sort((a, b) => a.revision - b.revision);
276
+ if (canonical.results.length === 0 ||
277
+ canonical.results.length !== supplied.length)
278
+ throw new RevisionConflictError(`Backfill must explicitly cover every revision of ${input.entityId}`);
279
+ revisionCount += supplied.length;
280
+ if (revisionCount > MAX_BACKFILL_REVISIONS)
281
+ throw new InvalidAuthorizationError(`A backfill plan may contain at most ${MAX_BACKFILL_REVISIONS} revisions`);
282
+ const revisions = [];
283
+ for (let index = 0; index < canonical.results.length; index += 1) {
284
+ const row = canonical.results[index];
285
+ const suppliedRevision = supplied[index];
286
+ if (Number(row.revision) !== suppliedRevision.revision ||
287
+ row.content_hash !== suppliedRevision.contentHash)
288
+ throw new RevisionConflictError(`Backfill revision/hash attestation failed for ${input.entityId}`);
289
+ const entity = parseEntityJson(row.entity_json);
290
+ const normalized = normalizeCanonicalAuthorizationPolicy({
291
+ installationId: context.installationId,
292
+ workspaceId: suppliedRevision.workspaceId,
293
+ ownerPrincipalId: suppliedRevision.ownerPrincipalId,
294
+ visibility: suppliedRevision.visibility,
295
+ statementRestrictions: suppliedRevision.statementRestrictions,
296
+ expectedAuthorizationRevision: context.authorizationRevision,
297
+ });
298
+ const expectedIds = statementIds(entity);
299
+ const providedIds = Object.keys(normalized.statementRestrictions).sort(compareCodeUnits);
300
+ if (JSON.stringify(expectedIds) !== JSON.stringify(providedIds))
301
+ throw new InvalidAuthorizationError(`Backfill statement restrictions do not exactly cover ${input.entityId}@${row.revision}`);
302
+ const statements = expectedIds.map((statementId) => {
303
+ const restrictions = normalized.statementRestrictions[statementId];
304
+ return {
305
+ statementId,
306
+ restrictionsJson: JSON.stringify(restrictions),
307
+ effectiveVisibilityJson: serializeVisibilityScope(intersectVisibilityScopes(normalized.visibility, ...restrictions)),
308
+ };
309
+ });
310
+ const effectiveVisibilityJson = serializeVisibilityScope(intersectVisibilityScopes(normalized.visibility, ...statements.map(({ effectiveVisibilityJson }) => JSON.parse(effectiveVisibilityJson))));
311
+ revisions.push({
312
+ revision: Number(row.revision),
313
+ contentHash: row.content_hash,
314
+ eventId: row.event_id,
315
+ deletedAt: row.deleted_at,
316
+ workspaceId: normalized.workspaceId,
317
+ ownerPrincipalId: normalized.ownerPrincipalId,
318
+ visibilityJson: serializeVisibilityScope(normalized.visibility),
319
+ effectiveVisibilityJson,
320
+ statements,
321
+ });
322
+ }
323
+ manifestEntities.push({ entityId: input.entityId, revisions });
324
+ }
325
+ const manifest = {
326
+ version: 1,
327
+ installationId: context.installationId,
328
+ entities: manifestEntities,
329
+ };
330
+ const manifestJson = JSON.stringify(manifest);
331
+ const manifestHash = await sha256(manifestJson);
332
+ const planId = crypto.randomUUID();
333
+ await db.batch([
334
+ db
335
+ .prepare(`INSERT INTO taproot_authorization_backfill_plans(
336
+ plan_id, installation_id, base_authorization_revision,
337
+ manifest_json, manifest_hash, entity_count, revision_count,
338
+ status, created_by, created_at
339
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'planned', ?, ?)`)
340
+ .bind(planId, context.installationId, context.authorizationRevision, manifestJson, manifestHash, manifestEntities.length, revisionCount, context.principalId, now),
341
+ db
342
+ .prepare(`INSERT INTO taproot_authorization_admin_audit(
343
+ audit_id, event_type, principal_id, plan_id,
344
+ authorization_revision, details_json, created_at
345
+ ) VALUES (?, 'backfill-plan', ?, ?, ?, ?, ?)`)
346
+ .bind(crypto.randomUUID(), context.principalId, planId, context.authorizationRevision, JSON.stringify({
347
+ manifestHash,
348
+ entityCount: manifestEntities.length,
349
+ revisionCount,
350
+ }), now),
351
+ ]);
352
+ return {
353
+ planId,
354
+ installationId: context.installationId,
355
+ baseAuthorizationRevision: context.authorizationRevision,
356
+ manifestHash,
357
+ entityCount: manifestEntities.length,
358
+ revisionCount,
359
+ status: 'planned',
360
+ };
361
+ }
362
+ export async function applyAuthorizationBackfill(db, rawContext, planId, now = new Date().toISOString()) {
363
+ const { context, state } = await currentAdmin(db, rawContext);
364
+ const plan = await db
365
+ .prepare(`SELECT installation_id, base_authorization_revision, manifest_json,
366
+ manifest_hash, entity_count, revision_count, status
367
+ FROM taproot_authorization_backfill_plans WHERE plan_id = ?`)
368
+ .bind(planId)
369
+ .all();
370
+ const row = plan.results[0];
371
+ if (!row)
372
+ throw new InvalidAuthorizationError('Backfill plan was not found');
373
+ if (row.status === 'complete')
374
+ return planResult(planId, row);
375
+ if (row.installation_id !== context.installationId ||
376
+ Number(row.base_authorization_revision) !== context.authorizationRevision ||
377
+ (await sha256(row.manifest_json)) !== row.manifest_hash)
378
+ throw new RevisionConflictError('Backfill plan is stale or corrupted');
379
+ const manifest = JSON.parse(row.manifest_json);
380
+ if (manifest.version !== 1 ||
381
+ manifest.installationId !== context.installationId ||
382
+ manifest.entities.length !== Number(row.entity_count))
383
+ throw new RevisionConflictError('Backfill manifest is invalid');
384
+ const entityCurrent = [];
385
+ const entityHistory = [];
386
+ const statementCurrent = [];
387
+ const statementHistory = [];
388
+ const outbox = [];
389
+ const newAuthorizationRevision = context.authorizationRevision + 1;
390
+ const newSearchGeneration = Number(state.search_generation) + 1;
391
+ for (const entity of manifest.entities) {
392
+ const current = entity.revisions.at(-1);
393
+ entityCurrent.push({ entityId: entity.entityId, ...current });
394
+ for (const revision of entity.revisions) {
395
+ entityHistory.push({ entityId: entity.entityId, ...revision });
396
+ for (const statement of revision.statements)
397
+ statementHistory.push({
398
+ entityId: entity.entityId,
399
+ revision: revision.revision,
400
+ ...statement,
401
+ });
402
+ }
403
+ for (const statement of current.statements)
404
+ statementCurrent.push({
405
+ entityId: entity.entityId,
406
+ revision: current.revision,
407
+ ...statement,
408
+ });
409
+ outbox.push({
410
+ eventId: current.eventId,
411
+ entityId: entity.entityId,
412
+ revision: current.revision,
413
+ operation: current.deletedAt ? 'delete' : 'backfill',
414
+ });
415
+ }
416
+ const statements = [
417
+ db
418
+ .prepare(`UPDATE taproot_installation_authorization
419
+ SET authorization_revision = ?, search_generation = ?, last_advance_id = ?, updated_at = ?
420
+ WHERE singleton = 1 AND installation_id = ?
421
+ AND authorization_revision = ? AND search_generation = ?
422
+ AND last_advance_id = ?`)
423
+ .bind(newAuthorizationRevision, newSearchGeneration, planId, now, context.installationId, context.authorizationRevision, state.search_generation, state.last_advance_id),
424
+ assertion(db, `EXISTS (SELECT 1 FROM taproot_installation_authorization
425
+ WHERE singleton = 1 AND installation_id = ?
426
+ AND authorization_revision = ? AND search_generation = ?
427
+ AND last_advance_id = ?)`, context.installationId, newAuthorizationRevision, newSearchGeneration, planId),
428
+ assertion(db, `NOT EXISTS (
429
+ SELECT 1 FROM taproot_entity_authorization
430
+ WHERE entity_id IN (
431
+ SELECT json_extract(value, '$.entityId') FROM json_each(?)
432
+ )
433
+ )`, JSON.stringify(entityCurrent)),
434
+ db
435
+ .prepare(`INSERT INTO taproot_entity_authorization(
436
+ entity_id, installation_id, workspace_id, owner_principal_id,
437
+ visibility_json, effective_visibility_json, source_revision,
438
+ authorization_revision, deleted_at, event_id, updated_at
439
+ )
440
+ SELECT json_extract(value, '$.entityId'), ?,
441
+ json_extract(value, '$.workspaceId'),
442
+ json_extract(value, '$.ownerPrincipalId'),
443
+ json_extract(value, '$.visibilityJson'),
444
+ json_extract(value, '$.effectiveVisibilityJson'),
445
+ json_extract(value, '$.revision'), ?,
446
+ json_extract(value, '$.deletedAt'), json_extract(value, '$.eventId'), ?
447
+ FROM json_each(?)`)
448
+ .bind(context.installationId, newAuthorizationRevision, now, JSON.stringify(entityCurrent)),
449
+ db
450
+ .prepare(`INSERT INTO taproot_entity_authorization_revisions(
451
+ entity_id, source_revision, installation_id, workspace_id,
452
+ owner_principal_id, visibility_json, effective_visibility_json,
453
+ authorization_revision, deleted_at, event_id, created_at
454
+ )
455
+ SELECT json_extract(value, '$.entityId'), json_extract(value, '$.revision'), ?,
456
+ json_extract(value, '$.workspaceId'),
457
+ json_extract(value, '$.ownerPrincipalId'),
458
+ json_extract(value, '$.visibilityJson'),
459
+ json_extract(value, '$.effectiveVisibilityJson'), ?,
460
+ json_extract(value, '$.deletedAt'), json_extract(value, '$.eventId'), ?
461
+ FROM json_each(?)`)
462
+ .bind(context.installationId, newAuthorizationRevision, now, JSON.stringify(entityHistory)),
463
+ db
464
+ .prepare(`INSERT INTO taproot_statement_authorization(
465
+ entity_id, statement_id, source_revision, restrictions_json,
466
+ effective_visibility_json, authorization_revision
467
+ )
468
+ SELECT json_extract(value, '$.entityId'),
469
+ json_extract(value, '$.statementId'), json_extract(value, '$.revision'),
470
+ json_extract(value, '$.restrictionsJson'),
471
+ json_extract(value, '$.effectiveVisibilityJson'), ?
472
+ FROM json_each(?)`)
473
+ .bind(newAuthorizationRevision, JSON.stringify(statementCurrent)),
474
+ db
475
+ .prepare(`INSERT INTO taproot_statement_authorization_revisions(
476
+ entity_id, source_revision, statement_id, restrictions_json,
477
+ effective_visibility_json, authorization_revision
478
+ )
479
+ SELECT json_extract(value, '$.entityId'), json_extract(value, '$.revision'),
480
+ json_extract(value, '$.statementId'),
481
+ json_extract(value, '$.restrictionsJson'),
482
+ json_extract(value, '$.effectiveVisibilityJson'), ?
483
+ FROM json_each(?)`)
484
+ .bind(newAuthorizationRevision, JSON.stringify(statementHistory)),
485
+ db
486
+ .prepare(`INSERT INTO taproot_authorization_projection_outbox(
487
+ event_id, entity_id, source_revision, authorization_revision,
488
+ search_generation, operation, created_at
489
+ )
490
+ SELECT json_extract(value, '$.eventId'), json_extract(value, '$.entityId'),
491
+ json_extract(value, '$.revision'), ?, ?,
492
+ json_extract(value, '$.operation'), ? FROM json_each(?)`)
493
+ .bind(newAuthorizationRevision, newSearchGeneration, now, JSON.stringify(outbox)),
494
+ db
495
+ .prepare(`UPDATE taproot_authorization_backfill_plans
496
+ SET status = 'complete', completed_at = ?
497
+ WHERE plan_id = ? AND status = 'planned'
498
+ AND base_authorization_revision = ?`)
499
+ .bind(now, planId, context.authorizationRevision),
500
+ assertion(db, `EXISTS (SELECT 1 FROM taproot_authorization_backfill_plans
501
+ WHERE plan_id = ? AND status = 'complete')`, planId),
502
+ db
503
+ .prepare(`INSERT INTO taproot_authorization_admin_audit(
504
+ audit_id, event_type, principal_id, plan_id,
505
+ authorization_revision, details_json, created_at
506
+ ) VALUES (?, 'backfill-apply', ?, ?, ?, ?, ?)`)
507
+ .bind(crypto.randomUUID(), context.principalId, planId, newAuthorizationRevision, JSON.stringify({
508
+ manifestHash: row.manifest_hash,
509
+ entityCount: row.entity_count,
510
+ revisionCount: row.revision_count,
511
+ }), now),
512
+ ];
513
+ try {
514
+ await db.batch(statements);
515
+ }
516
+ catch (cause) {
517
+ throw new RevisionConflictError('Backfill apply lost its canonical or authorization CAS', { cause: cause instanceof Error ? cause : undefined });
518
+ }
519
+ return {
520
+ ...planResult(planId, row),
521
+ status: 'complete',
522
+ };
523
+ }
524
+ async function currentAdmin(db, rawContext) {
525
+ const context = normalizeAuthorizationContext(rawContext);
526
+ requireSearchAdministration(context);
527
+ const result = await db
528
+ .prepare(`SELECT installation_id, authorization_revision, search_generation, last_advance_id
529
+ FROM taproot_installation_authorization WHERE singleton = 1`)
530
+ .all();
531
+ const state = result.results[0];
532
+ if (!state ||
533
+ state.installation_id !== context.installationId ||
534
+ Number(state.authorization_revision) !== context.authorizationRevision)
535
+ throw new AuthorizationDeniedError('Authorization denied');
536
+ return { context, state };
537
+ }
538
+ async function readinessCodes(db, entityId, currentRevision, state) {
539
+ const codes = [];
540
+ const policy = await db
541
+ .prepare(`SELECT installation_id, source_revision, authorization_revision
542
+ FROM taproot_entity_authorization WHERE entity_id = ?`)
543
+ .bind(entityId)
544
+ .all();
545
+ const current = policy.results[0];
546
+ if (!current)
547
+ codes.push('missing-current-policy');
548
+ else {
549
+ if (Number(current.source_revision) !== Number(currentRevision))
550
+ codes.push('current-revision-mismatch');
551
+ if (current.installation_id !== state.installation_id)
552
+ codes.push('cross-installation-policy');
553
+ if (Number(current.authorization_revision) < 1 ||
554
+ Number(current.authorization_revision) >
555
+ Number(state.authorization_revision))
556
+ codes.push('authorization-revision-invalid');
557
+ }
558
+ const missingHistory = await db
559
+ .prepare(`SELECT COUNT(*) AS count FROM taproot_entity_revisions r
560
+ WHERE r.entity_id = ? AND NOT EXISTS (
561
+ SELECT 1 FROM taproot_entity_authorization_revisions p
562
+ WHERE p.entity_id = r.entity_id AND p.source_revision = r.revision
563
+ )`)
564
+ .bind(entityId)
565
+ .all();
566
+ if (Number(missingHistory.results[0]?.count ?? 0) !== 0)
567
+ codes.push('missing-revision-policy');
568
+ const statementMismatch = await db
569
+ .prepare(`WITH expected_current(entity_id, revision, statement_id) AS (
570
+ SELECT e.entity_id, e.revision, json_extract(statement.value, '$.id')
571
+ FROM taproot_entities e,
572
+ json_each(e.entity_json, '$.claims') claim,
573
+ json_each(claim.value) statement
574
+ WHERE e.entity_id = ?
575
+ ), expected_history(entity_id, revision, statement_id) AS (
576
+ SELECT r.entity_id, r.revision, json_extract(statement.value, '$.id')
577
+ FROM taproot_entity_revisions r,
578
+ json_each(r.entity_json, '$.claims') claim,
579
+ json_each(claim.value) statement
580
+ WHERE r.entity_id = ?
581
+ )
582
+ SELECT
583
+ (SELECT COUNT(*) FROM expected_current e WHERE NOT EXISTS (
584
+ SELECT 1 FROM taproot_statement_authorization p
585
+ WHERE p.entity_id = e.entity_id AND p.source_revision = e.revision
586
+ AND p.statement_id = e.statement_id
587
+ )) +
588
+ (SELECT COUNT(*) FROM taproot_statement_authorization p
589
+ WHERE p.entity_id = ? AND NOT EXISTS (
590
+ SELECT 1 FROM expected_current e
591
+ WHERE e.entity_id = p.entity_id AND e.revision = p.source_revision
592
+ AND e.statement_id = p.statement_id
593
+ )) +
594
+ (SELECT COUNT(*) FROM expected_history e WHERE NOT EXISTS (
595
+ SELECT 1 FROM taproot_statement_authorization_revisions p
596
+ WHERE p.entity_id = e.entity_id AND p.source_revision = e.revision
597
+ AND p.statement_id = e.statement_id
598
+ )) +
599
+ (SELECT COUNT(*) FROM taproot_statement_authorization_revisions p
600
+ WHERE p.entity_id = ? AND NOT EXISTS (
601
+ SELECT 1 FROM expected_history e
602
+ WHERE e.entity_id = p.entity_id AND e.revision = p.source_revision
603
+ AND e.statement_id = p.statement_id
604
+ )) AS count`)
605
+ .bind(entityId, entityId, entityId, entityId)
606
+ .all();
607
+ if (Number(statementMismatch.results[0]?.count ?? 0) !== 0)
608
+ codes.push('statement-policy-mismatch');
609
+ const payloadMismatch = await policyPayloadMismatchCounts(db, state, entityId);
610
+ if (payloadMismatch.entity > 0 && !codes.includes('entity-policy-mismatch'))
611
+ codes.push('entity-policy-mismatch');
612
+ if (payloadMismatch.statement > 0 &&
613
+ !codes.includes('statement-policy-mismatch'))
614
+ codes.push('statement-policy-mismatch');
615
+ const parity = await db
616
+ .prepare(`SELECT
617
+ (SELECT COUNT(*) FROM taproot_entity_authorization current
618
+ WHERE current.entity_id = ? AND NOT EXISTS (
619
+ SELECT 1 FROM taproot_entity_authorization_revisions history
620
+ WHERE history.entity_id = current.entity_id
621
+ AND history.source_revision = current.source_revision
622
+ AND history.installation_id IS current.installation_id
623
+ AND history.workspace_id IS current.workspace_id
624
+ AND history.owner_principal_id IS current.owner_principal_id
625
+ AND history.visibility_json IS current.visibility_json
626
+ AND history.effective_visibility_json IS current.effective_visibility_json
627
+ AND history.authorization_revision IS current.authorization_revision
628
+ AND history.deleted_at IS current.deleted_at
629
+ AND history.event_id IS current.event_id
630
+ AND history.created_at IS current.updated_at
631
+ )) AS entity_count,
632
+ (SELECT COUNT(*) FROM taproot_statement_authorization current
633
+ WHERE current.entity_id = ? AND NOT EXISTS (
634
+ SELECT 1 FROM taproot_statement_authorization_revisions history
635
+ WHERE history.entity_id = current.entity_id
636
+ AND history.source_revision = current.source_revision
637
+ AND history.statement_id = current.statement_id
638
+ AND history.restrictions_json IS current.restrictions_json
639
+ AND history.effective_visibility_json IS current.effective_visibility_json
640
+ AND history.authorization_revision IS current.authorization_revision
641
+ )) AS statement_count`)
642
+ .bind(entityId, entityId)
643
+ .all();
644
+ if (Number(parity.results[0]?.entity_count ?? 0) > 0 &&
645
+ !codes.includes('entity-policy-mismatch'))
646
+ codes.push('entity-policy-mismatch');
647
+ if (Number(parity.results[0]?.statement_count ?? 0) > 0 &&
648
+ !codes.includes('statement-policy-mismatch'))
649
+ codes.push('statement-policy-mismatch');
650
+ return codes;
651
+ }
652
+ function assertion(db, condition, ...values) {
653
+ return db
654
+ .prepare(`INSERT INTO taproot_assertions(assertion_key)
655
+ SELECT NULL WHERE NOT (${condition})`)
656
+ .bind(...values);
657
+ }
658
+ function planResult(planId, row) {
659
+ return {
660
+ planId,
661
+ installationId: row.installation_id,
662
+ baseAuthorizationRevision: Number(row.base_authorization_revision),
663
+ manifestHash: row.manifest_hash,
664
+ entityCount: Number(row.entity_count),
665
+ revisionCount: Number(row.revision_count),
666
+ status: row.status,
667
+ };
668
+ }
669
+ function statementIds(entity) {
670
+ return Object.values(entity.claims)
671
+ .flatMap((statements) => statements.map(({ id }) => id))
672
+ .sort(compareCodeUnits);
673
+ }
674
+ function compareCodeUnits(left, right) {
675
+ return left < right ? -1 : left > right ? 1 : 0;
676
+ }
677
+ function isArrayValue(value) {
678
+ return Array.isArray(value);
679
+ }
680
+ async function sha256(value) {
681
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
682
+ return [...new Uint8Array(digest)]
683
+ .map((byte) => byte.toString(16).padStart(2, '0'))
684
+ .join('');
685
+ }
686
+ //# sourceMappingURL=authorization-maintenance.js.map