@anchrd/intel-api 0.23.0 → 0.25.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 +41 -9
- package/dist/adapters/db/db-flows.js +210 -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/auth/auth.js +46 -19
- package/dist/build/build.js +39 -0
- package/dist/bundle/bundle.js +104 -15
- package/dist/cli/cli.js +114 -16
- package/dist/flows/flows.js +196 -17
- package/dist/flows/flows.types.d.ts +49 -3
- package/dist/http/http.js +28 -4
- package/dist/intel/intel.js +29 -1
- package/dist/mcp/mcp.js +41 -3
- package/dist/nodes/nodes.js +26 -1
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -75,12 +75,29 @@ values it stops with `Error: GATE_URL and GATE_SERVICE_KEY must be set.` before
|
|
|
75
75
|
It declares five interfaces — `intel`, `nodes`, `flows`, `tools`, `mcp` — with the functions listed
|
|
76
76
|
under *Permission, layer one* below. It is idempotent and safe to repeat after an upgrade.
|
|
77
77
|
|
|
78
|
-
⚠️ **`bootstrap`
|
|
79
|
-
administrator can hand out; it hands out none of them. This
|
|
80
|
-
installation shows an empty screen to everybody including the
|
|
78
|
+
⚠️ **`bootstrap` hands out no grant, and it takes none away as long as the declared list does not
|
|
79
|
+
change.** It creates the permissions an administrator can hand out; it hands out none of them. This
|
|
80
|
+
is step 8, and it is why a fresh installation shows an empty screen to everybody including the
|
|
81
|
+
person who installed it.
|
|
81
82
|
|
|
82
|
-
⚠️
|
|
83
|
-
|
|
83
|
+
⚠️ **But a function that falls out of a still-declared interface takes its grants with it.**
|
|
84
|
+
Declaring is an upsert that REPLACES an interface's function list, and Gate deletes every grant on a
|
|
85
|
+
function the new list no longer names. So an upgrade that retires a function also retires every role
|
|
86
|
+
assignment on it — against a running installation that is a change to who may do what, not a
|
|
87
|
+
read. `bootstrap` reads the catalog before it writes and names each one as it goes:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
Removed flows:approve from Gate; every grant on it was deleted with it.
|
|
91
|
+
Declared 5 Intel interfaces in Gate.
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A run that dropped nothing says so instead: `No declared function was dropped, so Gate deleted no
|
|
95
|
+
grant.` And if the catalog cannot be read, `bootstrap` writes nothing at all rather than delete
|
|
96
|
+
grants it would then be unable to name.
|
|
97
|
+
|
|
98
|
+
⚠️ It never revokes a whole **interface**. One an older version declared stays declared in Gate:
|
|
99
|
+
`bootstrap` writes only the handles it names, so an interface outside that list keeps its functions
|
|
100
|
+
and its grants. That is harmless, and removing one is an act in Gate, by hand.
|
|
84
101
|
|
|
85
102
|
### 4. Copy the migrations out of `node_modules`
|
|
86
103
|
|
|
@@ -135,6 +152,19 @@ FAIL TOOL_SOURCE_ORIGINS is missing
|
|
|
135
152
|
FAIL INTEL_SESSION_SECRET must contain at least 32 bytes
|
|
136
153
|
```
|
|
137
154
|
|
|
155
|
+
It also compares the Gate interfaces **in both directions**. A function or a whole interface Gate
|
|
156
|
+
carries that this version of Intel no longer declares is reported as a `NOTE`, never as a `FAIL`:
|
|
157
|
+
nothing is broken by it, and a check one knows red stops being read. Each note names the surplus,
|
|
158
|
+
why it went, and what to do about it:
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
NOTE Gate carries flows:approve, which Intel does not declare — the approval node is gone (#73).
|
|
162
|
+
NOTE Gate carries the interface knowledge (read, write), which Intel does not declare — the whole interface became `nodes` (#152, after #125).
|
|
163
|
+
NOTE The next `intel bootstrap` removes a surplus function and every grant on it (#474). Take the grant away in Gate first if anybody should keep it.
|
|
164
|
+
NOTE `bootstrap` never touches an interface Intel does not declare, so a surplus one stays until somebody removes it in Gate by hand — which takes its grants with it.
|
|
165
|
+
OK Intel packages, configuration, and Gate interfaces are ready.
|
|
166
|
+
```
|
|
167
|
+
|
|
138
168
|
⚠️ **`doctor` cannot see the Worker's secrets.** It answers about the shell it runs in, so it says
|
|
139
169
|
nothing about whether the deployed Worker is configured. `GET /health` on the deployed Worker
|
|
140
170
|
answers `{"status":"ok"}`; a Worker missing one of the five required variables answers
|
|
@@ -255,10 +285,10 @@ grants them.
|
|
|
255
285
|
|
|
256
286
|
| Interface | Functions |
|
|
257
287
|
|---|---|
|
|
258
|
-
| `intel` | `
|
|
288
|
+
| `intel` | `admin` |
|
|
259
289
|
| `nodes` | `read`, `create`, `write`, `share` |
|
|
260
290
|
| `flows` | `read`, `create`, `write`, `publish`, `run`, `share` |
|
|
261
|
-
| `tools` | `read`, `test`, `execute
|
|
291
|
+
| `tools` | `read`, `test`, `execute` |
|
|
262
292
|
| `mcp` | `connect` |
|
|
263
293
|
|
|
264
294
|
A missing capability answers **`403`**, and the screen says a permission is missing.
|
|
@@ -303,8 +333,10 @@ the titles of the folders above it. Nobody has to be sent a link.
|
|
|
303
333
|
|
|
304
334
|
### Three things about grants that surprise people
|
|
305
335
|
|
|
306
|
-
- **A flow is not a node
|
|
307
|
-
|
|
336
|
+
- **A flow is not a node, and is reached by two things at once.** The folder it is filed in passes
|
|
337
|
+
its grants down, and since #530 a grant may sit on the flow itself; they add up. Move a flow and
|
|
338
|
+
you have changed the first half of that. A grant on one flow does not reach the flows it calls —
|
|
339
|
+
the answer names them, and `flow_validate` tells the person about to run what is still missing.
|
|
308
340
|
- **`organization` + `execute` on a folder makes that folder a library**: flows from anywhere in the
|
|
309
341
|
tree may then call into it. The share dialog warns before the click, because the way back is
|
|
310
342
|
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,10 +603,26 @@ 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
|
},
|
|
610
|
+
// ⚠️ No actor and no `archived_at` condition, both on purpose (#509). The question is whether
|
|
611
|
+
// the row is still there at all, and archiving keeps it — a reference to something archived is
|
|
612
|
+
// not broken, it is waiting for a restore. The caller has already asked the visibility door for
|
|
613
|
+
// every one of these ids and reaches this only for the ones it answered nothing for.
|
|
614
|
+
//
|
|
615
|
+
// ⚠️ An empty list short-circuits rather than building `IN ()`, which SQLite refuses to parse.
|
|
616
|
+
async existingNodes(nodeIds) {
|
|
617
|
+
const wanted = [...new Set(nodeIds)];
|
|
618
|
+
if (wanted.length === 0)
|
|
619
|
+
return [];
|
|
620
|
+
const result = await deps.db
|
|
621
|
+
.prepare(`SELECT id FROM nodes WHERE id IN (${wanted.map(() => "?").join(", ")}) ORDER BY id`)
|
|
622
|
+
.bind(...wanted)
|
|
623
|
+
.all();
|
|
624
|
+
return (result.results ?? []).map((row) => row.id);
|
|
625
|
+
},
|
|
432
626
|
async publishedCallees(flowId) {
|
|
433
627
|
const row = await deps.db
|
|
434
628
|
.prepare(`SELECT flow.title AS title, version.graph_json AS graph_json
|
|
@@ -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[];
|