@diister/quick-permission 0.9.0-beta.6 → 0.9.0-beta.7
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 +46 -5
- package/dist/mod.d.ts +2 -2
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +1 -1
- package/dist/mod.js.map +1 -1
- package/dist/system.d.ts +45 -70
- package/dist/system.d.ts.map +1 -1
- package/dist/system.js +264 -245
- package/dist/system.js.map +1 -1
- package/mod.ts +9 -1
- package/package.json +1 -1
- package/system.ts +349 -325
package/dist/system.js
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Moteur d'orchestration resource-pipe.
|
|
3
|
-
*
|
|
4
|
-
* Responsabilités :
|
|
5
|
-
* - Maintenir un cache `(resource.id, dedupKey) → Promise<T>` par context()
|
|
6
|
-
* - Filtrer les rules actives (activeWhen) avant de fetcher leurs needs
|
|
7
|
-
* - Lancer les fetches en parallèle (Promise.all) avec dedup
|
|
8
|
-
* - Évaluer les rules d'un grant en série (préserve l'ordre des reasons)
|
|
9
|
-
* - OR sémantique entre grants : un grant qui passe = `ok: true`
|
|
10
|
-
* - Validation d'arity du target au boot et au check
|
|
11
|
-
*
|
|
12
|
-
* Cf. RFC §"Sémantique d'exécution".
|
|
13
|
-
*/
|
|
14
1
|
import { aggregateConstraints, combineGrantConstraints, mergeFilterSpec, resolveFilteredData, } from "./aggregation.js";
|
|
15
2
|
import { buildAggregationStages } from "./indirect-aggregation.js";
|
|
16
3
|
import { extractIndirectResource, } from "./indirect-resource.js";
|
|
@@ -22,16 +9,41 @@ function keyMatchesPattern(key, pattern) {
|
|
|
22
9
|
}
|
|
23
10
|
return false;
|
|
24
11
|
}
|
|
25
|
-
function
|
|
26
|
-
if (!provider.keys && !provider.matches)
|
|
27
|
-
return true;
|
|
12
|
+
function providerDeclaresKey(provider, key) {
|
|
28
13
|
if (provider.keys?.some((p) => keyMatchesPattern(key, p)))
|
|
29
14
|
return true;
|
|
30
|
-
|
|
15
|
+
return provider.matches?.(key) === true;
|
|
16
|
+
}
|
|
17
|
+
export function refType(segment) {
|
|
18
|
+
if (typeof segment !== "string")
|
|
19
|
+
return undefined;
|
|
20
|
+
const colon = segment.indexOf(":");
|
|
21
|
+
return colon > 0 ? segment.slice(0, colon) : undefined;
|
|
22
|
+
}
|
|
23
|
+
function providerHandlesTarget(provider, target) {
|
|
24
|
+
if (provider.targetType === undefined)
|
|
31
25
|
return true;
|
|
32
|
-
|
|
26
|
+
const first = target?.[0];
|
|
27
|
+
if (first === undefined || isWildcardSegment(first))
|
|
28
|
+
return false;
|
|
29
|
+
const type = refType(first);
|
|
30
|
+
if (type === undefined)
|
|
31
|
+
return false;
|
|
32
|
+
return typeof provider.targetType === "string"
|
|
33
|
+
? provider.targetType === type
|
|
34
|
+
: provider.targetType.includes(type);
|
|
35
|
+
}
|
|
36
|
+
function callHook(invoke) {
|
|
37
|
+
try {
|
|
38
|
+
invoke();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function describeError(error) {
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
33
46
|
}
|
|
34
|
-
// ─── Schema validation au boot ──────────────────────────────────────────
|
|
35
47
|
function validateSchema(schema) {
|
|
36
48
|
const issues = [];
|
|
37
49
|
for (const [key, perm] of Object.entries(schema)) {
|
|
@@ -45,7 +57,6 @@ function validateSchema(schema) {
|
|
|
45
57
|
});
|
|
46
58
|
}
|
|
47
59
|
catch {
|
|
48
|
-
// Best-effort : si expandsTo throw sur le stub, on skip.
|
|
49
60
|
continue;
|
|
50
61
|
}
|
|
51
62
|
for (const child of samples) {
|
|
@@ -58,27 +69,16 @@ function validateSchema(schema) {
|
|
|
58
69
|
throw new Error(`Schema validation failed:\n - ${issues.join("\n - ")}`);
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
|
-
// ─── Target matching (wildcards par segment) ────────────────────────────
|
|
62
72
|
function asPath(value) {
|
|
63
73
|
return Array.isArray(value) ? value : [value];
|
|
64
74
|
}
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
* un wildcard (`"*"` ou suffix `":*"`). Dans ce mode, on n'a pas de
|
|
68
|
-
* ressource concrète à fetcher — les rules font silent-pass quand elles
|
|
69
|
-
* ne peuvent pas vérifier leur contrainte.
|
|
70
|
-
*/
|
|
71
|
-
function isCapabilityQuery(target) {
|
|
75
|
+
/** A query with any wildcard segment asks "could this subject, on some target"; rules that need a concrete document pass silently. */
|
|
76
|
+
export function isCapabilityQuery(target) {
|
|
72
77
|
if (!target)
|
|
73
78
|
return false;
|
|
74
79
|
return target.some(isWildcardSegment);
|
|
75
80
|
}
|
|
76
|
-
|
|
77
|
-
* True if a single target segment is a wildcard (`"*"` or `"xxx:*"`).
|
|
78
|
-
* Inverse de "concret" — utilisé pour décider si une resource peut être
|
|
79
|
-
* fetchée en cap-mode (segment concret → fetch OK).
|
|
80
|
-
*/
|
|
81
|
-
function isWildcardSegment(seg) {
|
|
81
|
+
export function isWildcardSegment(seg) {
|
|
82
82
|
return seg === "*" || (typeof seg === "string" && seg.endsWith("*"));
|
|
83
83
|
}
|
|
84
84
|
function segmentOverlaps(a, b) {
|
|
@@ -100,19 +100,13 @@ function segmentOverlaps(a, b) {
|
|
|
100
100
|
function targetMatches(grantTarget, requestTarget, schemaKind, capability) {
|
|
101
101
|
if (schemaKind === "none")
|
|
102
102
|
return true;
|
|
103
|
-
if (grantTarget === undefined)
|
|
104
|
-
// A target-less grant only matches when the schema permits it.
|
|
105
|
-
// `optional` documents this "global grant" mode; `required` and `path`
|
|
106
|
-
// require the grant to carry a target.
|
|
103
|
+
if (grantTarget === undefined)
|
|
107
104
|
return schemaKind === "optional";
|
|
108
|
-
}
|
|
109
105
|
if (requestTarget === undefined)
|
|
110
106
|
return false;
|
|
111
107
|
const g = asPath(grantTarget);
|
|
112
108
|
if (g.length !== requestTarget.length)
|
|
113
109
|
return false;
|
|
114
|
-
// Capability mode (request has wildcards) uses overlap semantics so a
|
|
115
|
-
// specific-target grant like ["user:lucas"] still matches ["user:*"].
|
|
116
110
|
if (capability) {
|
|
117
111
|
return g.every((seg, i) => segmentOverlaps(seg, requestTarget[i]));
|
|
118
112
|
}
|
|
@@ -127,20 +121,15 @@ function targetMatches(grantTarget, requestTarget, schemaKind, capability) {
|
|
|
127
121
|
return seg === requestTarget[i];
|
|
128
122
|
});
|
|
129
123
|
}
|
|
130
|
-
// ─── Intermediate expansion ──────────────────────────────────────────────
|
|
131
124
|
function expandGrants(grants, schema, maxDepth = 10) {
|
|
132
125
|
const out = [];
|
|
133
126
|
const queue = grants.map((g) => ({
|
|
134
127
|
grant: g,
|
|
135
128
|
depth: 0,
|
|
136
129
|
}));
|
|
137
|
-
|
|
138
|
-
const { grant, depth } = queue
|
|
130
|
+
for (let head = 0; head < queue.length; head++) {
|
|
131
|
+
const { grant, depth } = queue[head];
|
|
139
132
|
const perm = schema[grant.key];
|
|
140
|
-
// Drop grants that violate the schema's target contract: a grant
|
|
141
|
-
// without `target` on a `required` / `path` schema cannot match
|
|
142
|
-
// (see `targetMatches`) and its `expandsTo` cannot synthesize valid
|
|
143
|
-
// children. Skip silently so one bad grant doesn't break the call.
|
|
144
133
|
if (perm &&
|
|
145
134
|
grant.target === undefined &&
|
|
146
135
|
(perm.target.kind === "required" || perm.target.kind === "path")) {
|
|
@@ -158,7 +147,6 @@ function expandGrants(grants, schema, maxDepth = 10) {
|
|
|
158
147
|
}
|
|
159
148
|
return out;
|
|
160
149
|
}
|
|
161
|
-
// ─── Serialization (pour list/tree) ──────────────────────────────────────
|
|
162
150
|
function serializeSegment(s) {
|
|
163
151
|
const types = s.types;
|
|
164
152
|
return { name: s.name, types };
|
|
@@ -203,17 +191,6 @@ function collectIndirectsForPermission(perm) {
|
|
|
203
191
|
}
|
|
204
192
|
return Array.from(collected.values());
|
|
205
193
|
}
|
|
206
|
-
/**
|
|
207
|
-
* Build a wildcard target stub matching the schema's arity. Used to sample
|
|
208
|
-
* `expandsTo(grant)` at catalog enumeration time — we don't have a real
|
|
209
|
-
* grant target available, but we need ARG_ARITY to match or `targetMatches`
|
|
210
|
-
* would drop the synthesized child grants (see `expandGrants:228-234`).
|
|
211
|
-
*
|
|
212
|
-
* - none → undefined (no segments)
|
|
213
|
-
* - optional → ["*"]
|
|
214
|
-
* - required → ["*"]
|
|
215
|
-
* - path(n) → ["*", "*", ..., "*"] (n segments)
|
|
216
|
-
*/
|
|
217
194
|
function stubTargetFor(t) {
|
|
218
195
|
if (t.kind === "none")
|
|
219
196
|
return undefined;
|
|
@@ -221,70 +198,52 @@ function stubTargetFor(t) {
|
|
|
221
198
|
return ["*"];
|
|
222
199
|
return t.segments.map(() => "*");
|
|
223
200
|
}
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
* call expandsTo with a concrete grant carrying `with`/`filter`/`flags` —
|
|
232
|
-
* but for catalog enumeration we only care about the resulting child KEYS
|
|
233
|
-
* (the same keys would be produced regardless of grant payload, since
|
|
234
|
-
* macros are by convention pure key-routers).
|
|
235
|
-
*
|
|
236
|
-
* Defensive : caught exceptions in `expandsTo` (e.g. a callback that
|
|
237
|
-
* asserts on a concrete segment) silently drop that branch — same posture
|
|
238
|
-
* as `validateSchema`. Cycles are broken by the `visited` set.
|
|
239
|
-
*
|
|
240
|
-
* Returns `undefined` if `rootKey` is a leaf or not in schema (the caller
|
|
241
|
-
* uses this signal to omit the field from the serialized entry rather
|
|
242
|
-
* than emit an empty array).
|
|
243
|
-
*/
|
|
244
|
-
function computeDescendants(rootKey, schema, maxDepth = 10) {
|
|
245
|
-
const root = schema[rootKey];
|
|
246
|
-
if (!root?.expandsTo)
|
|
247
|
-
return undefined;
|
|
248
|
-
const leaves = new Set();
|
|
249
|
-
const visited = new Set([rootKey]);
|
|
250
|
-
const queue = [
|
|
251
|
-
{ key: rootKey, depth: 0 },
|
|
252
|
-
];
|
|
253
|
-
while (queue.length) {
|
|
254
|
-
const { key, depth } = queue.shift();
|
|
255
|
-
const perm = schema[key];
|
|
256
|
-
if (!perm)
|
|
257
|
-
continue;
|
|
258
|
-
if (!perm.expandsTo) {
|
|
259
|
-
// Leaf reached. Exclude the root itself from its own descendant list.
|
|
260
|
-
if (key !== rootKey)
|
|
261
|
-
leaves.add(key);
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
if (depth >= maxDepth)
|
|
201
|
+
// An intermediate is sampled with a wildcard grant: by convention `expandsTo`
|
|
202
|
+
// routes keys and does not depend on the grant's target or payload.
|
|
203
|
+
function expansionGraph(schema, maxDepth = 10) {
|
|
204
|
+
const descendants = new Map();
|
|
205
|
+
const ancestors = new Map();
|
|
206
|
+
for (const [rootKey, root] of Object.entries(schema)) {
|
|
207
|
+
if (!root.expandsTo)
|
|
265
208
|
continue;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
209
|
+
const leaves = [];
|
|
210
|
+
const visited = new Set([rootKey]);
|
|
211
|
+
const queue = [
|
|
212
|
+
{ key: rootKey, depth: 0 },
|
|
213
|
+
];
|
|
214
|
+
for (let head = 0; head < queue.length; head++) {
|
|
215
|
+
const { key, depth } = queue[head];
|
|
216
|
+
const perm = schema[key];
|
|
217
|
+
if (!perm)
|
|
218
|
+
continue;
|
|
219
|
+
if (!perm.expandsTo) {
|
|
220
|
+
leaves.push(key);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (depth >= maxDepth)
|
|
224
|
+
continue;
|
|
225
|
+
let children = [];
|
|
226
|
+
try {
|
|
227
|
+
children = perm.expandsTo({ key, target: stubTargetFor(perm.target) });
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
279
230
|
continue;
|
|
280
|
-
|
|
281
|
-
|
|
231
|
+
}
|
|
232
|
+
for (const child of children) {
|
|
233
|
+
if (visited.has(child.key))
|
|
234
|
+
continue;
|
|
235
|
+
visited.add(child.key);
|
|
236
|
+
const list = ancestors.get(child.key) ?? [];
|
|
237
|
+
list.push(rootKey);
|
|
238
|
+
ancestors.set(child.key, list);
|
|
239
|
+
queue.push({ key: child.key, depth: depth + 1 });
|
|
240
|
+
}
|
|
282
241
|
}
|
|
242
|
+
descendants.set(rootKey, leaves);
|
|
283
243
|
}
|
|
284
|
-
return
|
|
244
|
+
return { descendants, ancestors };
|
|
285
245
|
}
|
|
286
|
-
|
|
287
|
-
function buildTree(schema) {
|
|
246
|
+
function buildTree(schema, graph) {
|
|
288
247
|
const root = { children: {} };
|
|
289
248
|
function ensureGroup(parent, name) {
|
|
290
249
|
const existing = parent.children[name];
|
|
@@ -317,9 +276,7 @@ function buildTree(schema) {
|
|
|
317
276
|
}
|
|
318
277
|
}
|
|
319
278
|
const leafName = parts[parts.length - 1];
|
|
320
|
-
const descendants =
|
|
321
|
-
? computeDescendants(key, schema)
|
|
322
|
-
: undefined;
|
|
279
|
+
const descendants = graph.descendants.get(key);
|
|
323
280
|
cursor.children[leafName] = {
|
|
324
281
|
kind: perm.expandsTo ? "intermediate" : "permission",
|
|
325
282
|
key,
|
|
@@ -331,15 +288,31 @@ function buildTree(schema) {
|
|
|
331
288
|
}
|
|
332
289
|
return root;
|
|
333
290
|
}
|
|
334
|
-
// ─── createSystem ────────────────────────────────────────────────────────
|
|
335
291
|
export function createSystem(opts) {
|
|
336
|
-
const { schema, providers = [] } = opts;
|
|
292
|
+
const { schema, providers = [], hooks = {} } = opts;
|
|
337
293
|
validateSchema(schema);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
294
|
+
const graph = expansionGraph(schema);
|
|
295
|
+
const providerNames = new Map(providers.map((provider, index) => [
|
|
296
|
+
provider,
|
|
297
|
+
(typeof provider === "object" && provider.name) || `provider#${index}`,
|
|
298
|
+
]));
|
|
299
|
+
const handledKeys = new Map();
|
|
300
|
+
function providerHandlesKey(provider, key) {
|
|
301
|
+
if (!provider.keys && !provider.matches)
|
|
302
|
+
return true;
|
|
303
|
+
let memo = handledKeys.get(provider);
|
|
304
|
+
if (!memo) {
|
|
305
|
+
memo = new Map();
|
|
306
|
+
handledKeys.set(provider, memo);
|
|
307
|
+
}
|
|
308
|
+
const known = memo.get(key);
|
|
309
|
+
if (known !== undefined)
|
|
310
|
+
return known;
|
|
311
|
+
const handled = providerDeclaresKey(provider, key) ||
|
|
312
|
+
(graph.ancestors.get(key) ?? []).some((ancestor) => providerDeclaresKey(provider, ancestor));
|
|
313
|
+
memo.set(key, handled);
|
|
314
|
+
return handled;
|
|
315
|
+
}
|
|
343
316
|
const resourceRegistry = new Map();
|
|
344
317
|
for (const perm of Object.values(schema)) {
|
|
345
318
|
for (const rule of perm.rules) {
|
|
@@ -355,9 +328,7 @@ export function createSystem(opts) {
|
|
|
355
328
|
return {
|
|
356
329
|
list() {
|
|
357
330
|
return Object.entries(schema).map(([key, perm]) => {
|
|
358
|
-
const descendants =
|
|
359
|
-
? computeDescendants(key, schema)
|
|
360
|
-
: undefined;
|
|
331
|
+
const descendants = graph.descendants.get(key);
|
|
361
332
|
return {
|
|
362
333
|
key,
|
|
363
334
|
kind: perm.expandsTo
|
|
@@ -371,7 +342,7 @@ export function createSystem(opts) {
|
|
|
371
342
|
});
|
|
372
343
|
},
|
|
373
344
|
tree() {
|
|
374
|
-
return buildTree(schema);
|
|
345
|
+
return buildTree(schema, graph);
|
|
375
346
|
},
|
|
376
347
|
schema(key) {
|
|
377
348
|
return schema[key];
|
|
@@ -437,32 +408,95 @@ export function createSystem(opts) {
|
|
|
437
408
|
fetchCounters.clear();
|
|
438
409
|
},
|
|
439
410
|
async dumpGrants() {
|
|
440
|
-
const
|
|
441
|
-
return
|
|
411
|
+
const outcomes = await Promise.all([...grantsCache.values()].flatMap((byKey) => [...byKey.values()]));
|
|
412
|
+
return outcomes.flatMap((outcome) => outcome.grants);
|
|
442
413
|
},
|
|
443
414
|
};
|
|
444
415
|
},
|
|
445
416
|
};
|
|
417
|
+
function reportError(event) {
|
|
418
|
+
callHook(() => hooks.onError?.(event));
|
|
419
|
+
}
|
|
420
|
+
async function fetchFromProvider(provider, name, subject, key, target) {
|
|
421
|
+
const started = performance.now();
|
|
422
|
+
let grants;
|
|
423
|
+
try {
|
|
424
|
+
grants =
|
|
425
|
+
typeof provider === "function"
|
|
426
|
+
? await provider(subject, key, target)
|
|
427
|
+
: await provider.fetch(subject, key, target);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
reportError({
|
|
431
|
+
source: "provider",
|
|
432
|
+
provider: name,
|
|
433
|
+
subject,
|
|
434
|
+
key,
|
|
435
|
+
target,
|
|
436
|
+
error,
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
grants: [],
|
|
440
|
+
failure: `provider ${name} failed: ${describeError(error)}`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const durationMs = performance.now() - started;
|
|
444
|
+
callHook(() => hooks.onProviderFetch?.({
|
|
445
|
+
provider: name,
|
|
446
|
+
subject,
|
|
447
|
+
key,
|
|
448
|
+
target,
|
|
449
|
+
durationMs,
|
|
450
|
+
grantCount: grants.length,
|
|
451
|
+
}));
|
|
452
|
+
return { grants };
|
|
453
|
+
}
|
|
446
454
|
async function invokeProvider(provider, subject, key, target, state) {
|
|
447
|
-
|
|
448
|
-
|
|
455
|
+
const name = providerNames.get(provider) ?? "provider";
|
|
456
|
+
if (typeof provider === "object") {
|
|
457
|
+
if (!providerHandlesKey(provider, key))
|
|
458
|
+
return { grants: [] };
|
|
459
|
+
if (!providerHandlesTarget(provider, target))
|
|
460
|
+
return { grants: [] };
|
|
449
461
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
462
|
+
let ck;
|
|
463
|
+
try {
|
|
464
|
+
ck =
|
|
465
|
+
typeof provider === "object"
|
|
466
|
+
? provider.cacheKey?.(subject, key, target)
|
|
467
|
+
: undefined;
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
reportError({
|
|
471
|
+
source: "provider",
|
|
472
|
+
provider: name,
|
|
473
|
+
subject,
|
|
474
|
+
key,
|
|
475
|
+
target,
|
|
476
|
+
error,
|
|
477
|
+
});
|
|
478
|
+
return {
|
|
479
|
+
grants: [],
|
|
480
|
+
failure: `provider ${name} failed: ${describeError(error)}`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (!state || !ck) {
|
|
484
|
+
return fetchFromProvider(provider, name, subject, key, target);
|
|
485
|
+
}
|
|
486
|
+
let byKey = state.grantsCache.get(provider);
|
|
487
|
+
if (!byKey) {
|
|
488
|
+
byKey = new Map();
|
|
489
|
+
state.grantsCache.set(provider, byKey);
|
|
490
|
+
}
|
|
491
|
+
let pending = byKey.get(ck);
|
|
492
|
+
if (!pending) {
|
|
493
|
+
pending = fetchFromProvider(provider, name, subject, key, target);
|
|
494
|
+
byKey.set(ck, pending);
|
|
460
495
|
}
|
|
461
|
-
return
|
|
496
|
+
return pending;
|
|
462
497
|
}
|
|
463
498
|
async function fetchResource(resource, ctx, state) {
|
|
464
499
|
if (!state) {
|
|
465
|
-
// Mode `system.can()` direct — pas de cache cross-grant.
|
|
466
500
|
return Promise.resolve(resource.fetcher(ctx));
|
|
467
501
|
}
|
|
468
502
|
const key = resource.computeDedupKey(ctx);
|
|
@@ -501,36 +535,28 @@ export function createSystem(opts) {
|
|
|
501
535
|
const arityErr = validateArity(perm, target);
|
|
502
536
|
if (arityErr)
|
|
503
537
|
return { ok: false, reasons: [arityErr] };
|
|
504
|
-
// Collect grants depuis les providers (en série pour préserver l'ordre)
|
|
505
538
|
const allGrants = [];
|
|
539
|
+
const failures = [];
|
|
506
540
|
for (const provider of providers) {
|
|
507
|
-
const
|
|
508
|
-
allGrants.push(...grants);
|
|
541
|
+
const outcome = await invokeProvider(provider, subject, key, target, state);
|
|
542
|
+
allGrants.push(...outcome.grants);
|
|
543
|
+
if (outcome.failure)
|
|
544
|
+
failures.push(outcome.failure);
|
|
509
545
|
}
|
|
510
546
|
const expanded = expandGrants(allGrants, schema);
|
|
511
547
|
const capability = isCapabilityQuery(target);
|
|
512
548
|
const matching = expanded.filter((g) => g.key === key &&
|
|
513
549
|
targetMatches(g.target, target, perm.target.kind, capability));
|
|
514
550
|
if (matching.length === 0) {
|
|
515
|
-
return { ok: false, reasons: ["no matching grant"] };
|
|
551
|
+
return { ok: false, reasons: ["no matching grant", ...failures] };
|
|
516
552
|
}
|
|
517
553
|
const reasons = [];
|
|
518
554
|
let lastData = undefined;
|
|
519
555
|
const matchedGrantIds = [];
|
|
520
556
|
let anyOk = false;
|
|
521
|
-
// One entry per matched grant. undefined = "any" (no constraint).
|
|
522
557
|
const collectedConstraints = [];
|
|
523
|
-
// Filter rule cross-grant aggregation. `null` once any grant exposes
|
|
524
|
-
// no filter (= all fields). Else accumulates the union of filter specs.
|
|
525
558
|
let referenceSource = undefined;
|
|
526
559
|
let filterUnion = undefined;
|
|
527
|
-
// Grants that PASSED their rules — needed by indirect-resource
|
|
528
|
-
// aggregation. `matching` contains all grants that match key + target
|
|
529
|
-
// shape, but rules can reject some (e.g. a `with` spec evaluated
|
|
530
|
-
// against an auto-fetched resource in cap-mode). The indirect
|
|
531
|
-
// orchestrator must consider only successful grants — otherwise a
|
|
532
|
-
// rejected grant without indirect reference would trigger any-wins
|
|
533
|
-
// and disable the filter.
|
|
534
560
|
const successfulGrants = [];
|
|
535
561
|
for (const grant of matching) {
|
|
536
562
|
const ctx = {
|
|
@@ -547,20 +573,8 @@ export function createSystem(opts) {
|
|
|
547
573
|
return false;
|
|
548
574
|
return r.needs.every((res) => res.isActiveFor(grant));
|
|
549
575
|
});
|
|
550
|
-
//
|
|
551
|
-
// resource
|
|
552
|
-
// target AND whose corresponding segment in the request target is
|
|
553
|
-
// CONCRETE (not `*` / not `xxx:*`) is fetched anyway. This lets
|
|
554
|
-
// `match()` evaluate properly for foreign resources (auxiliary
|
|
555
|
-
// checks like "the request's exposition belongs to my tenant")
|
|
556
|
-
// when only one segment of a multi-segment target is wildcard.
|
|
557
|
-
//
|
|
558
|
-
// Auto-binding is by convention : `resource.id === segment.name`.
|
|
559
|
-
// No opt-in needed — it just works if the convention is respected.
|
|
560
|
-
// Resources whose id matches no segment in the permission's schema
|
|
561
|
-
// are skipped in cap-mode (silent-pass + constraint emit, the
|
|
562
|
-
// legacy behaviour that powers `users.read` + `userOf.match`
|
|
563
|
-
// self-referencing pushdown).
|
|
576
|
+
// A capability query still fetches a resource named like a segment
|
|
577
|
+
// (`resource.id === segment.name`) when that segment is concrete.
|
|
564
578
|
const requestTarget = target ?? [];
|
|
565
579
|
const targetSegmentIndexById = (() => {
|
|
566
580
|
const out = new Map();
|
|
@@ -589,73 +603,81 @@ export function createSystem(opts) {
|
|
|
589
603
|
: Array.from(new Map(activeRules
|
|
590
604
|
.flatMap((r) => r.needs)
|
|
591
605
|
.map((r) => [r.id, r])).values());
|
|
592
|
-
const fetched = new Map();
|
|
593
|
-
await Promise.all(uniqueResources.map(async (r) => {
|
|
594
|
-
fetched.set(r.id, await fetchResource(r, ctx, state));
|
|
595
|
-
}));
|
|
596
|
-
// In concrete mode, indirect resources that declared a `fetcher`
|
|
597
|
-
// are fetched as well (their joined docs are attached to the ctx
|
|
598
|
-
// so `indirect.match()` can evaluate the spec). The cache key is
|
|
599
|
-
// target-derived so `CanContext.preseed()` can inject values
|
|
600
|
-
// produced by a cap-mode pipeline aggregation (no DB hit when
|
|
601
|
-
// listed docs already carry the `_lookupAlias`).
|
|
602
|
-
const indirectFetched = new Map();
|
|
603
|
-
if (!capability) {
|
|
604
|
-
const indirectsToFetch = perm.rules
|
|
605
|
-
.map((r) => extractIndirectResource(r))
|
|
606
|
-
.filter((ir) => ir !== null && ir.fetcher !== undefined);
|
|
607
|
-
await Promise.all(indirectsToFetch.map(async (ir) => {
|
|
608
|
-
const sourceDoc = fetched.get(ir.from.id);
|
|
609
|
-
if (sourceDoc === undefined || sourceDoc === null)
|
|
610
|
-
return;
|
|
611
|
-
const cacheKey = ir.cacheKeyForTarget(ctx.target);
|
|
612
|
-
if (state) {
|
|
613
|
-
const existing = state.cache.get(cacheKey);
|
|
614
|
-
if (existing) {
|
|
615
|
-
indirectFetched.set(ir.id, (await existing));
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
const pending = Promise.resolve(ir.fetcher(sourceDoc, ctx));
|
|
619
|
-
state.cache.set(cacheKey, pending);
|
|
620
|
-
indirectFetched.set(ir.id, await pending);
|
|
621
|
-
}
|
|
622
|
-
else {
|
|
623
|
-
indirectFetched.set(ir.id, await ir.fetcher(sourceDoc, ctx));
|
|
624
|
-
}
|
|
625
|
-
}));
|
|
626
|
-
}
|
|
627
|
-
// Attach indirect-fetched joined docs to the context so
|
|
628
|
-
// `indirect.match()` rule can read them.
|
|
629
|
-
const ctxWithIndirect = Object.assign({}, ctx, {
|
|
630
|
-
_indirectFetched: indirectFetched,
|
|
631
|
-
});
|
|
632
606
|
let grantOk = true;
|
|
633
607
|
let grantData = undefined;
|
|
634
|
-
// Multiple match rules in one permission (e.g., expositionInfo + badge)
|
|
635
|
-
// contribute distinct constraints — AND-merge them per grant.
|
|
636
608
|
const grantConstraints = [];
|
|
609
|
+
const grantFilters = [];
|
|
637
610
|
let grantHasMatchRule = false;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
611
|
+
try {
|
|
612
|
+
const fetched = new Map();
|
|
613
|
+
await Promise.all(uniqueResources.map(async (r) => {
|
|
614
|
+
fetched.set(r.id, await fetchResource(r, ctx, state));
|
|
615
|
+
}));
|
|
616
|
+
const indirectFetched = new Map();
|
|
617
|
+
if (!capability) {
|
|
618
|
+
const indirectsToFetch = perm.rules
|
|
619
|
+
.map((r) => extractIndirectResource(r))
|
|
620
|
+
.filter((ir) => ir !== null && ir.fetcher !== undefined);
|
|
621
|
+
await Promise.all(indirectsToFetch.map(async (ir) => {
|
|
622
|
+
const sourceDoc = fetched.get(ir.from.id);
|
|
623
|
+
if (sourceDoc === undefined || sourceDoc === null)
|
|
624
|
+
return;
|
|
625
|
+
const cacheKey = ir.cacheKeyForTarget(ctx.target);
|
|
626
|
+
if (state) {
|
|
627
|
+
const existing = state.cache.get(cacheKey);
|
|
628
|
+
if (existing) {
|
|
629
|
+
indirectFetched.set(ir.id, (await existing));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const pending = Promise.resolve(ir.fetcher(sourceDoc, ctx));
|
|
633
|
+
state.cache.set(cacheKey, pending);
|
|
634
|
+
indirectFetched.set(ir.id, await pending);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
indirectFetched.set(ir.id, await ir.fetcher(sourceDoc, ctx));
|
|
638
|
+
}
|
|
639
|
+
}));
|
|
652
640
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
641
|
+
const ctxWithIndirect = Object.assign({}, ctx, {
|
|
642
|
+
_indirectFetched: indirectFetched,
|
|
643
|
+
});
|
|
644
|
+
for (const rule of activeRules) {
|
|
645
|
+
if (rule.descriptor.kind === "match")
|
|
646
|
+
grantHasMatchRule = true;
|
|
647
|
+
const data = rule.needs.map((r) => fetched.get(r.id));
|
|
648
|
+
const result = rule.check(data, ctxWithIndirect);
|
|
649
|
+
if (!result.ok) {
|
|
650
|
+
grantOk = false;
|
|
651
|
+
reasons.push(grant.id ? `[${grant.id}] ${result.reason}` : result.reason);
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
if (result.data !== undefined)
|
|
655
|
+
grantData = result.data;
|
|
656
|
+
if (result.constraint !== undefined) {
|
|
657
|
+
grantConstraints.push(result.constraint);
|
|
658
|
+
}
|
|
659
|
+
if (result.filter !== undefined)
|
|
660
|
+
grantFilters.push(result.filter);
|
|
656
661
|
}
|
|
657
662
|
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
grantOk = false;
|
|
665
|
+
reportError({
|
|
666
|
+
source: "rule",
|
|
667
|
+
grantId: grant.id,
|
|
668
|
+
subject,
|
|
669
|
+
key,
|
|
670
|
+
target,
|
|
671
|
+
error,
|
|
672
|
+
});
|
|
673
|
+
const reason = `rule evaluation failed: ${describeError(error)}`;
|
|
674
|
+
reasons.push(grant.id ? `[${grant.id}] ${reason}` : reason);
|
|
675
|
+
}
|
|
658
676
|
if (grantOk) {
|
|
677
|
+
for (const contribution of grantFilters) {
|
|
678
|
+
referenceSource = contribution.source;
|
|
679
|
+
filterUnion = mergeFilterSpec(filterUnion, contribution.spec);
|
|
680
|
+
}
|
|
659
681
|
anyOk = true;
|
|
660
682
|
successfulGrants.push(grant);
|
|
661
683
|
if (grant.id)
|
|
@@ -674,15 +696,14 @@ export function createSystem(opts) {
|
|
|
674
696
|
if (!anyOk) {
|
|
675
697
|
return {
|
|
676
698
|
ok: false,
|
|
677
|
-
reasons:
|
|
699
|
+
reasons: [
|
|
700
|
+
...(reasons.length > 0 ? reasons : ["no matching grant"]),
|
|
701
|
+
...failures,
|
|
702
|
+
],
|
|
678
703
|
};
|
|
679
704
|
}
|
|
680
705
|
const constraints = aggregateConstraints(collectedConstraints);
|
|
681
706
|
const finalData = resolveFilteredData(filterUnion, referenceSource, lastData);
|
|
682
|
-
// Collect indirect resources referenced by the permission's rules
|
|
683
|
-
// (sentinel rules with descriptor.kind === "indirect-match"). If any
|
|
684
|
-
// are referenced by matched grants' `with`, emit an aggregation
|
|
685
|
-
// pipeline that pushes the JOIN constraint to the DB.
|
|
686
707
|
const declaredIndirect = [];
|
|
687
708
|
for (const rule of perm.rules) {
|
|
688
709
|
const ir = extractIndirectResource(rule);
|
|
@@ -692,10 +713,8 @@ export function createSystem(opts) {
|
|
|
692
713
|
let stages;
|
|
693
714
|
if (declaredIndirect.length > 0) {
|
|
694
715
|
const baseFilter = constraints ?? {};
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
// contribute to indirect any-wins — otherwise it would silently
|
|
698
|
-
// disable the indirect filter for its siblings.
|
|
716
|
+
// A rejected grant without an indirect reference would win "any" and
|
|
717
|
+
// lift the join filter for its siblings, so only accepted grants count.
|
|
699
718
|
const result = buildAggregationStages(baseFilter, successfulGrants, declaredIndirect);
|
|
700
719
|
if (result !== null)
|
|
701
720
|
stages = result;
|