@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/system.ts
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
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
|
-
|
|
15
1
|
import type {
|
|
16
2
|
AnyTarget,
|
|
17
3
|
CanResult,
|
|
18
4
|
FetchCtx,
|
|
5
|
+
FilterContribution,
|
|
19
6
|
Grant,
|
|
20
7
|
IndirectResourceInfo,
|
|
21
8
|
ListEntry,
|
|
@@ -45,26 +32,53 @@ export type ProviderFn = (
|
|
|
45
32
|
target?: readonly unknown[],
|
|
46
33
|
) => readonly Grant[] | Promise<readonly Grant[]>;
|
|
47
34
|
|
|
48
|
-
/**
|
|
49
|
-
* Provider sous forme objet : permet d'opt-in à la dedup de grants
|
|
50
|
-
* (cacheKey) et au filtrage par préfixe de key (keys/matches).
|
|
51
|
-
*
|
|
52
|
-
* - `keys`/`matches` : le provider est skip si la key demandée ne match pas
|
|
53
|
-
* - `cacheKey` : grants memoizés par (subject, key, target) au sein d'un context
|
|
54
|
-
*/
|
|
55
35
|
export type ProviderObject = {
|
|
36
|
+
readonly name?: string;
|
|
56
37
|
readonly keys?: readonly string[];
|
|
57
38
|
readonly matches?: (key: string) => boolean;
|
|
39
|
+
readonly targetType?: string | readonly string[];
|
|
58
40
|
readonly cacheKey?: (
|
|
59
41
|
subject: Subject,
|
|
60
42
|
key: string,
|
|
61
43
|
target?: readonly unknown[],
|
|
62
|
-
) => string;
|
|
44
|
+
) => string | undefined;
|
|
63
45
|
readonly fetch: ProviderFn;
|
|
64
46
|
};
|
|
65
47
|
|
|
66
48
|
export type Provider = ProviderFn | ProviderObject;
|
|
67
49
|
|
|
50
|
+
export type ProviderFetchEvent = {
|
|
51
|
+
readonly provider: string;
|
|
52
|
+
readonly subject: Subject;
|
|
53
|
+
readonly key: string;
|
|
54
|
+
readonly target: readonly unknown[] | undefined;
|
|
55
|
+
readonly durationMs: number;
|
|
56
|
+
readonly grantCount: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type PermissionErrorEvent =
|
|
60
|
+
| {
|
|
61
|
+
readonly source: "provider";
|
|
62
|
+
readonly provider: string;
|
|
63
|
+
readonly subject: Subject;
|
|
64
|
+
readonly key: string;
|
|
65
|
+
readonly target: readonly unknown[] | undefined;
|
|
66
|
+
readonly error: unknown;
|
|
67
|
+
}
|
|
68
|
+
| {
|
|
69
|
+
readonly source: "rule";
|
|
70
|
+
readonly grantId: string | undefined;
|
|
71
|
+
readonly subject: Subject;
|
|
72
|
+
readonly key: string;
|
|
73
|
+
readonly target: readonly unknown[] | undefined;
|
|
74
|
+
readonly error: unknown;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type SystemHooks = {
|
|
78
|
+
readonly onProviderFetch?: (event: ProviderFetchEvent) => void;
|
|
79
|
+
readonly onError?: (event: PermissionErrorEvent) => void;
|
|
80
|
+
};
|
|
81
|
+
|
|
68
82
|
function keyMatchesPattern(key: string, pattern: string): boolean {
|
|
69
83
|
if (pattern === key) return true;
|
|
70
84
|
if (pattern.endsWith("*")) {
|
|
@@ -73,23 +87,54 @@ function keyMatchesPattern(key: string, pattern: string): boolean {
|
|
|
73
87
|
return false;
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
function
|
|
77
|
-
if (!provider.keys && !provider.matches) return true;
|
|
90
|
+
function providerDeclaresKey(provider: ProviderObject, key: string): boolean {
|
|
78
91
|
if (provider.keys?.some((p) => keyMatchesPattern(key, p))) return true;
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
return provider.matches?.(key) === true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function refType(segment: unknown): string | undefined {
|
|
96
|
+
if (typeof segment !== "string") return undefined;
|
|
97
|
+
const colon = segment.indexOf(":");
|
|
98
|
+
return colon > 0 ? segment.slice(0, colon) : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function providerHandlesTarget(
|
|
102
|
+
provider: ProviderObject,
|
|
103
|
+
target: readonly unknown[] | undefined,
|
|
104
|
+
): boolean {
|
|
105
|
+
if (provider.targetType === undefined) return true;
|
|
106
|
+
const first = target?.[0];
|
|
107
|
+
if (first === undefined || isWildcardSegment(first)) return false;
|
|
108
|
+
const type = refType(first);
|
|
109
|
+
if (type === undefined) return false;
|
|
110
|
+
return typeof provider.targetType === "string"
|
|
111
|
+
? provider.targetType === type
|
|
112
|
+
: provider.targetType.includes(type);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type ProviderOutcome = {
|
|
116
|
+
readonly grants: readonly Grant[];
|
|
117
|
+
readonly failure?: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type GrantsCache = Map<Provider, Map<string, Promise<ProviderOutcome>>>;
|
|
121
|
+
|
|
122
|
+
function callHook(invoke: () => void): void {
|
|
123
|
+
try {
|
|
124
|
+
invoke();
|
|
125
|
+
} catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function describeError(error: unknown): string {
|
|
131
|
+
return error instanceof Error ? error.message : String(error);
|
|
81
132
|
}
|
|
82
133
|
|
|
83
134
|
export type CanContext = {
|
|
84
135
|
readonly checkDate?: Date;
|
|
85
136
|
readonly checkIp?: string;
|
|
86
|
-
/**
|
|
87
|
-
readonly broadMatch?: boolean;
|
|
88
|
-
/**
|
|
89
|
-
* Check-time payload exposed to rules as `ctx.input`. Distinct from
|
|
90
|
-
* `grant.payload` (static, seed-time). Consumed by `inputMatch()` to
|
|
91
|
-
* validate a CREATE body against `grant.with`.
|
|
92
|
-
*/
|
|
137
|
+
/** Check-time payload read by `inputMatch()`, as opposed to the grant's static `payload`. */
|
|
93
138
|
readonly input?: unknown;
|
|
94
139
|
};
|
|
95
140
|
|
|
@@ -97,60 +142,28 @@ export type System<TMeta = unknown> = {
|
|
|
97
142
|
list(): readonly ListEntry<TMeta>[];
|
|
98
143
|
tree(): { readonly children: Readonly<Record<string, TreeNode<TMeta>>> };
|
|
99
144
|
schema(key: string): Permission<TMeta> | undefined;
|
|
100
|
-
/**
|
|
101
|
-
* Every distinct indirect resource referenced by the schema's rules,
|
|
102
|
-
* deduped by id. Returned in declaration-walk order (first-occurrence
|
|
103
|
-
* wins for dedup). Function fields are stripped — see
|
|
104
|
-
* `IndirectResourceInfo`. Empty if no permission uses `.match()` on an
|
|
105
|
-
* `indirectResource`.
|
|
106
|
-
*/
|
|
145
|
+
/** Deduplicated by id, in declaration order, without their functions. */
|
|
107
146
|
indirectResources(): readonly IndirectResourceInfo[];
|
|
108
|
-
/**
|
|
109
|
-
* Indirect resources referenced by a single permission's rules.
|
|
110
|
-
* `[]` if `key` is unknown or has no `indirect-match` rule. Useful so
|
|
111
|
-
* matrix UIs only render the indirect-match editor on permissions where
|
|
112
|
-
* it has a semantic.
|
|
113
|
-
*/
|
|
114
147
|
indirectsUsedBy(key: string): readonly IndirectResourceInfo[];
|
|
115
|
-
/**
|
|
148
|
+
/** Nothing is cached across calls; prefer `context()` for a request. */
|
|
116
149
|
can(
|
|
117
150
|
subject: Subject,
|
|
118
151
|
key: string,
|
|
119
152
|
target?: readonly unknown[],
|
|
120
153
|
context?: CanContext,
|
|
121
154
|
): Promise<CanResult>;
|
|
122
|
-
/**
|
|
123
|
-
* Crée un context request-scoped avec cache de fetches partagé entre
|
|
124
|
-
* tous les `can()` qui en découlent.
|
|
125
|
-
*/
|
|
155
|
+
/** Resources and provider grants are cached across every `can()` of the context. */
|
|
126
156
|
context(bound: { readonly subject: Subject } & CanContext): {
|
|
127
|
-
/**
|
|
128
|
-
* `perCall` overrides the bound `CanContext` for this single check.
|
|
129
|
-
* Canonical use : passing `input` for a CREATE while keeping the
|
|
130
|
-
* shared (per-request) context. Other fields stay bound — override
|
|
131
|
-
* only when truly per-call.
|
|
132
|
-
*/
|
|
157
|
+
/** `perCall` overrides the bound context for this check only, typically `input` on a create. */
|
|
133
158
|
can(
|
|
134
159
|
key: string,
|
|
135
160
|
target?: readonly unknown[],
|
|
136
161
|
perCall?: CanContext,
|
|
137
162
|
): Promise<CanResult>;
|
|
138
163
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* per-doc projection check doesn't hit the DB again.
|
|
143
|
-
*
|
|
144
|
-
* `dedupKey(doc)` MUST return the same target segments the resource's
|
|
145
|
-
* `dedupKey` would compute at fetch time — otherwise the cache key
|
|
146
|
-
* won't match and the preseed is silently ineffective. For most
|
|
147
|
-
* resources whose dedupKey is `target[i]`, just return `[doc._id]`
|
|
148
|
-
* (or the relevant segments).
|
|
149
|
-
*
|
|
150
|
-
* `value(doc)` is optional. Defaults to identity (the doc itself,
|
|
151
|
-
* used for direct resources). Provide it for indirect resources
|
|
152
|
-
* where the cached value is an array of joined docs nested under
|
|
153
|
-
* an alias (e.g. `d => d._memberships_of_participant`).
|
|
164
|
+
* `dedupKey(doc)` must return the segments the resource's own `dedupKey`
|
|
165
|
+
* computes at fetch time, or the seeded value is never found. `value(doc)`
|
|
166
|
+
* defaults to the doc; an indirect resource caches its joined docs instead.
|
|
154
167
|
*/
|
|
155
168
|
preseed<T, V = T>(
|
|
156
169
|
resource: Resource<unknown> | IndirectResource | string,
|
|
@@ -160,17 +173,14 @@ export type System<TMeta = unknown> = {
|
|
|
160
173
|
value?: (doc: T) => V;
|
|
161
174
|
},
|
|
162
175
|
): void;
|
|
163
|
-
/** Compteurs de fetches par resource.id (debug / observabilité). */
|
|
164
176
|
getFetchCounters(): Readonly<Record<string, number>>;
|
|
165
|
-
/**
|
|
177
|
+
/** Resets the counters, never the cache. */
|
|
166
178
|
clearCounters(): void;
|
|
167
|
-
/**
|
|
179
|
+
/** Only the grants of cached providers already consulted in this context. */
|
|
168
180
|
dumpGrants(): Promise<readonly Grant[]>;
|
|
169
181
|
};
|
|
170
182
|
};
|
|
171
183
|
|
|
172
|
-
// ─── Schema validation au boot ──────────────────────────────────────────
|
|
173
|
-
|
|
174
184
|
function validateSchema<TMeta>(
|
|
175
185
|
schema: Readonly<Record<string, Permission<TMeta>>>,
|
|
176
186
|
): void {
|
|
@@ -184,7 +194,6 @@ function validateSchema<TMeta>(
|
|
|
184
194
|
target: ["*"],
|
|
185
195
|
});
|
|
186
196
|
} catch {
|
|
187
|
-
// Best-effort : si expandsTo throw sur le stub, on skip.
|
|
188
197
|
continue;
|
|
189
198
|
}
|
|
190
199
|
for (const child of samples) {
|
|
@@ -200,29 +209,19 @@ function validateSchema<TMeta>(
|
|
|
200
209
|
}
|
|
201
210
|
}
|
|
202
211
|
|
|
203
|
-
// ─── Target matching (wildcards par segment) ────────────────────────────
|
|
204
|
-
|
|
205
212
|
function asPath(value: unknown): readonly unknown[] {
|
|
206
213
|
return Array.isArray(value) ? value : [value];
|
|
207
214
|
}
|
|
208
215
|
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
* ne peuvent pas vérifier leur contrainte.
|
|
214
|
-
*/
|
|
215
|
-
function isCapabilityQuery(target: readonly unknown[] | undefined): boolean {
|
|
216
|
+
/** A query with any wildcard segment asks "could this subject, on some target"; rules that need a concrete document pass silently. */
|
|
217
|
+
export function isCapabilityQuery(
|
|
218
|
+
target: readonly unknown[] | undefined,
|
|
219
|
+
): boolean {
|
|
216
220
|
if (!target) return false;
|
|
217
221
|
return target.some(isWildcardSegment);
|
|
218
222
|
}
|
|
219
223
|
|
|
220
|
-
|
|
221
|
-
* True if a single target segment is a wildcard (`"*"` or `"xxx:*"`).
|
|
222
|
-
* Inverse de "concret" — utilisé pour décider si une resource peut être
|
|
223
|
-
* fetchée en cap-mode (segment concret → fetch OK).
|
|
224
|
-
*/
|
|
225
|
-
function isWildcardSegment(seg: unknown): boolean {
|
|
224
|
+
export function isWildcardSegment(seg: unknown): boolean {
|
|
226
225
|
return seg === "*" || (typeof seg === "string" && seg.endsWith("*"));
|
|
227
226
|
}
|
|
228
227
|
|
|
@@ -247,17 +246,10 @@ function targetMatches(
|
|
|
247
246
|
capability: boolean,
|
|
248
247
|
): boolean {
|
|
249
248
|
if (schemaKind === "none") return true;
|
|
250
|
-
if (grantTarget === undefined)
|
|
251
|
-
// A target-less grant only matches when the schema permits it.
|
|
252
|
-
// `optional` documents this "global grant" mode; `required` and `path`
|
|
253
|
-
// require the grant to carry a target.
|
|
254
|
-
return schemaKind === "optional";
|
|
255
|
-
}
|
|
249
|
+
if (grantTarget === undefined) return schemaKind === "optional";
|
|
256
250
|
if (requestTarget === undefined) return false;
|
|
257
251
|
const g = asPath(grantTarget);
|
|
258
252
|
if (g.length !== requestTarget.length) return false;
|
|
259
|
-
// Capability mode (request has wildcards) uses overlap semantics so a
|
|
260
|
-
// specific-target grant like ["user:lucas"] still matches ["user:*"].
|
|
261
253
|
if (capability) {
|
|
262
254
|
return g.every((seg, i) => segmentOverlaps(seg, requestTarget[i]));
|
|
263
255
|
}
|
|
@@ -274,8 +266,6 @@ function targetMatches(
|
|
|
274
266
|
});
|
|
275
267
|
}
|
|
276
268
|
|
|
277
|
-
// ─── Intermediate expansion ──────────────────────────────────────────────
|
|
278
|
-
|
|
279
269
|
function expandGrants<TMeta>(
|
|
280
270
|
grants: readonly Grant[],
|
|
281
271
|
schema: Readonly<Record<string, Permission<TMeta>>>,
|
|
@@ -286,13 +276,9 @@ function expandGrants<TMeta>(
|
|
|
286
276
|
grant: g,
|
|
287
277
|
depth: 0,
|
|
288
278
|
}));
|
|
289
|
-
|
|
290
|
-
const { grant, depth } = queue
|
|
279
|
+
for (let head = 0; head < queue.length; head++) {
|
|
280
|
+
const { grant, depth } = queue[head];
|
|
291
281
|
const perm = schema[grant.key];
|
|
292
|
-
// Drop grants that violate the schema's target contract: a grant
|
|
293
|
-
// without `target` on a `required` / `path` schema cannot match
|
|
294
|
-
// (see `targetMatches`) and its `expandsTo` cannot synthesize valid
|
|
295
|
-
// children. Skip silently so one bad grant doesn't break the call.
|
|
296
282
|
if (
|
|
297
283
|
perm &&
|
|
298
284
|
grant.target === undefined &&
|
|
@@ -312,8 +298,6 @@ function expandGrants<TMeta>(
|
|
|
312
298
|
return out;
|
|
313
299
|
}
|
|
314
300
|
|
|
315
|
-
// ─── Serialization (pour list/tree) ──────────────────────────────────────
|
|
316
|
-
|
|
317
301
|
function serializeSegment(s: {
|
|
318
302
|
readonly name: string;
|
|
319
303
|
readonly types: unknown;
|
|
@@ -366,91 +350,64 @@ function collectIndirectsForPermission<TMeta>(
|
|
|
366
350
|
return Array.from(collected.values());
|
|
367
351
|
}
|
|
368
352
|
|
|
369
|
-
/**
|
|
370
|
-
* Build a wildcard target stub matching the schema's arity. Used to sample
|
|
371
|
-
* `expandsTo(grant)` at catalog enumeration time — we don't have a real
|
|
372
|
-
* grant target available, but we need ARG_ARITY to match or `targetMatches`
|
|
373
|
-
* would drop the synthesized child grants (see `expandGrants:228-234`).
|
|
374
|
-
*
|
|
375
|
-
* - none → undefined (no segments)
|
|
376
|
-
* - optional → ["*"]
|
|
377
|
-
* - required → ["*"]
|
|
378
|
-
* - path(n) → ["*", "*", ..., "*"] (n segments)
|
|
379
|
-
*/
|
|
380
353
|
function stubTargetFor(t: AnyTarget): readonly unknown[] | undefined {
|
|
381
354
|
if (t.kind === "none") return undefined;
|
|
382
355
|
if (t.kind === "optional" || t.kind === "required") return ["*"];
|
|
383
356
|
return t.segments.map(() => "*");
|
|
384
357
|
}
|
|
385
358
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
* call expandsTo with a concrete grant carrying `with`/`filter`/`flags` —
|
|
395
|
-
* but for catalog enumeration we only care about the resulting child KEYS
|
|
396
|
-
* (the same keys would be produced regardless of grant payload, since
|
|
397
|
-
* macros are by convention pure key-routers).
|
|
398
|
-
*
|
|
399
|
-
* Defensive : caught exceptions in `expandsTo` (e.g. a callback that
|
|
400
|
-
* asserts on a concrete segment) silently drop that branch — same posture
|
|
401
|
-
* as `validateSchema`. Cycles are broken by the `visited` set.
|
|
402
|
-
*
|
|
403
|
-
* Returns `undefined` if `rootKey` is a leaf or not in schema (the caller
|
|
404
|
-
* uses this signal to omit the field from the serialized entry rather
|
|
405
|
-
* than emit an empty array).
|
|
406
|
-
*/
|
|
407
|
-
function computeDescendants<TMeta>(
|
|
408
|
-
rootKey: string,
|
|
359
|
+
type ExpansionGraph = {
|
|
360
|
+
readonly descendants: ReadonlyMap<string, readonly string[]>;
|
|
361
|
+
readonly ancestors: ReadonlyMap<string, readonly string[]>;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// An intermediate is sampled with a wildcard grant: by convention `expandsTo`
|
|
365
|
+
// routes keys and does not depend on the grant's target or payload.
|
|
366
|
+
function expansionGraph<TMeta>(
|
|
409
367
|
schema: Readonly<Record<string, Permission<TMeta>>>,
|
|
410
368
|
maxDepth = 10,
|
|
411
|
-
):
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
{ key:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
queue.push({ key: child.key, depth: depth + 1 });
|
|
369
|
+
): ExpansionGraph {
|
|
370
|
+
const descendants = new Map<string, string[]>();
|
|
371
|
+
const ancestors = new Map<string, string[]>();
|
|
372
|
+
for (const [rootKey, root] of Object.entries(schema)) {
|
|
373
|
+
if (!root.expandsTo) continue;
|
|
374
|
+
const leaves: string[] = [];
|
|
375
|
+
const visited = new Set<string>([rootKey]);
|
|
376
|
+
const queue: Array<{ key: string; depth: number }> = [
|
|
377
|
+
{ key: rootKey, depth: 0 },
|
|
378
|
+
];
|
|
379
|
+
for (let head = 0; head < queue.length; head++) {
|
|
380
|
+
const { key, depth } = queue[head];
|
|
381
|
+
const perm = schema[key];
|
|
382
|
+
if (!perm) continue;
|
|
383
|
+
if (!perm.expandsTo) {
|
|
384
|
+
leaves.push(key);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (depth >= maxDepth) continue;
|
|
388
|
+
let children: readonly Grant[] = [];
|
|
389
|
+
try {
|
|
390
|
+
children = perm.expandsTo({ key, target: stubTargetFor(perm.target) });
|
|
391
|
+
} catch {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
for (const child of children) {
|
|
395
|
+
if (visited.has(child.key)) continue;
|
|
396
|
+
visited.add(child.key);
|
|
397
|
+
const list = ancestors.get(child.key) ?? [];
|
|
398
|
+
list.push(rootKey);
|
|
399
|
+
ancestors.set(child.key, list);
|
|
400
|
+
queue.push({ key: child.key, depth: depth + 1 });
|
|
401
|
+
}
|
|
445
402
|
}
|
|
403
|
+
descendants.set(rootKey, leaves);
|
|
446
404
|
}
|
|
447
|
-
return
|
|
405
|
+
return { descendants, ancestors };
|
|
448
406
|
}
|
|
449
407
|
|
|
450
|
-
// ─── Tree builder ────────────────────────────────────────────────────────
|
|
451
|
-
|
|
452
408
|
function buildTree<TMeta>(
|
|
453
409
|
schema: Readonly<Record<string, Permission<TMeta>>>,
|
|
410
|
+
graph: ExpansionGraph,
|
|
454
411
|
): { children: Record<string, TreeNode<TMeta>> } {
|
|
455
412
|
const root: { children: Record<string, TreeNode<TMeta>> } = { children: {} };
|
|
456
413
|
|
|
@@ -491,9 +448,7 @@ function buildTree<TMeta>(
|
|
|
491
448
|
}
|
|
492
449
|
}
|
|
493
450
|
const leafName = parts[parts.length - 1];
|
|
494
|
-
const descendants =
|
|
495
|
-
? computeDescendants(key, schema)
|
|
496
|
-
: undefined;
|
|
451
|
+
const descendants = graph.descendants.get(key);
|
|
497
452
|
cursor.children[leafName] = {
|
|
498
453
|
kind: perm.expandsTo ? "intermediate" : "permission",
|
|
499
454
|
key,
|
|
@@ -507,20 +462,41 @@ function buildTree<TMeta>(
|
|
|
507
462
|
return root;
|
|
508
463
|
}
|
|
509
464
|
|
|
510
|
-
// ─── createSystem ────────────────────────────────────────────────────────
|
|
511
|
-
|
|
512
465
|
export function createSystem<TMeta = unknown>(opts: {
|
|
513
466
|
readonly schema: Readonly<Record<string, Permission<TMeta>>>;
|
|
514
467
|
readonly providers?: readonly Provider[];
|
|
468
|
+
readonly hooks?: SystemHooks;
|
|
515
469
|
}): System<TMeta> {
|
|
516
|
-
const { schema, providers = [] } = opts;
|
|
470
|
+
const { schema, providers = [], hooks = {} } = opts;
|
|
517
471
|
validateSchema(schema);
|
|
518
472
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
473
|
+
const graph = expansionGraph(schema);
|
|
474
|
+
const providerNames = new Map<Provider, string>(
|
|
475
|
+
providers.map((provider, index) => [
|
|
476
|
+
provider,
|
|
477
|
+
(typeof provider === "object" && provider.name) || `provider#${index}`,
|
|
478
|
+
]),
|
|
479
|
+
);
|
|
480
|
+
const handledKeys = new Map<ProviderObject, Map<string, boolean>>();
|
|
481
|
+
|
|
482
|
+
function providerHandlesKey(provider: ProviderObject, key: string): boolean {
|
|
483
|
+
if (!provider.keys && !provider.matches) return true;
|
|
484
|
+
let memo = handledKeys.get(provider);
|
|
485
|
+
if (!memo) {
|
|
486
|
+
memo = new Map();
|
|
487
|
+
handledKeys.set(provider, memo);
|
|
488
|
+
}
|
|
489
|
+
const known = memo.get(key);
|
|
490
|
+
if (known !== undefined) return known;
|
|
491
|
+
const handled =
|
|
492
|
+
providerDeclaresKey(provider, key) ||
|
|
493
|
+
(graph.ancestors.get(key) ?? []).some((ancestor) =>
|
|
494
|
+
providerDeclaresKey(provider, ancestor),
|
|
495
|
+
);
|
|
496
|
+
memo.set(key, handled);
|
|
497
|
+
return handled;
|
|
498
|
+
}
|
|
499
|
+
|
|
524
500
|
const resourceRegistry = new Map<
|
|
525
501
|
string,
|
|
526
502
|
Resource<unknown> | IndirectResource
|
|
@@ -538,9 +514,7 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
538
514
|
return {
|
|
539
515
|
list() {
|
|
540
516
|
return Object.entries(schema).map(([key, perm]) => {
|
|
541
|
-
const descendants =
|
|
542
|
-
? computeDescendants(key, schema)
|
|
543
|
-
: undefined;
|
|
517
|
+
const descendants = graph.descendants.get(key);
|
|
544
518
|
return {
|
|
545
519
|
key,
|
|
546
520
|
kind: perm.expandsTo
|
|
@@ -555,7 +529,7 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
555
529
|
},
|
|
556
530
|
|
|
557
531
|
tree() {
|
|
558
|
-
return buildTree<TMeta>(schema);
|
|
532
|
+
return buildTree<TMeta>(schema, graph);
|
|
559
533
|
},
|
|
560
534
|
|
|
561
535
|
schema(key) {
|
|
@@ -584,7 +558,7 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
584
558
|
|
|
585
559
|
context(bound) {
|
|
586
560
|
const cache = new Map<string, Promise<unknown>>();
|
|
587
|
-
const grantsCache = new Map
|
|
561
|
+
const grantsCache: GrantsCache = new Map();
|
|
588
562
|
const fetchCounters = new Map<string, number>();
|
|
589
563
|
const { subject, ...boundCtx } = bound;
|
|
590
564
|
const resolveResource = (
|
|
@@ -627,44 +601,113 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
627
601
|
fetchCounters.clear();
|
|
628
602
|
},
|
|
629
603
|
async dumpGrants() {
|
|
630
|
-
const
|
|
631
|
-
|
|
604
|
+
const outcomes = await Promise.all(
|
|
605
|
+
[...grantsCache.values()].flatMap((byKey) => [...byKey.values()]),
|
|
606
|
+
);
|
|
607
|
+
return outcomes.flatMap((outcome) => outcome.grants);
|
|
632
608
|
},
|
|
633
609
|
};
|
|
634
610
|
},
|
|
635
611
|
};
|
|
636
612
|
|
|
637
|
-
// ─── Implémentation can() ─────────────────────────────────────────────
|
|
638
|
-
|
|
639
613
|
type ContextState = {
|
|
640
|
-
/** Cache des fetches de Resource au sein d'un context. */
|
|
641
614
|
readonly cache: Map<string, Promise<unknown>>;
|
|
642
|
-
|
|
643
|
-
readonly grantsCache: Map<string, Promise<readonly Grant[]>>;
|
|
615
|
+
readonly grantsCache: GrantsCache;
|
|
644
616
|
readonly fetchCounters: Map<string, number>;
|
|
645
617
|
};
|
|
646
618
|
|
|
619
|
+
function reportError(event: PermissionErrorEvent): void {
|
|
620
|
+
callHook(() => hooks.onError?.(event));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function fetchFromProvider(
|
|
624
|
+
provider: Provider,
|
|
625
|
+
name: string,
|
|
626
|
+
subject: Subject,
|
|
627
|
+
key: string,
|
|
628
|
+
target: readonly unknown[] | undefined,
|
|
629
|
+
): Promise<ProviderOutcome> {
|
|
630
|
+
const started = performance.now();
|
|
631
|
+
let grants: readonly Grant[];
|
|
632
|
+
try {
|
|
633
|
+
grants =
|
|
634
|
+
typeof provider === "function"
|
|
635
|
+
? await provider(subject, key, target)
|
|
636
|
+
: await provider.fetch(subject, key, target);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
reportError({
|
|
639
|
+
source: "provider",
|
|
640
|
+
provider: name,
|
|
641
|
+
subject,
|
|
642
|
+
key,
|
|
643
|
+
target,
|
|
644
|
+
error,
|
|
645
|
+
});
|
|
646
|
+
return {
|
|
647
|
+
grants: [],
|
|
648
|
+
failure: `provider ${name} failed: ${describeError(error)}`,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
const durationMs = performance.now() - started;
|
|
652
|
+
callHook(() =>
|
|
653
|
+
hooks.onProviderFetch?.({
|
|
654
|
+
provider: name,
|
|
655
|
+
subject,
|
|
656
|
+
key,
|
|
657
|
+
target,
|
|
658
|
+
durationMs,
|
|
659
|
+
grantCount: grants.length,
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
662
|
+
return { grants };
|
|
663
|
+
}
|
|
664
|
+
|
|
647
665
|
async function invokeProvider(
|
|
648
666
|
provider: Provider,
|
|
649
667
|
subject: Subject,
|
|
650
668
|
key: string,
|
|
651
669
|
target: readonly unknown[] | undefined,
|
|
652
670
|
state: ContextState | undefined,
|
|
653
|
-
): Promise<
|
|
654
|
-
|
|
655
|
-
|
|
671
|
+
): Promise<ProviderOutcome> {
|
|
672
|
+
const name = providerNames.get(provider) ?? "provider";
|
|
673
|
+
if (typeof provider === "object") {
|
|
674
|
+
if (!providerHandlesKey(provider, key)) return { grants: [] };
|
|
675
|
+
if (!providerHandlesTarget(provider, target)) return { grants: [] };
|
|
656
676
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
677
|
+
let ck: string | undefined;
|
|
678
|
+
try {
|
|
679
|
+
ck =
|
|
680
|
+
typeof provider === "object"
|
|
681
|
+
? provider.cacheKey?.(subject, key, target)
|
|
682
|
+
: undefined;
|
|
683
|
+
} catch (error) {
|
|
684
|
+
reportError({
|
|
685
|
+
source: "provider",
|
|
686
|
+
provider: name,
|
|
687
|
+
subject,
|
|
688
|
+
key,
|
|
689
|
+
target,
|
|
690
|
+
error,
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
grants: [],
|
|
694
|
+
failure: `provider ${name} failed: ${describeError(error)}`,
|
|
695
|
+
};
|
|
666
696
|
}
|
|
667
|
-
|
|
697
|
+
if (!state || !ck) {
|
|
698
|
+
return fetchFromProvider(provider, name, subject, key, target);
|
|
699
|
+
}
|
|
700
|
+
let byKey = state.grantsCache.get(provider);
|
|
701
|
+
if (!byKey) {
|
|
702
|
+
byKey = new Map();
|
|
703
|
+
state.grantsCache.set(provider, byKey);
|
|
704
|
+
}
|
|
705
|
+
let pending = byKey.get(ck);
|
|
706
|
+
if (!pending) {
|
|
707
|
+
pending = fetchFromProvider(provider, name, subject, key, target);
|
|
708
|
+
byKey.set(ck, pending);
|
|
709
|
+
}
|
|
710
|
+
return pending;
|
|
668
711
|
}
|
|
669
712
|
|
|
670
713
|
async function fetchResource(
|
|
@@ -673,7 +716,6 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
673
716
|
state: ContextState | undefined,
|
|
674
717
|
): Promise<unknown> {
|
|
675
718
|
if (!state) {
|
|
676
|
-
// Mode `system.can()` direct — pas de cache cross-grant.
|
|
677
719
|
return Promise.resolve(resource.fetcher(ctx));
|
|
678
720
|
}
|
|
679
721
|
const key = resource.computeDedupKey(ctx);
|
|
@@ -725,17 +767,18 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
725
767
|
const arityErr = validateArity(perm, target);
|
|
726
768
|
if (arityErr) return { ok: false, reasons: [arityErr] };
|
|
727
769
|
|
|
728
|
-
// Collect grants depuis les providers (en série pour préserver l'ordre)
|
|
729
770
|
const allGrants: Grant[] = [];
|
|
771
|
+
const failures: string[] = [];
|
|
730
772
|
for (const provider of providers) {
|
|
731
|
-
const
|
|
773
|
+
const outcome = await invokeProvider(
|
|
732
774
|
provider,
|
|
733
775
|
subject,
|
|
734
776
|
key,
|
|
735
777
|
target,
|
|
736
778
|
state,
|
|
737
779
|
);
|
|
738
|
-
allGrants.push(...grants);
|
|
780
|
+
allGrants.push(...outcome.grants);
|
|
781
|
+
if (outcome.failure) failures.push(outcome.failure);
|
|
739
782
|
}
|
|
740
783
|
|
|
741
784
|
const expanded = expandGrants(allGrants, schema);
|
|
@@ -747,26 +790,16 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
747
790
|
targetMatches(g.target, target, perm.target.kind, capability),
|
|
748
791
|
);
|
|
749
792
|
if (matching.length === 0) {
|
|
750
|
-
return { ok: false, reasons: ["no matching grant"] };
|
|
793
|
+
return { ok: false, reasons: ["no matching grant", ...failures] };
|
|
751
794
|
}
|
|
752
795
|
|
|
753
796
|
const reasons: string[] = [];
|
|
754
797
|
let lastData: unknown = undefined;
|
|
755
798
|
const matchedGrantIds: string[] = [];
|
|
756
799
|
let anyOk = false;
|
|
757
|
-
// One entry per matched grant. undefined = "any" (no constraint).
|
|
758
800
|
const collectedConstraints: Array<Record<string, unknown> | undefined> = [];
|
|
759
|
-
// Filter rule cross-grant aggregation. `null` once any grant exposes
|
|
760
|
-
// no filter (= all fields). Else accumulates the union of filter specs.
|
|
761
801
|
let referenceSource: unknown = undefined;
|
|
762
802
|
let filterUnion: FilterUnion = undefined;
|
|
763
|
-
// Grants that PASSED their rules — needed by indirect-resource
|
|
764
|
-
// aggregation. `matching` contains all grants that match key + target
|
|
765
|
-
// shape, but rules can reject some (e.g. a `with` spec evaluated
|
|
766
|
-
// against an auto-fetched resource in cap-mode). The indirect
|
|
767
|
-
// orchestrator must consider only successful grants — otherwise a
|
|
768
|
-
// rejected grant without indirect reference would trigger any-wins
|
|
769
|
-
// and disable the filter.
|
|
770
803
|
const successfulGrants: Grant[] = [];
|
|
771
804
|
|
|
772
805
|
for (const grant of matching) {
|
|
@@ -785,20 +818,8 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
785
818
|
return r.needs.every((res) => res.isActiveFor(grant));
|
|
786
819
|
});
|
|
787
820
|
|
|
788
|
-
//
|
|
789
|
-
// resource
|
|
790
|
-
// target AND whose corresponding segment in the request target is
|
|
791
|
-
// CONCRETE (not `*` / not `xxx:*`) is fetched anyway. This lets
|
|
792
|
-
// `match()` evaluate properly for foreign resources (auxiliary
|
|
793
|
-
// checks like "the request's exposition belongs to my tenant")
|
|
794
|
-
// when only one segment of a multi-segment target is wildcard.
|
|
795
|
-
//
|
|
796
|
-
// Auto-binding is by convention : `resource.id === segment.name`.
|
|
797
|
-
// No opt-in needed — it just works if the convention is respected.
|
|
798
|
-
// Resources whose id matches no segment in the permission's schema
|
|
799
|
-
// are skipped in cap-mode (silent-pass + constraint emit, the
|
|
800
|
-
// legacy behaviour that powers `users.read` + `userOf.match`
|
|
801
|
-
// self-referencing pushdown).
|
|
821
|
+
// A capability query still fetches a resource named like a segment
|
|
822
|
+
// (`resource.id === segment.name`) when that segment is concrete.
|
|
802
823
|
const requestTarget = target ?? [];
|
|
803
824
|
const targetSegmentIndexById = (() => {
|
|
804
825
|
const out = new Map<string, number>();
|
|
@@ -833,85 +854,91 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
833
854
|
.map((r) => [r.id, r] as const),
|
|
834
855
|
).values(),
|
|
835
856
|
);
|
|
836
|
-
const fetched = new Map<string, unknown>();
|
|
837
|
-
await Promise.all(
|
|
838
|
-
uniqueResources.map(async (r) => {
|
|
839
|
-
fetched.set(r.id, await fetchResource(r, ctx, state));
|
|
840
|
-
}),
|
|
841
|
-
);
|
|
842
|
-
|
|
843
|
-
// In concrete mode, indirect resources that declared a `fetcher`
|
|
844
|
-
// are fetched as well (their joined docs are attached to the ctx
|
|
845
|
-
// so `indirect.match()` can evaluate the spec). The cache key is
|
|
846
|
-
// target-derived so `CanContext.preseed()` can inject values
|
|
847
|
-
// produced by a cap-mode pipeline aggregation (no DB hit when
|
|
848
|
-
// listed docs already carry the `_lookupAlias`).
|
|
849
|
-
const indirectFetched = new Map<string, readonly unknown[]>();
|
|
850
|
-
if (!capability) {
|
|
851
|
-
const indirectsToFetch = perm.rules
|
|
852
|
-
.map((r) => extractIndirectResource(r))
|
|
853
|
-
.filter(
|
|
854
|
-
(ir): ir is IndirectResource =>
|
|
855
|
-
ir !== null && ir.fetcher !== undefined,
|
|
856
|
-
);
|
|
857
|
-
await Promise.all(
|
|
858
|
-
indirectsToFetch.map(async (ir) => {
|
|
859
|
-
const sourceDoc = fetched.get(ir.from.id);
|
|
860
|
-
if (sourceDoc === undefined || sourceDoc === null) return;
|
|
861
|
-
const cacheKey = ir.cacheKeyForTarget(ctx.target);
|
|
862
|
-
if (state) {
|
|
863
|
-
const existing = state.cache.get(cacheKey);
|
|
864
|
-
if (existing) {
|
|
865
|
-
indirectFetched.set(
|
|
866
|
-
ir.id,
|
|
867
|
-
(await existing) as readonly unknown[],
|
|
868
|
-
);
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
const pending = Promise.resolve(ir.fetcher!(sourceDoc, ctx));
|
|
872
|
-
state.cache.set(cacheKey, pending);
|
|
873
|
-
indirectFetched.set(ir.id, await pending);
|
|
874
|
-
} else {
|
|
875
|
-
indirectFetched.set(ir.id, await ir.fetcher!(sourceDoc, ctx));
|
|
876
|
-
}
|
|
877
|
-
}),
|
|
878
|
-
);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// Attach indirect-fetched joined docs to the context so
|
|
882
|
-
// `indirect.match()` rule can read them.
|
|
883
|
-
const ctxWithIndirect = Object.assign({}, ctx, {
|
|
884
|
-
_indirectFetched: indirectFetched,
|
|
885
|
-
});
|
|
886
|
-
|
|
887
857
|
let grantOk = true;
|
|
888
858
|
let grantData: unknown = undefined;
|
|
889
|
-
// Multiple match rules in one permission (e.g., expositionInfo + badge)
|
|
890
|
-
// contribute distinct constraints — AND-merge them per grant.
|
|
891
859
|
const grantConstraints: Record<string, unknown>[] = [];
|
|
860
|
+
const grantFilters: FilterContribution[] = [];
|
|
892
861
|
let grantHasMatchRule = false;
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
862
|
+
try {
|
|
863
|
+
const fetched = new Map<string, unknown>();
|
|
864
|
+
await Promise.all(
|
|
865
|
+
uniqueResources.map(async (r) => {
|
|
866
|
+
fetched.set(r.id, await fetchResource(r, ctx, state));
|
|
867
|
+
}),
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
const indirectFetched = new Map<string, readonly unknown[]>();
|
|
871
|
+
if (!capability) {
|
|
872
|
+
const indirectsToFetch = perm.rules
|
|
873
|
+
.map((r) => extractIndirectResource(r))
|
|
874
|
+
.filter(
|
|
875
|
+
(ir): ir is IndirectResource =>
|
|
876
|
+
ir !== null && ir.fetcher !== undefined,
|
|
877
|
+
);
|
|
878
|
+
await Promise.all(
|
|
879
|
+
indirectsToFetch.map(async (ir) => {
|
|
880
|
+
const sourceDoc = fetched.get(ir.from.id);
|
|
881
|
+
if (sourceDoc === undefined || sourceDoc === null) return;
|
|
882
|
+
const cacheKey = ir.cacheKeyForTarget(ctx.target);
|
|
883
|
+
if (state) {
|
|
884
|
+
const existing = state.cache.get(cacheKey);
|
|
885
|
+
if (existing) {
|
|
886
|
+
indirectFetched.set(
|
|
887
|
+
ir.id,
|
|
888
|
+
(await existing) as readonly unknown[],
|
|
889
|
+
);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const pending = Promise.resolve(ir.fetcher!(sourceDoc, ctx));
|
|
893
|
+
state.cache.set(cacheKey, pending);
|
|
894
|
+
indirectFetched.set(ir.id, await pending);
|
|
895
|
+
} else {
|
|
896
|
+
indirectFetched.set(ir.id, await ir.fetcher!(sourceDoc, ctx));
|
|
897
|
+
}
|
|
898
|
+
}),
|
|
901
899
|
);
|
|
902
|
-
break;
|
|
903
|
-
}
|
|
904
|
-
if (result.data !== undefined) grantData = result.data;
|
|
905
|
-
if (result.constraint !== undefined) {
|
|
906
|
-
grantConstraints.push(result.constraint);
|
|
907
900
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
901
|
+
|
|
902
|
+
const ctxWithIndirect = Object.assign({}, ctx, {
|
|
903
|
+
_indirectFetched: indirectFetched,
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
for (const rule of activeRules) {
|
|
907
|
+
if (rule.descriptor.kind === "match") grantHasMatchRule = true;
|
|
908
|
+
const data = rule.needs.map((r) => fetched.get(r.id));
|
|
909
|
+
const result = rule.check(data, ctxWithIndirect);
|
|
910
|
+
if (!result.ok) {
|
|
911
|
+
grantOk = false;
|
|
912
|
+
reasons.push(
|
|
913
|
+
grant.id ? `[${grant.id}] ${result.reason}` : result.reason,
|
|
914
|
+
);
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
if (result.data !== undefined) grantData = result.data;
|
|
918
|
+
if (result.constraint !== undefined) {
|
|
919
|
+
grantConstraints.push(result.constraint);
|
|
920
|
+
}
|
|
921
|
+
if (result.filter !== undefined) grantFilters.push(result.filter);
|
|
911
922
|
}
|
|
923
|
+
} catch (error) {
|
|
924
|
+
grantOk = false;
|
|
925
|
+
reportError({
|
|
926
|
+
source: "rule",
|
|
927
|
+
grantId: grant.id,
|
|
928
|
+
subject,
|
|
929
|
+
key,
|
|
930
|
+
target,
|
|
931
|
+
error,
|
|
932
|
+
});
|
|
933
|
+
const reason = `rule evaluation failed: ${describeError(error)}`;
|
|
934
|
+
reasons.push(grant.id ? `[${grant.id}] ${reason}` : reason);
|
|
912
935
|
}
|
|
913
936
|
|
|
914
937
|
if (grantOk) {
|
|
938
|
+
for (const contribution of grantFilters) {
|
|
939
|
+
referenceSource = contribution.source;
|
|
940
|
+
filterUnion = mergeFilterSpec(filterUnion, contribution.spec);
|
|
941
|
+
}
|
|
915
942
|
anyOk = true;
|
|
916
943
|
successfulGrants.push(grant);
|
|
917
944
|
if (grant.id) matchedGrantIds.push(grant.id);
|
|
@@ -928,7 +955,10 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
928
955
|
if (!anyOk) {
|
|
929
956
|
return {
|
|
930
957
|
ok: false,
|
|
931
|
-
reasons:
|
|
958
|
+
reasons: [
|
|
959
|
+
...(reasons.length > 0 ? reasons : ["no matching grant"]),
|
|
960
|
+
...failures,
|
|
961
|
+
],
|
|
932
962
|
};
|
|
933
963
|
}
|
|
934
964
|
|
|
@@ -939,10 +969,6 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
939
969
|
lastData,
|
|
940
970
|
);
|
|
941
971
|
|
|
942
|
-
// Collect indirect resources referenced by the permission's rules
|
|
943
|
-
// (sentinel rules with descriptor.kind === "indirect-match"). If any
|
|
944
|
-
// are referenced by matched grants' `with`, emit an aggregation
|
|
945
|
-
// pipeline that pushes the JOIN constraint to the DB.
|
|
946
972
|
const declaredIndirect: IndirectResource[] = [];
|
|
947
973
|
for (const rule of perm.rules) {
|
|
948
974
|
const ir = extractIndirectResource(rule);
|
|
@@ -951,10 +977,8 @@ export function createSystem<TMeta = unknown>(opts: {
|
|
|
951
977
|
let stages: readonly Record<string, unknown>[] | undefined;
|
|
952
978
|
if (declaredIndirect.length > 0) {
|
|
953
979
|
const baseFilter = constraints ?? {};
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
// contribute to indirect any-wins — otherwise it would silently
|
|
957
|
-
// disable the indirect filter for its siblings.
|
|
980
|
+
// A rejected grant without an indirect reference would win "any" and
|
|
981
|
+
// lift the join filter for its siblings, so only accepted grants count.
|
|
958
982
|
const result = buildAggregationStages(
|
|
959
983
|
baseFilter,
|
|
960
984
|
successfulGrants,
|