@aooth/arbac-moost 0.1.1
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 +38 -0
- package/dist/atscript/index.d.mts +80 -0
- package/dist/atscript/index.mjs +229 -0
- package/dist/index.d.mts +334 -0
- package/dist/index.mjs +470 -0
- package/dist/plugin.d.mts +28 -0
- package/dist/plugin.mjs +50 -0
- package/dist/user-CFXfHtsb.mjs +15 -0
- package/dist/user.provider-CGWueNjx.mjs +31 -0
- package/dist/user.provider-JpI5dD6Z.d.mts +42 -0
- package/package.json +101 -0
- package/src/atscript/models/user.as +10 -0
- package/src/atscript/models/user.as.d.ts +28 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { n as ArbacUserProviderToken, r as __decorate, t as ArbacUserProvider } from "./user.provider-CGWueNjx.mjs";
|
|
2
|
+
import { Arbac, Arbac as Arbac$1, arbacPatternToRegex } from "@aooth/arbac-core";
|
|
3
|
+
import { Authenticate, HttpError } from "@moostjs/event-http";
|
|
4
|
+
import { current, key } from "@wooksjs/event-core";
|
|
5
|
+
import { Inherit, Injectable, TInterceptorPriority, defineBeforeInterceptor, getConstructor, getInstanceOwnMethods, getMoostMate, useControllerContext, useLogger } from "moost";
|
|
6
|
+
import { mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections } from "@aooth/arbac";
|
|
7
|
+
import { AsDbController, AsDbReadableController } from "@atscript/moost-db";
|
|
8
|
+
//#region src/moost-arbac.ts
|
|
9
|
+
let MoostArbac = class MoostArbac extends Arbac$1 {};
|
|
10
|
+
MoostArbac = __decorate([Injectable()], MoostArbac);
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/arbac.composables.ts
|
|
13
|
+
/**
|
|
14
|
+
* Writable slot holding the evaluated scopes for the current event.
|
|
15
|
+
*
|
|
16
|
+
* Module-level `key()` per the `@wooksjs/event-core` slot system. The
|
|
17
|
+
* authorize interceptor calls `setScopes(...)` after a successful evaluate;
|
|
18
|
+
* downstream `@ArbacScopes()` resolvers read it back from the same event context.
|
|
19
|
+
*/
|
|
20
|
+
const arbacScopesKey = key("arbac.scopes");
|
|
21
|
+
/**
|
|
22
|
+
* Composable for ARBAC utilities within Moost handlers and interceptors.
|
|
23
|
+
*
|
|
24
|
+
* Exposes scope read/write, lazy `evaluate`, and the resolved
|
|
25
|
+
* resource/action/public flags derived from the current controller +
|
|
26
|
+
* method metadata.
|
|
27
|
+
*
|
|
28
|
+
* Resource/action/isPublic are recomputed on every call because WF events
|
|
29
|
+
* dispatch multiple step handlers under one `EventContext` — each step
|
|
30
|
+
* mutates the controller-context method via `setControllerContext`, so any
|
|
31
|
+
* per-ctx memo would lock in the first dispatched method's metadata (e.g.
|
|
32
|
+
* the `@Public()` WF_FLOW body) and incorrectly bypass ARBAC on the gated
|
|
33
|
+
* step handlers. Scope read/write goes through `arbacScopesKey` directly
|
|
34
|
+
* so it still lives on the per-event slot.
|
|
35
|
+
*/
|
|
36
|
+
const useArbac = (_ctx) => {
|
|
37
|
+
const ctx = _ctx ?? current();
|
|
38
|
+
const cc = useControllerContext(ctx);
|
|
39
|
+
const getScopes = () => ctx.has(arbacScopesKey) ? ctx.get(arbacScopesKey) : void 0;
|
|
40
|
+
const setScopes = (scope) => {
|
|
41
|
+
ctx.set(arbacScopesKey, scope);
|
|
42
|
+
};
|
|
43
|
+
const cMeta = cc.getControllerMeta();
|
|
44
|
+
const mMeta = cc.getMethodMeta();
|
|
45
|
+
const resource = mMeta?.arbacResourceId || cMeta?.arbacResourceId || cMeta?.id || getConstructor(cc.getController()).name;
|
|
46
|
+
const action = mMeta?.arbacActionId || mMeta?.atscript_db_action?.name || cMeta?.arbacActionId || mMeta?.id || (cc.getMethod() ?? "");
|
|
47
|
+
const isPublic = mMeta?.arbacPublic || cMeta?.arbacPublic || false;
|
|
48
|
+
const evaluate = async (opts) => {
|
|
49
|
+
const effectiveResource = opts?.resource || resource;
|
|
50
|
+
const effectiveAction = opts?.action || action;
|
|
51
|
+
if (!effectiveResource) throw new Error("useArbac().evaluate(): `resource` is required — could not be resolved from controller/method metadata. Pass it explicitly.");
|
|
52
|
+
if (!effectiveAction) throw new Error("useArbac().evaluate(): `action` is required — could not be resolved from controller/method metadata. Pass it explicitly.");
|
|
53
|
+
const [user, arbac] = await Promise.all([cc.instantiate(ArbacUserProviderToken), cc.instantiate(MoostArbac)]);
|
|
54
|
+
const userId = await user.getUserId();
|
|
55
|
+
return {
|
|
56
|
+
...await arbac.evaluate({
|
|
57
|
+
resource: effectiveResource,
|
|
58
|
+
action: effectiveAction
|
|
59
|
+
}, {
|
|
60
|
+
id: userId,
|
|
61
|
+
roles: await user.getRoles(userId),
|
|
62
|
+
attrs: (id) => user.getAttrs(id)
|
|
63
|
+
}),
|
|
64
|
+
userId
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
const evaluateOrThrow = async (opts) => {
|
|
68
|
+
const result = await evaluate(opts);
|
|
69
|
+
if (!result.allowed) throw new HttpError(403, `Forbidden: ${opts?.resource || resource}/${opts?.action || action}`);
|
|
70
|
+
return {
|
|
71
|
+
...result,
|
|
72
|
+
allowed: true
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
getScopes,
|
|
77
|
+
setScopes,
|
|
78
|
+
evaluate,
|
|
79
|
+
evaluateOrThrow,
|
|
80
|
+
resource,
|
|
81
|
+
action,
|
|
82
|
+
isPublic
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/arbac.mate.ts
|
|
87
|
+
/**
|
|
88
|
+
* Returns the shared `Mate` instance typed with ARBAC metadata fields.
|
|
89
|
+
*
|
|
90
|
+
* All ARBAC decorators write through this typed wrapper so reads and writes
|
|
91
|
+
* stay type-checked against `TArbacMeta`.
|
|
92
|
+
*/
|
|
93
|
+
function getArbacMate() {
|
|
94
|
+
return getMoostMate();
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/arbac.decorator.ts
|
|
98
|
+
/**
|
|
99
|
+
* ARBAC checks authorization, not authentication, so the transport
|
|
100
|
+
* declaration is empty — pair with an upstream auth guard (e.g. JWT
|
|
101
|
+
* bearer) that publishes the principal.
|
|
102
|
+
*
|
|
103
|
+
* Runs for every event kind (HTTP, WF, CLI, WS). The `arbacPublic` mate
|
|
104
|
+
* flag (written by auth-moost's combined `@Public()`) is the only bypass —
|
|
105
|
+
* apply it to controllers/handlers that should remain reachable without an
|
|
106
|
+
* evaluated rule (e.g. login/recovery workflows, health probes).
|
|
107
|
+
*/
|
|
108
|
+
const arbacAuthorizeInterceptor = Object.assign(defineBeforeInterceptor(async () => {
|
|
109
|
+
const ctx = current();
|
|
110
|
+
const { setScopes, evaluate, resource, action, isPublic } = useArbac(ctx);
|
|
111
|
+
if (!action || !resource || isPublic) return;
|
|
112
|
+
const logger = useLogger("arbac", ctx);
|
|
113
|
+
try {
|
|
114
|
+
const { allowed, scopes, userId } = await evaluate();
|
|
115
|
+
logger.debug(`[${userId}] ${allowed ? "Authorized" : "Blocked"} "${resource}" : "${action}"`);
|
|
116
|
+
if (!allowed) throw new HttpError(403, `Insufficient privileges for action "${action}" on resource "${resource}"`);
|
|
117
|
+
setScopes(scopes);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof HttpError) throw error;
|
|
120
|
+
logger.warn(String(error));
|
|
121
|
+
throw new HttpError(401, error instanceof Error ? error.message : String(error));
|
|
122
|
+
}
|
|
123
|
+
}, TInterceptorPriority.GUARD), { __authTransports: {} });
|
|
124
|
+
/** Wrapped via `Authenticate` so `@moostjs/swagger` picks up the auth-guard metadata. */
|
|
125
|
+
const ArbacAuthorize = () => Authenticate(arbacAuthorizeInterceptor);
|
|
126
|
+
/**
|
|
127
|
+
* Decorator to specify a resource id for ARBAC evaluation. Apply to a
|
|
128
|
+
* controller class or a handler method.
|
|
129
|
+
*/
|
|
130
|
+
const ArbacResource = (name) => getArbacMate().decorate("arbacResourceId", name);
|
|
131
|
+
/**
|
|
132
|
+
* Decorator to specify an action id for ARBAC evaluation. Typically applied
|
|
133
|
+
* at the method level.
|
|
134
|
+
*/
|
|
135
|
+
const ArbacAction = (name) => getArbacMate().decorate("arbacActionId", name);
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/db/shared-read-helpers.ts
|
|
138
|
+
/** Filter that matches no rows; used when ARBAC denies the request. */
|
|
139
|
+
const DENY_FILTER = { $or: [] };
|
|
140
|
+
/** Single point of access to the per-event scope cache. */
|
|
141
|
+
function readCachedScopes() {
|
|
142
|
+
return useArbac().getScopes() ?? [];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Full body of `transformFilter` for both ARBAC DB controllers: evaluate
|
|
146
|
+
* ARBAC once, cache the scopes for the per-event hooks that follow
|
|
147
|
+
* (`transformProjection`, `validateControls`), and merge the user filter
|
|
148
|
+
* with the union of scope filters.
|
|
149
|
+
*
|
|
150
|
+
* Wraps the merge with `$and` (not object spread) so a top-level
|
|
151
|
+
* `$or`/`$and`/`$not` in `filter` cannot drop the scope's sibling field
|
|
152
|
+
* keys when @uniqu/core's `walkFilter` short-circuits on logical
|
|
153
|
+
* operators (see BUG-2). Returns a match-nothing filter on denial so the
|
|
154
|
+
* downstream pipeline returns an empty result set without leaking rows.
|
|
155
|
+
*/
|
|
156
|
+
async function transformArbacFilter(filter) {
|
|
157
|
+
const arbac = useArbac();
|
|
158
|
+
const { allowed, scopes } = await arbac.evaluate();
|
|
159
|
+
if (!allowed) return DENY_FILTER;
|
|
160
|
+
arbac.setScopes(scopes);
|
|
161
|
+
const scopeList = scopes ?? [];
|
|
162
|
+
const merged = scopeList.length > 0 ? mergeScopeFilters(scopeList.map((s) => s.filter ?? {})) : void 0;
|
|
163
|
+
const userFilter = filter && Object.keys(filter).length > 0 ? filter : void 0;
|
|
164
|
+
if (!merged) return userFilter ?? {};
|
|
165
|
+
if (!userFilter) return merged;
|
|
166
|
+
return { $and: [merged, userFilter] };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Union the per-scope `projection` whitelists and restrict the user-supplied
|
|
170
|
+
* projection to that union. Returns the original projection untouched when
|
|
171
|
+
* no scope declares a projection (so caller sees the unrestricted shape).
|
|
172
|
+
*/
|
|
173
|
+
function applyArbacProjection(projection, scopes) {
|
|
174
|
+
if (scopes.length === 0) return projection;
|
|
175
|
+
const allowed = unionProjections(...scopes.map((s) => s.projection ?? {}));
|
|
176
|
+
if (Object.keys(allowed).length === 0) return projection;
|
|
177
|
+
return restrictProjection(projection ?? {}, allowed);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Enforce the union of per-scope `controls` gates against the parsed
|
|
181
|
+
* Uniquery controls. Throws `HttpError(403)` on the first violation.
|
|
182
|
+
*/
|
|
183
|
+
function applyArbacControls(controls, scopes) {
|
|
184
|
+
if (scopes.length === 0) return;
|
|
185
|
+
enforceControlsPolicy(unionControlsPolicy(scopes), controls);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Walk the user-supplied `$with` items in `controls` and inject per-relation
|
|
189
|
+
* filter/projection/controls/nested-$with from the role scopes' `with` field.
|
|
190
|
+
*
|
|
191
|
+
* Mutates `controls` in place (matches the `validateControls` contract).
|
|
192
|
+
* Throws `HttpError(403)` if a per-relation control policy is violated
|
|
193
|
+
* (delegates to `enforceControlsPolicy` like top-level `applyArbacControls`).
|
|
194
|
+
*
|
|
195
|
+
* Silence wins: if no role declares `with.<name>` for a relation, that entry
|
|
196
|
+
* passes through unchanged. The outermost check skips the entire walk when no
|
|
197
|
+
* scope declares `with` at all — this is the common case on every read.
|
|
198
|
+
*/
|
|
199
|
+
function applyArbacRelationScopes(controls, scopes) {
|
|
200
|
+
if (scopes.length === 0) return;
|
|
201
|
+
const withArr = controls.$with;
|
|
202
|
+
if (!Array.isArray(withArr) || withArr.length === 0) return;
|
|
203
|
+
if (!scopes.some((s) => s.with)) return;
|
|
204
|
+
for (const raw of withArr) {
|
|
205
|
+
if (!raw || typeof raw !== "object") continue;
|
|
206
|
+
const entry = raw;
|
|
207
|
+
if (typeof entry.name !== "string") continue;
|
|
208
|
+
const subScopes = collectSubScopes(scopes, entry.name);
|
|
209
|
+
if (subScopes.length === 0) continue;
|
|
210
|
+
const subFilter = mergeScopeFilters(subScopes.map((s) => s.filter ?? {}));
|
|
211
|
+
if (subFilter) {
|
|
212
|
+
const userFilter = entry.filter && Object.keys(entry.filter).length > 0 ? entry.filter : void 0;
|
|
213
|
+
entry.filter = userFilter ? { $and: [subFilter, userFilter] } : subFilter;
|
|
214
|
+
}
|
|
215
|
+
const entryControls = entry.controls ?? {};
|
|
216
|
+
const restricted = applyArbacProjection(normalizeSelect(entryControls.$select), subScopes);
|
|
217
|
+
if (restricted !== void 0) {
|
|
218
|
+
entryControls.$select = restricted;
|
|
219
|
+
entry.controls = entryControls;
|
|
220
|
+
}
|
|
221
|
+
enforceControlsPolicy(unionControlsPolicy(subScopes), entryControls);
|
|
222
|
+
applyArbacRelationScopes(entryControls, subScopes);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Pick `scopes[i].with?.[name]`, dropping undefined. */
|
|
226
|
+
function collectSubScopes(scopes, name) {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const s of scopes) {
|
|
229
|
+
const sub = s.with?.[name];
|
|
230
|
+
if (sub) out.push(sub);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* `$select` may be an array of field names (uniquery inclusion list) or a
|
|
236
|
+
* `TProjection` object. `applyArbacProjection` / `restrictProjection` operate
|
|
237
|
+
* on the object form, so normalize the array to `{ field: 1 }` first. Returns
|
|
238
|
+
* undefined if the input is empty (caller treats as "no projection set").
|
|
239
|
+
*/
|
|
240
|
+
function normalizeSelect(value) {
|
|
241
|
+
if (value === void 0 || value === null) return void 0;
|
|
242
|
+
if (Array.isArray(value)) {
|
|
243
|
+
const out = {};
|
|
244
|
+
for (const item of value) if (typeof item === "string") out[item] = 1;
|
|
245
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
246
|
+
}
|
|
247
|
+
if (typeof value === "object") return value;
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/db/as-arbac-db-controller.ts
|
|
251
|
+
let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
252
|
+
transformFilter(filter) {
|
|
253
|
+
return transformArbacFilter(filter);
|
|
254
|
+
}
|
|
255
|
+
transformProjection(projection) {
|
|
256
|
+
return applyArbacProjection(projection, readCachedScopes());
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Enforce per-role `ArbacDbScope.controls` gates against the parsed Uniquery
|
|
260
|
+
* controls of a request. Runs after the base validator (which checks the
|
|
261
|
+
* controls DTO shape) and BEFORE the query/aggregation pipeline executes.
|
|
262
|
+
*
|
|
263
|
+
* `transformFilter` runs first on every read endpoint and caches the
|
|
264
|
+
* evaluated scopes via `arbac.setScopes(...)`, so we read those scopes
|
|
265
|
+
* directly here without re-evaluating ARBAC.
|
|
266
|
+
*
|
|
267
|
+
* On a violation we throw `HttpError(403)`. moost-db's `query` / `pages` /
|
|
268
|
+
* `getOne` handlers do NOT wrap `validateParsed` in try/catch, so the
|
|
269
|
+
* thrown error bubbles to moost which translates it to a 403 response.
|
|
270
|
+
*/
|
|
271
|
+
validateControls(controls, type) {
|
|
272
|
+
const baseErr = super.validateControls(controls, type);
|
|
273
|
+
if (baseErr) return baseErr;
|
|
274
|
+
const scopes = readCachedScopes();
|
|
275
|
+
applyArbacControls(controls, scopes);
|
|
276
|
+
applyArbacRelationScopes(controls, scopes);
|
|
277
|
+
}
|
|
278
|
+
async applyMetaOverlay(meta) {
|
|
279
|
+
const arbac = useArbac();
|
|
280
|
+
const actionToMethodMeta = collectActionMetaByName();
|
|
281
|
+
const crudKeys = Object.keys(meta.crud);
|
|
282
|
+
const [actionResults, crudResults] = await Promise.all([Promise.all(meta.actions.map((entry) => {
|
|
283
|
+
const methodMeta = actionToMethodMeta.get(entry.name);
|
|
284
|
+
const arbacAction = methodMeta?.arbacActionId ?? methodMeta?.id ?? entry.name;
|
|
285
|
+
return arbac.evaluate({ action: arbacAction });
|
|
286
|
+
})), Promise.all(crudKeys.map((key) => arbac.evaluate({ action: key })))]);
|
|
287
|
+
const filteredActions = [];
|
|
288
|
+
for (let i = 0; i < meta.actions.length; i++) if (actionResults[i].allowed) filteredActions.push(meta.actions[i]);
|
|
289
|
+
const filteredCrud = {};
|
|
290
|
+
for (let i = 0; i < crudKeys.length; i++) if (crudResults[i].allowed) filteredCrud[crudKeys[i]] = meta.crud[crudKeys[i]];
|
|
291
|
+
return {
|
|
292
|
+
...meta,
|
|
293
|
+
actions: filteredActions,
|
|
294
|
+
crud: filteredCrud
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
async onWrite(action, data) {
|
|
298
|
+
const scopes = readCachedScopes();
|
|
299
|
+
if (action !== "insert" && action !== "insertMany") await this.assertInScope(data, scopes);
|
|
300
|
+
return applyAllowedFieldsAndSet(data, scopes, this.identifierFields());
|
|
301
|
+
}
|
|
302
|
+
async onRemove(id) {
|
|
303
|
+
await this.assertInScope(id, readCachedScopes());
|
|
304
|
+
return id;
|
|
305
|
+
}
|
|
306
|
+
async assertInScope(idOrIds, scopes) {
|
|
307
|
+
const scopeFilter = mergeScopeFilters(scopes.map((s) => s.filter ?? {}));
|
|
308
|
+
if (!scopeFilter) return;
|
|
309
|
+
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
|
310
|
+
const idFilters = ids.map((id) => this.table.resolveIdFilter(id));
|
|
311
|
+
if (idFilters.some((f) => !f)) throw new HttpError(404, "Not found");
|
|
312
|
+
const idFilter = idFilters.length === 1 ? idFilters[0] : { $or: idFilters };
|
|
313
|
+
if (await this.table.count({ filter: { $and: [idFilter, scopeFilter] } }) < ids.length) throw new HttpError(404, "Not found");
|
|
314
|
+
}
|
|
315
|
+
identifierFields() {
|
|
316
|
+
const ctor = this.constructor;
|
|
317
|
+
const cached = identifierFieldsCache.get(ctor);
|
|
318
|
+
if (cached) return cached;
|
|
319
|
+
const out = /* @__PURE__ */ new Set();
|
|
320
|
+
for (const ident of this.table.identifications) for (const f of ident.fields) out.add(f);
|
|
321
|
+
const arr = [...out];
|
|
322
|
+
identifierFieldsCache.set(ctor, arr);
|
|
323
|
+
return arr;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
AsArbacDbController = __decorate([Inherit()], AsArbacDbController);
|
|
327
|
+
const identifierFieldsCache = /* @__PURE__ */ new WeakMap();
|
|
328
|
+
const actionMetaByClassCache = /* @__PURE__ */ new WeakMap();
|
|
329
|
+
function collectActionMetaByName() {
|
|
330
|
+
const cc = useControllerContext();
|
|
331
|
+
const instance = cc.getController();
|
|
332
|
+
const ctor = getConstructor(instance);
|
|
333
|
+
const cached = actionMetaByClassCache.get(ctor);
|
|
334
|
+
if (cached) return cached;
|
|
335
|
+
const map = /* @__PURE__ */ new Map();
|
|
336
|
+
const ctrlMeta = cc.getControllerMeta();
|
|
337
|
+
for (const entry of ctrlMeta?.atscript_db_actions ?? []) map.set(entry.name, {});
|
|
338
|
+
for (const methodName of getInstanceOwnMethods(instance)) {
|
|
339
|
+
if (typeof methodName !== "string") continue;
|
|
340
|
+
const m = cc.getMethodMeta(methodName);
|
|
341
|
+
if (!m) continue;
|
|
342
|
+
const actionMeta = m.atscript_db_action;
|
|
343
|
+
if (actionMeta?.name) map.set(actionMeta.name, {
|
|
344
|
+
arbacActionId: m.arbacActionId,
|
|
345
|
+
id: m.id
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
actionMetaByClassCache.set(ctor, map);
|
|
349
|
+
return map;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Test-friendly internal helper — exported for unit tests and helper
|
|
353
|
+
* composition; regular consumers should not call this directly.
|
|
354
|
+
*
|
|
355
|
+
* Enforce a per-control policy against a parsed Uniquery `controls` map.
|
|
356
|
+
*
|
|
357
|
+
* Throws `HttpError(403)` on the first violation. Pure (no DI) so it is
|
|
358
|
+
* trivially unit-testable; `validateControls` wires it up to the controller's
|
|
359
|
+
* cached scopes.
|
|
360
|
+
*
|
|
361
|
+
* Semantics per gate (see {@link ControlGate}):
|
|
362
|
+
* - `true` (or absent — dropped by `unionControlsPolicy`): allow.
|
|
363
|
+
* - `false`: deny if the control is used at all.
|
|
364
|
+
* - `readonly string[]`: allow only the listed values; reject any other.
|
|
365
|
+
*
|
|
366
|
+
* "Used" means the control key is present AND non-empty (an empty array is
|
|
367
|
+
* treated as not used, matching how the parser leaves missing controls).
|
|
368
|
+
*/
|
|
369
|
+
function enforceControlsPolicy(policy, controls) {
|
|
370
|
+
if (Object.keys(policy).length === 0) return;
|
|
371
|
+
for (const [key, gate] of Object.entries(policy)) {
|
|
372
|
+
const used = controls[key];
|
|
373
|
+
if (used === void 0 || used === null) continue;
|
|
374
|
+
if (Array.isArray(used) && used.length === 0) continue;
|
|
375
|
+
if (gate === false) throw new HttpError(403, `Control "${key}" is not allowed for your role`);
|
|
376
|
+
if (Array.isArray(gate)) {
|
|
377
|
+
const usedValues = extractUsedControlValues(key, used);
|
|
378
|
+
for (const v of usedValues) if (!gate.includes(v)) throw new HttpError(403, `Control "${key}=${v}" is not allowed for your role`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Test-friendly internal helper — exported for unit tests and helper
|
|
384
|
+
* composition; regular consumers should not call this directly.
|
|
385
|
+
*
|
|
386
|
+
* Extract the set of "named values" from a Uniquery control payload, for
|
|
387
|
+
* use against a whitelist gate.
|
|
388
|
+
*
|
|
389
|
+
* Currently supported (matches `WHITELISTABLE_CONTROLS` in `unionControlsPolicy`):
|
|
390
|
+
* - `$with` — array of `{ name, … }` objects (per `TypedWithRelation`,
|
|
391
|
+
* see `@uniqu/core` parser at `parseWithSegment`); we extract `name`.
|
|
392
|
+
* Bare strings are tolerated for forward compatibility.
|
|
393
|
+
* - `$groupBy` — array of column names (strings). Returned as-is.
|
|
394
|
+
*
|
|
395
|
+
* For unknown controls we return an empty array; the caller then enforces
|
|
396
|
+
* `false`-only semantics (controlled by `unionControlsPolicy`'s whitelist
|
|
397
|
+
* gate, which throws if a non-whitelistable control receives a string[]).
|
|
398
|
+
*/
|
|
399
|
+
function extractUsedControlValues(key, value) {
|
|
400
|
+
if (!Array.isArray(value)) return [];
|
|
401
|
+
if (key === "$with") {
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const entry of value) if (typeof entry === "string") out.push(entry);
|
|
404
|
+
else if (typeof entry?.name === "string") out.push(entry.name);
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
if (key === "$groupBy") return value.filter((x) => typeof x === "string");
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Test-friendly internal helper — exported for unit tests and helper
|
|
412
|
+
* composition; regular consumers should not call this directly.
|
|
413
|
+
*
|
|
414
|
+
* Apply the union of `allowedFields` whitelists (with `preserveFields`
|
|
415
|
+
* always preserved) and overlay each scope's `set` overrides. Returns a
|
|
416
|
+
* shallow copy; original `data` is not mutated.
|
|
417
|
+
*/
|
|
418
|
+
function applyAllowedFieldsAndSet(data, scopes, preserveFields = []) {
|
|
419
|
+
if (scopes.length === 0) return data;
|
|
420
|
+
const prepared = prepareScopeOverlay(scopes, preserveFields);
|
|
421
|
+
if (Array.isArray(data)) return data.map((row) => applyPreparedOverlay(row, prepared));
|
|
422
|
+
return applyPreparedOverlay(data, prepared);
|
|
423
|
+
}
|
|
424
|
+
function prepareScopeOverlay(scopes, preserveFields) {
|
|
425
|
+
let union = null;
|
|
426
|
+
for (const s of scopes) if (Array.isArray(s.allowedFields)) {
|
|
427
|
+
if (!union) union = /* @__PURE__ */ new Set();
|
|
428
|
+
for (const f of s.allowedFields) union.add(f);
|
|
429
|
+
}
|
|
430
|
+
if (union) for (const f of preserveFields) union.add(f);
|
|
431
|
+
let setOverrides = null;
|
|
432
|
+
for (const s of scopes) if (s.set) {
|
|
433
|
+
if (!setOverrides) setOverrides = {};
|
|
434
|
+
Object.assign(setOverrides, s.set);
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
union,
|
|
438
|
+
setOverrides
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function applyPreparedOverlay(data, prepared) {
|
|
442
|
+
if (Array.isArray(data)) return data.map((row) => applyPreparedOverlay(row, prepared));
|
|
443
|
+
if (!data || typeof data !== "object") return data;
|
|
444
|
+
const merged = { ...data };
|
|
445
|
+
if (prepared.union) {
|
|
446
|
+
for (const k of Object.keys(merged)) if (!prepared.union.has(k)) delete merged[k];
|
|
447
|
+
}
|
|
448
|
+
if (prepared.setOverrides) Object.assign(merged, prepared.setOverrides);
|
|
449
|
+
return merged;
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/db/as-arbac-db-readable-controller.ts
|
|
453
|
+
let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDbReadableController {
|
|
454
|
+
transformFilter(filter) {
|
|
455
|
+
return transformArbacFilter(filter);
|
|
456
|
+
}
|
|
457
|
+
transformProjection(projection) {
|
|
458
|
+
return applyArbacProjection(projection, readCachedScopes());
|
|
459
|
+
}
|
|
460
|
+
validateControls(controls, type) {
|
|
461
|
+
const baseErr = super.validateControls(controls, type);
|
|
462
|
+
if (baseErr) return baseErr;
|
|
463
|
+
const scopes = readCachedScopes();
|
|
464
|
+
applyArbacControls(controls, scopes);
|
|
465
|
+
applyArbacRelationScopes(controls, scopes);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
AsArbacDbReadableController = __decorate([Inherit()], AsArbacDbReadableController);
|
|
469
|
+
//#endregion
|
|
470
|
+
export { Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, MoostArbac, applyAllowedFieldsAndSet, arbacAuthorizeInterceptor, arbacPatternToRegex, enforceControlsPolicy, extractUsedControlValues, getArbacMate, useArbac };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { TAtscriptPlugin } from "@atscript/core";
|
|
2
|
+
|
|
3
|
+
//#region src/plugin.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Registers the `@arbac.*` annotation namespace for `@aooth/arbac-moost`:
|
|
6
|
+
*
|
|
7
|
+
* - `@arbac.role` — field is the source of role identifiers. Exactly one
|
|
8
|
+
* field per type. Two valid shapes:
|
|
9
|
+
* - inline `string | string[]` — values are read directly,
|
|
10
|
+
* - `@db.rel.from` nav prop (1:N to a role table) — provider auto-injects
|
|
11
|
+
* `controls.$with` and pulls role names off the joined records.
|
|
12
|
+
* Multiple `@arbac.role` declarations throw at spec computation time.
|
|
13
|
+
* - `@arbac.attribute` — field becomes a user attribute keyed by its prop name.
|
|
14
|
+
* Multiple `@arbac.attribute` fields are merged into the `UserAttrs` map.
|
|
15
|
+
* - `@arbac.userId` — overrides the user-id source. Resolution order is
|
|
16
|
+
* (1) `@arbac.userId`, (2) the single field of the `@db.table.preferredId.uniqueIndex`
|
|
17
|
+
* group, (3) `@meta.id` — first match wins.
|
|
18
|
+
*
|
|
19
|
+
* Install in `atscript.config.ts`:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* import arbacPlugin from '@aooth/arbac-moost/plugin'
|
|
23
|
+
* export default { plugins: [arbacPlugin()] }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
declare function arbacPlugin(): TAtscriptPlugin;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { arbacPlugin as default };
|
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { AnnotationSpec } from "@atscript/core";
|
|
2
|
+
//#region src/plugin.ts
|
|
3
|
+
/**
|
|
4
|
+
* Registers the `@arbac.*` annotation namespace for `@aooth/arbac-moost`:
|
|
5
|
+
*
|
|
6
|
+
* - `@arbac.role` — field is the source of role identifiers. Exactly one
|
|
7
|
+
* field per type. Two valid shapes:
|
|
8
|
+
* - inline `string | string[]` — values are read directly,
|
|
9
|
+
* - `@db.rel.from` nav prop (1:N to a role table) — provider auto-injects
|
|
10
|
+
* `controls.$with` and pulls role names off the joined records.
|
|
11
|
+
* Multiple `@arbac.role` declarations throw at spec computation time.
|
|
12
|
+
* - `@arbac.attribute` — field becomes a user attribute keyed by its prop name.
|
|
13
|
+
* Multiple `@arbac.attribute` fields are merged into the `UserAttrs` map.
|
|
14
|
+
* - `@arbac.userId` — overrides the user-id source. Resolution order is
|
|
15
|
+
* (1) `@arbac.userId`, (2) the single field of the `@db.table.preferredId.uniqueIndex`
|
|
16
|
+
* group, (3) `@meta.id` — first match wins.
|
|
17
|
+
*
|
|
18
|
+
* Install in `atscript.config.ts`:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* import arbacPlugin from '@aooth/arbac-moost/plugin'
|
|
22
|
+
* export default { plugins: [arbacPlugin()] }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
function arbacPlugin() {
|
|
26
|
+
return {
|
|
27
|
+
name: "aoothjs-arbac",
|
|
28
|
+
config() {
|
|
29
|
+
return { annotations: { arbac: {
|
|
30
|
+
role: new AnnotationSpec({
|
|
31
|
+
description: "Marks this field as THE source of role identifiers for ARBAC evaluation. Two shapes are supported: inline (string | string[]) or @db.rel.from nav prop. Exactly one @arbac.role field per type — multiple declarations throw at boot.",
|
|
32
|
+
nodeType: ["prop"],
|
|
33
|
+
multiple: false
|
|
34
|
+
}),
|
|
35
|
+
attribute: new AnnotationSpec({
|
|
36
|
+
description: "Marks this field as a user attribute used by ARBAC scope evaluation. The field name becomes the attribute key. Multiple fields are merged.",
|
|
37
|
+
nodeType: ["prop"],
|
|
38
|
+
multiple: false
|
|
39
|
+
}),
|
|
40
|
+
userId: new AnnotationSpec({
|
|
41
|
+
description: "Overrides which field provides the user identifier for ARBAC. Resolution chain: @arbac.userId → @db.table.preferredId.uniqueIndex field → @meta.id.",
|
|
42
|
+
nodeType: ["prop"],
|
|
43
|
+
multiple: false
|
|
44
|
+
})
|
|
45
|
+
} } };
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { arbacPlugin as default };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
|
|
2
|
+
import "@aooth/user/atscript-db/model.as";
|
|
3
|
+
//#region src/atscript/models/user.as
|
|
4
|
+
var AoothArbacUserCredentials = class {
|
|
5
|
+
static __is_atscript_annotated_type = true;
|
|
6
|
+
static type = {};
|
|
7
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
8
|
+
static id = "AoothArbacUserCredentials";
|
|
9
|
+
static toJsonSchema() {
|
|
10
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
defineAnnotatedType("object", AoothArbacUserCredentials).prop("username", defineAnnotatedType().designType("string").tags("string").annotate("db.index.unique", "username_idx", true).$type).prop("password", defineAnnotatedType("object").prop("hash", defineAnnotatedType().designType("string").tags("string").$type).prop("history", defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).prop("lastChanged", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("isInitial", defineAnnotatedType().designType("boolean").tags("boolean").$type).annotate("db.patch.strategy", "merge").$type).prop("account", defineAnnotatedType("object").prop("active", defineAnnotatedType().designType("boolean").tags("boolean").$type).prop("locked", defineAnnotatedType().designType("boolean").tags("boolean").$type).prop("lockReason", defineAnnotatedType().designType("string").tags("string").$type).prop("lockEnds", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("failedLoginAttempts", defineAnnotatedType().designType("number").tags("number").$type).prop("lastLogin", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("pendingInvitation", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type).annotate("db.patch.strategy", "merge").$type).prop("mfa", defineAnnotatedType("object").prop("methods", defineAnnotatedType("array").of(defineAnnotatedType("object").prop("name", defineAnnotatedType().designType("string").tags("string").$type).prop("confirmed", defineAnnotatedType().designType("boolean").tags("boolean").$type).prop("value", defineAnnotatedType().designType("string").tags("string").$type).$type).$type).prop("defaultMethod", defineAnnotatedType().designType("string").tags("string").$type).prop("autoSend", defineAnnotatedType().designType("boolean").tags("boolean").$type).annotate("db.patch.strategy", "merge").$type).prop("trustedDevices", defineAnnotatedType("array").of(defineAnnotatedType("object").prop("token", defineAnnotatedType().designType("string").tags("string").$type).prop("ip", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("issuedAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("expiresAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("name", defineAnnotatedType().designType("string").tags("string").optional().$type).$type).annotate("db.patch.strategy", "merge").optional().$type).prop("roles", defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).annotate("arbac.role", true).$type);
|
|
14
|
+
//#endregion
|
|
15
|
+
export { AoothArbacUserCredentials as t };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region \0@oxc-project+runtime@0.129.0/helpers/decorate.js
|
|
2
|
+
function __decorate(decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/user.provider.ts
|
|
10
|
+
/**
|
|
11
|
+
* Abstract base class for providing user data required for ARBAC evaluations.
|
|
12
|
+
*
|
|
13
|
+
* Consumers must extend this class and implement the three abstract methods
|
|
14
|
+
* to plug their own authentication/identity layer into the ARBAC authorize
|
|
15
|
+
* interceptor. The subclass must re-apply `@Injectable()` (moost does not
|
|
16
|
+
* inherit decorator metadata) and be registered via Moost's
|
|
17
|
+
* `setReplaceRegistry` (or `@Replace`) so DI resolves the concrete provider.
|
|
18
|
+
*
|
|
19
|
+
* @template TUserAttrs - The type representing user attributes relevant to access control.
|
|
20
|
+
*/
|
|
21
|
+
var ArbacUserProvider = class {};
|
|
22
|
+
/**
|
|
23
|
+
* DI token form of {@link ArbacUserProvider}. The class itself is `abstract`,
|
|
24
|
+
* so it does not satisfy moost's `TClassConstructor` (`new (...) => T`)
|
|
25
|
+
* structurally; this re-typed alias is the supported way to pass the base
|
|
26
|
+
* class to `cc.instantiate(...)`, `createReplaceRegistry(...)`, `@Replace(...)`,
|
|
27
|
+
* etc. Runtime resolves to the concrete subclass registered via DI.
|
|
28
|
+
*/
|
|
29
|
+
const ArbacUserProviderToken = ArbacUserProvider;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { ArbacUserProviderToken as n, __decorate as r, ArbacUserProvider as t };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { TClassConstructor } from "moost";
|
|
2
|
+
|
|
3
|
+
//#region src/user.provider.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Abstract base class for providing user data required for ARBAC evaluations.
|
|
6
|
+
*
|
|
7
|
+
* Consumers must extend this class and implement the three abstract methods
|
|
8
|
+
* to plug their own authentication/identity layer into the ARBAC authorize
|
|
9
|
+
* interceptor. The subclass must re-apply `@Injectable()` (moost does not
|
|
10
|
+
* inherit decorator metadata) and be registered via Moost's
|
|
11
|
+
* `setReplaceRegistry` (or `@Replace`) so DI resolves the concrete provider.
|
|
12
|
+
*
|
|
13
|
+
* @template TUserAttrs - The type representing user attributes relevant to access control.
|
|
14
|
+
*/
|
|
15
|
+
declare abstract class ArbacUserProvider<TUserAttrs extends object = object> {
|
|
16
|
+
/**
|
|
17
|
+
* Retrieves the unique identifier of the current user.
|
|
18
|
+
*/
|
|
19
|
+
abstract getUserId(): string | Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Retrieves the roles assigned to a user based on their ID.
|
|
22
|
+
*
|
|
23
|
+
* @param id - The user ID.
|
|
24
|
+
*/
|
|
25
|
+
abstract getRoles(id: string): string[] | Promise<string[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Retrieves the attributes associated with a user based on their ID.
|
|
28
|
+
*
|
|
29
|
+
* @param id - The user ID.
|
|
30
|
+
*/
|
|
31
|
+
abstract getAttrs(id: string): TUserAttrs | Promise<TUserAttrs>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* DI token form of {@link ArbacUserProvider}. The class itself is `abstract`,
|
|
35
|
+
* so it does not satisfy moost's `TClassConstructor` (`new (...) => T`)
|
|
36
|
+
* structurally; this re-typed alias is the supported way to pass the base
|
|
37
|
+
* class to `cc.instantiate(...)`, `createReplaceRegistry(...)`, `@Replace(...)`,
|
|
38
|
+
* etc. Runtime resolves to the concrete subclass registered via DI.
|
|
39
|
+
*/
|
|
40
|
+
declare const ArbacUserProviderToken: TClassConstructor<ArbacUserProvider>;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { ArbacUserProviderToken as n, ArbacUserProvider as t };
|