@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,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
|