@anchrd/intel-api 0.22.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 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.** It carries no grants of its own; it inherits those of the folder it
307
- lives in. Move a flow and you have changed who can reach it.
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
- // A flow holds no grant of its own any more (ADR-0004 §2). What reaches it is the folder it is
111
- // filed in, read through the same walk the tree uses, so the two can never drift apart. The CTE
112
- // stands in front of the statement, so its bindings come before every other one.
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), ...flowInSubtreeBindings(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), ...flowInSubtreeBindings(actor), ...scope.bindings)
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), ...flowInSubtreeBindings(actor), ...scope.bindings, limit)
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, ...flowInSubtreeBindings(actor))
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), ...flowInSubtreeBindings(actor))
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 (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
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, ...flowInSubtreeBindings(actor))
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()), ...flowInSubtreeBindings(actor), flowId, flowId)
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()), ...flowInSubtreeBindings(actor), nodeId)
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, ...flowInSubtreeBindings(actor))
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 `flowInSubtreeBindings(actor)`.
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 = "(\n ? = 1\n OR flow.owner_id = ?\n OR flow.parent_id IN (SELECT id FROM readable)\n OR flow.parent_id IN (SELECT id FROM runnable)\n)";
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. A flow carries no grant of its own any more: what reaches it is the
47
- * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a node's
48
- * owner keeps theirs otherwise a flow at the root of the tree would be unreachable by the person
49
- * who created it. `UNION` for the same reason as above.
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 = "(? = 1 OR flow.owner_id = ? OR flow.parent_id IN (SELECT id FROM allowed))";
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
- function grantExists(nodeColumn) {
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 node_grants grant_row
9
- WHERE grant_row.node_id = ${nodeColumn}
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 (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
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 ${grantExists("seed.id")}
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 `flowInSubtreeBindings(actor)`.
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 ${grantExists("ancestors.id")}
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. A flow carries no grant of its own any more: what reaches it is the
109
- * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a node's
110
- * owner keeps theirs otherwise a flow at the root of the tree would be unreachable by the person
111
- * who created it. `UNION` for the same reason as above.
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 EXISTS (SELECT 1 FROM ancestors WHERE ${grantExists("ancestors.id")})
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 [flowId, flowId, actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
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 = `(? = 1 OR flow.owner_id = ? OR flow.parent_id IN (SELECT id FROM allowed))`;
140
- export function flowInSubtreeBindings(actor) {
141
- return [actor.isAdmin ? 1 : 0, actor.id];
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
  }
@@ -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 = `id, node_id, principal_type, principal_id, verb, expires_at,
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, 'node_id', node_id, 'principal_type', principal_type,
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 grant_row.expires_at IS NULL OR grant_row.expires_at > ?
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 (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
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.type;
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
@@ -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)
@@ -1310,10 +1454,24 @@ export function createFlows(deps) {
1310
1454
  async previewPublish(actor, input) {
1311
1455
  const flow = await requireEdit(actor, input.flowId);
1312
1456
  const version = await requireVersion(input.versionId, flow.id);
1457
+ // ⚠️ `available` is asked with the VERY call that publishing uses, not with a cheaper one that
1458
+ // answers a similar question. `unavailableServers` only says whether a server reaches
1459
+ // anything; publishing asks whether THIS step's surface is whole — and those differ exactly
1460
+ // where it hurts: a narrowed step whose allowed function disappeared keeps a reachable
1461
+ // server, so the cheap question says "fine" and the confirmation then answers 409. A preview
1462
+ // that promises a freeze the confirmation denies is worse than no preview.
1463
+ const tools = await Promise.all(toolNodes(version.graph).map(async (node) => ({
1464
+ nodeId: node.id,
1465
+ nodeLabel: node.label,
1466
+ server: node.configuration.server,
1467
+ allow: node.configuration.allow,
1468
+ available: (await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow)) !== null,
1469
+ })));
1313
1470
  return {
1314
1471
  flowId: flow.id,
1315
1472
  versionId: version.id,
1316
1473
  calls: await calls(actor, version.graph),
1474
+ tools,
1317
1475
  };
1318
1476
  },
1319
1477
  async publish(actor, input) {
@@ -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 = Extract<ResourceVerb, "read" | "write" | "execute">;
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
- // A flow has no grant route of its own. It is shared through the folder it is filed in, under
474
- // /nodes/:nodeId/grants one place answers the question for the documents and the flows in
475
- // that folder alike (ADR-0004 §2).
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
- // A flow has no share tool of its own. Sharing happens on the folder a flow is filed in, through
670
- // node_grant_create, so a narrower grant cannot sit beside the folder grant (ADR-0004 §2).
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",
@@ -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 { grant, unreadable: await unreadableForPrincipal(actor, node.id, principal) };
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.22.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.18.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",