@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/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.
|