@diister/quick-permission 0.9.0-beta.5
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/LICENSE +21 -0
- package/README.md +747 -0
- package/aggregation.ts +114 -0
- package/core/filtering.ts +70 -0
- package/core/matching.ts +82 -0
- package/core/merging.ts +143 -0
- package/dist/aggregation.d.ts +62 -0
- package/dist/aggregation.d.ts.map +1 -0
- package/dist/aggregation.js +97 -0
- package/dist/aggregation.js.map +1 -0
- package/dist/core/filtering.d.ts +35 -0
- package/dist/core/filtering.d.ts.map +1 -0
- package/dist/core/filtering.js +62 -0
- package/dist/core/filtering.js.map +1 -0
- package/dist/core/matching.d.ts +31 -0
- package/dist/core/matching.d.ts.map +1 -0
- package/dist/core/matching.js +75 -0
- package/dist/core/matching.js.map +1 -0
- package/dist/core/merging.d.ts +29 -0
- package/dist/core/merging.d.ts.map +1 -0
- package/dist/core/merging.js +124 -0
- package/dist/core/merging.js.map +1 -0
- package/dist/indirect-aggregation.d.ts +41 -0
- package/dist/indirect-aggregation.d.ts.map +1 -0
- package/dist/indirect-aggregation.js +185 -0
- package/dist/indirect-aggregation.js.map +1 -0
- package/dist/indirect-resource.d.ts +126 -0
- package/dist/indirect-resource.d.ts.map +1 -0
- package/dist/indirect-resource.js +109 -0
- package/dist/indirect-resource.js.map +1 -0
- package/dist/mod.d.ts +25 -0
- package/dist/mod.d.ts.map +1 -0
- package/dist/mod.js +25 -0
- package/dist/mod.js.map +1 -0
- package/dist/mongo-query.d.ts +38 -0
- package/dist/mongo-query.d.ts.map +1 -0
- package/dist/mongo-query.js +88 -0
- package/dist/mongo-query.js.map +1 -0
- package/dist/permission.d.ts +57 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +60 -0
- package/dist/permission.js.map +1 -0
- package/dist/resource.d.ts +48 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +298 -0
- package/dist/resource.js.map +1 -0
- package/dist/rules.d.ts +106 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +183 -0
- package/dist/rules.js.map +1 -0
- package/dist/sift/core.d.ts +104 -0
- package/dist/sift/core.d.ts.map +1 -0
- package/dist/sift/core.js +248 -0
- package/dist/sift/core.js.map +1 -0
- package/dist/sift/index.d.ts +10 -0
- package/dist/sift/index.d.ts.map +1 -0
- package/dist/sift/index.js +18 -0
- package/dist/sift/index.js.map +1 -0
- package/dist/sift/operations.d.ts +87 -0
- package/dist/sift/operations.d.ts.map +1 -0
- package/dist/sift/operations.js +257 -0
- package/dist/sift/operations.js.map +1 -0
- package/dist/sift/utils.d.ts +12 -0
- package/dist/sift/utils.d.ts.map +1 -0
- package/dist/sift/utils.js +80 -0
- package/dist/sift/utils.js.map +1 -0
- package/dist/system.d.ts +113 -0
- package/dist/system.d.ts.map +1 -0
- package/dist/system.js +712 -0
- package/dist/system.js.map +1 -0
- package/dist/target.d.ts +18 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +41 -0
- package/dist/target.js.map +1 -0
- package/dist/types.d.ts +345 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/indirect-aggregation.ts +216 -0
- package/indirect-resource.ts +205 -0
- package/mod.ts +81 -0
- package/mongo-query.ts +94 -0
- package/package.json +58 -0
- package/permission.ts +88 -0
- package/resource.ts +352 -0
- package/rules.ts +241 -0
- package/sift/MIT-LICENSE.txt +20 -0
- package/sift/core.ts +551 -0
- package/sift/index.ts +62 -0
- package/sift/operations.ts +449 -0
- package/sift/utils.ts +96 -0
- package/system.ts +974 -0
- package/target.ts +84 -0
- package/types.ts +408 -0
package/system.ts
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
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
|
+
import type {
|
|
16
|
+
AnyTarget,
|
|
17
|
+
CanResult,
|
|
18
|
+
FetchCtx,
|
|
19
|
+
Grant,
|
|
20
|
+
IndirectResourceInfo,
|
|
21
|
+
ListEntry,
|
|
22
|
+
Permission,
|
|
23
|
+
Resource,
|
|
24
|
+
SerializableSegment,
|
|
25
|
+
SerializableTarget,
|
|
26
|
+
Subject,
|
|
27
|
+
TreeNode,
|
|
28
|
+
} from "./types.ts";
|
|
29
|
+
import {
|
|
30
|
+
aggregateConstraints,
|
|
31
|
+
combineGrantConstraints,
|
|
32
|
+
type FilterUnion,
|
|
33
|
+
mergeFilterSpec,
|
|
34
|
+
resolveFilteredData,
|
|
35
|
+
} from "./aggregation.ts";
|
|
36
|
+
import { buildAggregationStages } from "./indirect-aggregation.ts";
|
|
37
|
+
import {
|
|
38
|
+
extractIndirectResource,
|
|
39
|
+
type IndirectResource,
|
|
40
|
+
} from "./indirect-resource.ts";
|
|
41
|
+
|
|
42
|
+
export type ProviderFn = (
|
|
43
|
+
subject: Subject,
|
|
44
|
+
key: string,
|
|
45
|
+
target?: readonly unknown[],
|
|
46
|
+
) => readonly Grant[] | Promise<readonly Grant[]>;
|
|
47
|
+
|
|
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
|
+
export type ProviderObject = {
|
|
56
|
+
readonly keys?: readonly string[];
|
|
57
|
+
readonly matches?: (key: string) => boolean;
|
|
58
|
+
readonly cacheKey?: (
|
|
59
|
+
subject: Subject,
|
|
60
|
+
key: string,
|
|
61
|
+
target?: readonly unknown[],
|
|
62
|
+
) => string;
|
|
63
|
+
readonly fetch: ProviderFn;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type Provider = ProviderFn | ProviderObject;
|
|
67
|
+
|
|
68
|
+
function keyMatchesPattern(key: string, pattern: string): boolean {
|
|
69
|
+
if (pattern === key) return true;
|
|
70
|
+
if (pattern.endsWith("*")) {
|
|
71
|
+
return key.startsWith(pattern.slice(0, -1));
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function providerHandlesKey(provider: ProviderObject, key: string): boolean {
|
|
77
|
+
if (!provider.keys && !provider.matches) return true;
|
|
78
|
+
if (provider.keys?.some((p) => keyMatchesPattern(key, p))) return true;
|
|
79
|
+
if (provider.matches?.(key)) return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type CanContext = {
|
|
84
|
+
readonly checkDate?: Date;
|
|
85
|
+
readonly checkIp?: string;
|
|
86
|
+
/** True : le target peut contenir des wildcards et matche les grants overlap. */
|
|
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
|
+
*/
|
|
93
|
+
readonly input?: unknown;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type System<TMeta = unknown> = {
|
|
97
|
+
list(): readonly ListEntry<TMeta>[];
|
|
98
|
+
tree(): { readonly children: Readonly<Record<string, TreeNode<TMeta>>> };
|
|
99
|
+
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
|
+
*/
|
|
107
|
+
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
|
+
indirectsUsedBy(key: string): readonly IndirectResourceInfo[];
|
|
115
|
+
/** Check direct (sans cache cross-can) — préfère `context()` en HTTP. */
|
|
116
|
+
can(
|
|
117
|
+
subject: Subject,
|
|
118
|
+
key: string,
|
|
119
|
+
target?: readonly unknown[],
|
|
120
|
+
context?: CanContext,
|
|
121
|
+
): 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
|
+
*/
|
|
126
|
+
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
|
+
*/
|
|
133
|
+
can(
|
|
134
|
+
key: string,
|
|
135
|
+
target?: readonly unknown[],
|
|
136
|
+
perCall?: CanContext,
|
|
137
|
+
): Promise<CanResult>;
|
|
138
|
+
/**
|
|
139
|
+
* Inject pre-loaded docs into the resource cache so subsequent
|
|
140
|
+
* `can()` calls skip the fetch. Typical use : after a paginated
|
|
141
|
+
* list, the caller has all docs in memory — pre-seed them so the
|
|
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`).
|
|
154
|
+
*/
|
|
155
|
+
preseed<T, V = T>(
|
|
156
|
+
resource: Resource<unknown> | IndirectResource | string,
|
|
157
|
+
docs: ReadonlyArray<T>,
|
|
158
|
+
opts: {
|
|
159
|
+
dedupKey: (doc: T) => readonly unknown[];
|
|
160
|
+
value?: (doc: T) => V;
|
|
161
|
+
},
|
|
162
|
+
): void;
|
|
163
|
+
/** Compteurs de fetches par resource.id (debug / observabilité). */
|
|
164
|
+
getFetchCounters(): Readonly<Record<string, number>>;
|
|
165
|
+
/** Reset des compteurs (pas du cache). */
|
|
166
|
+
clearCounters(): void;
|
|
167
|
+
/** Dump des grants émis dans ce context (debug). */
|
|
168
|
+
dumpGrants(): Promise<readonly Grant[]>;
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ─── Schema validation au boot ──────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function validateSchema<TMeta>(
|
|
175
|
+
schema: Readonly<Record<string, Permission<TMeta>>>,
|
|
176
|
+
): void {
|
|
177
|
+
const issues: string[] = [];
|
|
178
|
+
for (const [key, perm] of Object.entries(schema)) {
|
|
179
|
+
if (!perm.expandsTo) continue;
|
|
180
|
+
let samples: readonly Grant[] = [];
|
|
181
|
+
try {
|
|
182
|
+
samples = perm.expandsTo({
|
|
183
|
+
key,
|
|
184
|
+
target: ["*"],
|
|
185
|
+
});
|
|
186
|
+
} catch {
|
|
187
|
+
// Best-effort : si expandsTo throw sur le stub, on skip.
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
for (const child of samples) {
|
|
191
|
+
if (!(child.key in schema)) {
|
|
192
|
+
issues.push(
|
|
193
|
+
`intermediate "${key}" expands to unknown key "${child.key}"`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (issues.length > 0) {
|
|
199
|
+
throw new Error(`Schema validation failed:\n - ${issues.join("\n - ")}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Target matching (wildcards par segment) ────────────────────────────
|
|
204
|
+
|
|
205
|
+
function asPath(value: unknown): readonly unknown[] {
|
|
206
|
+
return Array.isArray(value) ? value : [value];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Une request target est une "capability query" si elle contient au moins
|
|
211
|
+
* un wildcard (`"*"` ou suffix `":*"`). Dans ce mode, on n'a pas de
|
|
212
|
+
* ressource concrète à fetcher — les rules font silent-pass quand elles
|
|
213
|
+
* ne peuvent pas vérifier leur contrainte.
|
|
214
|
+
*/
|
|
215
|
+
function isCapabilityQuery(target: readonly unknown[] | undefined): boolean {
|
|
216
|
+
if (!target) return false;
|
|
217
|
+
return target.some(isWildcardSegment);
|
|
218
|
+
}
|
|
219
|
+
|
|
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 {
|
|
226
|
+
return seg === "*" || (typeof seg === "string" && seg.endsWith("*"));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function segmentOverlaps(a: unknown, b: unknown): boolean {
|
|
230
|
+
if (a === "*" || b === "*") return true;
|
|
231
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
232
|
+
const aPrefix = a.endsWith("*") ? a.slice(0, -1) : null;
|
|
233
|
+
const bPrefix = b.endsWith("*") ? b.slice(0, -1) : null;
|
|
234
|
+
if (aPrefix !== null && bPrefix !== null) {
|
|
235
|
+
return aPrefix.startsWith(bPrefix) || bPrefix.startsWith(aPrefix);
|
|
236
|
+
}
|
|
237
|
+
if (aPrefix !== null) return b.startsWith(aPrefix);
|
|
238
|
+
if (bPrefix !== null) return a.startsWith(bPrefix);
|
|
239
|
+
}
|
|
240
|
+
return a === b;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function targetMatches(
|
|
244
|
+
grantTarget: readonly unknown[] | unknown | undefined,
|
|
245
|
+
requestTarget: readonly unknown[] | undefined,
|
|
246
|
+
schemaKind: AnyTarget["kind"],
|
|
247
|
+
capability: boolean,
|
|
248
|
+
): boolean {
|
|
249
|
+
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
|
+
}
|
|
256
|
+
if (requestTarget === undefined) return false;
|
|
257
|
+
const g = asPath(grantTarget);
|
|
258
|
+
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
|
+
if (capability) {
|
|
262
|
+
return g.every((seg, i) => segmentOverlaps(seg, requestTarget[i]));
|
|
263
|
+
}
|
|
264
|
+
return g.every((seg, i) => {
|
|
265
|
+
if (seg === "*") return true;
|
|
266
|
+
if (typeof seg === "string" && seg.endsWith("*")) {
|
|
267
|
+
const prefix = seg.slice(0, -1);
|
|
268
|
+
return (
|
|
269
|
+
typeof requestTarget[i] === "string" &&
|
|
270
|
+
(requestTarget[i] as string).startsWith(prefix)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return seg === requestTarget[i];
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Intermediate expansion ──────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
function expandGrants<TMeta>(
|
|
280
|
+
grants: readonly Grant[],
|
|
281
|
+
schema: Readonly<Record<string, Permission<TMeta>>>,
|
|
282
|
+
maxDepth = 10,
|
|
283
|
+
): Grant[] {
|
|
284
|
+
const out: Grant[] = [];
|
|
285
|
+
const queue: Array<{ grant: Grant; depth: number }> = grants.map((g) => ({
|
|
286
|
+
grant: g,
|
|
287
|
+
depth: 0,
|
|
288
|
+
}));
|
|
289
|
+
while (queue.length) {
|
|
290
|
+
const { grant, depth } = queue.shift()!;
|
|
291
|
+
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
|
+
if (
|
|
297
|
+
perm &&
|
|
298
|
+
grant.target === undefined &&
|
|
299
|
+
(perm.target.kind === "required" || perm.target.kind === "path")
|
|
300
|
+
) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
out.push(grant);
|
|
304
|
+
if (depth >= maxDepth) continue;
|
|
305
|
+
if (perm?.expandsTo) {
|
|
306
|
+
const children = perm.expandsTo(grant);
|
|
307
|
+
for (const child of children) {
|
|
308
|
+
queue.push({ grant: child, depth: depth + 1 });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── Serialization (pour list/tree) ──────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
function serializeSegment(s: {
|
|
318
|
+
readonly name: string;
|
|
319
|
+
readonly types: unknown;
|
|
320
|
+
}): SerializableSegment {
|
|
321
|
+
const types = s.types as string | readonly string[];
|
|
322
|
+
return { name: s.name, types };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function serializeTarget(t: AnyTarget): SerializableTarget {
|
|
326
|
+
switch (t.kind) {
|
|
327
|
+
case "none":
|
|
328
|
+
return { kind: "none" };
|
|
329
|
+
case "optional":
|
|
330
|
+
return { kind: "optional", segment: serializeSegment(t.segments[0]) };
|
|
331
|
+
case "required":
|
|
332
|
+
return { kind: "required", segment: serializeSegment(t.segments[0]) };
|
|
333
|
+
case "path":
|
|
334
|
+
return {
|
|
335
|
+
kind: "path",
|
|
336
|
+
segments: t.segments.map(serializeSegment),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function serializeIndirectResource(ir: IndirectResource): IndirectResourceInfo {
|
|
342
|
+
return {
|
|
343
|
+
id: ir.id,
|
|
344
|
+
kind: "indirect",
|
|
345
|
+
from: { id: ir.from.id, kind: ir.from.kind },
|
|
346
|
+
on: {
|
|
347
|
+
localField: ir.on.localField,
|
|
348
|
+
foreignField: ir.on.foreignField,
|
|
349
|
+
...(ir.on.foreignCollection !== undefined && {
|
|
350
|
+
foreignCollection: ir.on.foreignCollection,
|
|
351
|
+
}),
|
|
352
|
+
},
|
|
353
|
+
...(ir.to !== undefined && { to: { ...ir.to } }),
|
|
354
|
+
cardinality: ir.cardinality,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function collectIndirectsForPermission<TMeta>(
|
|
359
|
+
perm: Permission<TMeta>,
|
|
360
|
+
): IndirectResource[] {
|
|
361
|
+
const collected = new Map<string, IndirectResource>();
|
|
362
|
+
for (const rule of perm.rules) {
|
|
363
|
+
const ir = extractIndirectResource(rule);
|
|
364
|
+
if (ir && !collected.has(ir.id)) collected.set(ir.id, ir);
|
|
365
|
+
}
|
|
366
|
+
return Array.from(collected.values());
|
|
367
|
+
}
|
|
368
|
+
|
|
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
|
+
function stubTargetFor(t: AnyTarget): readonly unknown[] | undefined {
|
|
381
|
+
if (t.kind === "none") return undefined;
|
|
382
|
+
if (t.kind === "optional" || t.kind === "required") return ["*"];
|
|
383
|
+
return t.segments.map(() => "*");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─── Descendant computation (pour list/tree) ─────────────────────────────
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* BFS transitive expansion of an intermediate's `expandsTo` callback,
|
|
390
|
+
* collecting LEAVES only (keys whose schema entry has no `expandsTo`).
|
|
391
|
+
*
|
|
392
|
+
* Sampling strategy : we call `perm.expandsTo({ key, target: <wildcard> })`
|
|
393
|
+
* with an arity-matched wildcard stub. Real consumers (`expandGrants`)
|
|
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,
|
|
409
|
+
schema: Readonly<Record<string, Permission<TMeta>>>,
|
|
410
|
+
maxDepth = 10,
|
|
411
|
+
): readonly string[] | undefined {
|
|
412
|
+
const root = schema[rootKey];
|
|
413
|
+
if (!root?.expandsTo) return undefined;
|
|
414
|
+
|
|
415
|
+
const leaves = new Set<string>();
|
|
416
|
+
const visited = new Set<string>([rootKey]);
|
|
417
|
+
const queue: Array<{ key: string; depth: number }> = [
|
|
418
|
+
{ key: rootKey, depth: 0 },
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
while (queue.length) {
|
|
422
|
+
const { key, depth } = queue.shift()!;
|
|
423
|
+
const perm = schema[key];
|
|
424
|
+
if (!perm) continue;
|
|
425
|
+
if (!perm.expandsTo) {
|
|
426
|
+
// Leaf reached. Exclude the root itself from its own descendant list.
|
|
427
|
+
if (key !== rootKey) leaves.add(key);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (depth >= maxDepth) continue;
|
|
431
|
+
let children: readonly Grant[] = [];
|
|
432
|
+
try {
|
|
433
|
+
children = perm.expandsTo({
|
|
434
|
+
key,
|
|
435
|
+
target: stubTargetFor(perm.target),
|
|
436
|
+
});
|
|
437
|
+
} catch {
|
|
438
|
+
// Stub-throwing macro — best-effort skip, same as validateSchema.
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
for (const child of children) {
|
|
442
|
+
if (visited.has(child.key)) continue;
|
|
443
|
+
visited.add(child.key);
|
|
444
|
+
queue.push({ key: child.key, depth: depth + 1 });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return [...leaves];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ─── Tree builder ────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
function buildTree<TMeta>(
|
|
453
|
+
schema: Readonly<Record<string, Permission<TMeta>>>,
|
|
454
|
+
): { children: Record<string, TreeNode<TMeta>> } {
|
|
455
|
+
const root: { children: Record<string, TreeNode<TMeta>> } = { children: {} };
|
|
456
|
+
|
|
457
|
+
function ensureGroup(
|
|
458
|
+
parent: { children: Record<string, TreeNode<TMeta>> },
|
|
459
|
+
name: string,
|
|
460
|
+
): TreeNode<TMeta> {
|
|
461
|
+
const existing = parent.children[name];
|
|
462
|
+
if (existing) return existing;
|
|
463
|
+
const group: TreeNode<TMeta> = {
|
|
464
|
+
kind: "group",
|
|
465
|
+
metadata: undefined,
|
|
466
|
+
children: {},
|
|
467
|
+
};
|
|
468
|
+
parent.children[name] = group;
|
|
469
|
+
return group;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const [key, perm] of Object.entries(schema)) {
|
|
473
|
+
const parts = key.split(".");
|
|
474
|
+
let cursor: { children: Record<string, TreeNode<TMeta>> } = root;
|
|
475
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
476
|
+
const node = ensureGroup(cursor, parts[i]);
|
|
477
|
+
if (node.kind !== "group") {
|
|
478
|
+
const newGroup: TreeNode<TMeta> = {
|
|
479
|
+
kind: "group",
|
|
480
|
+
metadata: undefined,
|
|
481
|
+
children: {},
|
|
482
|
+
};
|
|
483
|
+
cursor.children[parts[i]] = newGroup;
|
|
484
|
+
cursor = newGroup;
|
|
485
|
+
} else {
|
|
486
|
+
cursor = node as {
|
|
487
|
+
kind: "group";
|
|
488
|
+
metadata: undefined;
|
|
489
|
+
children: Record<string, TreeNode<TMeta>>;
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const leafName = parts[parts.length - 1];
|
|
494
|
+
const descendants = perm.expandsTo
|
|
495
|
+
? computeDescendants(key, schema)
|
|
496
|
+
: undefined;
|
|
497
|
+
cursor.children[leafName] = {
|
|
498
|
+
kind: perm.expandsTo ? "intermediate" : "permission",
|
|
499
|
+
key,
|
|
500
|
+
metadata: perm.metadata,
|
|
501
|
+
target: serializeTarget(perm.target),
|
|
502
|
+
rules: perm.rules.map((r) => r.descriptor),
|
|
503
|
+
...(descendants !== undefined && { expandsTo: descendants }),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return root;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── createSystem ────────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
export function createSystem<TMeta = unknown>(opts: {
|
|
513
|
+
readonly schema: Readonly<Record<string, Permission<TMeta>>>;
|
|
514
|
+
readonly providers?: readonly Provider[];
|
|
515
|
+
}): System<TMeta> {
|
|
516
|
+
const { schema, providers = [] } = opts;
|
|
517
|
+
validateSchema(schema);
|
|
518
|
+
|
|
519
|
+
// Resource registry built at boot from all rules' `needs` (direct
|
|
520
|
+
// resources) and indirect descriptors. Enables `ctx.preseed("id", ...)`
|
|
521
|
+
// to look up the resource by id — callers don't need to import the
|
|
522
|
+
// resource instance (handy when it lives inside a factory closure).
|
|
523
|
+
// Typo guard : throws with the list of available ids when missed.
|
|
524
|
+
const resourceRegistry = new Map<
|
|
525
|
+
string,
|
|
526
|
+
Resource<unknown> | IndirectResource
|
|
527
|
+
>();
|
|
528
|
+
for (const perm of Object.values(schema)) {
|
|
529
|
+
for (const rule of perm.rules) {
|
|
530
|
+
for (const r of rule.needs) {
|
|
531
|
+
if (!resourceRegistry.has(r.id)) resourceRegistry.set(r.id, r);
|
|
532
|
+
}
|
|
533
|
+
const ir = extractIndirectResource(rule);
|
|
534
|
+
if (ir && !resourceRegistry.has(ir.id)) resourceRegistry.set(ir.id, ir);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
list() {
|
|
540
|
+
return Object.entries(schema).map(([key, perm]) => {
|
|
541
|
+
const descendants = perm.expandsTo
|
|
542
|
+
? computeDescendants(key, schema)
|
|
543
|
+
: undefined;
|
|
544
|
+
return {
|
|
545
|
+
key,
|
|
546
|
+
kind: perm.expandsTo
|
|
547
|
+
? ("intermediate" as const)
|
|
548
|
+
: ("permission" as const),
|
|
549
|
+
metadata: perm.metadata,
|
|
550
|
+
target: serializeTarget(perm.target),
|
|
551
|
+
rules: perm.rules.map((r) => r.descriptor),
|
|
552
|
+
...(descendants !== undefined && { expandsTo: descendants }),
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
tree() {
|
|
558
|
+
return buildTree<TMeta>(schema);
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
schema(key) {
|
|
562
|
+
return schema[key];
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
indirectResources() {
|
|
566
|
+
const seen = new Map<string, IndirectResource>();
|
|
567
|
+
for (const perm of Object.values(schema)) {
|
|
568
|
+
for (const ir of collectIndirectsForPermission(perm)) {
|
|
569
|
+
if (!seen.has(ir.id)) seen.set(ir.id, ir);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return Array.from(seen.values()).map(serializeIndirectResource);
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
indirectsUsedBy(key) {
|
|
576
|
+
const perm = schema[key];
|
|
577
|
+
if (!perm) return [];
|
|
578
|
+
return collectIndirectsForPermission(perm).map(serializeIndirectResource);
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
can(subject, key, target, context) {
|
|
582
|
+
return systemCan(subject, key, target, context, undefined);
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
context(bound) {
|
|
586
|
+
const cache = new Map<string, Promise<unknown>>();
|
|
587
|
+
const grantsCache = new Map<string, Promise<readonly Grant[]>>();
|
|
588
|
+
const fetchCounters = new Map<string, number>();
|
|
589
|
+
const { subject, ...boundCtx } = bound;
|
|
590
|
+
const resolveResource = (
|
|
591
|
+
resourceOrId: Resource<unknown> | IndirectResource | string,
|
|
592
|
+
): Resource<unknown> | IndirectResource => {
|
|
593
|
+
if (typeof resourceOrId !== "string") return resourceOrId;
|
|
594
|
+
const r = resourceRegistry.get(resourceOrId);
|
|
595
|
+
if (!r) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`preseed: no resource registered with id "${resourceOrId}". ` +
|
|
598
|
+
`Available: ${[...resourceRegistry.keys()].join(", ") || "(none)"}`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
return r;
|
|
602
|
+
};
|
|
603
|
+
return {
|
|
604
|
+
can(key, target, perCall) {
|
|
605
|
+
const ctx: CanContext = perCall
|
|
606
|
+
? { ...boundCtx, ...perCall }
|
|
607
|
+
: boundCtx;
|
|
608
|
+
return systemCan(subject, key, target, ctx, {
|
|
609
|
+
cache,
|
|
610
|
+
grantsCache,
|
|
611
|
+
fetchCounters,
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
preseed(resourceOrId, docs, opts) {
|
|
615
|
+
const resource = resolveResource(resourceOrId);
|
|
616
|
+
const extract = opts.value ?? ((d: unknown) => d);
|
|
617
|
+
for (const doc of docs) {
|
|
618
|
+
const target = opts.dedupKey(doc as never);
|
|
619
|
+
const cacheKey = resource.cacheKeyForTarget(target);
|
|
620
|
+
cache.set(cacheKey, Promise.resolve(extract(doc as never)));
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
getFetchCounters() {
|
|
624
|
+
return Object.fromEntries(fetchCounters);
|
|
625
|
+
},
|
|
626
|
+
clearCounters() {
|
|
627
|
+
fetchCounters.clear();
|
|
628
|
+
},
|
|
629
|
+
async dumpGrants() {
|
|
630
|
+
const arrays = await Promise.all(grantsCache.values());
|
|
631
|
+
return arrays.flat();
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// ─── Implémentation can() ─────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
type ContextState = {
|
|
640
|
+
/** Cache des fetches de Resource au sein d'un context. */
|
|
641
|
+
readonly cache: Map<string, Promise<unknown>>;
|
|
642
|
+
/** Cache des grants émis par les providers (pour cacheKey). */
|
|
643
|
+
readonly grantsCache: Map<string, Promise<readonly Grant[]>>;
|
|
644
|
+
readonly fetchCounters: Map<string, number>;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
async function invokeProvider(
|
|
648
|
+
provider: Provider,
|
|
649
|
+
subject: Subject,
|
|
650
|
+
key: string,
|
|
651
|
+
target: readonly unknown[] | undefined,
|
|
652
|
+
state: ContextState | undefined,
|
|
653
|
+
): Promise<readonly Grant[]> {
|
|
654
|
+
if (typeof provider === "function") {
|
|
655
|
+
return await provider(subject, key, target);
|
|
656
|
+
}
|
|
657
|
+
if (!providerHandlesKey(provider, key)) return [];
|
|
658
|
+
const ck = provider.cacheKey?.(subject, key, target);
|
|
659
|
+
if (state && ck) {
|
|
660
|
+
let pending = state.grantsCache.get(ck);
|
|
661
|
+
if (!pending) {
|
|
662
|
+
pending = Promise.resolve(provider.fetch(subject, key, target));
|
|
663
|
+
state.grantsCache.set(ck, pending);
|
|
664
|
+
}
|
|
665
|
+
return await pending;
|
|
666
|
+
}
|
|
667
|
+
return await provider.fetch(subject, key, target);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function fetchResource(
|
|
671
|
+
resource: Resource<unknown>,
|
|
672
|
+
ctx: FetchCtx,
|
|
673
|
+
state: ContextState | undefined,
|
|
674
|
+
): Promise<unknown> {
|
|
675
|
+
if (!state) {
|
|
676
|
+
// Mode `system.can()` direct — pas de cache cross-grant.
|
|
677
|
+
return Promise.resolve(resource.fetcher(ctx));
|
|
678
|
+
}
|
|
679
|
+
const key = resource.computeDedupKey(ctx);
|
|
680
|
+
const existing = state.cache.get(key);
|
|
681
|
+
if (existing) return existing;
|
|
682
|
+
state.fetchCounters.set(
|
|
683
|
+
resource.id,
|
|
684
|
+
(state.fetchCounters.get(resource.id) ?? 0) + 1,
|
|
685
|
+
);
|
|
686
|
+
const pending = Promise.resolve(resource.fetcher(ctx));
|
|
687
|
+
state.cache.set(key, pending);
|
|
688
|
+
return pending;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function validateArity(
|
|
692
|
+
perm: Permission<TMeta>,
|
|
693
|
+
target: readonly unknown[] | undefined,
|
|
694
|
+
): string | null {
|
|
695
|
+
const expected = perm.target.segments.length;
|
|
696
|
+
const actual = target?.length ?? 0;
|
|
697
|
+
if (perm.target.kind === "none") {
|
|
698
|
+
if (actual > 0) return `target arity mismatch: expected 0, got ${actual}`;
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
if (perm.target.kind === "optional") {
|
|
702
|
+
if (actual !== 0 && actual !== expected) {
|
|
703
|
+
return `target arity mismatch: expected 0 or ${expected}, got ${actual}`;
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
if (actual !== expected) {
|
|
708
|
+
return `target arity mismatch: expected ${expected}, got ${actual}`;
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function systemCan(
|
|
714
|
+
subject: Subject,
|
|
715
|
+
key: string,
|
|
716
|
+
target: readonly unknown[] | undefined,
|
|
717
|
+
context: CanContext | undefined,
|
|
718
|
+
state: ContextState | undefined,
|
|
719
|
+
): Promise<CanResult> {
|
|
720
|
+
const perm = schema[key];
|
|
721
|
+
if (!perm) {
|
|
722
|
+
return { ok: false, reasons: [`unknown permission: ${key}`] };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const arityErr = validateArity(perm, target);
|
|
726
|
+
if (arityErr) return { ok: false, reasons: [arityErr] };
|
|
727
|
+
|
|
728
|
+
// Collect grants depuis les providers (en série pour préserver l'ordre)
|
|
729
|
+
const allGrants: Grant[] = [];
|
|
730
|
+
for (const provider of providers) {
|
|
731
|
+
const grants = await invokeProvider(
|
|
732
|
+
provider,
|
|
733
|
+
subject,
|
|
734
|
+
key,
|
|
735
|
+
target,
|
|
736
|
+
state,
|
|
737
|
+
);
|
|
738
|
+
allGrants.push(...grants);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const expanded = expandGrants(allGrants, schema);
|
|
742
|
+
|
|
743
|
+
const capability = isCapabilityQuery(target);
|
|
744
|
+
const matching = expanded.filter(
|
|
745
|
+
(g) =>
|
|
746
|
+
g.key === key &&
|
|
747
|
+
targetMatches(g.target, target, perm.target.kind, capability),
|
|
748
|
+
);
|
|
749
|
+
if (matching.length === 0) {
|
|
750
|
+
return { ok: false, reasons: ["no matching grant"] };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const reasons: string[] = [];
|
|
754
|
+
let lastData: unknown = undefined;
|
|
755
|
+
const matchedGrantIds: string[] = [];
|
|
756
|
+
let anyOk = false;
|
|
757
|
+
// One entry per matched grant. undefined = "any" (no constraint).
|
|
758
|
+
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
|
+
let referenceSource: unknown = undefined;
|
|
762
|
+
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
|
+
const successfulGrants: Grant[] = [];
|
|
771
|
+
|
|
772
|
+
for (const grant of matching) {
|
|
773
|
+
const ctx: FetchCtx = {
|
|
774
|
+
subject,
|
|
775
|
+
target: target ?? [],
|
|
776
|
+
grant,
|
|
777
|
+
checkDate: context?.checkDate,
|
|
778
|
+
checkIp: context?.checkIp,
|
|
779
|
+
capability,
|
|
780
|
+
input: context?.input,
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const activeRules = perm.rules.filter((r) => {
|
|
784
|
+
if (r.activeWhen && !r.activeWhen(grant)) return false;
|
|
785
|
+
return r.needs.every((res) => res.isActiveFor(grant));
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// In cap-mode the engine normally skips all fetches. Exception : a
|
|
789
|
+
// resource whose `id` matches a segment NAME of the permission's
|
|
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).
|
|
802
|
+
const requestTarget = target ?? [];
|
|
803
|
+
const targetSegmentIndexById = (() => {
|
|
804
|
+
const out = new Map<string, number>();
|
|
805
|
+
if (perm.target.kind === "none") return out;
|
|
806
|
+
const segments =
|
|
807
|
+
perm.target.kind === "path"
|
|
808
|
+
? perm.target.segments
|
|
809
|
+
: [perm.target.segments[0]];
|
|
810
|
+
segments.forEach((seg, i) => {
|
|
811
|
+
if (seg) out.set(seg.name, i);
|
|
812
|
+
});
|
|
813
|
+
return out;
|
|
814
|
+
})();
|
|
815
|
+
const uniqueResources = capability
|
|
816
|
+
? Array.from(
|
|
817
|
+
new Map(
|
|
818
|
+
activeRules
|
|
819
|
+
.flatMap((r) => r.needs)
|
|
820
|
+
.filter((r) => {
|
|
821
|
+
const idx = targetSegmentIndexById.get(r.id);
|
|
822
|
+
if (idx === undefined) return false;
|
|
823
|
+
const seg = requestTarget[idx];
|
|
824
|
+
return seg !== undefined && !isWildcardSegment(seg);
|
|
825
|
+
})
|
|
826
|
+
.map((r) => [r.id, r] as const),
|
|
827
|
+
).values(),
|
|
828
|
+
)
|
|
829
|
+
: Array.from(
|
|
830
|
+
new Map(
|
|
831
|
+
activeRules
|
|
832
|
+
.flatMap((r) => r.needs)
|
|
833
|
+
.map((r) => [r.id, r] as const),
|
|
834
|
+
).values(),
|
|
835
|
+
);
|
|
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
|
+
let grantOk = true;
|
|
888
|
+
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
|
+
const grantConstraints: Record<string, unknown>[] = [];
|
|
892
|
+
let grantHasMatchRule = false;
|
|
893
|
+
for (const rule of activeRules) {
|
|
894
|
+
if (rule.descriptor.kind === "match") grantHasMatchRule = true;
|
|
895
|
+
const data = rule.needs.map((r) => fetched.get(r.id));
|
|
896
|
+
const result = rule.check(data, ctxWithIndirect);
|
|
897
|
+
if (!result.ok) {
|
|
898
|
+
grantOk = false;
|
|
899
|
+
reasons.push(
|
|
900
|
+
grant.id ? `[${grant.id}] ${result.reason}` : result.reason,
|
|
901
|
+
);
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
if (result.data !== undefined) grantData = result.data;
|
|
905
|
+
if (result.constraint !== undefined) {
|
|
906
|
+
grantConstraints.push(result.constraint);
|
|
907
|
+
}
|
|
908
|
+
if (result.filter !== undefined) {
|
|
909
|
+
referenceSource = result.filter.source;
|
|
910
|
+
filterUnion = mergeFilterSpec(filterUnion, result.filter.spec);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (grantOk) {
|
|
915
|
+
anyOk = true;
|
|
916
|
+
successfulGrants.push(grant);
|
|
917
|
+
if (grant.id) matchedGrantIds.push(grant.id);
|
|
918
|
+
if (grantData !== undefined) lastData = grantData;
|
|
919
|
+
const grantConstraint = combineGrantConstraints(grantConstraints);
|
|
920
|
+
if (grantHasMatchRule && grantConstraint === undefined) {
|
|
921
|
+
collectedConstraints.push({});
|
|
922
|
+
} else {
|
|
923
|
+
collectedConstraints.push(grantConstraint);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!anyOk) {
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
reasons: reasons.length > 0 ? reasons : ["no matching grant"],
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const constraints = aggregateConstraints(collectedConstraints);
|
|
936
|
+
const finalData = resolveFilteredData(
|
|
937
|
+
filterUnion,
|
|
938
|
+
referenceSource,
|
|
939
|
+
lastData,
|
|
940
|
+
);
|
|
941
|
+
|
|
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
|
+
const declaredIndirect: IndirectResource[] = [];
|
|
947
|
+
for (const rule of perm.rules) {
|
|
948
|
+
const ir = extractIndirectResource(rule);
|
|
949
|
+
if (ir) declaredIndirect.push(ir);
|
|
950
|
+
}
|
|
951
|
+
let stages: readonly Record<string, unknown>[] | undefined;
|
|
952
|
+
if (declaredIndirect.length > 0) {
|
|
953
|
+
const baseFilter = constraints ?? {};
|
|
954
|
+
// Must pass `successfulGrants` (rules accepted), NOT `matching`
|
|
955
|
+
// (raw key+target match). A grant rejected by a rule must not
|
|
956
|
+
// contribute to indirect any-wins — otherwise it would silently
|
|
957
|
+
// disable the indirect filter for its siblings.
|
|
958
|
+
const result = buildAggregationStages(
|
|
959
|
+
baseFilter,
|
|
960
|
+
successfulGrants,
|
|
961
|
+
declaredIndirect,
|
|
962
|
+
);
|
|
963
|
+
if (result !== null) stages = result;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return {
|
|
967
|
+
ok: true,
|
|
968
|
+
...(finalData !== undefined ? { data: finalData } : {}),
|
|
969
|
+
...(constraints !== undefined ? { constraints } : {}),
|
|
970
|
+
...(stages !== undefined ? { stages } : {}),
|
|
971
|
+
...(matchedGrantIds.length > 0 ? { matchedGrants: matchedGrantIds } : {}),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
}
|