@anchrd/intel-api 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/adapters/db/db-flows.js +194 -16
- package/dist/adapters/db/db-grants.d.ts +61 -9
- package/dist/adapters/db/db-grants.js +118 -16
- package/dist/adapters/db/db.js +6 -37
- package/dist/flows/flows.js +144 -0
- package/dist/flows/flows.types.d.ts +28 -3
- package/dist/http/http.js +28 -4
- package/dist/mcp/mcp.js +41 -3
- package/dist/nodes/nodes.js +11 -1
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -303,8 +303,10 @@ the titles of the folders above it. Nobody has to be sent a link.
|
|
|
303
303
|
|
|
304
304
|
### Three things about grants that surprise people
|
|
305
305
|
|
|
306
|
-
- **A flow is not a node
|
|
307
|
-
|
|
306
|
+
- **A flow is not a node, and is reached by two things at once.** The folder it is filed in passes
|
|
307
|
+
its grants down, and since #530 a grant may sit on the flow itself; they add up. Move a flow and
|
|
308
|
+
you have changed the first half of that. A grant on one flow does not reach the flows it calls —
|
|
309
|
+
the answer names them, and `flow_validate` tells the person about to run what is still missing.
|
|
308
310
|
- **`organization` + `execute` on a folder makes that folder a library**: flows from anywhere in the
|
|
309
311
|
tree may then call into it. The share dialog warns before the click, because the way back is
|
|
310
312
|
narrow — revoking is refused with `409 folder_execute_in_use` for as long as one of those callers
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Flow, FlowGraph, FlowVersion, } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import { FlowRun } from "@anchrd/intel-contract/flow-run";
|
|
3
3
|
import { calleeIds, treeLinkKinds } from "../../flows/flows.js";
|
|
4
|
-
import { flowCallable, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
4
|
+
import { flowCallable, flowCallableBindings, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, grantColumns, grantInForce, mapGrant, principalColumns, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
5
5
|
const flowColumnNames = [
|
|
6
6
|
"id",
|
|
7
7
|
"parent_id",
|
|
@@ -71,9 +71,9 @@ function mapVersion(row) {
|
|
|
71
71
|
// it was started. Everything else is the folder the flow is filed in, read through `flowInSubtree`,
|
|
72
72
|
// the very predicate the flow list itself uses.
|
|
73
73
|
//
|
|
74
|
-
// It expects `flow_runs run` joined to `flows flow`, and its bindings follow the CTE's.
|
|
74
|
+
// It expects `flow_runs run` joined to `flows flow`, and its bindings follow the CTE's. The bindings
|
|
75
|
+
// live in the factory below because `flowInSubtree` now needs the moment expiry is measured against.
|
|
75
76
|
const runVisible = `(run.initiated_by = ? OR ${flowInSubtree})`;
|
|
76
|
-
const runVisibleBindings = (actor) => [actor.id, ...flowInSubtreeBindings(actor)];
|
|
77
77
|
function mapStep(row) {
|
|
78
78
|
return {
|
|
79
79
|
runId: row.run_id,
|
|
@@ -106,11 +106,17 @@ function mapRun(row) {
|
|
|
106
106
|
// statement may carry, and the lists here are as long as a graph, a call chain or a drawn level —
|
|
107
107
|
// so an `IN (?, ?, …)` built from the input would be a limit waiting to be hit by real data (#30).
|
|
108
108
|
const idList = "(SELECT value FROM json_each(?))";
|
|
109
|
+
const flowGrantColumns = grantColumns("flow_id");
|
|
109
110
|
export function createFlowRepository(deps) {
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
111
|
+
// The folders this actor may open, read through the same walk the tree uses, so the two can never
|
|
112
|
+
// drift apart. The CTE stands in front of the statement, so its bindings come before every other
|
|
113
|
+
// one.
|
|
113
114
|
const readableBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
|
|
115
|
+
// The other half of the same question since #530: the folders this actor reads come from the walk
|
|
116
|
+
// above, the flows handed to them directly come from `flow_grants`. Always `read` here, because
|
|
117
|
+
// every statement these two appear in together is seeded with `read`.
|
|
118
|
+
const inSubtreeBindings = (actor) => flowInSubtreeBindings(actor, "read", deps.now().toISOString());
|
|
119
|
+
const runVisibleBindings = (actor) => [actor.id, ...inSubtreeBindings(actor)];
|
|
114
120
|
// An absent `parentId` asks for every visible flow; `null` asks for the root of the shared tree.
|
|
115
121
|
// `IS ?` would collapse the two, so the two cases are separate SQL rather than one binding that
|
|
116
122
|
// silently means both.
|
|
@@ -152,14 +158,14 @@ export function createFlowRepository(deps) {
|
|
|
152
158
|
SELECT ${flowColumns} FROM flows flow
|
|
153
159
|
WHERE ${flowInSubtree} AND flow.archived_at IS NOT NULL
|
|
154
160
|
ORDER BY flow.archived_at DESC, lower(flow.title), flow.id`)
|
|
155
|
-
.bind(...readableBindings(actor), ...
|
|
161
|
+
.bind(...readableBindings(actor), ...inSubtreeBindings(actor))
|
|
156
162
|
.all();
|
|
157
163
|
return (result.results ?? []).map(mapFlow);
|
|
158
164
|
}
|
|
159
165
|
const scope = scopeOf(input.parentId);
|
|
160
166
|
const result = await deps.db
|
|
161
167
|
.prepare(visibleFlows(scope.clause, false, input.includeArchived === true))
|
|
162
|
-
.bind(...readableBindings(actor), ...
|
|
168
|
+
.bind(...readableBindings(actor), ...inSubtreeBindings(actor), ...scope.bindings)
|
|
163
169
|
.all();
|
|
164
170
|
return (result.results ?? []).map(mapFlow);
|
|
165
171
|
},
|
|
@@ -167,7 +173,7 @@ export function createFlowRepository(deps) {
|
|
|
167
173
|
const scope = scopeOf(folderId);
|
|
168
174
|
const result = await deps.db
|
|
169
175
|
.prepare(visibleFlows(scope.clause, true))
|
|
170
|
-
.bind(...readableBindings(actor), ...
|
|
176
|
+
.bind(...readableBindings(actor), ...inSubtreeBindings(actor), ...scope.bindings, limit)
|
|
171
177
|
.all();
|
|
172
178
|
const rows = result.results ?? [];
|
|
173
179
|
return { items: rows.map(mapFlow), total: rows[0]?.total ?? 0 };
|
|
@@ -177,10 +183,175 @@ export function createFlowRepository(deps) {
|
|
|
177
183
|
.prepare(`${subtreeCte}
|
|
178
184
|
SELECT ${flowColumns} FROM flows flow
|
|
179
185
|
WHERE flow.id = ? AND ${flowInSubtree}`)
|
|
180
|
-
.bind(...readableBindings(actor), flowId, ...
|
|
186
|
+
.bind(...readableBindings(actor), flowId, ...inSubtreeBindings(actor))
|
|
181
187
|
.first();
|
|
182
188
|
return row ? mapFlow(row) : null;
|
|
183
189
|
},
|
|
190
|
+
async listFlowGrants(flowId) {
|
|
191
|
+
const result = await deps.db
|
|
192
|
+
.prepare(`SELECT ${flowGrantColumns} FROM flow_grants
|
|
193
|
+
WHERE flow_id = ?
|
|
194
|
+
ORDER BY principal_type, principal_id, verb`)
|
|
195
|
+
.bind(flowId)
|
|
196
|
+
.all();
|
|
197
|
+
return (result.results ?? []).map(mapGrant);
|
|
198
|
+
},
|
|
199
|
+
// ⚠️ Owners and grants out of ONE statement, hence one D1 snapshot, for the reason the node side
|
|
200
|
+
// gives: two parallel SELECTs are not atomic, and a grant written between their snapshots
|
|
201
|
+
// produces a principal list that never existed as a whole.
|
|
202
|
+
//
|
|
203
|
+
// ⚠️ The grants come from BOTH tables. Reading only `flow_grants` here would draw a flow as
|
|
204
|
+
// though the folder above it granted nothing — which is the everyday case, not the exception.
|
|
205
|
+
async listEffectiveFlowAccess(flowId) {
|
|
206
|
+
const row = await deps.db
|
|
207
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
|
|
208
|
+
SELECT folder.id, folder.parent_id, folder.owner_id
|
|
209
|
+
FROM nodes folder
|
|
210
|
+
JOIN flows flow ON flow.parent_id = folder.id
|
|
211
|
+
WHERE flow.id = ?
|
|
212
|
+
UNION
|
|
213
|
+
SELECT parent.id, parent.parent_id, parent.owner_id
|
|
214
|
+
FROM nodes parent
|
|
215
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
216
|
+
)
|
|
217
|
+
SELECT
|
|
218
|
+
(SELECT json_group_array(owner_id) FROM (
|
|
219
|
+
SELECT DISTINCT owner_id FROM (
|
|
220
|
+
SELECT owner_id FROM flows WHERE id = ?
|
|
221
|
+
UNION
|
|
222
|
+
SELECT owner_id FROM ancestors
|
|
223
|
+
) ORDER BY owner_id
|
|
224
|
+
)) AS owner_ids_json,
|
|
225
|
+
(SELECT json_group_array(json_object(
|
|
226
|
+
'id', id, 'resource_id', resource_id, 'principal_type', principal_type,
|
|
227
|
+
'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
|
|
228
|
+
'created_by', created_by, 'created_at', created_at
|
|
229
|
+
))
|
|
230
|
+
FROM (
|
|
231
|
+
SELECT grant_row.id, grant_row.flow_id AS resource_id, grant_row.principal_type,
|
|
232
|
+
grant_row.principal_id, grant_row.verb, grant_row.expires_at,
|
|
233
|
+
grant_row.created_by, grant_row.created_at
|
|
234
|
+
FROM flow_grants grant_row
|
|
235
|
+
WHERE grant_row.flow_id = ?
|
|
236
|
+
AND ${grantInForce}
|
|
237
|
+
UNION ALL
|
|
238
|
+
SELECT grant_row.id, grant_row.node_id AS resource_id, grant_row.principal_type,
|
|
239
|
+
grant_row.principal_id, grant_row.verb, grant_row.expires_at,
|
|
240
|
+
grant_row.created_by, grant_row.created_at
|
|
241
|
+
FROM node_grants grant_row
|
|
242
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
243
|
+
WHERE ${grantInForce}
|
|
244
|
+
ORDER BY principal_type, principal_id, verb
|
|
245
|
+
)) AS grants_json`)
|
|
246
|
+
.bind(flowId, flowId, flowId, deps.now().toISOString(), deps.now().toISOString())
|
|
247
|
+
.first();
|
|
248
|
+
return {
|
|
249
|
+
ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
|
|
250
|
+
items: JSON.parse(row?.grants_json ?? "[]").map(mapGrant),
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
async setFlowGrant(input) {
|
|
254
|
+
const grant = input.grant;
|
|
255
|
+
const { type: principalType, id: principalId } = principalColumns(grant.principal);
|
|
256
|
+
try {
|
|
257
|
+
await deps.db.batch([
|
|
258
|
+
deps.db
|
|
259
|
+
// The verb is part of the key, so re-granting the same verb only refreshes its expiry
|
|
260
|
+
// and never turns one verb into another.
|
|
261
|
+
.prepare(`INSERT INTO flow_grants (
|
|
262
|
+
id, flow_id, principal_type, principal_id, verb,
|
|
263
|
+
expires_at, created_by, created_at
|
|
264
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
265
|
+
ON CONFLICT (flow_id, principal_type, principal_id, verb)
|
|
266
|
+
DO UPDATE SET expires_at = excluded.expires_at`)
|
|
267
|
+
.bind(grant.id, input.flowId, principalType, principalId, grant.verb, grant.expiresAt, grant.createdBy, grant.createdAt),
|
|
268
|
+
deps.db
|
|
269
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
270
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
271
|
+
) SELECT ?, 'flows.share', ?, id, ? FROM flow_grants
|
|
272
|
+
WHERE flow_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
|
|
273
|
+
.bind(input.actorId, input.idempotencyKey, grant.createdAt, input.flowId, principalType, principalId, grant.verb),
|
|
274
|
+
deps.db
|
|
275
|
+
.prepare(`INSERT INTO audit_events (
|
|
276
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
277
|
+
) VALUES (?, ?, 'flows.share', 'flow', ?, ?, ?)`)
|
|
278
|
+
.bind(input.auditId, input.actorId, input.flowId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
|
|
279
|
+
]);
|
|
280
|
+
const stored = await deps.db
|
|
281
|
+
.prepare(`SELECT ${flowGrantColumns} FROM flow_grants
|
|
282
|
+
WHERE flow_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
|
|
283
|
+
.bind(input.flowId, principalType, principalId, grant.verb)
|
|
284
|
+
.first();
|
|
285
|
+
if (!stored)
|
|
286
|
+
throw new Error("Flow grant disappeared after upsert");
|
|
287
|
+
return mapGrant(stored);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const replayed = await deps.db
|
|
291
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
292
|
+
WHERE actor_id = ? AND operation = 'flows.share' AND idempotency_key = ?`)
|
|
293
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
294
|
+
.first();
|
|
295
|
+
if (replayed) {
|
|
296
|
+
const row = await deps.db
|
|
297
|
+
.prepare(`SELECT ${flowGrantColumns} FROM flow_grants WHERE id = ?`)
|
|
298
|
+
.bind(replayed.resource_id)
|
|
299
|
+
.first();
|
|
300
|
+
if (row)
|
|
301
|
+
return mapGrant(row);
|
|
302
|
+
}
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
// ⚠️ Whether the row was there is decided BEFORE it is deleted and remembered in the
|
|
307
|
+
// idempotency key's own value, because afterwards nothing can tell "I removed it" from "it was
|
|
308
|
+
// never here" — and a replay that answered `true` to the second would claim a deletion that did
|
|
309
|
+
// not happen. The `1:`/`0:` prefix is the node side's device, and it is the same device here.
|
|
310
|
+
async revokeFlowGrant(input) {
|
|
311
|
+
try {
|
|
312
|
+
await deps.db.batch([
|
|
313
|
+
deps.db
|
|
314
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
315
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
316
|
+
) SELECT ?, 'flows.revoke', ?,
|
|
317
|
+
(CASE WHEN EXISTS (
|
|
318
|
+
SELECT 1 FROM flow_grants WHERE id = ? AND flow_id = ?
|
|
319
|
+
) THEN '1:' ELSE '0:' END) || ?, ?`)
|
|
320
|
+
.bind(input.actorId, input.idempotencyKey, input.grantId, input.flowId, input.grantId, input.occurredAt),
|
|
321
|
+
deps.db
|
|
322
|
+
.prepare("DELETE FROM flow_grants WHERE id = ? AND flow_id = ?")
|
|
323
|
+
.bind(input.grantId, input.flowId),
|
|
324
|
+
deps.db
|
|
325
|
+
.prepare(`INSERT INTO audit_events (
|
|
326
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
327
|
+
) SELECT ?, ?, 'flows.revoke', 'flow', ?,
|
|
328
|
+
json_object(
|
|
329
|
+
'grantId', ?,
|
|
330
|
+
'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
|
|
331
|
+
), ?
|
|
332
|
+
FROM idempotency_keys
|
|
333
|
+
WHERE actor_id = ? AND operation = 'flows.revoke' AND idempotency_key = ?`)
|
|
334
|
+
.bind(input.auditId, input.actorId, input.flowId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
|
|
335
|
+
]);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
const replayed = await this.findIdempotentFlowRevocation(input.actorId, input.idempotencyKey);
|
|
339
|
+
if (replayed === null)
|
|
340
|
+
throw error;
|
|
341
|
+
return replayed;
|
|
342
|
+
}
|
|
343
|
+
return ((await this.findIdempotentFlowRevocation(input.actorId, input.idempotencyKey)) ?? false);
|
|
344
|
+
},
|
|
345
|
+
async findIdempotentFlowRevocation(actorId, idempotencyKey) {
|
|
346
|
+
const row = await deps.db
|
|
347
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
348
|
+
WHERE actor_id = ? AND operation = 'flows.revoke' AND idempotency_key = ?`)
|
|
349
|
+
.bind(actorId, idempotencyKey)
|
|
350
|
+
.first();
|
|
351
|
+
if (!row)
|
|
352
|
+
return null;
|
|
353
|
+
return row.resource_id.startsWith("1:");
|
|
354
|
+
},
|
|
184
355
|
async can(actor, flowId, verb) {
|
|
185
356
|
const row = await deps.db
|
|
186
357
|
.prepare(flowVerbQuery)
|
|
@@ -202,7 +373,7 @@ export function createFlowRepository(deps) {
|
|
|
202
373
|
SELECT ${flowColumns} FROM flows flow
|
|
203
374
|
WHERE flow.id IN ${idList} AND ${flowCallable}
|
|
204
375
|
ORDER BY lower(flow.title), flow.id`)
|
|
205
|
-
.bind(...subtreeBindings(actor, "read", occurredAt), ...subtreeBindings(actor, "execute", occurredAt), JSON.stringify(flowIds), ...
|
|
376
|
+
.bind(...subtreeBindings(actor, "read", occurredAt), ...subtreeBindings(actor, "execute", occurredAt), JSON.stringify(flowIds), ...flowCallableBindings(actor, occurredAt))
|
|
206
377
|
.all();
|
|
207
378
|
return (result.results ?? []).map(mapFlow);
|
|
208
379
|
},
|
|
@@ -233,7 +404,7 @@ export function createFlowRepository(deps) {
|
|
|
233
404
|
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
234
405
|
WHERE grant_row.principal_type = 'organization'
|
|
235
406
|
AND grant_row.verb = 'execute'
|
|
236
|
-
AND
|
|
407
|
+
AND ${grantInForce}
|
|
237
408
|
) AS library`)
|
|
238
409
|
.bind(calleeFolderId, callerFolderId, deps.now().toISOString())
|
|
239
410
|
.first();
|
|
@@ -267,7 +438,7 @@ export function createFlowRepository(deps) {
|
|
|
267
438
|
AND callee.parent_id IN (SELECT id FROM scope)
|
|
268
439
|
AND (flow.parent_id IS NULL OR flow.parent_id NOT IN (SELECT id FROM scope))
|
|
269
440
|
ORDER BY lower(flow.title), flow.id`)
|
|
270
|
-
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...
|
|
441
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...inSubtreeBindings(actor))
|
|
271
442
|
.all();
|
|
272
443
|
const rows = result.results ?? [];
|
|
273
444
|
return {
|
|
@@ -291,7 +462,7 @@ export function createFlowRepository(deps) {
|
|
|
291
462
|
AND flow.archived_at IS NULL
|
|
292
463
|
AND flow.id <> ?
|
|
293
464
|
ORDER BY lower(flow.title), flow.id`)
|
|
294
|
-
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...
|
|
465
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...inSubtreeBindings(actor), flowId, flowId)
|
|
295
466
|
.all();
|
|
296
467
|
const rows = result.results ?? [];
|
|
297
468
|
return {
|
|
@@ -315,7 +486,7 @@ export function createFlowRepository(deps) {
|
|
|
315
486
|
AND json_extract(node.value, '$.configuration.resourceId') = ?
|
|
316
487
|
AND flow.archived_at IS NULL
|
|
317
488
|
ORDER BY lower(flow.title), flow.id`)
|
|
318
|
-
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...
|
|
489
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...inSubtreeBindings(actor), nodeId)
|
|
319
490
|
.all();
|
|
320
491
|
const rows = result.results ?? [];
|
|
321
492
|
return {
|
|
@@ -381,6 +552,13 @@ export function createFlowRepository(deps) {
|
|
|
381
552
|
deps.db
|
|
382
553
|
.prepare(`DELETE FROM idempotency_keys WHERE resource_id = ? AND ${aliveFlow}`)
|
|
383
554
|
.bind(input.flowId, input.flowId),
|
|
555
|
+
// The grants that sat on the flow itself (#530). They point at `flows(id)`, so they have to
|
|
556
|
+
// go before the row does — and they carry the same guard as everything above for the same
|
|
557
|
+
// reason: a batch whose last statement matches nothing still commits, and a restore in
|
|
558
|
+
// between would leave a living flow stripped of every grant somebody set on it.
|
|
559
|
+
deps.db
|
|
560
|
+
.prepare(`DELETE FROM flow_grants WHERE flow_id = ? AND ${aliveFlow}`)
|
|
561
|
+
.bind(input.flowId, input.flowId),
|
|
384
562
|
// ⚠️ LAST, and still guarded — a restore between the service's check and this statement
|
|
385
563
|
// would otherwise cost a living flow.
|
|
386
564
|
deps.db
|
|
@@ -425,7 +603,7 @@ export function createFlowRepository(deps) {
|
|
|
425
603
|
FROM referencing
|
|
426
604
|
WHERE resource_id IS NOT NULL
|
|
427
605
|
ORDER BY resource_id`)
|
|
428
|
-
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...
|
|
606
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...inSubtreeBindings(actor))
|
|
429
607
|
.all();
|
|
430
608
|
return (result.results ?? []).map((row) => row.resource_id);
|
|
431
609
|
},
|
|
@@ -1,9 +1,46 @@
|
|
|
1
|
-
import type { ResourceVerb } from "@anchrd/intel-contract/share";
|
|
1
|
+
import type { ResourceGrant, ResourceVerb } from "@anchrd/intel-contract/share";
|
|
2
2
|
export interface GrantActor {
|
|
3
3
|
id: string;
|
|
4
4
|
email: string;
|
|
5
5
|
isAdmin?: boolean;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* A grant row as every reader of one wants it: the subject column already aliased, so the same row
|
|
9
|
+
* shape comes back from `node_grants` and from `flow_grants`.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ The alias is what lets `mapGrant` stay one function. Two mappers would each carry the same
|
|
12
|
+
* `principal_type` switch, and the day a fourth principal shape arrives one of them gets it.
|
|
13
|
+
*/
|
|
14
|
+
export interface GrantRow {
|
|
15
|
+
id: string;
|
|
16
|
+
resource_id: string;
|
|
17
|
+
principal_type: "user" | "email" | "organization";
|
|
18
|
+
principal_id: string;
|
|
19
|
+
verb: ResourceVerb;
|
|
20
|
+
expires_at: string | null;
|
|
21
|
+
created_by: string;
|
|
22
|
+
created_at: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const grantColumns: (subjectColumn: string) => string;
|
|
25
|
+
export declare function mapGrant(row: GrantRow): ResourceGrant;
|
|
26
|
+
export declare function principalColumns(principal: ResourceGrant["principal"]): {
|
|
27
|
+
type: ResourceGrant["principal"]["type"];
|
|
28
|
+
id: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Whether a grant row is in force at the moment bound after it. Takes one binding: that moment.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ The ONE place this repository decides what an expiry means, for both grant tables and for
|
|
34
|
+
* every statement that reads either — the point check, the subtree walk, and the two effective
|
|
35
|
+
* views alike. It is a fragment rather than part of `grantExists` because half the readers do not
|
|
36
|
+
* ask about a principal at all: `listEffectiveAccess` lists every grant along a path, and
|
|
37
|
+
* `callReach` asks only whether an organization-wide `execute` stands. Those had written the
|
|
38
|
+
* condition out by hand, which is exactly the second set of doors the header above warns about —
|
|
39
|
+
* a grace period, a different comparison, and the two spellings drift apart in silence.
|
|
40
|
+
*
|
|
41
|
+
* `git grep "expires_at IS NULL" -- packages/api/src` must find this line and nothing else.
|
|
42
|
+
*/
|
|
43
|
+
export declare const grantInForce = "(grant_row.expires_at IS NULL OR grant_row.expires_at > ?)";
|
|
7
44
|
export declare const subtreeCte: string;
|
|
8
45
|
export declare function subtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
|
|
9
46
|
/**
|
|
@@ -27,9 +64,14 @@ export declare const readableOrRunnableCte: string;
|
|
|
27
64
|
* A flow the actor may open *or* may run, for a statement carrying `readableOrRunnableCte`. It is
|
|
28
65
|
* the callable rule of ADR-0004 §2/§3 — `execute` without `read` is the library, a building block
|
|
29
66
|
* anyone may run and few may open — asked of a whole list of flows at once instead of one flow at a
|
|
30
|
-
* time. Its bindings follow the two CTEs' and are `
|
|
67
|
+
* time. Its bindings follow the two CTEs' and are `flowCallableBindings(actor, now)`.
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ Both direct grants, and both are needed. A flow handed out on its own for `execute` alone is
|
|
70
|
+
* the library case shrunk to one flow — runnable by people who may not open it — and leaving `read`
|
|
71
|
+
* out here would hide a flow somebody was explicitly given to look at.
|
|
31
72
|
*/
|
|
32
|
-
export declare const flowCallable
|
|
73
|
+
export declare const flowCallable: string;
|
|
74
|
+
export declare function flowCallableBindings(actor: GrantActor, now: string): unknown[];
|
|
33
75
|
/**
|
|
34
76
|
* The point check for one node: the node itself and every ancestor above it. Cheaper than
|
|
35
77
|
* the subtree walk and the same answer, because a grant reaches down and never sideways.
|
|
@@ -43,16 +85,26 @@ export declare const flowCallable = "(\n ? = 1\n OR flow.owner_id = ?\n OR fl
|
|
|
43
85
|
export declare const nodeVerbQuery: string;
|
|
44
86
|
export declare function nodeVerbBindings(nodeId: string, actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
|
|
45
87
|
/**
|
|
46
|
-
* The same question for a flow.
|
|
47
|
-
* folder it is filed in
|
|
48
|
-
*
|
|
49
|
-
* who created it. `UNION` for the
|
|
88
|
+
* The same question for a flow. Three things reach it, and they are OR-ed rather than ranked: its
|
|
89
|
+
* owner, a grant on the folder it is filed in or any folder above that, and — since #530 — a grant
|
|
90
|
+
* that sits on the flow itself. Its owner keeps it the way a node's owner keeps theirs, otherwise a
|
|
91
|
+
* flow at the root of the tree would be unreachable by the person who created it. `UNION` for the
|
|
92
|
+
* same reason as above.
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ The direct grant is asked BEFORE the ancestor walk, and only because it is cheaper: a point
|
|
95
|
+
* lookup on an indexed column against a recursive walk of the tree. It decides nothing the walk
|
|
96
|
+
* would have decided differently — both are the same OR.
|
|
50
97
|
*/
|
|
51
98
|
export declare const flowVerbQuery: string;
|
|
52
99
|
export declare function flowVerbBindings(flowId: string, actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
|
|
53
100
|
/**
|
|
54
101
|
* The predicate a query over `flows` uses when it already carries `subtreeCte` for the same verb.
|
|
55
102
|
* Its bindings follow the CTE's.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ The verb is now a binding of its own, and it has to be the SAME verb the CTE was seeded with.
|
|
105
|
+
* The walk answers "which folders", the direct grant answers "this flow" — asking them for two
|
|
106
|
+
* different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
|
|
107
|
+
* fails on: the query still runs and quietly hands out the wrong list.
|
|
56
108
|
*/
|
|
57
|
-
export declare const flowInSubtree
|
|
58
|
-
export declare function flowInSubtreeBindings(actor: GrantActor): unknown[];
|
|
109
|
+
export declare const flowInSubtree: string;
|
|
110
|
+
export declare function flowInSubtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
|
|
@@ -1,17 +1,82 @@
|
|
|
1
|
+
export const grantColumns = (subjectColumn) => `id, ${subjectColumn} AS resource_id, principal_type, principal_id, verb, expires_at,
|
|
2
|
+
created_by, created_at`;
|
|
3
|
+
export function mapGrant(row) {
|
|
4
|
+
let principal;
|
|
5
|
+
switch (row.principal_type) {
|
|
6
|
+
case "user":
|
|
7
|
+
principal = { type: "user", id: row.principal_id };
|
|
8
|
+
break;
|
|
9
|
+
case "email":
|
|
10
|
+
principal = { type: "email", email: row.principal_id };
|
|
11
|
+
break;
|
|
12
|
+
case "organization":
|
|
13
|
+
principal = { type: "organization" };
|
|
14
|
+
break;
|
|
15
|
+
default:
|
|
16
|
+
throw new Error("Unsupported grant principal type");
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
id: row.id,
|
|
20
|
+
resourceId: row.resource_id,
|
|
21
|
+
principal,
|
|
22
|
+
verb: row.verb,
|
|
23
|
+
expiresAt: row.expires_at,
|
|
24
|
+
createdBy: row.created_by,
|
|
25
|
+
createdAt: row.created_at,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// How a principal is written into the subject columns of both grant tables. An organization is the
|
|
29
|
+
// literal `*` every reader above matches on; an email is folded to lower case on the way in so the
|
|
30
|
+
// comparison there has one shape to worry about.
|
|
31
|
+
export function principalColumns(principal) {
|
|
32
|
+
if (principal.type === "user")
|
|
33
|
+
return { type: "user", id: principal.id };
|
|
34
|
+
if (principal.type === "email")
|
|
35
|
+
return { type: "email", id: principal.email.toLowerCase() };
|
|
36
|
+
return { type: "organization", id: "*" };
|
|
37
|
+
}
|
|
1
38
|
const principalMatch = `(
|
|
2
39
|
(grant_row.principal_type = 'user' AND grant_row.principal_id = ?)
|
|
3
40
|
OR (grant_row.principal_type = 'email' AND lower(grant_row.principal_id) = lower(?))
|
|
4
41
|
OR (grant_row.principal_type = 'organization' AND grant_row.principal_id = '*')
|
|
5
42
|
)`;
|
|
6
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Whether a grant row is in force at the moment bound after it. Takes one binding: that moment.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ The ONE place this repository decides what an expiry means, for both grant tables and for
|
|
47
|
+
* every statement that reads either — the point check, the subtree walk, and the two effective
|
|
48
|
+
* views alike. It is a fragment rather than part of `grantExists` because half the readers do not
|
|
49
|
+
* ask about a principal at all: `listEffectiveAccess` lists every grant along a path, and
|
|
50
|
+
* `callReach` asks only whether an organization-wide `execute` stands. Those had written the
|
|
51
|
+
* condition out by hand, which is exactly the second set of doors the header above warns about —
|
|
52
|
+
* a grace period, a different comparison, and the two spellings drift apart in silence.
|
|
53
|
+
*
|
|
54
|
+
* `git grep "expires_at IS NULL" -- packages/api/src` must find this line and nothing else.
|
|
55
|
+
*/
|
|
56
|
+
export const grantInForce = "(grant_row.expires_at IS NULL OR grant_row.expires_at > ?)";
|
|
57
|
+
/**
|
|
58
|
+
* One grant row that is in force, asked of one table.
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ The table is a parameter and the rest is not, which is the whole reason #530 could give a flow
|
|
61
|
+
* its own grants without a second set of doors. `flow_grants` and `node_grants` differ in their
|
|
62
|
+
* subject column and in nothing else — same principal shapes, same verbs, same expiry — so a
|
|
63
|
+
* question asked of one is asked of the other by the same string. A hand-written copy for flows is
|
|
64
|
+
* how one of the two would eventually stop honouring `expires_at`.
|
|
65
|
+
*/
|
|
66
|
+
function grantExists(table, subjectColumn, targetColumn) {
|
|
7
67
|
return `EXISTS (
|
|
8
|
-
SELECT 1 FROM
|
|
9
|
-
WHERE grant_row
|
|
68
|
+
SELECT 1 FROM ${table} grant_row
|
|
69
|
+
WHERE grant_row.${subjectColumn} = ${targetColumn}
|
|
10
70
|
AND ${principalMatch}
|
|
11
71
|
AND grant_row.verb = ?
|
|
12
|
-
AND
|
|
72
|
+
AND ${grantInForce}
|
|
13
73
|
)`;
|
|
14
74
|
}
|
|
75
|
+
const nodeGrantExists = (nodeColumn) => grantExists("node_grants", "node_id", nodeColumn);
|
|
76
|
+
// A grant that sits on the flow itself (#530), beside whatever its folder already passes down. It
|
|
77
|
+
// adds to the folder's reach and never replaces it, exactly as a grant on a document adds to its
|
|
78
|
+
// folder's — the two are OR-ed everywhere this appears.
|
|
79
|
+
const flowGrantExists = (flowColumn) => grantExists("flow_grants", "flow_id", flowColumn);
|
|
15
80
|
// Bindings for one `grantExists`: principal (twice), verb, and the moment expiry is measured
|
|
16
81
|
// against. Callers concatenate these in SQL order; the helpers below say which order that is.
|
|
17
82
|
function grantBindings(actor, verb, now) {
|
|
@@ -29,7 +94,7 @@ function subtreeWalk(name) {
|
|
|
29
94
|
FROM nodes seed
|
|
30
95
|
WHERE ? = 1
|
|
31
96
|
OR seed.owner_id = ?
|
|
32
|
-
OR ${
|
|
97
|
+
OR ${nodeGrantExists("seed.id")}
|
|
33
98
|
UNION
|
|
34
99
|
SELECT child.id, child.parent_id
|
|
35
100
|
FROM nodes child
|
|
@@ -70,14 +135,28 @@ export const readableOrRunnableCte = `WITH RECURSIVE ${subtreeWalk("readable")},
|
|
|
70
135
|
* A flow the actor may open *or* may run, for a statement carrying `readableOrRunnableCte`. It is
|
|
71
136
|
* the callable rule of ADR-0004 §2/§3 — `execute` without `read` is the library, a building block
|
|
72
137
|
* anyone may run and few may open — asked of a whole list of flows at once instead of one flow at a
|
|
73
|
-
* time. Its bindings follow the two CTEs' and are `
|
|
138
|
+
* time. Its bindings follow the two CTEs' and are `flowCallableBindings(actor, now)`.
|
|
139
|
+
*
|
|
140
|
+
* ⚠️ Both direct grants, and both are needed. A flow handed out on its own for `execute` alone is
|
|
141
|
+
* the library case shrunk to one flow — runnable by people who may not open it — and leaving `read`
|
|
142
|
+
* out here would hide a flow somebody was explicitly given to look at.
|
|
74
143
|
*/
|
|
75
144
|
export const flowCallable = `(
|
|
76
145
|
? = 1
|
|
77
146
|
OR flow.owner_id = ?
|
|
78
147
|
OR flow.parent_id IN (SELECT id FROM readable)
|
|
79
148
|
OR flow.parent_id IN (SELECT id FROM runnable)
|
|
149
|
+
OR ${flowGrantExists("flow.id")}
|
|
150
|
+
OR ${flowGrantExists("flow.id")}
|
|
80
151
|
)`;
|
|
152
|
+
export function flowCallableBindings(actor, now) {
|
|
153
|
+
return [
|
|
154
|
+
actor.isAdmin ? 1 : 0,
|
|
155
|
+
actor.id,
|
|
156
|
+
...grantBindings(actor, "read", now),
|
|
157
|
+
...grantBindings(actor, "execute", now),
|
|
158
|
+
];
|
|
159
|
+
}
|
|
81
160
|
/**
|
|
82
161
|
* The point check for one node: the node itself and every ancestor above it. Cheaper than
|
|
83
162
|
* the subtree walk and the same answer, because a grant reaches down and never sideways.
|
|
@@ -99,16 +178,21 @@ export const nodeVerbQuery = `WITH RECURSIVE ancestors(id, parent_id, owner_id)
|
|
|
99
178
|
FROM ancestors
|
|
100
179
|
WHERE ? = 1
|
|
101
180
|
OR owner_id = ?
|
|
102
|
-
OR ${
|
|
181
|
+
OR ${nodeGrantExists("ancestors.id")}
|
|
103
182
|
LIMIT 1`;
|
|
104
183
|
export function nodeVerbBindings(nodeId, actor, verb, now) {
|
|
105
184
|
return [nodeId, actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
|
|
106
185
|
}
|
|
107
186
|
/**
|
|
108
|
-
* The same question for a flow.
|
|
109
|
-
* folder it is filed in
|
|
110
|
-
*
|
|
111
|
-
* who created it. `UNION` for the
|
|
187
|
+
* The same question for a flow. Three things reach it, and they are OR-ed rather than ranked: its
|
|
188
|
+
* owner, a grant on the folder it is filed in or any folder above that, and — since #530 — a grant
|
|
189
|
+
* that sits on the flow itself. Its owner keeps it the way a node's owner keeps theirs, otherwise a
|
|
190
|
+
* flow at the root of the tree would be unreachable by the person who created it. `UNION` for the
|
|
191
|
+
* same reason as above.
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ The direct grant is asked BEFORE the ancestor walk, and only because it is cheaper: a point
|
|
194
|
+
* lookup on an indexed column against a recursive walk of the tree. It decides nothing the walk
|
|
195
|
+
* would have decided differently — both are the same OR.
|
|
112
196
|
*/
|
|
113
197
|
export const flowVerbQuery = `WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
114
198
|
SELECT folder.id, folder.parent_id
|
|
@@ -126,17 +210,35 @@ export const flowVerbQuery = `WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
|
126
210
|
AND (
|
|
127
211
|
? = 1
|
|
128
212
|
OR flow.owner_id = ?
|
|
129
|
-
OR
|
|
213
|
+
OR ${flowGrantExists("flow.id")}
|
|
214
|
+
OR EXISTS (SELECT 1 FROM ancestors WHERE ${nodeGrantExists("ancestors.id")})
|
|
130
215
|
)
|
|
131
216
|
LIMIT 1`;
|
|
132
217
|
export function flowVerbBindings(flowId, actor, verb, now) {
|
|
133
|
-
return [
|
|
218
|
+
return [
|
|
219
|
+
flowId,
|
|
220
|
+
flowId,
|
|
221
|
+
actor.isAdmin ? 1 : 0,
|
|
222
|
+
actor.id,
|
|
223
|
+
...grantBindings(actor, verb, now),
|
|
224
|
+
...grantBindings(actor, verb, now),
|
|
225
|
+
];
|
|
134
226
|
}
|
|
135
227
|
/**
|
|
136
228
|
* The predicate a query over `flows` uses when it already carries `subtreeCte` for the same verb.
|
|
137
229
|
* Its bindings follow the CTE's.
|
|
230
|
+
*
|
|
231
|
+
* ⚠️ The verb is now a binding of its own, and it has to be the SAME verb the CTE was seeded with.
|
|
232
|
+
* The walk answers "which folders", the direct grant answers "this flow" — asking them for two
|
|
233
|
+
* different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
|
|
234
|
+
* fails on: the query still runs and quietly hands out the wrong list.
|
|
138
235
|
*/
|
|
139
|
-
export const flowInSubtree = `(
|
|
140
|
-
|
|
141
|
-
|
|
236
|
+
export const flowInSubtree = `(
|
|
237
|
+
? = 1
|
|
238
|
+
OR flow.owner_id = ?
|
|
239
|
+
OR flow.parent_id IN (SELECT id FROM allowed)
|
|
240
|
+
OR ${flowGrantExists("flow.id")}
|
|
241
|
+
)`;
|
|
242
|
+
export function flowInSubtreeBindings(actor, verb, now) {
|
|
243
|
+
return [actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
|
|
142
244
|
}
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { descendantsCte, nodeVerbBindings, nodeVerbQuery, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
1
|
+
import { descendantsCte, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
2
2
|
const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
|
|
3
3
|
size, segment, created_by, created_at`;
|
|
4
|
-
const grantColumns =
|
|
5
|
-
created_by, created_at`;
|
|
4
|
+
const grantColumns = grantColumnsFor("node_id");
|
|
6
5
|
const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
|
|
7
6
|
link.origin, link.label, link.created_by, link.created_at`;
|
|
8
7
|
// The first row of each node, in the order they arrive. A chunked kind — one whose index holds
|
|
@@ -47,31 +46,6 @@ function mapVersion(row) {
|
|
|
47
46
|
createdAt: row.created_at,
|
|
48
47
|
};
|
|
49
48
|
}
|
|
50
|
-
function mapGrant(row) {
|
|
51
|
-
let principal;
|
|
52
|
-
switch (row.principal_type) {
|
|
53
|
-
case "user":
|
|
54
|
-
principal = { type: "user", id: row.principal_id };
|
|
55
|
-
break;
|
|
56
|
-
case "email":
|
|
57
|
-
principal = { type: "email", email: row.principal_id };
|
|
58
|
-
break;
|
|
59
|
-
case "organization":
|
|
60
|
-
principal = { type: "organization" };
|
|
61
|
-
break;
|
|
62
|
-
default:
|
|
63
|
-
throw new Error("Unsupported node grant principal type");
|
|
64
|
-
}
|
|
65
|
-
return {
|
|
66
|
-
id: row.id,
|
|
67
|
-
resourceId: row.node_id,
|
|
68
|
-
principal,
|
|
69
|
-
verb: row.verb,
|
|
70
|
-
expiresAt: row.expires_at,
|
|
71
|
-
createdBy: row.created_by,
|
|
72
|
-
createdAt: row.created_at,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
49
|
function mapLink(row) {
|
|
76
50
|
return {
|
|
77
51
|
id: row.id,
|
|
@@ -1029,7 +1003,7 @@ export function createNodeRepository(deps) {
|
|
|
1029
1003
|
(SELECT json_group_array(owner_id)
|
|
1030
1004
|
FROM (SELECT DISTINCT owner_id FROM ancestors ORDER BY owner_id)) AS owner_ids_json,
|
|
1031
1005
|
(SELECT json_group_array(json_object(
|
|
1032
|
-
'id', id, '
|
|
1006
|
+
'id', id, 'resource_id', node_id, 'principal_type', principal_type,
|
|
1033
1007
|
'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
|
|
1034
1008
|
'created_by', created_by, 'created_at', created_at
|
|
1035
1009
|
))
|
|
@@ -1039,7 +1013,7 @@ export function createNodeRepository(deps) {
|
|
|
1039
1013
|
grant_row.created_by, grant_row.created_at
|
|
1040
1014
|
FROM node_grants grant_row
|
|
1041
1015
|
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
1042
|
-
WHERE
|
|
1016
|
+
WHERE ${grantInForce}
|
|
1043
1017
|
ORDER BY grant_row.principal_type, grant_row.principal_id, grant_row.verb
|
|
1044
1018
|
)) AS grants_json`)
|
|
1045
1019
|
.bind(resourceId, deps.now().toISOString())
|
|
@@ -1066,7 +1040,7 @@ export function createNodeRepository(deps) {
|
|
|
1066
1040
|
WHERE grant_row.id <> ?
|
|
1067
1041
|
AND grant_row.principal_type = 'organization'
|
|
1068
1042
|
AND grant_row.verb = 'execute'
|
|
1069
|
-
AND
|
|
1043
|
+
AND ${grantInForce}
|
|
1070
1044
|
LIMIT 1`)
|
|
1071
1045
|
.bind(nodeId, exceptGrantId, deps.now().toISOString())
|
|
1072
1046
|
.first();
|
|
@@ -1173,12 +1147,7 @@ export function createNodeRepository(deps) {
|
|
|
1173
1147
|
},
|
|
1174
1148
|
async setGrant(input) {
|
|
1175
1149
|
const grant = input.grant;
|
|
1176
|
-
const principalType = grant.principal
|
|
1177
|
-
const principalId = grant.principal.type === "user"
|
|
1178
|
-
? grant.principal.id
|
|
1179
|
-
: grant.principal.type === "email"
|
|
1180
|
-
? grant.principal.email.toLowerCase()
|
|
1181
|
-
: "*";
|
|
1150
|
+
const { type: principalType, id: principalId } = principalColumns(grant.principal);
|
|
1182
1151
|
try {
|
|
1183
1152
|
await deps.db.batch([
|
|
1184
1153
|
deps.db
|
package/dist/flows/flows.js
CHANGED
|
@@ -7,6 +7,27 @@ function invalid(detail) {
|
|
|
7
7
|
// A call chain deeper than this is a runaway rather than a design. It also bounds the publish-time
|
|
8
8
|
// walk and the run trail, both of which follow data that other writers can change.
|
|
9
9
|
const MaxCallDepth = 20;
|
|
10
|
+
// All four, and unlike a node this needs no lookup: every verb means something on a flow. `read`
|
|
11
|
+
// opens it, `write` edits it, `execute` runs it, `share` passes access on (#530).
|
|
12
|
+
const flowVerbs = ["read", "write", "execute", "share"];
|
|
13
|
+
/**
|
|
14
|
+
* The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ Never `isAdmin` and never `canRun`. Whether Gate hands this person `intel/admin` or `flows/run`
|
|
17
|
+
* is Gate's to know, so the warning built on this can be pessimistic and never permissive — it may
|
|
18
|
+
* say "they will not be able to reach this" about somebody who turns out to be able to. The other
|
|
19
|
+
* direction would be a warning that stays silent about a real gap.
|
|
20
|
+
*
|
|
21
|
+
* A grant to an email address is judged as that address, which is what the grant will be attached
|
|
22
|
+
* to; a second grant the same person holds under their user id is not folded in.
|
|
23
|
+
*/
|
|
24
|
+
function asPrincipalActor(principal) {
|
|
25
|
+
if (principal.type === "user")
|
|
26
|
+
return { id: principal.id, email: "", canRun: false };
|
|
27
|
+
if (principal.type === "email")
|
|
28
|
+
return { id: "", email: principal.email, canRun: false };
|
|
29
|
+
return { id: "", email: "", canRun: false };
|
|
30
|
+
}
|
|
10
31
|
// The four link kinds that name something in the shared tree (D25). `tool` is a link too, but it
|
|
11
32
|
// names a portal tool rather than a resource, and every rule about reachability applies to these
|
|
12
33
|
// four and not to it.
|
|
@@ -369,6 +390,67 @@ export function createFlows(deps) {
|
|
|
369
390
|
throw new IntelError(403, "flow_run_forbidden", "Flow run permission is required");
|
|
370
391
|
}
|
|
371
392
|
}
|
|
393
|
+
// Managing a flow's sharing is `share` on the flow — held directly, passed down by a folder above
|
|
394
|
+
// it, or held by owning it. Asked before any grant row is read or written, so a refusal leaves no
|
|
395
|
+
// trace and tells the caller nothing about what grants exist.
|
|
396
|
+
async function requireShareable(actor, flowId) {
|
|
397
|
+
const flow = await requireFlow(actor, flowId);
|
|
398
|
+
if (!(await deps.repository.can(actor, flow.id, "share"))) {
|
|
399
|
+
throw new IntelError(403, "flow_forbidden", "Sharing of this flow cannot be managed");
|
|
400
|
+
}
|
|
401
|
+
return flow;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* What this grant does NOT reach, described to whoever just made it (ADR-0004 §4).
|
|
405
|
+
*
|
|
406
|
+
* Two halves, and the second is the one a folder grant never needed. A grant on a folder covered
|
|
407
|
+
* every flow beneath it, so the sub-flows were covered with it; a grant on ONE flow stops at that
|
|
408
|
+
* flow, and the flows it calls are then a separate grant somebody has to make.
|
|
409
|
+
*
|
|
410
|
+
* ⚠️ It is a warning and not a refusal, for the reason the node side gives: blocking would force
|
|
411
|
+
* everyone whose flow reads a central policy document to duplicate it. What makes the warning
|
|
412
|
+
* enough — and what makes this whole feature possible at all — is that `validate` answers the same
|
|
413
|
+
* question later, for the person about to run, at the moment they run (#530).
|
|
414
|
+
*
|
|
415
|
+
* ⚠️ Titles only where the SHARER may see them. Whoever holds `share` on one flow must not learn
|
|
416
|
+
* the titles of documents or flows they cannot reach themselves, so the rest is a number. A
|
|
417
|
+
* warning must not become a way of reading the tree (ADR-0004 §3, #17's review).
|
|
418
|
+
*/
|
|
419
|
+
async function withShareWarnings(actor, flow, grant, principal) {
|
|
420
|
+
const grantee = asPrincipalActor(principal);
|
|
421
|
+
// What the published version does is what a grantee will run. A flow with nothing published
|
|
422
|
+
// has nothing to warn about yet, and the draft is not what `execute` would reach.
|
|
423
|
+
const versionId = flow.publishedVersionId;
|
|
424
|
+
const version = versionId ? await deps.repository.getVersion(versionId) : null;
|
|
425
|
+
if (!version) {
|
|
426
|
+
return { grant, unreadable: empty(), unrunnable: empty() };
|
|
427
|
+
}
|
|
428
|
+
const unreadable = empty();
|
|
429
|
+
// Deduplicated: a document two steps both name is one thing the grantee cannot read, not two.
|
|
430
|
+
for (const nodeId of [...new Set(graphReferences(version.graph).nodes)]) {
|
|
431
|
+
if (await deps.visibleNodes(grantee, nodeId))
|
|
432
|
+
continue;
|
|
433
|
+
const node = await deps.visibleNodes(actor, nodeId);
|
|
434
|
+
if (node)
|
|
435
|
+
unreadable.titles.push(node.title);
|
|
436
|
+
else
|
|
437
|
+
unreadable.hidden += 1;
|
|
438
|
+
}
|
|
439
|
+
const unrunnable = empty();
|
|
440
|
+
for (const calleeId of calleeIds(version.graph)) {
|
|
441
|
+
if (await deps.repository.can(grantee, calleeId, "execute"))
|
|
442
|
+
continue;
|
|
443
|
+
const callee = await deps.repository.getVisible(actor, calleeId);
|
|
444
|
+
if (callee)
|
|
445
|
+
unrunnable.titles.push(callee.title);
|
|
446
|
+
else
|
|
447
|
+
unrunnable.hidden += 1;
|
|
448
|
+
}
|
|
449
|
+
return { grant, unreadable, unrunnable };
|
|
450
|
+
}
|
|
451
|
+
function empty() {
|
|
452
|
+
return { titles: [], hidden: 0 };
|
|
453
|
+
}
|
|
372
454
|
async function requireEdit(actor, flowId) {
|
|
373
455
|
const flow = await requireFlow(actor, flowId);
|
|
374
456
|
if (!(await deps.repository.can(actor, flowId, "write"))) {
|
|
@@ -1125,6 +1207,68 @@ export function createFlows(deps) {
|
|
|
1125
1207
|
servers: referenced.servers,
|
|
1126
1208
|
};
|
|
1127
1209
|
},
|
|
1210
|
+
async listGrants(actor, flowId) {
|
|
1211
|
+
const flow = await requireShareable(actor, flowId);
|
|
1212
|
+
return {
|
|
1213
|
+
resourceId: flow.id,
|
|
1214
|
+
applicableVerbs: flowVerbs,
|
|
1215
|
+
items: await deps.repository.listFlowGrants(flow.id),
|
|
1216
|
+
};
|
|
1217
|
+
},
|
|
1218
|
+
async listEffectiveAccess(actor, flowId) {
|
|
1219
|
+
const flow = await requireShareable(actor, flowId);
|
|
1220
|
+
const effective = await deps.repository.listEffectiveFlowAccess(flow.id);
|
|
1221
|
+
return { resourceId: flow.id, ...effective };
|
|
1222
|
+
},
|
|
1223
|
+
async share(actor, input) {
|
|
1224
|
+
const flow = await requireShareable(actor, input.flowId);
|
|
1225
|
+
const replayedId = await deps.repository.findIdempotent(actor.id, "flows.share", input.idempotencyKey);
|
|
1226
|
+
const principal = input.principal.type === "email"
|
|
1227
|
+
? { type: "email", email: input.principal.email.toLowerCase() }
|
|
1228
|
+
: input.principal;
|
|
1229
|
+
if (replayedId) {
|
|
1230
|
+
const replayed = (await deps.repository.listFlowGrants(flow.id)).find((grant) => grant.id === replayedId);
|
|
1231
|
+
// A replay describes the same access the first attempt did, so the warning is asked again
|
|
1232
|
+
// rather than remembered: what the grantee can reach may have changed since.
|
|
1233
|
+
if (replayed)
|
|
1234
|
+
return await withShareWarnings(actor, flow, replayed, replayed.principal);
|
|
1235
|
+
}
|
|
1236
|
+
const timestamp = deps.now().toISOString();
|
|
1237
|
+
const grant = await deps.repository.setFlowGrant({
|
|
1238
|
+
grant: {
|
|
1239
|
+
id: deps.id(),
|
|
1240
|
+
resourceId: flow.id,
|
|
1241
|
+
principal,
|
|
1242
|
+
verb: input.verb,
|
|
1243
|
+
expiresAt: input.expiresAt,
|
|
1244
|
+
createdBy: actor.id,
|
|
1245
|
+
createdAt: timestamp,
|
|
1246
|
+
},
|
|
1247
|
+
flowId: flow.id,
|
|
1248
|
+
actorId: actor.id,
|
|
1249
|
+
idempotencyKey: input.idempotencyKey,
|
|
1250
|
+
auditId: deps.id(),
|
|
1251
|
+
});
|
|
1252
|
+
// ⚠️ After the grant is written, never before. The answer describes the access now in force,
|
|
1253
|
+
// and a warning computed a moment earlier would still list what this very grant just opened.
|
|
1254
|
+
return await withShareWarnings(actor, flow, grant, principal);
|
|
1255
|
+
},
|
|
1256
|
+
async revokeGrant(actor, input) {
|
|
1257
|
+
const flow = await requireShareable(actor, input.flowId);
|
|
1258
|
+
const replayed = await deps.repository.findIdempotentFlowRevocation(actor.id, input.idempotencyKey);
|
|
1259
|
+
if (replayed !== null)
|
|
1260
|
+
return { revoked: replayed };
|
|
1261
|
+
return {
|
|
1262
|
+
revoked: await deps.repository.revokeFlowGrant({
|
|
1263
|
+
flowId: flow.id,
|
|
1264
|
+
grantId: input.grantId,
|
|
1265
|
+
actorId: actor.id,
|
|
1266
|
+
idempotencyKey: input.idempotencyKey,
|
|
1267
|
+
auditId: deps.id(),
|
|
1268
|
+
occurredAt: deps.now().toISOString(),
|
|
1269
|
+
}),
|
|
1270
|
+
};
|
|
1271
|
+
},
|
|
1128
1272
|
async create(actor, input) {
|
|
1129
1273
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.create", input.idempotencyKey);
|
|
1130
1274
|
if (replayed)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ArchiveFlowInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowValidation, FlowVersion, FlowVersionList, FlowVersionSummary, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { CancelFlowRunInput, CompleteFlowRunStepInput, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, ListFlowRunsInput, StartFlowRunInput } from "@anchrd/intel-contract/flow-run";
|
|
3
3
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
4
|
-
import type { ResourceVerb } from "@anchrd/intel-contract/share";
|
|
4
|
+
import type { ResourceAccessList, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeFlowGrantInput, RevokeGrantResult, ShareFlowInput, ShareResult } from "@anchrd/intel-contract/share";
|
|
5
5
|
export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
|
|
6
6
|
export type FlowCallReach = "subtree" | "library" | "out-of-reach";
|
|
7
7
|
export interface FlowRunChainEntry {
|
|
@@ -42,8 +42,8 @@ export interface FlowActor {
|
|
|
42
42
|
canRun: boolean;
|
|
43
43
|
isAdmin?: boolean;
|
|
44
44
|
}
|
|
45
|
-
export type FlowVerb =
|
|
46
|
-
export type FlowOperation = "flows.create" | "flows.update" | "flows.archive" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.cancel" | "flows.unpublish";
|
|
45
|
+
export type FlowVerb = ResourceVerb;
|
|
46
|
+
export type FlowOperation = "flows.create" | "flows.update" | "flows.archive" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.cancel" | "flows.unpublish" | "flows.share" | "flows.revoke";
|
|
47
47
|
export interface BoundedLevel<T> {
|
|
48
48
|
items: T[];
|
|
49
49
|
total: number;
|
|
@@ -53,6 +53,27 @@ export interface FlowRepository {
|
|
|
53
53
|
listVisibleBounded(actor: FlowActor, folderId: string | null, limit: number): Promise<BoundedLevel<Flow>>;
|
|
54
54
|
getVisible(actor: FlowActor, flowId: string): Promise<Flow | null>;
|
|
55
55
|
can(actor: FlowActor, flowId: string, verb: FlowVerb): Promise<boolean>;
|
|
56
|
+
listFlowGrants(flowId: string): Promise<ResourceGrant[]>;
|
|
57
|
+
listEffectiveFlowAccess(flowId: string): Promise<{
|
|
58
|
+
ownerIds: string[];
|
|
59
|
+
items: ResourceGrant[];
|
|
60
|
+
}>;
|
|
61
|
+
setFlowGrant(input: {
|
|
62
|
+
grant: ResourceGrant;
|
|
63
|
+
flowId: string;
|
|
64
|
+
actorId: string;
|
|
65
|
+
idempotencyKey: string;
|
|
66
|
+
auditId: string;
|
|
67
|
+
}): Promise<ResourceGrant>;
|
|
68
|
+
revokeFlowGrant(input: {
|
|
69
|
+
flowId: string;
|
|
70
|
+
grantId: string;
|
|
71
|
+
actorId: string;
|
|
72
|
+
idempotencyKey: string;
|
|
73
|
+
auditId: string;
|
|
74
|
+
occurredAt: string;
|
|
75
|
+
}): Promise<boolean>;
|
|
76
|
+
findIdempotentFlowRevocation(actorId: string, idempotencyKey: string): Promise<boolean | null>;
|
|
56
77
|
getCallable(actor: FlowActor, flowId: string): Promise<Flow | null>;
|
|
57
78
|
listCallable(actor: FlowActor, flowIds: string[]): Promise<Flow[]>;
|
|
58
79
|
callReach(callerFolderId: string | null, calleeFolderId: string | null): Promise<FlowCallReach>;
|
|
@@ -228,6 +249,10 @@ export interface FlowService {
|
|
|
228
249
|
validate(actor: FlowActor, flowId: string): Promise<FlowValidation>;
|
|
229
250
|
relationGraph(actor: FlowActor, input: RelationGraphInput): Promise<RelationGraph>;
|
|
230
251
|
listRequirements(actor: FlowActor, flowId: string): Promise<FlowRequirements>;
|
|
252
|
+
listGrants(actor: FlowActor, flowId: string): Promise<ResourceGrantList>;
|
|
253
|
+
listEffectiveAccess(actor: FlowActor, flowId: string): Promise<ResourceAccessList>;
|
|
254
|
+
share(actor: FlowActor, input: ShareFlowInput): Promise<ShareResult>;
|
|
255
|
+
revokeGrant(actor: FlowActor, input: RevokeFlowGrantInput): Promise<RevokeGrantResult>;
|
|
231
256
|
create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
|
|
232
257
|
update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
|
|
233
258
|
archive(actor: FlowActor, input: ArchiveFlowInput): Promise<Flow>;
|
package/dist/http/http.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
|
|
3
3
|
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
4
|
-
import { RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
|
|
4
|
+
import { RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, ShareInput, } from "@anchrd/intel-contract/share";
|
|
5
5
|
import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
|
|
6
6
|
import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
|
|
7
7
|
import { Hono } from "hono";
|
|
@@ -470,9 +470,33 @@ export function createHttp(deps) {
|
|
|
470
470
|
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
471
471
|
return context.json(await deps.flows.listRequirements(asFlowActor(auth), input.flowId));
|
|
472
472
|
});
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
473
|
+
// The flow's own sharing (#530), segment for segment the node routes one screen up. `flows/share`
|
|
474
|
+
// was declared to Gate from the beginning and had no consumer until now; these four are it.
|
|
475
|
+
app.get("/flows/:flowId/grants", async (context) => {
|
|
476
|
+
const auth = requireCapability(context, "flows", "share");
|
|
477
|
+
return context.json(await deps.flows.listGrants(asFlowActor(auth), context.req.param("flowId")));
|
|
478
|
+
});
|
|
479
|
+
app.get("/flows/:flowId/effective-access", async (context) => {
|
|
480
|
+
const auth = requireCapability(context, "flows", "share");
|
|
481
|
+
return context.json(await deps.flows.listEffectiveAccess(asFlowActor(auth), context.req.param("flowId")));
|
|
482
|
+
});
|
|
483
|
+
app.post("/flows/:flowId/grants", async (context) => {
|
|
484
|
+
const auth = requireCapability(context, "flows", "share");
|
|
485
|
+
const input = ShareFlowInput.parse(await context.req.json().catch(() => null));
|
|
486
|
+
if (input.flowId !== context.req.param("flowId")) {
|
|
487
|
+
throw new IntelError(400, "flow_id_mismatch", "Path and body flow IDs differ");
|
|
488
|
+
}
|
|
489
|
+
return context.json(await deps.flows.share(asFlowActor(auth), input), 201);
|
|
490
|
+
});
|
|
491
|
+
app.post("/flows/:flowId/grants/:grantId/revoke", async (context) => {
|
|
492
|
+
const auth = requireCapability(context, "flows", "share");
|
|
493
|
+
const input = RevokeFlowGrantInput.parse(await context.req.json().catch(() => null));
|
|
494
|
+
if (input.flowId !== context.req.param("flowId") ||
|
|
495
|
+
input.grantId !== context.req.param("grantId")) {
|
|
496
|
+
throw new IntelError(400, "grant_id_mismatch", "Path and body grant IDs differ");
|
|
497
|
+
}
|
|
498
|
+
return context.json(await deps.flows.revokeGrant(asFlowActor(auth), input));
|
|
499
|
+
});
|
|
476
500
|
app.post("/flows", async (context) => {
|
|
477
501
|
const auth = requireCapability(context, "flows", "create");
|
|
478
502
|
const input = CreateFlowInput.parse(await context.req.json().catch(() => null));
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -2,7 +2,7 @@ import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
|
|
|
2
2
|
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
3
3
|
import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
|
|
4
4
|
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
5
|
-
import { ListGrantsInput, RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
|
|
5
|
+
import { ListFlowGrantsInput, ListGrantsInput, RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, ShareInput, } from "@anchrd/intel-contract/share";
|
|
6
6
|
import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
|
|
7
7
|
import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
|
|
8
8
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -666,8 +666,46 @@ export async function handleMcp(request, deps) {
|
|
|
666
666
|
},
|
|
667
667
|
}, async (input) => text(await deps.flows.listRunSteps(flowActor, input.runId)));
|
|
668
668
|
}
|
|
669
|
-
//
|
|
670
|
-
//
|
|
669
|
+
// The flow's own sharing (#530), named by the same grammar as the node tools: domain singular,
|
|
670
|
+
// the way narrowing left to right, the verb last.
|
|
671
|
+
if (permits(deps.authorization, "flows", "share")) {
|
|
672
|
+
server.registerTool("flow_grant_list", {
|
|
673
|
+
title: "List flow grants",
|
|
674
|
+
description: "List the grants sitting on one flow. What the folder it is filed in passes down is not listed here.",
|
|
675
|
+
inputSchema: ListFlowGrantsInput,
|
|
676
|
+
annotations: {
|
|
677
|
+
title: "List flow grants",
|
|
678
|
+
readOnlyHint: true,
|
|
679
|
+
destructiveHint: false,
|
|
680
|
+
idempotentHint: true,
|
|
681
|
+
openWorldHint: false,
|
|
682
|
+
},
|
|
683
|
+
}, async (input) => text(await deps.flows.listGrants(flowActor, input.flowId)));
|
|
684
|
+
server.registerTool("flow_grant_create", {
|
|
685
|
+
title: "Grant flow access",
|
|
686
|
+
description: "Grant access to one flow for a Gate user, verified email, or the organization. The grant reaches this flow alone — not the folder it is filed in, and not the flows it calls; the answer names what it does not cover.",
|
|
687
|
+
inputSchema: ShareFlowInput,
|
|
688
|
+
annotations: {
|
|
689
|
+
title: "Grant flow access",
|
|
690
|
+
readOnlyHint: false,
|
|
691
|
+
destructiveHint: false,
|
|
692
|
+
idempotentHint: true,
|
|
693
|
+
openWorldHint: false,
|
|
694
|
+
},
|
|
695
|
+
}, async (input) => text(await deps.flows.share(flowActor, input)));
|
|
696
|
+
server.registerTool("flow_grant_revoke", {
|
|
697
|
+
title: "Revoke flow grant",
|
|
698
|
+
description: "Revoke one grant sitting on a flow by ID.",
|
|
699
|
+
inputSchema: RevokeFlowGrantInput,
|
|
700
|
+
annotations: {
|
|
701
|
+
title: "Revoke flow grant",
|
|
702
|
+
readOnlyHint: false,
|
|
703
|
+
destructiveHint: true,
|
|
704
|
+
idempotentHint: true,
|
|
705
|
+
openWorldHint: false,
|
|
706
|
+
},
|
|
707
|
+
}, async (input) => text(await deps.flows.revokeGrant(flowActor, input)));
|
|
708
|
+
}
|
|
671
709
|
if (permits(deps.authorization, "flows", "create")) {
|
|
672
710
|
server.registerTool("flow_create", {
|
|
673
711
|
title: "Create flow",
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -10,6 +10,11 @@ import { documentLinkTargets } from "./document-links/document-links.js";
|
|
|
10
10
|
function contentKeyFor(nodeId, versionId) {
|
|
11
11
|
return `nodes/${nodeId}/versions/${versionId}`;
|
|
12
12
|
}
|
|
13
|
+
// A node grant covers every flow beneath the node it sits on, so there is never a sub-flow it fails
|
|
14
|
+
// to reach and this half of the warning is empty here by construction (#530). Written out rather
|
|
15
|
+
// than left to the schema's default: nothing parses a service's answer on the way out, so a default
|
|
16
|
+
// would arrive at the UI as `undefined`.
|
|
17
|
+
const nothingWithheld = { titles: [], hidden: 0 };
|
|
13
18
|
// A verb that cannot apply to a node is neither offered on it nor accepted for it (ADR-0004 §2).
|
|
14
19
|
// The answer lives here rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
|
|
15
20
|
//
|
|
@@ -1016,6 +1021,7 @@ export function createNodes(deps) {
|
|
|
1016
1021
|
return {
|
|
1017
1022
|
grant: replayed,
|
|
1018
1023
|
unreadable: await unreadableForPrincipal(actor, node.id, replayed.principal),
|
|
1024
|
+
unrunnable: nothingWithheld,
|
|
1019
1025
|
};
|
|
1020
1026
|
}
|
|
1021
1027
|
}
|
|
@@ -1037,7 +1043,11 @@ export function createNodes(deps) {
|
|
|
1037
1043
|
// ⚠️ After the grant is written, never before. The answer has to describe the access that is
|
|
1038
1044
|
// now in force — sharing `read` on this folder is exactly what makes the documents inside it
|
|
1039
1045
|
// readable, and a warning computed a moment earlier would name them all.
|
|
1040
|
-
return {
|
|
1046
|
+
return {
|
|
1047
|
+
grant,
|
|
1048
|
+
unreadable: await unreadableForPrincipal(actor, node.id, principal),
|
|
1049
|
+
unrunnable: nothingWithheld,
|
|
1050
|
+
};
|
|
1041
1051
|
},
|
|
1042
1052
|
async revokeGrant(actor, input) {
|
|
1043
1053
|
await requireVisible(actor, input.resourceId);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- #530: a flow is shared on its own again, the way a document and a table are.
|
|
2
|
+
--
|
|
3
|
+
-- `0003` moved every per-flow grant onto a folder because ADR-0004 §3 promised something a narrower
|
|
4
|
+
-- grant would have broken: "everything A calls is covered by the same grant — automatically, and
|
|
5
|
+
-- for all time". The word that carried the promise was *automatically*. A grant on the flow alone
|
|
6
|
+
-- does not reach the sub-flow filed beside it, and in 2026-08 the run would simply have stopped at
|
|
7
|
+
-- that step, without anyone having been told beforehand.
|
|
8
|
+
--
|
|
9
|
+
-- Being told beforehand is what has been built since: `flow_validate` gathers everything standing
|
|
10
|
+
-- between one person and one run — missing `execute`, an unreachable sub-flow, an unreachable tool,
|
|
11
|
+
-- an unreadable tree link — and answers it per person and per moment, on all three surfaces. The
|
|
12
|
+
-- promise is therefore not restored here; it is replaced by a question anybody can ask. ADR-0004 §2
|
|
13
|
+
-- carries the dated amendment, D49 the reason.
|
|
14
|
+
--
|
|
15
|
+
-- ⚠️ A table of its own, not a widened `node_grants`. A flow is not a node — that is the one thing
|
|
16
|
+
-- ADR-0004 §1 was most explicit about — and `node_grants.node_id` carries a REFERENCES clause into
|
|
17
|
+
-- `nodes`. Making the column polymorphic would mean dropping that clause, and an ACL table whose
|
|
18
|
+
-- rows can outlive their subject is precisely where a stale row keeps handing out access.
|
|
19
|
+
--
|
|
20
|
+
-- Everything else is deliberately identical to `node_grants`, down to the index shape: the two are
|
|
21
|
+
-- read by one predicate builder (`db-grants.ts`), and a difference here would become a difference
|
|
22
|
+
-- in the questions it can ask.
|
|
23
|
+
CREATE TABLE flow_grants (
|
|
24
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
25
|
+
flow_id TEXT NOT NULL REFERENCES flows(id),
|
|
26
|
+
principal_type TEXT NOT NULL CHECK (principal_type IN ('user', 'email', 'organization')),
|
|
27
|
+
principal_id TEXT NOT NULL,
|
|
28
|
+
verb TEXT NOT NULL CHECK (verb IN ('read', 'write', 'execute', 'share')),
|
|
29
|
+
expires_at TEXT,
|
|
30
|
+
created_by TEXT NOT NULL,
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
-- The verb belongs in the key, for the reason `0003` gives: without it one principal holds one
|
|
33
|
+
-- verb per flow, which is the ladder the four independent verbs exist instead of.
|
|
34
|
+
UNIQUE (flow_id, principal_type, principal_id, verb)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX flow_grants_principal_idx ON flow_grants(
|
|
38
|
+
principal_type,
|
|
39
|
+
principal_id,
|
|
40
|
+
verb,
|
|
41
|
+
expires_at
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX flow_grants_flow_idx ON flow_grants(flow_id, verb);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.15.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.20.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|