@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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrateur : transforme une liste de grants + ressources indirectes
|
|
3
|
+
* déclarées en un pipeline d'aggregation MongoDB qui pushe les
|
|
4
|
+
* contraintes de jointure en DB.
|
|
5
|
+
*
|
|
6
|
+
* Algorithme :
|
|
7
|
+
* 1. Identifier les indirect resources réellement référencées par les
|
|
8
|
+
* `with` keys des grants.
|
|
9
|
+
* 2. Transitivement pull les indirect resources parentes (chaînage).
|
|
10
|
+
* 3. Trier topologiquement (parent avant enfant) pour que les `$lookup`
|
|
11
|
+
* enfants puissent référencer l'alias produit par leur parent.
|
|
12
|
+
* 4. Émettre un `$lookup` par indirect resource (dédup par `id`).
|
|
13
|
+
* 5. Émettre un `$match` final OR-isé cross-grant par indirect resource,
|
|
14
|
+
* AND-é entre indirect resources distinctes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Grant } from "./types.ts";
|
|
18
|
+
import type { IndirectResource } from "./indirect-resource.ts";
|
|
19
|
+
import { validateSpec } from "./mongo-query.ts";
|
|
20
|
+
|
|
21
|
+
export type AggregationStage = Record<string, unknown>;
|
|
22
|
+
export type MongoFilter = Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Construit le pipeline complet à partir du `baseFilter` (les contraintes
|
|
26
|
+
* find-style classiques agrégées par `aggregateConstraints`) et de la
|
|
27
|
+
* liste des grants matchés.
|
|
28
|
+
*
|
|
29
|
+
* Retourne :
|
|
30
|
+
* - `null` quand aucune indirect resource ne contribue effectivement
|
|
31
|
+
* de filtre — soit aucun grant ne les référence, soit la sémantique
|
|
32
|
+
* "any wins" s'applique (au moins un grant ne référence pas
|
|
33
|
+
* l'indirect → le set complet est admissible). Dans ce cas le
|
|
34
|
+
* consommateur retombe sur le mode find classique avec `constraints`.
|
|
35
|
+
* - Un array de stages sinon (mode aggregation pipeline).
|
|
36
|
+
*
|
|
37
|
+
* La sémantique "any wins" est essentielle pour la composition
|
|
38
|
+
* cross-grant : un admin global avec un grant "open" sur la même perm
|
|
39
|
+
* doit court-circuiter le filtre que d'autres grants tenteraient
|
|
40
|
+
* d'imposer via une indirect resource — exactement comme
|
|
41
|
+
* `aggregateConstraints` traite `undefined` comme "any wins" pour les
|
|
42
|
+
* constraints find-style.
|
|
43
|
+
*/
|
|
44
|
+
export function buildAggregationStages(
|
|
45
|
+
baseFilter: MongoFilter,
|
|
46
|
+
grants: readonly Grant[],
|
|
47
|
+
declaredIndirect: readonly IndirectResource[],
|
|
48
|
+
): AggregationStage[] | null {
|
|
49
|
+
const indirectById = new Map<string, IndirectResource>();
|
|
50
|
+
for (const ir of declaredIndirect) indirectById.set(ir.id, ir);
|
|
51
|
+
|
|
52
|
+
// Indirects référencées par AU MOINS un grant.
|
|
53
|
+
const referenced = new Set<string>();
|
|
54
|
+
for (const g of grants) {
|
|
55
|
+
if (!g.with) continue;
|
|
56
|
+
for (const id of Object.keys(g.with)) {
|
|
57
|
+
if (indirectById.has(id)) referenced.add(id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (referenced.size === 0) return null;
|
|
62
|
+
|
|
63
|
+
// Sémantique "any wins" : une indirect ne contribue de filtre que si
|
|
64
|
+
// TOUS les grants matchés portent une condition pour elle. Sinon le
|
|
65
|
+
// grant sans condition ouvre la porte au set complet → on n'a pas le
|
|
66
|
+
// droit de filtrer sur cette indirect.
|
|
67
|
+
const effective = new Set<string>();
|
|
68
|
+
for (const id of referenced) {
|
|
69
|
+
if (grants.every((g) => g.with?.[id] !== undefined)) {
|
|
70
|
+
effective.add(id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (effective.size === 0) return null;
|
|
75
|
+
|
|
76
|
+
// Pull in chained parents (transitivement) pour les indirects effectives.
|
|
77
|
+
const required = new Set<string>(effective);
|
|
78
|
+
for (const id of effective) {
|
|
79
|
+
walkDeps(indirectById.get(id)!, required, indirectById);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Topological order: parent before child.
|
|
83
|
+
const ordered = topoSort([...required].map((id) => indirectById.get(id)!));
|
|
84
|
+
|
|
85
|
+
const stages: AggregationStage[] = [{ $match: baseFilter }];
|
|
86
|
+
for (const ir of ordered) {
|
|
87
|
+
stages.push(buildLookupStage(ir));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Per-indirect cross-grant OR; cross-indirect AND.
|
|
91
|
+
const matchClauses: MongoFilter[] = [];
|
|
92
|
+
for (const irId of effective) {
|
|
93
|
+
const ir = indirectById.get(irId)!;
|
|
94
|
+
const perGrantSpecs: MongoFilter[] = [];
|
|
95
|
+
for (const g of grants) {
|
|
96
|
+
const spec = g.with?.[irId] as MongoFilter | undefined;
|
|
97
|
+
if (!spec) continue;
|
|
98
|
+
// Security: same whitelist as `match` rule for direct resources —
|
|
99
|
+
// grants may come from untrusted providers, the spec ends up
|
|
100
|
+
// pushed to Mongo as-is. Reject dangerous operators ($where,
|
|
101
|
+
// $expr, $function, etc.) before they reach the DB.
|
|
102
|
+
validateSpec(spec);
|
|
103
|
+
perGrantSpecs.push(spec);
|
|
104
|
+
}
|
|
105
|
+
// `effective` guarantees all grants have a spec, so perGrantSpecs is
|
|
106
|
+
// non-empty here.
|
|
107
|
+
|
|
108
|
+
const path = lookupAlias(ir);
|
|
109
|
+
const elemMatches: MongoFilter[] = perGrantSpecs.map((spec) => ({
|
|
110
|
+
[path]: { $elemMatch: { ...staticTypeFilter(ir), ...spec } },
|
|
111
|
+
}));
|
|
112
|
+
matchClauses.push(
|
|
113
|
+
elemMatches.length === 1 ? elemMatches[0] : { $or: elemMatches },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (matchClauses.length > 0) {
|
|
118
|
+
stages.push({
|
|
119
|
+
$match:
|
|
120
|
+
matchClauses.length === 1 ? matchClauses[0] : { $and: matchClauses },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return stages;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function walkDeps(
|
|
130
|
+
ir: IndirectResource,
|
|
131
|
+
acc: Set<string>,
|
|
132
|
+
byId: Map<string, IndirectResource>,
|
|
133
|
+
): void {
|
|
134
|
+
if (ir.from.kind === "indirect") {
|
|
135
|
+
const parent = byId.get(ir.from.id);
|
|
136
|
+
if (parent && !acc.has(parent.id)) {
|
|
137
|
+
acc.add(parent.id);
|
|
138
|
+
walkDeps(parent, acc, byId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function topoSort(items: IndirectResource[]): IndirectResource[] {
|
|
144
|
+
const sorted: IndirectResource[] = [];
|
|
145
|
+
const remaining = new Set(items.map((i) => i.id));
|
|
146
|
+
const byId = new Map(items.map((i) => [i.id, i] as const));
|
|
147
|
+
|
|
148
|
+
while (remaining.size > 0) {
|
|
149
|
+
let progressed = false;
|
|
150
|
+
for (const id of [...remaining]) {
|
|
151
|
+
const ir = byId.get(id)!;
|
|
152
|
+
const parentId = ir.from.kind === "indirect" ? ir.from.id : null;
|
|
153
|
+
const parentInSet = parentId !== null && remaining.has(parentId);
|
|
154
|
+
if (!parentInSet) {
|
|
155
|
+
sorted.push(ir);
|
|
156
|
+
remaining.delete(id);
|
|
157
|
+
progressed = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!progressed) {
|
|
161
|
+
throw new Error("Cycle detected in indirect resource chain");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return sorted;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function lookupAlias(ir: IndirectResource): string {
|
|
168
|
+
return `_${ir.id}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function staticTypeFilter(ir: IndirectResource): MongoFilter {
|
|
172
|
+
return ir.to?._type ? { _type: ir.to._type } : {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildLookupStage(ir: IndirectResource): AggregationStage {
|
|
176
|
+
const isChained = ir.from.kind === "indirect";
|
|
177
|
+
// `<self>` est un placeholder neutre — le consommateur (mongodbee
|
|
178
|
+
// adapter) substitue le nom de la collection courante côté driver.
|
|
179
|
+
const fromCollection = ir.on.foreignCollection ?? "<self>";
|
|
180
|
+
const alias = lookupAlias(ir);
|
|
181
|
+
|
|
182
|
+
if (!isChained) {
|
|
183
|
+
const subPipeline = ir.to?._type
|
|
184
|
+
? [{ $match: { _type: ir.to._type } }]
|
|
185
|
+
: undefined;
|
|
186
|
+
return {
|
|
187
|
+
$lookup: {
|
|
188
|
+
from: fromCollection,
|
|
189
|
+
localField: ir.on.localField,
|
|
190
|
+
foreignField: ir.on.foreignField,
|
|
191
|
+
...(subPipeline && { pipeline: subPipeline }),
|
|
192
|
+
as: alias,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Chained: lookup uses $let + $expr to dereference the parent alias.
|
|
198
|
+
const parentAlias = lookupAlias(ir.from as IndirectResource);
|
|
199
|
+
return {
|
|
200
|
+
$lookup: {
|
|
201
|
+
from: fromCollection,
|
|
202
|
+
let: {
|
|
203
|
+
src: { $arrayElemAt: [`$${parentAlias}.${ir.on.localField}`, 0] },
|
|
204
|
+
},
|
|
205
|
+
pipeline: [
|
|
206
|
+
{
|
|
207
|
+
$match: {
|
|
208
|
+
$expr: { $eq: [`$${ir.on.foreignField}`, "$$src"] },
|
|
209
|
+
...(ir.to?._type && { _type: ir.to._type }),
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
as: alias,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `indirectResource` — déclaration d'une ressource atteignable via une
|
|
3
|
+
* jointure (DB-pushdown via aggregation pipeline). Pendant de `resource()`
|
|
4
|
+
* pour les cas où la donnée filtrante vit dans une autre collection (ou
|
|
5
|
+
* dans la même via un foreign field).
|
|
6
|
+
*
|
|
7
|
+
* Cas d'usage typique :
|
|
8
|
+
* - lister les `participant` qui ont une `org_membership` matching org=X
|
|
9
|
+
* - lister les `participant` dont l'`user` lié appartient à entreprise=Y
|
|
10
|
+
* - chaîner sur plusieurs niveaux (`participant → user → entreprise_member`)
|
|
11
|
+
*
|
|
12
|
+
* La méthode `.match()` retourne une `Rule` sentinelle (kind="indirect-match")
|
|
13
|
+
* que l'orchestrateur de `system.ts` détecte pour générer le pipeline
|
|
14
|
+
* d'aggregation correspondant — voir `indirect-aggregation.ts`.
|
|
15
|
+
*
|
|
16
|
+
* À la différence des ressources directes :
|
|
17
|
+
* - Pas de `fetch` ni de `dedupKey` : aucune donnée chargée à l'évaluation
|
|
18
|
+
* - Pas de rule `filter`/`include`/`require*` — uniquement `match` pour
|
|
19
|
+
* contribuer une contrainte au pipeline généré.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { defineRule } from "./rules.ts";
|
|
23
|
+
import { evaluateSpec, validateSpec } from "./mongo-query.ts";
|
|
24
|
+
import type { FetchCtx, Rule } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Identité de jointure entre une ressource source et la cible jointe.
|
|
28
|
+
*
|
|
29
|
+
* - `localField` : nom du champ sur le doc source.
|
|
30
|
+
* - `foreignField` : nom du champ sur le doc cible.
|
|
31
|
+
* - `foreignCollection` : si absent, jointure dans la même collection
|
|
32
|
+
* (self-lookup) — utile pour les multi-collections mongodbee où le
|
|
33
|
+
* discriminator de type vit dans un champ `_type`. Si présent,
|
|
34
|
+
* jointure vers une collection externe (équivalent `externalLookup`).
|
|
35
|
+
*/
|
|
36
|
+
export interface IndirectResourceJoin {
|
|
37
|
+
readonly localField: string;
|
|
38
|
+
readonly foreignField: string;
|
|
39
|
+
readonly foreignCollection?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Ressource indirecte. Identifie une cible joignable et expose des rules
|
|
44
|
+
* pour contribuer aux contraintes du pipeline généré.
|
|
45
|
+
*
|
|
46
|
+
* - `from` accepte une `Resource` directe (l'objet retourné par
|
|
47
|
+
* `resource({...})`, sans champ `kind` — on le marque "direct" par
|
|
48
|
+
* défaut) OU une autre `IndirectResource` (qui a `kind: "indirect"`)
|
|
49
|
+
* pour chaîner sur plusieurs niveaux.
|
|
50
|
+
* - `to._type` discrimine le type cible quand la jointure vise une
|
|
51
|
+
* collection multi-types (mongodbee). **Barrière de sécurité** : un
|
|
52
|
+
* grant ne peut pas adresser un autre `_type` via le `with`.
|
|
53
|
+
* - `cardinality` indique si on attend N résultats (`many`) ou 1
|
|
54
|
+
* (`one`) — utilisé pour optimiser le pipeline généré ($unwind potentiel).
|
|
55
|
+
*/
|
|
56
|
+
export interface IndirectResource {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
/** Marker discriminant les ressources directes des indirectes. */
|
|
59
|
+
readonly kind: "indirect";
|
|
60
|
+
/** Normalisé au factory à partir du `from` du spec (Resource → direct). */
|
|
61
|
+
readonly from: { readonly id: string; readonly kind: "direct" | "indirect" };
|
|
62
|
+
readonly on: IndirectResourceJoin;
|
|
63
|
+
readonly to?: { readonly _type?: string };
|
|
64
|
+
readonly cardinality: "one" | "many";
|
|
65
|
+
/**
|
|
66
|
+
* Fetcher opt-in pour le mode concrete (per-doc check). Quand défini,
|
|
67
|
+
* la rule `match()` évalue effectivement la condition en mode concrete
|
|
68
|
+
* (au lieu d'être une sentinelle no-op). Reçoit la valeur de la source
|
|
69
|
+
* (déjà fetchée par l'engine) et retourne le tableau de docs joints.
|
|
70
|
+
*
|
|
71
|
+
* Toujours retourner un array, même pour `cardinality: "one"` — la
|
|
72
|
+
* sémantique du match utilise `$elemMatch`-like (au moins un match).
|
|
73
|
+
*
|
|
74
|
+
* Si absent, l'indirect reste sentinelle (legacy) : ne contribue qu'au
|
|
75
|
+
* pipeline en cap-mode.
|
|
76
|
+
*/
|
|
77
|
+
readonly fetcher?: (
|
|
78
|
+
sourceDoc: unknown,
|
|
79
|
+
ctx: FetchCtx,
|
|
80
|
+
) => readonly unknown[] | Promise<readonly unknown[]>;
|
|
81
|
+
/** Calcule la cache key pour un target (cf. `Resource.cacheKeyForTarget`). */
|
|
82
|
+
cacheKeyForTarget(target: readonly unknown[]): string;
|
|
83
|
+
/**
|
|
84
|
+
* Crée une rule qui déclare l'utilisation de cette resource indirecte
|
|
85
|
+
* dans la permission.
|
|
86
|
+
*
|
|
87
|
+
* - En cap-mode : sentinelle, détectée par `system.ts` qui collecte
|
|
88
|
+
* l'indirect pour générer le pipeline (cf. `buildAggregationStages`).
|
|
89
|
+
* - En concrete-mode + `fetcher` défini : fetche les docs joints et
|
|
90
|
+
* évalue `grant.with[id]` contre eux (au moins un doc match).
|
|
91
|
+
* Sans `fetcher`, sentinelle aussi (no-op).
|
|
92
|
+
*/
|
|
93
|
+
match(): Rule;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface IndirectResourceSpec {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
/**
|
|
99
|
+
* `Resource` (sans champ `kind`) ou `IndirectResource` (avec
|
|
100
|
+
* `kind: "indirect"`). Le factory normalise en stockant un
|
|
101
|
+
* `{ id, kind }` minimal pour la résolution du graphe.
|
|
102
|
+
*/
|
|
103
|
+
readonly from: { readonly id: string; readonly kind?: "direct" | "indirect" };
|
|
104
|
+
readonly on: IndirectResourceJoin;
|
|
105
|
+
readonly to?: { readonly _type?: string };
|
|
106
|
+
readonly cardinality: "one" | "many";
|
|
107
|
+
readonly fetch?: (
|
|
108
|
+
sourceDoc: unknown,
|
|
109
|
+
ctx: FetchCtx,
|
|
110
|
+
) => readonly unknown[] | Promise<readonly unknown[]>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Sentinelle dans `Rule.descriptor` pour qu'`system.ts` reconnaisse
|
|
115
|
+
* cette rule comme "indirect" et collecte la resource correspondante.
|
|
116
|
+
*
|
|
117
|
+
* Le champ est typé `unknown` côté `RuleDescriptor` parce que
|
|
118
|
+
* `[extra: string]: unknown` est la signature catch-all des descriptors —
|
|
119
|
+
* on cast à l'extraction.
|
|
120
|
+
*/
|
|
121
|
+
export const INDIRECT_RULE_KIND = "indirect-match";
|
|
122
|
+
|
|
123
|
+
export function indirectResource(spec: IndirectResourceSpec): IndirectResource {
|
|
124
|
+
// Normalize `from`: a `Resource` (from `resource({...})`) has no `kind`
|
|
125
|
+
// field; we treat it as "direct". An `IndirectResource` has
|
|
126
|
+
// `kind: "indirect"` set by this very factory. Walking the chain in
|
|
127
|
+
// the orchestrator reads `from.kind` to know whether to descend.
|
|
128
|
+
const fromNormalized = {
|
|
129
|
+
id: spec.from.id,
|
|
130
|
+
kind:
|
|
131
|
+
spec.from.kind === "indirect"
|
|
132
|
+
? ("indirect" as const)
|
|
133
|
+
: ("direct" as const),
|
|
134
|
+
};
|
|
135
|
+
const impl: IndirectResource = {
|
|
136
|
+
id: spec.id,
|
|
137
|
+
kind: "indirect",
|
|
138
|
+
from: fromNormalized,
|
|
139
|
+
on: spec.on,
|
|
140
|
+
to: spec.to,
|
|
141
|
+
cardinality: spec.cardinality,
|
|
142
|
+
fetcher: spec.fetch,
|
|
143
|
+
cacheKeyForTarget(target: readonly unknown[]): string {
|
|
144
|
+
// Mirror of Resource.cacheKeyForTarget — the indirect's cache key
|
|
145
|
+
// is target-derived (typically by source dedup). Useful for
|
|
146
|
+
// `CanContext.preseed()` when the caller wants to inject joined
|
|
147
|
+
// docs already returned by a pipeline aggregation.
|
|
148
|
+
return `${spec.id}::${JSON.stringify(target)}`;
|
|
149
|
+
},
|
|
150
|
+
match() {
|
|
151
|
+
return defineRule({
|
|
152
|
+
kind: INDIRECT_RULE_KIND,
|
|
153
|
+
needs: [],
|
|
154
|
+
describe: () => ({ indirectResource: impl }),
|
|
155
|
+
check: (_data, _payload, ctx) => {
|
|
156
|
+
// Cap-mode : sentinelle. `system.ts` reads the descriptor and
|
|
157
|
+
// emits the pipeline via `buildAggregationStages`. The rule
|
|
158
|
+
// itself contributes nothing.
|
|
159
|
+
if (ctx.capability) return { ok: true };
|
|
160
|
+
|
|
161
|
+
// Concrete mode without fetcher : sentinelle aussi (legacy).
|
|
162
|
+
if (!impl.fetcher) return { ok: true };
|
|
163
|
+
|
|
164
|
+
// Concrete mode with fetcher : evaluate `grant.with[id]` spec
|
|
165
|
+
// against the joined docs. The engine already populated the
|
|
166
|
+
// joined docs in the context cache (see system.ts) — we just
|
|
167
|
+
// read them from `_indirectFetched` (an opaque per-rule pass).
|
|
168
|
+
// If no spec is set, the rule passes (the grant trusts the
|
|
169
|
+
// indirect resource without further check).
|
|
170
|
+
const spec = ctx.grant.with?.[impl.id];
|
|
171
|
+
if (spec === undefined) return { ok: true };
|
|
172
|
+
if (typeof spec === "object" && spec !== null) validateSpec(spec);
|
|
173
|
+
|
|
174
|
+
const joined =
|
|
175
|
+
(
|
|
176
|
+
ctx as FetchCtx & {
|
|
177
|
+
_indirectFetched?: Map<string, readonly unknown[]>;
|
|
178
|
+
}
|
|
179
|
+
)._indirectFetched?.get(impl.id) ?? [];
|
|
180
|
+
const passes = joined.some((d) =>
|
|
181
|
+
evaluateSpec(spec as Record<string, unknown>, d),
|
|
182
|
+
);
|
|
183
|
+
return passes
|
|
184
|
+
? { ok: true }
|
|
185
|
+
: {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: `indirect[${impl.id}] no joined doc matches spec`,
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
return impl;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extraction utilitaire pour `system.ts` : récupère la `IndirectResource`
|
|
198
|
+
* portée par une rule sentinelle, ou `null` si la rule n'en est pas une.
|
|
199
|
+
*/
|
|
200
|
+
export function extractIndirectResource(rule: Rule): IndirectResource | null {
|
|
201
|
+
if (rule.descriptor.kind !== INDIRECT_RULE_KIND) return null;
|
|
202
|
+
const ref = rule.descriptor.indirectResource;
|
|
203
|
+
if (!ref || typeof ref !== "object") return null;
|
|
204
|
+
return ref as IndirectResource;
|
|
205
|
+
}
|
package/mod.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@diister/quick-permission` — API publique.
|
|
3
|
+
*
|
|
4
|
+
* Expose une primitive unique `defineRule({ needs, check })` autour de
|
|
5
|
+
* laquelle sont organisés les `Resource`, méthodes de sucre, et le moteur
|
|
6
|
+
* d'orchestration avec dedup par contexte.
|
|
7
|
+
*
|
|
8
|
+
* Cf. `docs/rfc-resource-pipe-api.md` pour la motivation, les décisions
|
|
9
|
+
* de design et le plan de migration.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ─── Targets ─────────────────────────────────────────────────────────────
|
|
13
|
+
export { seg, target } from "./target.ts";
|
|
14
|
+
|
|
15
|
+
// ─── Core types ──────────────────────────────────────────────────────────
|
|
16
|
+
export type {
|
|
17
|
+
AnySegment,
|
|
18
|
+
AnyTarget,
|
|
19
|
+
CanResult,
|
|
20
|
+
FetchCtx,
|
|
21
|
+
FilterContribution,
|
|
22
|
+
Grant,
|
|
23
|
+
IndirectResourceInfo,
|
|
24
|
+
ListEntry,
|
|
25
|
+
Permission,
|
|
26
|
+
Resource,
|
|
27
|
+
ResourceData,
|
|
28
|
+
ResourcesData,
|
|
29
|
+
Rule,
|
|
30
|
+
RuleDescriptor,
|
|
31
|
+
RuleResult,
|
|
32
|
+
SegmentSpec,
|
|
33
|
+
SerializableSegment,
|
|
34
|
+
SerializableTarget,
|
|
35
|
+
SpecToSegment,
|
|
36
|
+
Subject,
|
|
37
|
+
TargetArgs,
|
|
38
|
+
TargetNone,
|
|
39
|
+
TargetOptional,
|
|
40
|
+
TargetPath,
|
|
41
|
+
TargetRequired,
|
|
42
|
+
TreeNode,
|
|
43
|
+
} from "./types.ts";
|
|
44
|
+
|
|
45
|
+
// ─── Resource factory + sugar methods ────────────────────────────────────
|
|
46
|
+
export { resource } from "./resource.ts";
|
|
47
|
+
|
|
48
|
+
// ─── Indirect resource (JOIN-based, generates aggregation pipeline) ──────
|
|
49
|
+
export { indirectResource } from "./indirect-resource.ts";
|
|
50
|
+
export type {
|
|
51
|
+
IndirectResource,
|
|
52
|
+
IndirectResourceJoin,
|
|
53
|
+
} from "./indirect-resource.ts";
|
|
54
|
+
export type { AggregationStage } from "./indirect-aggregation.ts";
|
|
55
|
+
|
|
56
|
+
// ─── defineRule + standalone helpers ─────────────────────────────────────
|
|
57
|
+
export { defineRule, inputMatch, matchPath, requireSelf } from "./rules.ts";
|
|
58
|
+
export type { DefineRuleOpts } from "./rules.ts";
|
|
59
|
+
|
|
60
|
+
// ─── Permission builders ─────────────────────────────────────────────────
|
|
61
|
+
export { intermediate, permission } from "./permission.ts";
|
|
62
|
+
export type {
|
|
63
|
+
IntermediateBuilder,
|
|
64
|
+
IntermediateConfig,
|
|
65
|
+
PermissionBuilder,
|
|
66
|
+
PermissionConfig,
|
|
67
|
+
} from "./permission.ts";
|
|
68
|
+
|
|
69
|
+
// ─── System ──────────────────────────────────────────────────────────────
|
|
70
|
+
export { createSystem } from "./system.ts";
|
|
71
|
+
export type {
|
|
72
|
+
CanContext,
|
|
73
|
+
Provider,
|
|
74
|
+
ProviderFn,
|
|
75
|
+
ProviderObject,
|
|
76
|
+
System,
|
|
77
|
+
} from "./system.ts";
|
|
78
|
+
|
|
79
|
+
// ─── Field-projection helpers (used outside permission checks too) ───────
|
|
80
|
+
export { applyFilter, pickFields } from "./core/filtering.ts";
|
|
81
|
+
export type { FilterSpec } from "./core/merging.ts";
|
package/mongo-query.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Évaluateur d'expressions style MongoDB query basé sur `sift`.
|
|
3
|
+
*
|
|
4
|
+
* Sift supporte ~100% du Mongo query language en pur JS. On délègue
|
|
5
|
+
* l'évaluation à sift mais on **valide la spec en amont** pour s'assurer
|
|
6
|
+
* qu'aucun opérateur dangereux n'est utilisé (`$where`, `$function`, etc.
|
|
7
|
+
* permettent de l'exécution de code arbitraire — interdits dans un grant
|
|
8
|
+
* qui peut venir de la DB).
|
|
9
|
+
*
|
|
10
|
+
* Whitelist :
|
|
11
|
+
* - Comparaisons : `$eq`, `$ne`, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`
|
|
12
|
+
* - Existence : `$exists`, `$type`
|
|
13
|
+
* - Logiques : `$and`, `$or`, `$nor`, `$not`
|
|
14
|
+
* - String : `$regex`, `$options`
|
|
15
|
+
* - Tableaux : `$all`, `$elemMatch`, `$size`
|
|
16
|
+
*
|
|
17
|
+
* Interdit (pas dans la whitelist, throw au boot ou au check) :
|
|
18
|
+
* - `$where`, `$function`, `$expr`, `$jsonSchema`, `$accumulator`
|
|
19
|
+
*
|
|
20
|
+
* La même spec sert à :
|
|
21
|
+
* 1. Évaluer en mémoire un document (per-doc check via sift)
|
|
22
|
+
* 2. Filtrer une collection MongoDB en pushdown (la spec EST déjà du Mongo)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import sift from "./sift/index.ts";
|
|
26
|
+
|
|
27
|
+
const ALLOWED_OPERATORS: ReadonlySet<string> = new Set([
|
|
28
|
+
// Comparison
|
|
29
|
+
"$eq",
|
|
30
|
+
"$ne",
|
|
31
|
+
"$in",
|
|
32
|
+
"$nin",
|
|
33
|
+
"$gt",
|
|
34
|
+
"$gte",
|
|
35
|
+
"$lt",
|
|
36
|
+
"$lte",
|
|
37
|
+
// Existence / type
|
|
38
|
+
"$exists",
|
|
39
|
+
"$type",
|
|
40
|
+
// Logical
|
|
41
|
+
"$and",
|
|
42
|
+
"$or",
|
|
43
|
+
"$nor",
|
|
44
|
+
"$not",
|
|
45
|
+
// String
|
|
46
|
+
"$regex",
|
|
47
|
+
"$options",
|
|
48
|
+
// Array
|
|
49
|
+
"$all",
|
|
50
|
+
"$elemMatch",
|
|
51
|
+
"$size",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
export type MongoSpec = Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Vérifie récursivement qu'aucun opérateur non-whitelisté n'est présent
|
|
58
|
+
* dans la spec. Throw au premier opérateur interdit (`$where`, etc.).
|
|
59
|
+
*
|
|
60
|
+
* À appeler à la création du grant (validation côté API) ou au pire au
|
|
61
|
+
* check time, pour empêcher l'exécution de code arbitraire.
|
|
62
|
+
*/
|
|
63
|
+
export function validateSpec(spec: unknown, path = "$"): void {
|
|
64
|
+
if (spec === null || typeof spec !== "object") return;
|
|
65
|
+
if (Array.isArray(spec)) {
|
|
66
|
+
// A `for` loop, not `forEach`: the arrow's implicit return handed back
|
|
67
|
+
// validateSpec's value, which the iteration then discarded.
|
|
68
|
+
for (const [i, item] of spec.entries()) {
|
|
69
|
+
validateSpec(item, `${path}[${i}]`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const [key, value] of Object.entries(spec as object)) {
|
|
74
|
+
if (key.startsWith("$")) {
|
|
75
|
+
if (!ALLOWED_OPERATORS.has(key)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Forbidden Mongo operator at ${path}: "${key}". ` +
|
|
78
|
+
`Allowed: ${[...ALLOWED_OPERATORS].join(", ")}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
validateSpec(value, `${path}.${key}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Évalue une spec contre un document. La spec est validée d'abord pour
|
|
88
|
+
* rejeter tout opérateur non-whitelisté.
|
|
89
|
+
*/
|
|
90
|
+
export function evaluateSpec(spec: MongoSpec, doc: unknown): boolean {
|
|
91
|
+
validateSpec(spec);
|
|
92
|
+
// deno-lint-ignore no-explicit-any
|
|
93
|
+
return (sift as any)(spec)(doc);
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diister/quick-permission",
|
|
3
|
+
"version": "0.9.0-beta.5",
|
|
4
|
+
"description": "Declarative, type-safe permission rules with MongoDB query generation",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"permissions",
|
|
10
|
+
"authorization",
|
|
11
|
+
"access-control",
|
|
12
|
+
"mongodb",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/diister-dev/quick-permission.git",
|
|
18
|
+
"directory": "library"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/diister-dev/quick-permission#readme",
|
|
21
|
+
"bugs": "https://github.com/diister-dev/quick-permission/issues",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20.11"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"core",
|
|
28
|
+
"sift",
|
|
29
|
+
"*.ts",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/mod.d.ts",
|
|
36
|
+
"default": "./dist/mod.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@biomejs/biome": "^2.5.12",
|
|
42
|
+
"@types/bun": "^1.4.1",
|
|
43
|
+
"@types/node": "^22.15.0",
|
|
44
|
+
"mongodb": "^6.21.0",
|
|
45
|
+
"typescript": "^5.9.3"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"check:package": "bun run scripts/check-package.ts",
|
|
49
|
+
"version:set": "bun run scripts/check-package.ts --write",
|
|
50
|
+
"prebuild": "bun run check:package",
|
|
51
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
52
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
53
|
+
"lint": "biome lint .",
|
|
54
|
+
"fmt": "biome format --write .",
|
|
55
|
+
"fmt:check": "biome format .",
|
|
56
|
+
"test": "bun test"
|
|
57
|
+
}
|
|
58
|
+
}
|