@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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +747 -0
  3. package/aggregation.ts +114 -0
  4. package/core/filtering.ts +70 -0
  5. package/core/matching.ts +82 -0
  6. package/core/merging.ts +143 -0
  7. package/dist/aggregation.d.ts +62 -0
  8. package/dist/aggregation.d.ts.map +1 -0
  9. package/dist/aggregation.js +97 -0
  10. package/dist/aggregation.js.map +1 -0
  11. package/dist/core/filtering.d.ts +35 -0
  12. package/dist/core/filtering.d.ts.map +1 -0
  13. package/dist/core/filtering.js +62 -0
  14. package/dist/core/filtering.js.map +1 -0
  15. package/dist/core/matching.d.ts +31 -0
  16. package/dist/core/matching.d.ts.map +1 -0
  17. package/dist/core/matching.js +75 -0
  18. package/dist/core/matching.js.map +1 -0
  19. package/dist/core/merging.d.ts +29 -0
  20. package/dist/core/merging.d.ts.map +1 -0
  21. package/dist/core/merging.js +124 -0
  22. package/dist/core/merging.js.map +1 -0
  23. package/dist/indirect-aggregation.d.ts +41 -0
  24. package/dist/indirect-aggregation.d.ts.map +1 -0
  25. package/dist/indirect-aggregation.js +185 -0
  26. package/dist/indirect-aggregation.js.map +1 -0
  27. package/dist/indirect-resource.d.ts +126 -0
  28. package/dist/indirect-resource.d.ts.map +1 -0
  29. package/dist/indirect-resource.js +109 -0
  30. package/dist/indirect-resource.js.map +1 -0
  31. package/dist/mod.d.ts +25 -0
  32. package/dist/mod.d.ts.map +1 -0
  33. package/dist/mod.js +25 -0
  34. package/dist/mod.js.map +1 -0
  35. package/dist/mongo-query.d.ts +38 -0
  36. package/dist/mongo-query.d.ts.map +1 -0
  37. package/dist/mongo-query.js +88 -0
  38. package/dist/mongo-query.js.map +1 -0
  39. package/dist/permission.d.ts +57 -0
  40. package/dist/permission.d.ts.map +1 -0
  41. package/dist/permission.js +60 -0
  42. package/dist/permission.js.map +1 -0
  43. package/dist/resource.d.ts +48 -0
  44. package/dist/resource.d.ts.map +1 -0
  45. package/dist/resource.js +298 -0
  46. package/dist/resource.js.map +1 -0
  47. package/dist/rules.d.ts +106 -0
  48. package/dist/rules.d.ts.map +1 -0
  49. package/dist/rules.js +183 -0
  50. package/dist/rules.js.map +1 -0
  51. package/dist/sift/core.d.ts +104 -0
  52. package/dist/sift/core.d.ts.map +1 -0
  53. package/dist/sift/core.js +248 -0
  54. package/dist/sift/core.js.map +1 -0
  55. package/dist/sift/index.d.ts +10 -0
  56. package/dist/sift/index.d.ts.map +1 -0
  57. package/dist/sift/index.js +18 -0
  58. package/dist/sift/index.js.map +1 -0
  59. package/dist/sift/operations.d.ts +87 -0
  60. package/dist/sift/operations.d.ts.map +1 -0
  61. package/dist/sift/operations.js +257 -0
  62. package/dist/sift/operations.js.map +1 -0
  63. package/dist/sift/utils.d.ts +12 -0
  64. package/dist/sift/utils.d.ts.map +1 -0
  65. package/dist/sift/utils.js +80 -0
  66. package/dist/sift/utils.js.map +1 -0
  67. package/dist/system.d.ts +113 -0
  68. package/dist/system.d.ts.map +1 -0
  69. package/dist/system.js +712 -0
  70. package/dist/system.js.map +1 -0
  71. package/dist/target.d.ts +18 -0
  72. package/dist/target.d.ts.map +1 -0
  73. package/dist/target.js +41 -0
  74. package/dist/target.js.map +1 -0
  75. package/dist/types.d.ts +345 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +10 -0
  78. package/dist/types.js.map +1 -0
  79. package/indirect-aggregation.ts +216 -0
  80. package/indirect-resource.ts +205 -0
  81. package/mod.ts +81 -0
  82. package/mongo-query.ts +94 -0
  83. package/package.json +58 -0
  84. package/permission.ts +88 -0
  85. package/resource.ts +352 -0
  86. package/rules.ts +241 -0
  87. package/sift/MIT-LICENSE.txt +20 -0
  88. package/sift/core.ts +551 -0
  89. package/sift/index.ts +62 -0
  90. package/sift/operations.ts +449 -0
  91. package/sift/utils.ts +96 -0
  92. package/system.ts +974 -0
  93. package/target.ts +84 -0
  94. package/types.ts +408 -0
package/permission.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Builder de permission pour resource-pipe.
3
+ *
4
+ * API en deux étapes (`permission(opts).rules([])`), même justification que
5
+ * `library/permission.ts` (cf. RFC declarative §"Décision two-call").
6
+ *
7
+ * Différence majeure vs l'API declarative actuelle : pas de slot `fetch` au
8
+ * niveau permission. Les fetches sont déclarés au niveau des `Resource`s
9
+ * que les rules consomment via `needs`.
10
+ */
11
+
12
+ import type { AnyTarget, Grant, Permission, Rule } from "./types.ts";
13
+
14
+ export type PermissionConfig<TMeta> = {
15
+ readonly metadata?: TMeta;
16
+ readonly target: AnyTarget;
17
+ };
18
+
19
+ export type PermissionBuilder<TMeta> = {
20
+ rules(rules: readonly Rule[]): Permission<TMeta>;
21
+ };
22
+
23
+ /**
24
+ * Déclare une permission "feuille" (vérifiable directement via `can()`).
25
+ *
26
+ * @example
27
+ * "users.read": permission({
28
+ * metadata: { description: "Lire un user" },
29
+ * target: target.required("user"),
30
+ * }).rules([
31
+ * userOf.match(),
32
+ * userOf.filter(),
33
+ * ]),
34
+ */
35
+ export function permission<TMeta = unknown>(
36
+ config: PermissionConfig<TMeta>,
37
+ ): PermissionBuilder<TMeta> {
38
+ return {
39
+ rules(rules) {
40
+ return {
41
+ metadata: config.metadata,
42
+ target: config.target,
43
+ rules,
44
+ };
45
+ },
46
+ };
47
+ }
48
+
49
+ export type IntermediateConfig<TMeta> = {
50
+ readonly metadata?: TMeta;
51
+ readonly target: AnyTarget;
52
+ /** Expand un grant sur cette intermediate en N grants sur les enfants. */
53
+ readonly expandsTo: (grant: Grant) => readonly Grant[];
54
+ };
55
+
56
+ export type IntermediateBuilder<TMeta> = {
57
+ /** Optionnellement attacher des rules au niveau intermediate. */
58
+ rules(rules: readonly Rule[]): Permission<TMeta>;
59
+ };
60
+
61
+ /**
62
+ * Déclare une permission "intermediate" (macro qui s'expand en grants
63
+ * enfants au moment du check).
64
+ *
65
+ * @example
66
+ * "users.manage": intermediate({
67
+ * target: target.required("user"),
68
+ * expandsTo: (grant) => [
69
+ * { ...grant, key: "users.read" },
70
+ * { ...grant, key: "users.update" },
71
+ * { ...grant, key: "users.delete" },
72
+ * ],
73
+ * }).rules([]),
74
+ */
75
+ export function intermediate<TMeta = unknown>(
76
+ config: IntermediateConfig<TMeta>,
77
+ ): IntermediateBuilder<TMeta> {
78
+ return {
79
+ rules(rules) {
80
+ return {
81
+ metadata: config.metadata,
82
+ target: config.target,
83
+ rules,
84
+ expandsTo: config.expandsTo,
85
+ };
86
+ },
87
+ };
88
+ }
package/resource.ts ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * `Resource` : source de donnée fetchable, dédupliquée par contexte.
3
+ *
4
+ * Une Resource est conçue pour être déclarée une fois dans son domaine
5
+ * (`userOf`, `expositionInfoOf`, etc.) et réutilisée entre permissions.
6
+ * Le moteur dédup les fetches au sein d'un context via `(id, dedupKey)`.
7
+ *
8
+ * Les méthodes `match`/`filter`/`includes`/`require*` sont du sucre :
9
+ * elles produisent des Rule via `defineRule`, avec la resource comme need.
10
+ *
11
+ * ─── Capability-mode contract ────────────────────────────────────────────
12
+ * A request target containing a wildcard (`"*"` or `"xxx:*"`) is a capability
13
+ * query — "can the subject act on ANY resource of this shape?". The engine
14
+ * skips resource fetches in this mode (no concrete resource to fetch). Each
15
+ * rule must declare what it does when its data is unavailable:
16
+ *
17
+ * match : silent-pass (cannot verify spec against absent data),
18
+ * but exposes the spec as a constraint for DB pushdown.
19
+ * filter : silent-pass — no projection happens; `output.data`
20
+ * stays undefined.
21
+ * includes : deny — membership cannot be checked without the resource.
22
+ * requireTruthy : deny — no value to test for truthiness.
23
+ * requireOwner : deny — no owner field to compare.
24
+ * requireMembership: deny — no membership list to scan.
25
+ * requireCustom : deny — predicate cannot run without `data`.
26
+ *
27
+ * Pure rules (no `needs`) like `matchPath` and `requireSelf` are unaffected:
28
+ * they read `ctx.target` / `ctx.subject` only.
29
+ */
30
+
31
+ import type { FetchCtx, Grant, Resource, Rule } from "./types.ts";
32
+ import { deepEqual, defineRule } from "./rules.ts";
33
+ import { evaluateSpec, validateSpec } from "./mongo-query.ts";
34
+
35
+ class ResourceImpl<T> implements Resource<T> {
36
+ // Spelled out rather than declared as constructor parameter properties:
37
+ // those are the one TypeScript-only construct Node's type stripping cannot
38
+ // erase, so they made this file unloadable as raw `.ts` there.
39
+ public readonly id: string;
40
+ public readonly fetcher: (ctx: FetchCtx) => T | Promise<T>;
41
+ public readonly activator?: (grant: Grant) => boolean;
42
+ public readonly dedupKey?: (ctx: FetchCtx) => string;
43
+
44
+ constructor(
45
+ id: string,
46
+ fetcher: (ctx: FetchCtx) => T | Promise<T>,
47
+ activator?: (grant: Grant) => boolean,
48
+ dedupKey?: (ctx: FetchCtx) => string,
49
+ ) {
50
+ this.id = id;
51
+ this.fetcher = fetcher;
52
+ this.activator = activator;
53
+ this.dedupKey = dedupKey;
54
+ }
55
+
56
+ isActiveFor(grant: Grant): boolean {
57
+ return this.activator ? this.activator(grant) : true;
58
+ }
59
+
60
+ computeDedupKey(ctx: FetchCtx): string {
61
+ if (this.dedupKey) return `${this.id}::${this.dedupKey(ctx)}`;
62
+ // Default safe : hash de tous les inputs (subject + target + grant.with).
63
+ // Jamais de fausse dedup, mais peut être sous-optimal — d'où l'incitation
64
+ // à fournir un dedupKey explicite quand on connaît les params pertinents.
65
+ return `${this.id}::${ctx.subject.id}::${JSON.stringify(
66
+ ctx.target,
67
+ )}::${JSON.stringify(ctx.grant.with ?? {})}`;
68
+ }
69
+
70
+ cacheKeyForTarget(target: readonly unknown[]): string {
71
+ // Build a synthetic FetchCtx (target-only). For `preseed()`, the
72
+ // caller knows the target shape but neither subject nor grant. We
73
+ // assume `dedupKey` only reads `target` (the recommended pattern)
74
+ // — if a resource's dedupKey reaches into subject/grant, `preseed`
75
+ // will produce a key that won't match runtime `computeDedupKey`,
76
+ // and the cache will silently miss. Document this limit.
77
+ const syntheticCtx: FetchCtx = {
78
+ subject: { id: "__preseed__" },
79
+ target,
80
+ grant: { key: "__preseed__" },
81
+ capability: false,
82
+ };
83
+ if (this.dedupKey) return `${this.id}::${this.dedupKey(syntheticCtx)}`;
84
+ return `${this.id}::${syntheticCtx.subject.id}::${JSON.stringify(
85
+ target,
86
+ )}::${JSON.stringify({})}`;
87
+ }
88
+
89
+ // ─── Méthodes de sucre — toutes produites via defineRule ─────────────
90
+
91
+ match(extractor?: (data: T) => unknown): Rule {
92
+ const id = this.id;
93
+ const project = extractor ?? ((d: T) => d);
94
+ return defineRule({
95
+ kind: "match",
96
+ needs: [this] as const,
97
+ check: ([data], _payload, ctx) => {
98
+ const spec = ctx.grant.with?.[id];
99
+ if (spec === undefined) return { ok: true };
100
+
101
+ if (typeof spec === "object" && spec !== null) {
102
+ validateSpec(spec);
103
+ }
104
+
105
+ // Cap-mode without fetched data: silent-pass + expose spec as
106
+ // constraint (legacy behaviour — pushdown to DB for resources
107
+ // that ARE the listed collection).
108
+ //
109
+ // When `data` is present in cap-mode it means the resource was
110
+ // fetched anyway (because `Resource.targetSegment` was concrete
111
+ // — see system.ts collection logic). In that case we evaluate
112
+ // the spec like in concrete mode and DON'T emit a constraint :
113
+ // the check is fully resolved and the (potentially inappropriate)
114
+ // constraint would pollute the listed collection's `find()`.
115
+ if (ctx.capability && data === undefined) {
116
+ if (
117
+ typeof spec === "object" &&
118
+ spec !== null &&
119
+ !Array.isArray(spec)
120
+ ) {
121
+ return { ok: true, constraint: spec as Record<string, unknown> };
122
+ }
123
+ return { ok: true };
124
+ }
125
+
126
+ const actual = project(data as T);
127
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
128
+ return deepEqual(spec, actual)
129
+ ? { ok: true }
130
+ : { ok: false, reason: `match[${id}] mismatch` };
131
+ }
132
+ const passes = evaluateSpec(spec as Record<string, unknown>, actual);
133
+ if (!passes) {
134
+ return { ok: false, reason: `match[${id}] mismatch` };
135
+ }
136
+ // Concrete mode (or cap-mode w/ fetched data): the rule resolved
137
+ // fully via `evaluateSpec`. Emitting `constraint: spec` here is
138
+ // safe ONLY when the resource id matches the listed collection
139
+ // — for foreign resources it would push a wrong filter. Conservative
140
+ // choice : emit only in legacy concrete mode (preserves existing
141
+ // listWithPermission semantics for self-referencing resources like
142
+ // `users.read` + `userOf.match`).
143
+ if (!ctx.capability) {
144
+ return { ok: true, constraint: spec as Record<string, unknown> };
145
+ }
146
+ return { ok: true };
147
+ },
148
+ });
149
+ }
150
+
151
+ filter(extractor?: (data: T) => unknown): Rule {
152
+ const project = extractor ?? ((d: T) => d);
153
+ return defineRule({
154
+ kind: "filter",
155
+ needs: [this] as const,
156
+ check: ([data], _payload, ctx) => {
157
+ if (ctx.capability) return { ok: true };
158
+ const sub = project(data as T);
159
+ const filter = ctx.grant.filter;
160
+ // No filter on this grant → expose `null` spec so the system can
161
+ // collapse the cross-grant union to "all fields".
162
+ if (
163
+ !filter ||
164
+ sub === null ||
165
+ typeof sub !== "object" ||
166
+ Array.isArray(sub)
167
+ ) {
168
+ return { ok: true, data: sub, filter: { source: sub, spec: null } };
169
+ }
170
+ const out: Record<string, unknown> = {};
171
+ for (const [k, v] of Object.entries(filter)) {
172
+ if (v === true && k in (sub as Record<string, unknown>)) {
173
+ out[k] = (sub as Record<string, unknown>)[k];
174
+ }
175
+ }
176
+ return {
177
+ ok: true,
178
+ data: out,
179
+ filter: { source: sub, spec: filter },
180
+ };
181
+ },
182
+ });
183
+ }
184
+
185
+ includes(
186
+ grantField: string,
187
+ extractor: (data: T) => readonly unknown[],
188
+ ): Rule {
189
+ return defineRule({
190
+ kind: "includes",
191
+ needs: [this] as const,
192
+ // Lazy : skip si pas de spec dans le grant.
193
+ activeWhen: (grant) => grant.with?.[grantField] !== undefined,
194
+ describe: () => ({ grantField }),
195
+ check: ([data], _payload, ctx) => {
196
+ // Capability : ressource non fetchée, on ne peut pas vérifier
197
+ // l'inclusion concrète → deny.
198
+ if (ctx.capability) {
199
+ return {
200
+ ok: false,
201
+ reason: `includes[${grantField}] cannot be verified in capability query`,
202
+ };
203
+ }
204
+ const required = ctx.grant.with![grantField];
205
+ const list = extractor(data as T);
206
+ if (Array.isArray(required)) {
207
+ return required.some((r) => list.includes(r))
208
+ ? { ok: true }
209
+ : {
210
+ ok: false,
211
+ reason: `includes[${grantField}] none of ${JSON.stringify(
212
+ required,
213
+ )} present`,
214
+ };
215
+ }
216
+ return list.includes(required)
217
+ ? { ok: true }
218
+ : {
219
+ ok: false,
220
+ reason: `includes[${grantField}] ${String(required)} absent`,
221
+ };
222
+ },
223
+ });
224
+ }
225
+
226
+ requireTruthy(): Rule {
227
+ const id = this.id;
228
+ return defineRule({
229
+ kind: "require-truthy",
230
+ needs: [this] as const,
231
+ check: ([data], _payload, ctx) => {
232
+ if (ctx.capability) {
233
+ return {
234
+ ok: false,
235
+ reason: `require-truthy[${id}] cannot be verified in capability query`,
236
+ };
237
+ }
238
+ return data
239
+ ? { ok: true }
240
+ : { ok: false, reason: `require-truthy[${id}] is falsy` };
241
+ },
242
+ });
243
+ }
244
+
245
+ requireOwner(
246
+ getter: (data: T) => string | undefined,
247
+ opts: { readonly flag: string },
248
+ ): Rule {
249
+ const id = this.id;
250
+ return defineRule({
251
+ kind: "require-owner",
252
+ needs: [this] as const,
253
+ flag: opts.flag,
254
+ check: ([data], _payload, ctx) => {
255
+ if (ctx.capability) {
256
+ return {
257
+ ok: false,
258
+ reason: `require-owner[${id}] cannot be verified in capability query`,
259
+ };
260
+ }
261
+ return getter(data as T) === ctx.subject.id
262
+ ? { ok: true }
263
+ : {
264
+ ok: false,
265
+ reason: `not owner of ${id} (flag: ${opts.flag})`,
266
+ };
267
+ },
268
+ });
269
+ }
270
+
271
+ requireMembership(
272
+ getter: (data: T) => readonly string[],
273
+ opts: { readonly flag: string },
274
+ ): Rule {
275
+ const id = this.id;
276
+ return defineRule({
277
+ kind: "require-membership",
278
+ needs: [this] as const,
279
+ flag: opts.flag,
280
+ check: ([data], _payload, ctx) => {
281
+ if (ctx.capability) {
282
+ return {
283
+ ok: false,
284
+ reason: `require-membership[${id}] cannot be verified in capability query`,
285
+ };
286
+ }
287
+ const list = getter(data as T);
288
+ return list.includes(ctx.subject.id)
289
+ ? { ok: true }
290
+ : {
291
+ ok: false,
292
+ reason: `subject not member of ${id} (flag: ${opts.flag})`,
293
+ };
294
+ },
295
+ });
296
+ }
297
+
298
+ requireCustom(
299
+ predicate: (data: T, ctx: FetchCtx) => boolean,
300
+ opts: {
301
+ readonly flag: string;
302
+ readonly descriptor?: Readonly<Record<string, unknown>>;
303
+ },
304
+ ): Rule {
305
+ const id = this.id;
306
+ return defineRule({
307
+ kind: "require-custom",
308
+ needs: [this] as const,
309
+ flag: opts.flag,
310
+ describe: opts.descriptor ? () => opts.descriptor! : undefined,
311
+ check: ([data], _payload, ctx) => {
312
+ if (ctx.capability) {
313
+ return {
314
+ ok: false,
315
+ reason: `require-custom[${id}] cannot be verified in capability query`,
316
+ };
317
+ }
318
+ return predicate(data as T, ctx)
319
+ ? { ok: true }
320
+ : {
321
+ ok: false,
322
+ reason: `require-custom[${id}] denied (flag: ${opts.flag})`,
323
+ };
324
+ },
325
+ });
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Crée une Resource. À utiliser une fois par entité de domaine, partagée
331
+ * entre toutes les permissions qui la consomment.
332
+ *
333
+ * @example
334
+ * const userOf = resource({
335
+ * id: "user",
336
+ * fetch: ({ target }) => usersRepo.findById(target[0] as string),
337
+ * dedupKey: ({ target }) => target[0] as string,
338
+ * });
339
+ */
340
+ export function resource<T>(opts: {
341
+ readonly id: string;
342
+ readonly fetch: (ctx: FetchCtx) => T | Promise<T>;
343
+ readonly activeWhen?: (grant: Grant) => boolean;
344
+ readonly dedupKey?: (ctx: FetchCtx) => string;
345
+ }): Resource<T> {
346
+ return new ResourceImpl<T>(
347
+ opts.id,
348
+ opts.fetch,
349
+ opts.activeWhen,
350
+ opts.dedupKey,
351
+ );
352
+ }
package/rules.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * `defineRule` — primitive unique de fabrication de rule.
3
+ *
4
+ * Toutes les rules de la lib (match, filter, includes, requireXxx) sont du
5
+ * sucre par-dessus cette fonction. Les devs en aval l'utilisent aussi pour
6
+ * écrire leurs rules métier réutilisables (cf. RFC §"Le rôle des devs").
7
+ */
8
+
9
+ import type {
10
+ FetchCtx,
11
+ Grant,
12
+ Resource,
13
+ ResourcesData,
14
+ Rule,
15
+ RuleResult,
16
+ } from "./types.ts";
17
+ import { evaluateSpec, validateSpec } from "./mongo-query.ts";
18
+
19
+ export type DefineRuleOpts<RS extends readonly Resource<unknown>[], P> = {
20
+ /** Identifiant de famille de rule (matrix UI). */
21
+ readonly kind: string;
22
+ /**
23
+ * Resources à fetcher avant le check. L'ordre détermine l'ordre du tuple
24
+ * passé à `check`. Tableau vide = rule pure (ex: requireSelf).
25
+ */
26
+ readonly needs?: RS;
27
+ /**
28
+ * Sucre opt-in : la rule est active ssi `grant.flags?.[flag] === true`.
29
+ * Mutuellement exclusif avec `activeWhen`.
30
+ */
31
+ readonly flag?: string;
32
+ /**
33
+ * Escape hatch d'activation. Si retourne false, la rule est skip et ses
34
+ * needs ne sont pas fetchées.
35
+ */
36
+ readonly activeWhen?: (grant: Grant) => boolean;
37
+ /**
38
+ * Métadonnées additionnelles fusionnées dans le `descriptor` (matrix UI).
39
+ * Ne doit pas inclure les champs gérés automatiquement (kind/source/sources/flag).
40
+ */
41
+ readonly describe?: () => Readonly<Record<string, unknown>>;
42
+ /**
43
+ * Logique d'évaluation. `data` est un tuple aligné sur `needs`. `payload`
44
+ * est un alias de `ctx.grant.payload` casté au type générique P.
45
+ * Retourne soit un boolean (true = ok, false = deny avec raison auto),
46
+ * soit un RuleResult complet pour propager `data` ou un message custom.
47
+ */
48
+ readonly check: (
49
+ data: ResourcesData<RS>,
50
+ payload: P,
51
+ ctx: FetchCtx,
52
+ ) => boolean | RuleResult;
53
+ };
54
+
55
+ /**
56
+ * Fabrique une rule à partir d'une définition déclarative.
57
+ */
58
+ export function defineRule<
59
+ const RS extends readonly Resource<unknown>[],
60
+ P = unknown,
61
+ >(opts: DefineRuleOpts<RS, P>): Rule {
62
+ if (opts.flag !== undefined && opts.activeWhen !== undefined) {
63
+ throw new Error(
64
+ `defineRule(${opts.kind}): cannot specify both 'flag' and 'activeWhen'`,
65
+ );
66
+ }
67
+
68
+ const needs = (opts.needs ?? []) as readonly Resource<unknown>[];
69
+ const flag = opts.flag;
70
+ const activeWhen =
71
+ flag !== undefined
72
+ ? (grant: Grant) => grant.flags?.[flag] === true
73
+ : opts.activeWhen;
74
+
75
+ const descriptor: Record<string, unknown> = { kind: opts.kind };
76
+ if (needs.length === 1) descriptor.source = needs[0].id;
77
+ if (needs.length > 1) descriptor.sources = needs.map((n) => n.id);
78
+ if (flag !== undefined) descriptor.flag = flag;
79
+ if (opts.describe) {
80
+ const extra = opts.describe();
81
+ for (const [k, v] of Object.entries(extra)) {
82
+ // Les champs auto ne peuvent pas être écrasés.
83
+ if (k === "kind" || k === "source" || k === "sources" || k === "flag") {
84
+ continue;
85
+ }
86
+ descriptor[k] = v;
87
+ }
88
+ }
89
+
90
+ return {
91
+ descriptor: descriptor as Rule["descriptor"],
92
+ needs,
93
+ activeWhen,
94
+ check: (data, ctx) => {
95
+ const payload = ctx.grant.payload as P;
96
+ const result = opts.check(data as ResourcesData<RS>, payload, ctx);
97
+ if (typeof result === "boolean") {
98
+ return result
99
+ ? { ok: true }
100
+ : { ok: false, reason: `${opts.kind} denied` };
101
+ }
102
+ return result;
103
+ },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Rule pure (pas de needs) : compare un segment du target à `subject.id`.
109
+ * Opt-in via flag.
110
+ *
111
+ * @example
112
+ * "users.update": permission({...}).rules([
113
+ * userOf.match(),
114
+ * requireSelf({ flag: "selfOnly" }),
115
+ * ]),
116
+ *
117
+ * // Grant d'admin (no flag) → silent pass
118
+ * { key: "users.update", target: ["user:*"] }
119
+ * // Grant self-only (flag activé) → check target[0] === subject.id
120
+ * { key: "users.update", target: ["user:*"], flags: { selfOnly: true } }
121
+ */
122
+ /**
123
+ * Translates a grant's `target[segment]` to a Mongo `{[field]: value}`
124
+ * constraint, exposed via the rule's `constraint` for cross-grant OR
125
+ * aggregation and DB pushdown. Wildcard segments (`*` or `xxx:*`) emit
126
+ * no constraint (= no restriction).
127
+ *
128
+ * Common case for resources stored in MongoDB by `_id`:
129
+ *
130
+ * matchPath() // {_id: target[0]}
131
+ * matchPath({ field: "userId" }) // {userId: target[0]}
132
+ * matchPath({ segment: 1 }) // {_id: target[1]} (e.g., target.path)
133
+ */
134
+ export function matchPath(
135
+ opts: { readonly field?: string; readonly segment?: number } = {},
136
+ ): Rule {
137
+ const field = opts.field ?? "_id";
138
+ const segment = opts.segment ?? 0;
139
+ return defineRule({
140
+ kind: "match-path",
141
+ needs: [] as const,
142
+ describe: () => ({ field, segment }),
143
+ check: (_data, _payload, ctx) => {
144
+ const target = ctx.grant.target;
145
+ if (!target || !Array.isArray(target)) return { ok: true };
146
+ const value = target[segment];
147
+ if (typeof value !== "string") return { ok: true };
148
+ if (value === "*" || value.endsWith("*")) return { ok: true };
149
+ return { ok: true, constraint: { [field]: value } };
150
+ },
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Validates `ctx.input` against `grant.inputWith` (Mongo spec). Distinct
156
+ * slot from `grant.with` so payload-shaped specs don't collide with
157
+ * resource-shaped specs in the same grant.
158
+ *
159
+ * Per-grant:
160
+ * - no `inputWith` → ok (unconditional grant)
161
+ * - `inputWith` + input → `evaluateSpec(inputWith, input)`
162
+ * - `inputWith` + no input → deny (cannot verify)
163
+ *
164
+ * Cross-grant OR aggregation: a single unconditional grant lets every check
165
+ * through (broadest wins); a UI capability check with no input only passes
166
+ * if at least one unconditional grant exists.
167
+ *
168
+ * @example
169
+ * "invitations.create": permission({
170
+ * target: target.path("exposition", "expo_organization"),
171
+ * }).rules([inputMatch()])
172
+ *
173
+ * { key: "invitations.create",
174
+ * target: [expoId, orgId],
175
+ * inputWith: { flowId: "flow:abc" } }
176
+ *
177
+ * await ctx.can("invitations.create", [expoId, orgId], { input: body })
178
+ */
179
+ export function inputMatch(): Rule {
180
+ return defineRule({
181
+ kind: "input-match",
182
+ needs: [] as const,
183
+ check: (_data, _payload, ctx) => {
184
+ const spec = ctx.grant.inputWith;
185
+ if (spec === undefined || Object.keys(spec).length === 0) {
186
+ return { ok: true };
187
+ }
188
+ if (ctx.input === undefined) {
189
+ return { ok: false, reason: "input-match: input required" };
190
+ }
191
+ validateSpec(spec);
192
+ return evaluateSpec(spec as Record<string, unknown>, ctx.input)
193
+ ? { ok: true }
194
+ : { ok: false, reason: "input-match: mismatch" };
195
+ },
196
+ });
197
+ }
198
+
199
+ export function requireSelf(opts: {
200
+ readonly flag: string;
201
+ readonly segment?: number;
202
+ }): Rule {
203
+ const segment = opts.segment ?? 0;
204
+ return defineRule({
205
+ kind: "require-self",
206
+ needs: [] as const,
207
+ flag: opts.flag,
208
+ describe: () => ({ segment }),
209
+ check: (_data, _payload, ctx) =>
210
+ ctx.target[segment] === ctx.subject.id
211
+ ? { ok: true }
212
+ : {
213
+ ok: false,
214
+ reason: `target[${segment}] is not self (flag: ${opts.flag})`,
215
+ },
216
+ });
217
+ }
218
+
219
+ // ─────────────────────────────────────────────────────────────────────────
220
+ // Helpers internes (utilisés par les méthodes resource dans resource.ts)
221
+ // ─────────────────────────────────────────────────────────────────────────
222
+
223
+ export function deepEqual(a: unknown, b: unknown): boolean {
224
+ if (a === b) return true;
225
+ if (typeof a !== typeof b) return false;
226
+ if (a === null || b === null) return false;
227
+ if (typeof a !== "object") return false;
228
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
229
+ if (Array.isArray(a) && Array.isArray(b)) {
230
+ return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
231
+ }
232
+ const aKeys = Object.keys(a as object);
233
+ const bKeys = Object.keys(b as object);
234
+ if (aKeys.length !== bKeys.length) return false;
235
+ return aKeys.every((k) =>
236
+ deepEqual(
237
+ (a as Record<string, unknown>)[k],
238
+ (b as Record<string, unknown>)[k],
239
+ ),
240
+ );
241
+ }
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2015 Craig Condon
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.