@dudousxd/nestjs-authz 0.8.1 → 0.9.0
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/CHANGELOG.md +109 -0
- package/README.md +279 -0
- package/dist/authz.guard.d.ts +48 -0
- package/dist/authz.guard.d.ts.map +1 -0
- package/dist/authz.guard.js +144 -0
- package/dist/authz.guard.js.map +1 -0
- package/dist/can-endpoint.controller.d.ts +0 -19
- package/dist/can-endpoint.controller.d.ts.map +1 -1
- package/dist/can-endpoint.controller.js +18 -9
- package/dist/can-endpoint.controller.js.map +1 -1
- package/dist/decision-provider.d.ts +25 -16
- package/dist/decision-provider.d.ts.map +1 -1
- package/dist/decision-provider.js +11 -1
- package/dist/decision-provider.js.map +1 -1
- package/dist/decisions.d.ts +24 -0
- package/dist/decisions.d.ts.map +1 -0
- package/dist/decisions.js +87 -0
- package/dist/decisions.js.map +1 -0
- package/dist/decorator/roles.decorator.d.ts +6 -12
- package/dist/decorator/roles.decorator.d.ts.map +1 -1
- package/dist/decorator/roles.decorator.js +13 -15
- package/dist/decorator/roles.decorator.js.map +1 -1
- package/dist/diagnostics.d.ts +15 -5
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +3 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/errors/exceptions.d.ts +0 -8
- package/dist/errors/exceptions.d.ts.map +1 -1
- package/dist/errors/exceptions.js +0 -14
- package/dist/errors/exceptions.js.map +1 -1
- package/dist/gate.d.ts +83 -218
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +209 -611
- package/dist/gate.js.map +1 -1
- package/dist/helpers.d.ts +46 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +89 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +28 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -13
- package/dist/index.js.map +1 -1
- package/dist/links.d.ts +39 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +57 -0
- package/dist/links.js.map +1 -0
- package/dist/module.d.ts +23 -22
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +129 -158
- package/dist/module.js.map +1 -1
- package/dist/official.d.ts +39 -0
- package/dist/official.d.ts.map +1 -0
- package/dist/official.js +44 -0
- package/dist/official.js.map +1 -0
- package/dist/policies/authz-policy.d.ts +42 -0
- package/dist/policies/authz-policy.d.ts.map +1 -0
- package/dist/policies/authz-policy.js +80 -0
- package/dist/policies/authz-policy.js.map +1 -0
- package/dist/policies/gate.policy.d.ts +18 -0
- package/dist/policies/gate.policy.d.ts.map +1 -0
- package/dist/policies/gate.policy.js +37 -0
- package/dist/policies/gate.policy.js.map +1 -0
- package/dist/policies/index.d.ts +5 -0
- package/dist/policies/index.d.ts.map +1 -0
- package/dist/policies/index.js +5 -0
- package/dist/policies/index.js.map +1 -0
- package/dist/policies/permission.policy.d.ts +27 -0
- package/dist/policies/permission.policy.d.ts.map +1 -0
- package/dist/policies/permission.policy.js +61 -0
- package/dist/policies/permission.policy.js.map +1 -0
- package/dist/policies/role.policy.d.ts +27 -0
- package/dist/policies/role.policy.d.ts.map +1 -0
- package/dist/policies/role.policy.js +51 -0
- package/dist/policies/role.policy.js.map +1 -0
- package/dist/policy-index.d.ts +51 -0
- package/dist/policy-index.d.ts.map +1 -0
- package/dist/policy-index.js +135 -0
- package/dist/policy-index.js.map +1 -0
- package/dist/rbac.d.ts +92 -0
- package/dist/rbac.d.ts.map +1 -0
- package/dist/rbac.js +293 -0
- package/dist/rbac.js.map +1 -0
- package/dist/resource-resolver.d.ts +15 -17
- package/dist/resource-resolver.d.ts.map +1 -1
- package/dist/resource-resolver.js +8 -8
- package/dist/resource-resolver.js.map +1 -1
- package/dist/runtime.d.ts +31 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +40 -0
- package/dist/runtime.js.map +1 -0
- package/dist/tokens.d.ts +4 -8
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +4 -8
- package/dist/tokens.js.map +1 -1
- package/dist/types.d.ts +52 -171
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -10
- package/skills/authz-enforcement/SKILL.md +67 -135
- package/skills/authz-policies/SKILL.md +43 -117
- package/skills/authz-query-scopes/SKILL.md +16 -9
- package/skills/authz-rbac-seams/SKILL.md +29 -12
- package/skills/authz-setup/SKILL.md +45 -106
- package/dist/decorator/can.decorator.d.ts +0 -29
- package/dist/decorator/can.decorator.d.ts.map +0 -1
- package/dist/decorator/can.decorator.js +0 -23
- package/dist/decorator/can.decorator.js.map +0 -1
- package/dist/decorator/policy.decorator.d.ts +0 -18
- package/dist/decorator/policy.decorator.d.ts.map +0 -1
- package/dist/decorator/policy.decorator.js +0 -28
- package/dist/decorator/policy.decorator.js.map +0 -1
- package/dist/guard/can.guard.d.ts +0 -31
- package/dist/guard/can.guard.d.ts.map +0 -1
- package/dist/guard/can.guard.js +0 -92
- package/dist/guard/can.guard.js.map +0 -1
- package/dist/guard/roles.guard.d.ts +0 -21
- package/dist/guard/roles.guard.d.ts.map +0 -1
- package/dist/guard/roles.guard.js +0 -50
- package/dist/guard/roles.guard.js.map +0 -1
- package/dist/policy-registry.d.ts +0 -46
- package/dist/policy-registry.d.ts.map +0 -1
- package/dist/policy-registry.js +0 -101
- package/dist/policy-registry.js.map +0 -1
package/dist/gate.js
CHANGED
|
@@ -10,230 +10,51 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
10
10
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
11
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
12
|
};
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
13
|
+
import { AuthorizationError, AuthorizationService } from '@nestjs/authorization';
|
|
14
|
+
import { Inject, Injectable, Optional } from '@nestjs/common';
|
|
15
|
+
import { normalizeVerdict } from './decision-provider.js';
|
|
16
16
|
import { publishAuthzDecision } from './diagnostics.js';
|
|
17
17
|
import { AbilityNotResolvedException, AmbiguousAbilityException } from './errors/exceptions.js';
|
|
18
|
+
import { isOfficialPolicy, requireOfficial } from './official.js';
|
|
18
19
|
import { PermissionCache } from './permission-cache.js';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { withAskedCheck } from './policies/authz-policy.js';
|
|
21
|
+
import { PolicyIndex } from './policy-index.js';
|
|
22
|
+
import { AuthzRbac } from './rbac.js';
|
|
22
23
|
import { normalizeScope, scopeAll, scopeNone, } from './scope.js';
|
|
23
|
-
import { AUTHZ_MODULE_OPTIONS
|
|
24
|
-
|
|
25
|
-
function normalizeResult(value) {
|
|
26
|
-
if (value == null)
|
|
27
|
-
return { allowed: undefined };
|
|
28
|
-
if (typeof value === 'boolean')
|
|
29
|
-
return { allowed: value };
|
|
30
|
-
// `exactOptionalPropertyTypes`: only attach `message` when actually present.
|
|
31
|
-
return value.message === undefined
|
|
32
|
-
? { allowed: Boolean(value.allowed) }
|
|
33
|
-
: { allowed: Boolean(value.allowed), message: value.message };
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Attach `message` to a decision only when defined (honors `exactOptionalPropertyTypes`,
|
|
37
|
-
* which forbids an explicit `message: undefined` on an optional property).
|
|
38
|
-
*/
|
|
39
|
-
function withMessage(decision, message) {
|
|
40
|
-
return message === undefined ? decision : { ...decision, message };
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* Build the `ForbiddenException` for a denied `authorize(...)`, surfacing the
|
|
44
|
-
* decision's `reason` (and the policy/hook `message` when present) instead of
|
|
45
|
-
* discarding it. The message string defaults to the generic `Unauthorized: <ability>`
|
|
46
|
-
* for backward compatibility; the structured body carries `ability` + `reason` so a
|
|
47
|
-
* 403 is debuggable by clients and the diagnostics watcher alike.
|
|
48
|
-
*/
|
|
49
|
-
function forbidden(ability, decision) {
|
|
50
|
-
const message = decision.message ?? `Unauthorized: ${ability}`;
|
|
51
|
-
return new ForbiddenException({
|
|
52
|
-
statusCode: 403,
|
|
53
|
-
error: 'Forbidden',
|
|
54
|
-
message,
|
|
55
|
-
ability,
|
|
56
|
-
reason: decision.reason,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
// A sentinel marking "no user resolved" distinct from a legitimately-`undefined`
|
|
60
|
-
// user. `forUser(undefined)` explicitly authorizes an anonymous user.
|
|
61
|
-
const NO_USER = Symbol('authz:no-user');
|
|
24
|
+
import { AUTHZ_MODULE_OPTIONS } from './tokens.js';
|
|
25
|
+
const isPolicyCheck = (item) => typeof item.policy === 'function';
|
|
62
26
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* this module (e.g. multiple installs in a monorepo) sharing one store.
|
|
66
|
-
*/
|
|
67
|
-
const REQUEST_PERMISSION_CACHE = Symbol.for('@dudousxd/nestjs-authz:request-permission-cache');
|
|
68
|
-
/** Key under which the per-request `resolveUser` results are stashed on the context store. */
|
|
69
|
-
const REQUEST_USERS = Symbol.for('@dudousxd/nestjs-authz:request-users');
|
|
70
|
-
/**
|
|
71
|
-
* Laravel-style authorization Gate.
|
|
27
|
+
* A façade over the official `AuthorizationService` for service-layer checks, with what
|
|
28
|
+
* the official service does not have:
|
|
72
29
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* {@link define}
|
|
76
|
-
* `
|
|
30
|
+
* - **string abilities**, Laravel-style: `gate.allows('update', post)` finds the policy
|
|
31
|
+
* linked to `Post` with `@PolicyFor(Post)`; `gate.allows('access-admin')` runs an
|
|
32
|
+
* ad-hoc gate from {@link define}; `gate.allows('posts.publish')` asks the persisted
|
|
33
|
+
* permissions. Asks `superAdmin` first, then the external decision point
|
|
34
|
+
* (`DECISION_PROVIDER`), then the policy — which has the final word: a persisted
|
|
35
|
+
* permission named like the ability does not bypass it — then the persisted
|
|
36
|
+
* permissions, then the ad-hoc gate.
|
|
37
|
+
* - **official policy checks**: `gate.allows(PostPolicy, 'update', post)`, exactly
|
|
38
|
+
* `AuthorizationService.can()`.
|
|
39
|
+
* - **batches** ({@link allowsMany}) sharing one permission fetch, **query scopes**
|
|
40
|
+
* ({@link scope}), **roles** ({@link hasRole}), an allow+deny decision event, and an
|
|
41
|
+
* ambient user when none is passed ({@link forUser} passes one): `@nestjs/authentication`'s
|
|
42
|
+
* `AuthenticationContext.user` when that package is installed, else nestjs-context's.
|
|
77
43
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
44
|
+
* Every policy decision is the official service's: the policy's own `before()`, then
|
|
45
|
+
* the ability, allowed only on a literal `true`.
|
|
80
46
|
*/
|
|
81
47
|
let Gate = class Gate {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
moduleRef;
|
|
85
|
-
permissionProvider;
|
|
86
|
-
roleProvider;
|
|
87
|
-
decisionProvider;
|
|
48
|
+
index;
|
|
49
|
+
rbac;
|
|
88
50
|
gates = new Map();
|
|
89
51
|
superAdmin;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// needs to run once per seam — never on every authorization check.
|
|
96
|
-
contextResolved = false;
|
|
97
|
-
contextCached;
|
|
98
|
-
permissionProviderResolved = false;
|
|
99
|
-
permissionProviderCached;
|
|
100
|
-
roleProviderResolved = false;
|
|
101
|
-
roleProviderCached;
|
|
102
|
-
decisionProviderResolved = false;
|
|
103
|
-
decisionProviderCached;
|
|
104
|
-
constructor(policies, options, context, moduleRef, permissionProvider, roleProvider, decisionProvider) {
|
|
105
|
-
this.policies = policies;
|
|
106
|
-
this.context = context;
|
|
107
|
-
this.moduleRef = moduleRef;
|
|
108
|
-
this.permissionProvider = permissionProvider;
|
|
109
|
-
this.roleProvider = roleProvider;
|
|
110
|
-
this.decisionProvider = decisionProvider;
|
|
52
|
+
authz;
|
|
53
|
+
constructor(authz, index, rbac, options) {
|
|
54
|
+
this.index = index;
|
|
55
|
+
this.rbac = rbac;
|
|
56
|
+
this.authz = requireOfficial(authz, 'AuthorizationService', 'Gate');
|
|
111
57
|
this.superAdmin = options?.superAdmin;
|
|
112
|
-
this.after = options?.after;
|
|
113
|
-
this.resolveUser = options?.resolveUser;
|
|
114
|
-
this.roleResolver = options?.resolveRoles ?? defaultRoleResolver;
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Locate the context accessor. Prefers the value injected into this module;
|
|
118
|
-
* falls back to a non-strict {@link ModuleRef} lookup so an accessor provided
|
|
119
|
-
* by ANY module (e.g. a global ContextModule, or the app root) is still found.
|
|
120
|
-
*/
|
|
121
|
-
resolveContext() {
|
|
122
|
-
if (this.context)
|
|
123
|
-
return this.context;
|
|
124
|
-
if (this.contextResolved)
|
|
125
|
-
return this.contextCached;
|
|
126
|
-
this.contextResolved = true;
|
|
127
|
-
if (!this.moduleRef)
|
|
128
|
-
return this.contextCached;
|
|
129
|
-
try {
|
|
130
|
-
this.contextCached = this.moduleRef.get(CONTEXT_ACCESSOR, { strict: false });
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
this.contextCached = undefined;
|
|
134
|
-
}
|
|
135
|
-
return this.contextCached;
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Locate the optional {@link PermissionProvider} (the RBAC seam). Prefers the value
|
|
139
|
-
* injected into this module; falls back to a non-strict {@link ModuleRef} lookup so a
|
|
140
|
-
* provider registered by ANY module (e.g. the RBAC adapter's global module) is found.
|
|
141
|
-
*/
|
|
142
|
-
resolvePermissionProvider() {
|
|
143
|
-
if (this.permissionProvider)
|
|
144
|
-
return this.permissionProvider;
|
|
145
|
-
if (this.permissionProviderResolved)
|
|
146
|
-
return this.permissionProviderCached;
|
|
147
|
-
this.permissionProviderResolved = true;
|
|
148
|
-
if (!this.moduleRef)
|
|
149
|
-
return this.permissionProviderCached;
|
|
150
|
-
try {
|
|
151
|
-
this.permissionProviderCached = this.moduleRef.get(PERMISSION_PROVIDER, {
|
|
152
|
-
strict: false,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
this.permissionProviderCached = undefined;
|
|
157
|
-
}
|
|
158
|
-
return this.permissionProviderCached;
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Locate the optional {@link RoleProvider} (the coarse role seam). Prefers the value
|
|
162
|
-
* injected into this module; falls back to a non-strict {@link ModuleRef} lookup so a
|
|
163
|
-
* provider registered by ANY module (e.g. the RBAC adapter's global module) is found.
|
|
164
|
-
*/
|
|
165
|
-
resolveRoleProvider() {
|
|
166
|
-
if (this.roleProvider)
|
|
167
|
-
return this.roleProvider;
|
|
168
|
-
if (this.roleProviderResolved)
|
|
169
|
-
return this.roleProviderCached;
|
|
170
|
-
this.roleProviderResolved = true;
|
|
171
|
-
if (!this.moduleRef)
|
|
172
|
-
return this.roleProviderCached;
|
|
173
|
-
try {
|
|
174
|
-
this.roleProviderCached = this.moduleRef.get(ROLE_PROVIDER, { strict: false });
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
this.roleProviderCached = undefined;
|
|
178
|
-
}
|
|
179
|
-
return this.roleProviderCached;
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Locate the optional {@link DecisionProvider} (the external-PDP seam). Same lookup strategy as
|
|
183
|
-
* {@link resolvePermissionProvider}: injected value first, then a memoized non-strict scan.
|
|
184
|
-
*/
|
|
185
|
-
resolveDecisionProvider() {
|
|
186
|
-
if (this.decisionProvider)
|
|
187
|
-
return this.decisionProvider;
|
|
188
|
-
if (this.decisionProviderResolved)
|
|
189
|
-
return this.decisionProviderCached;
|
|
190
|
-
this.decisionProviderResolved = true;
|
|
191
|
-
if (!this.moduleRef)
|
|
192
|
-
return this.decisionProviderCached;
|
|
193
|
-
try {
|
|
194
|
-
this.decisionProviderCached = this.moduleRef.get(DECISION_PROVIDER, {
|
|
195
|
-
strict: false,
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
this.decisionProviderCached = undefined;
|
|
200
|
-
}
|
|
201
|
-
return this.decisionProviderCached;
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* The per-request {@link PermissionCache}, stashed on the nestjs-context store so
|
|
205
|
-
* every check within one HTTP request shares a single `getPermissions` fetch
|
|
206
|
-
* (kills the N+1 a list page would otherwise cause). Returns `undefined` when no
|
|
207
|
-
* context store is available (standalone use), in which case callers fall back to
|
|
208
|
-
* a transient cache (e.g. one per `allowsMany` batch) or no memoization.
|
|
209
|
-
*/
|
|
210
|
-
resolveRequestCache() {
|
|
211
|
-
const store = this.requestStore();
|
|
212
|
-
if (!store)
|
|
213
|
-
return undefined;
|
|
214
|
-
const existing = store[REQUEST_PERMISSION_CACHE];
|
|
215
|
-
if (existing instanceof PermissionCache)
|
|
216
|
-
return existing;
|
|
217
|
-
const cache = new PermissionCache();
|
|
218
|
-
store[REQUEST_PERMISSION_CACHE] = cache;
|
|
219
|
-
return cache;
|
|
220
|
-
}
|
|
221
|
-
/** The nestjs-context store for the current request, or `undefined` when there is none. */
|
|
222
|
-
requestStore() {
|
|
223
|
-
const context = this.resolveContext();
|
|
224
|
-
// `get()` is part of the ContextAccessor contract but a stub/partial accessor
|
|
225
|
-
// may omit it (or it may throw outside a request scope) — degrade to "no
|
|
226
|
-
// request cache" rather than failing the check.
|
|
227
|
-
if (!context || typeof context.get !== 'function')
|
|
228
|
-
return undefined;
|
|
229
|
-
let store;
|
|
230
|
-
try {
|
|
231
|
-
store = context.get();
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
store = undefined;
|
|
235
|
-
}
|
|
236
|
-
return store;
|
|
237
58
|
}
|
|
238
59
|
/** Register an ad-hoc, model-less gate resolved by `ability` name. */
|
|
239
60
|
define(ability, fn) {
|
|
@@ -244,472 +65,253 @@ let Gate = class Gate {
|
|
|
244
65
|
hasGate(ability) {
|
|
245
66
|
return this.gates.has(ability);
|
|
246
67
|
}
|
|
247
|
-
/**
|
|
248
|
-
* Names of every ad-hoc gate registered via {@link define}. Used by integrations
|
|
249
|
-
* (e.g. `@dudousxd/nestjs-authz-inertia`) that enumerate the user's class-level
|
|
250
|
-
* abilities to share them as Inertia props — no network round-trip needed.
|
|
251
|
-
*/
|
|
68
|
+
/** Names of every ad-hoc gate registered with {@link define}. */
|
|
252
69
|
gateNames() {
|
|
253
70
|
return [...this.gates.keys()];
|
|
254
71
|
}
|
|
255
72
|
/**
|
|
256
|
-
* Bind an explicit user
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* A nullish user (`forUser(undefined)` / `forUser(null)`) is an explicit
|
|
260
|
-
* anonymous request: it maps to the same deny path as an unauthenticated
|
|
261
|
-
* context (`NO_USER`), so policies/gates are never invoked with `undefined` and
|
|
262
|
-
* never throw. The `resolveUser` hook is NOT applied to `forUser` — the value
|
|
263
|
-
* you pass is used verbatim.
|
|
73
|
+
* Bind an explicit user. `null`/`undefined` is a guest. The usual way to check in a
|
|
74
|
+
* service: pass the user your handler received.
|
|
264
75
|
*/
|
|
265
76
|
forUser(user) {
|
|
266
|
-
return new BoundGate(this, user
|
|
77
|
+
return new BoundGate(this, user ?? null);
|
|
267
78
|
}
|
|
268
79
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* context's
|
|
272
|
-
*
|
|
273
|
-
* hook runs once per request rather than once per check.
|
|
80
|
+
* The ambient user, for calls without {@link forUser}: `@nestjs/authentication`'s
|
|
81
|
+
* `AuthenticationContext.user` when that optional peer is installed and has one, else
|
|
82
|
+
* nestjs-context's user ref (hydrated by `resolveUser` when configured, once per
|
|
83
|
+
* request). `null` without either.
|
|
274
84
|
*/
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (!context)
|
|
278
|
-
return NO_USER;
|
|
279
|
-
const ref = context.userRef();
|
|
280
|
-
if (ref === undefined)
|
|
281
|
-
return NO_USER;
|
|
282
|
-
const resolveUser = this.resolveUser;
|
|
283
|
-
if (!resolveUser)
|
|
284
|
-
return ref;
|
|
285
|
-
const hydrate = async () => {
|
|
286
|
-
const hydrated = await resolveUser(ref);
|
|
287
|
-
return hydrated == null ? NO_USER : hydrated;
|
|
288
|
-
};
|
|
289
|
-
const store = this.requestStore();
|
|
290
|
-
if (!store)
|
|
291
|
-
return hydrate();
|
|
292
|
-
let users = store[REQUEST_USERS];
|
|
293
|
-
if (!(users instanceof Map)) {
|
|
294
|
-
users = new Map();
|
|
295
|
-
store[REQUEST_USERS] = users;
|
|
296
|
-
}
|
|
297
|
-
const key = `${ref.type}:${typeof ref.id}:${ref.id}`;
|
|
298
|
-
const cached = users.get(key);
|
|
299
|
-
if (cached)
|
|
300
|
-
return cached;
|
|
301
|
-
const pending = hydrate();
|
|
302
|
-
users.set(key, pending);
|
|
303
|
-
pending.catch(() => users.delete(key));
|
|
304
|
-
return pending;
|
|
85
|
+
currentUser() {
|
|
86
|
+
return this.rbac.currentUser();
|
|
305
87
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return this.check(await this.currentUser(), ability, resource, this.resolveRequestCache());
|
|
88
|
+
async allows(first, ...rest) {
|
|
89
|
+
return this.allowsFor(await this.currentUser(), first, rest);
|
|
309
90
|
}
|
|
310
|
-
async denies(
|
|
311
|
-
return !(await this.
|
|
91
|
+
async denies(first, ...rest) {
|
|
92
|
+
return !(await this.allowsFor(await this.currentUser(), first, rest));
|
|
312
93
|
}
|
|
313
|
-
async authorize(
|
|
314
|
-
|
|
315
|
-
if (!decision.allowed)
|
|
316
|
-
throw forbidden(ability, decision);
|
|
94
|
+
async authorize(first, ...rest) {
|
|
95
|
+
return this.authorizeFor(await this.currentUser(), first, rest);
|
|
317
96
|
}
|
|
318
|
-
/**
|
|
319
|
-
* Authorize MANY abilities for the current (context) user in a single pass. The
|
|
320
|
-
* user is resolved ONCE and a single {@link PermissionCache} is shared across all
|
|
321
|
-
* items, so a list page (N cards) costs one user resolution + at most one
|
|
322
|
-
* permission fetch instead of N of each. Order is preserved; each result echoes
|
|
323
|
-
* its input `ability`/`resource` plus the `allowed` verdict.
|
|
324
|
-
*
|
|
325
|
-
* Fault-isolating: an item whose ability is unresolved/ambiguous denies just
|
|
326
|
-
* THAT item (it does not throw and abort the batch) — a batch is a best-effort
|
|
327
|
-
* verdict list, not an enforcement gate.
|
|
328
|
-
*/
|
|
97
|
+
/** Check many abilities at once (one user, one permission fetch). A failing item denies. */
|
|
329
98
|
async allowsMany(items) {
|
|
330
|
-
return this.
|
|
99
|
+
return this.allowsManyFor(await this.currentUser(), items);
|
|
331
100
|
}
|
|
332
101
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
* helper) turns into a parameterized `WHERE`.
|
|
337
|
-
*
|
|
338
|
-
* Consistent with the Gate's single-resource semantics:
|
|
339
|
-
* - super-admin / before-grant / permission-provider grant → `allow-all`,
|
|
340
|
-
* - anonymous, or no policy/scope → `deny-all` (default-deny),
|
|
341
|
-
* - otherwise the policy's `scope` (or `viewAny`-style) constraint.
|
|
342
|
-
*
|
|
343
|
-
* `ability` (default `'viewAny'`) names the permission-provider check used for
|
|
344
|
-
* the allow-all grant and the policy fallback method.
|
|
102
|
+
* The query scope of `entity` (a resource class linked with `@PolicyFor`, or the
|
|
103
|
+
* policy class itself) for the current user, from the policy's `scope(user, entity)`
|
|
104
|
+
* method (else the `ability` method, `viewAny` by default). See {@link ScopeConstraint}.
|
|
345
105
|
*/
|
|
346
106
|
async scope(entity, ability = 'viewAny') {
|
|
347
|
-
return this.
|
|
107
|
+
return this.scopeFor(await this.currentUser(), entity, ability);
|
|
348
108
|
}
|
|
349
|
-
|
|
350
|
-
/** True when the current user holds `role`. */
|
|
109
|
+
/** True when the current user holds `role` (a fact: `superAdmin` does not apply). */
|
|
351
110
|
async hasRole(role) {
|
|
352
|
-
return this.
|
|
111
|
+
return this.rbac.hasAnyRole(await this.currentUser(), [role]);
|
|
353
112
|
}
|
|
354
|
-
/** True when the current user holds
|
|
113
|
+
/** True when the current user holds any of `roles`. */
|
|
355
114
|
async hasAnyRole(roles) {
|
|
356
|
-
return this.
|
|
115
|
+
return this.rbac.hasAnyRole(await this.currentUser(), roles);
|
|
357
116
|
}
|
|
358
|
-
// ---
|
|
117
|
+
// --- explicit user (BoundGate) ---
|
|
359
118
|
/** @internal */
|
|
360
|
-
|
|
361
|
-
return this.
|
|
119
|
+
async allowsFor(user, first, rest) {
|
|
120
|
+
return (await this.decide(user, first, rest, this.rbac.requestCache(), false)).allowed;
|
|
362
121
|
}
|
|
363
|
-
/** @internal
|
|
364
|
-
|
|
365
|
-
|
|
122
|
+
/** @internal */
|
|
123
|
+
async authorizeFor(user, first, rest) {
|
|
124
|
+
const decision = await this.decide(user, first, rest, this.rbac.requestCache(), true);
|
|
125
|
+
if (!decision.allowed) {
|
|
126
|
+
throw new AuthorizationError(user == null ? 'unauthenticated' : 'forbidden', decision.policy, typeof first === 'string' ? first : String(rest[0]));
|
|
127
|
+
}
|
|
366
128
|
}
|
|
367
|
-
/**
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
* Prefers the per-request cache (so the batch also coalesces with other checks in
|
|
371
|
-
* the same request); falls back to a transient per-batch cache when standalone.
|
|
372
|
-
*/
|
|
373
|
-
async checkMany(user, items) {
|
|
374
|
-
const cache = this.resolveRequestCache() ?? new PermissionCache();
|
|
129
|
+
/** @internal */
|
|
130
|
+
async allowsManyFor(user, items) {
|
|
131
|
+
const cache = this.rbac.requestCache() ?? new PermissionCache();
|
|
375
132
|
const prefetched = await this.prefetchDecisions(user, items);
|
|
376
133
|
const out = [];
|
|
377
134
|
for (const [index, item] of items.entries()) {
|
|
378
135
|
let allowed;
|
|
379
136
|
try {
|
|
380
|
-
|
|
137
|
+
const [first, rest] = isPolicyCheck(item)
|
|
138
|
+
? [item.policy, [item.ability, ...(item.args ?? [])]]
|
|
139
|
+
: [item.ability, item.resource === undefined ? [] : [item.resource]];
|
|
140
|
+
allowed = (await this.decide(user, first, rest, cache, false, prefetched.get(index)))
|
|
381
141
|
.allowed;
|
|
382
142
|
}
|
|
383
143
|
catch {
|
|
384
|
-
// Unresolved/ambiguous ability → deny just this item (don't abort the batch).
|
|
385
144
|
allowed = false;
|
|
386
145
|
}
|
|
387
|
-
out.push(item
|
|
388
|
-
? { ability: item.ability, allowed }
|
|
389
|
-
: { ability: item.ability, resource: item.resource, allowed });
|
|
146
|
+
out.push({ ...item, allowed });
|
|
390
147
|
}
|
|
391
148
|
return out;
|
|
392
149
|
}
|
|
393
150
|
/**
|
|
394
|
-
* One
|
|
395
|
-
*
|
|
396
|
-
*
|
|
151
|
+
* One `decideMany` round-trip to the external decision point for the string abilities
|
|
152
|
+
* of a batch, when it supports batching. Empty otherwise, and when the batch call fails
|
|
153
|
+
* (each item then asks `decide` on its own).
|
|
397
154
|
*/
|
|
398
|
-
async prefetchDecisions(
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
155
|
+
async prefetchDecisions(user, items) {
|
|
156
|
+
const out = new Map();
|
|
157
|
+
const provider = this.rbac.decisions();
|
|
158
|
+
if (!provider || typeof provider.decideMany !== 'function')
|
|
159
|
+
return out;
|
|
160
|
+
const indexes = [];
|
|
161
|
+
const requests = [];
|
|
162
|
+
for (const [index, item] of items.entries()) {
|
|
163
|
+
if (isPolicyCheck(item))
|
|
164
|
+
continue;
|
|
165
|
+
indexes.push(index);
|
|
166
|
+
requests.push(item.resource === undefined
|
|
167
|
+
? { ability: item.ability }
|
|
168
|
+
: { ability: item.ability, resource: item.resource });
|
|
402
169
|
}
|
|
403
|
-
|
|
170
|
+
if (requests.length === 0)
|
|
171
|
+
return out;
|
|
404
172
|
try {
|
|
405
|
-
const verdicts = await provider.decideMany(user,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
return verdicts.map((verdict) => ({ verdict }));
|
|
173
|
+
const verdicts = await provider.decideMany(user, requests);
|
|
174
|
+
if (!Array.isArray(verdicts) || verdicts.length !== requests.length)
|
|
175
|
+
return out;
|
|
176
|
+
for (const [i, index] of indexes.entries())
|
|
177
|
+
out.set(index, { verdict: verdicts[i] });
|
|
411
178
|
}
|
|
412
179
|
catch {
|
|
413
|
-
|
|
180
|
+
out.clear();
|
|
414
181
|
}
|
|
415
|
-
|
|
416
|
-
/** @internal — used by {@link BoundGate.authorize} to surface the deny reason/message. */
|
|
417
|
-
decideForUser(user, ability, resource) {
|
|
418
|
-
return this.decide(user, ability, resource, this.resolveRequestCache());
|
|
182
|
+
return out;
|
|
419
183
|
}
|
|
420
184
|
/** @internal */
|
|
421
|
-
|
|
422
|
-
return this.
|
|
185
|
+
async hasAnyRoleFor(user, roles) {
|
|
186
|
+
return this.rbac.hasAnyRole(user, roles);
|
|
423
187
|
}
|
|
424
|
-
/** @internal
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
/**
|
|
429
|
-
* Resolve a query-scope constraint for `entity`, mirroring {@link resolveBase}'s
|
|
430
|
-
* grant order so scoping stays consistent with single-resource decisions:
|
|
431
|
-
*
|
|
432
|
-
* 1. super-admin hook grants → `allow-all`;
|
|
433
|
-
* 1b. decision provider `planScope` → its constraint (unless it abstains);
|
|
434
|
-
* 2. (authenticated) permission-provider grant for `ability` → `allow-all`;
|
|
435
|
-
* 3. the policy's `scope` (or `viewAny`-style) method → its constraint;
|
|
436
|
-
* 4. otherwise (anonymous, no policy, no scope method) → `deny-all`.
|
|
437
|
-
*
|
|
438
|
-
* The policy `before` hook is also honored as an allow/deny grant before the
|
|
439
|
-
* scope method runs, so a policy that broadly allows/denies in `before` scopes
|
|
440
|
-
* the same way it decides.
|
|
441
|
-
*/
|
|
442
|
-
async resolveScope(maybeUser, entity, ability) {
|
|
443
|
-
const user = maybeUser === NO_USER ? undefined : maybeUser;
|
|
444
|
-
// 1. Global super-admin hook → allow-all (a `false` does not deny here, it
|
|
445
|
-
// just falls through, mirroring resolveBase's grant-or-fall-through).
|
|
446
|
-
const sa = normalizeResult(await this.superAdmin?.(user, ability, entity));
|
|
447
|
-
if (sa.allowed === true)
|
|
188
|
+
/** @internal */
|
|
189
|
+
async scopeFor(user, entity, ability) {
|
|
190
|
+
if ((await this.superAdmin?.(user, ability, entity)) === true)
|
|
448
191
|
return scopeAll;
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
const planner = this.resolveDecisionProvider();
|
|
192
|
+
// The external decision point's query plan (e.g. Cerbos PlanResources), used as-is.
|
|
193
|
+
const planner = this.rbac.decisions();
|
|
452
194
|
if (planner && typeof planner.planScope === 'function') {
|
|
453
195
|
const planned = await planner.planScope(user, entity, ability);
|
|
454
196
|
if (planned !== undefined)
|
|
455
197
|
return planned;
|
|
456
198
|
}
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
if (scopeMethod) {
|
|
467
|
-
const before = policy.before;
|
|
468
|
-
if (typeof before === 'function') {
|
|
469
|
-
const result = normalizeResult(await before.call(policy, user, ability));
|
|
470
|
-
if (result.allowed === true)
|
|
471
|
-
return scopeAll;
|
|
472
|
-
if (result.allowed === false)
|
|
473
|
-
return scopeNone;
|
|
474
|
-
}
|
|
475
|
-
// Anonymous users see no rows (default-deny, like the single-resource path).
|
|
476
|
-
if (maybeUser === NO_USER)
|
|
477
|
-
return scopeNone;
|
|
478
|
-
return normalizeScope(await scopeMethod.call(policy, user, entity));
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
// 4. No way to scope → deny-all.
|
|
482
|
-
return scopeNone;
|
|
483
|
-
}
|
|
484
|
-
/**
|
|
485
|
-
* Pick the policy's scope method: the conventional `scope`, else the `ability`
|
|
486
|
-
* method (e.g. `viewAny`) when it is scope-shaped. Returns `undefined` when the
|
|
487
|
-
* policy declares neither.
|
|
488
|
-
*/
|
|
489
|
-
resolveScopeMethod(policy, ability) {
|
|
490
|
-
const scope = policy.scope;
|
|
491
|
-
if (typeof scope === 'function')
|
|
492
|
-
return scope;
|
|
493
|
-
const fallback = policy[ability];
|
|
494
|
-
if (typeof fallback === 'function')
|
|
495
|
-
return fallback;
|
|
496
|
-
return undefined;
|
|
497
|
-
}
|
|
498
|
-
/**
|
|
499
|
-
* Whether the registered {@link PermissionProvider} grants `ability` for an authenticated `user` —
|
|
500
|
-
* the RBAC "before" grant shared by the single-resource ({@link resolveBase}) and scope
|
|
501
|
-
* ({@link resolveScope}) paths, single-sourced here so the grant order can't drift between them.
|
|
502
|
-
*
|
|
503
|
-
* Grant-only: a `false`/nullish result is "no grant", never a deny. When the provider lists the
|
|
504
|
-
* user's granted permissions, segment-based wildcard matching runs in the core (a granted
|
|
505
|
-
* `posts.*` satisfies `posts.update`, `*` satisfies anything) regardless of adapter; a nullish
|
|
506
|
-
* list defers to the exact-match `hasPermission`. A set the provider marks
|
|
507
|
-
* {@link PermissionProvider.exhaustivePermissions} answers the check on its own; otherwise an
|
|
508
|
-
* unsatisfied set still defers to `hasPermission`. The granted set is fetched through `cache`
|
|
509
|
-
* when one is supplied, else via a direct fetch.
|
|
510
|
-
*/
|
|
511
|
-
async permissionProviderGrants(user, ability, resource, cache) {
|
|
512
|
-
const provider = this.resolvePermissionProvider();
|
|
513
|
-
if (!provider)
|
|
514
|
-
return false;
|
|
515
|
-
if (typeof provider.getPermissions === 'function') {
|
|
516
|
-
const satisfied = cache
|
|
517
|
-
? await cache.satisfies(provider, user, ability)
|
|
518
|
-
: await (async () => {
|
|
519
|
-
const granted = await provider.getPermissions?.(user);
|
|
520
|
-
return granted == null ? undefined : permissionSatisfied(granted, ability);
|
|
521
|
-
})();
|
|
522
|
-
if (satisfied === true)
|
|
523
|
-
return true;
|
|
524
|
-
if (satisfied === false && provider.exhaustivePermissions === true)
|
|
525
|
-
return false;
|
|
199
|
+
// The policy has the final word; a persisted permission only scopes an entity
|
|
200
|
+
// without a policy method to ask.
|
|
201
|
+
const policy = isOfficialPolicy(entity) ? entity : this.index.policyFor(entity);
|
|
202
|
+
const instance = policy
|
|
203
|
+
? this.index.instanceOf(policy)
|
|
204
|
+
: undefined;
|
|
205
|
+
const method = (instance && typeof instance.scope === 'function' ? instance.scope : instance?.[ability]);
|
|
206
|
+
if (!instance || typeof method !== 'function') {
|
|
207
|
+
return (await this.rbac.grants(user, ability)) ? scopeAll : scopeNone;
|
|
526
208
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
* seam (a persisted store) — so an app needs neither to opt in.
|
|
535
|
-
*/
|
|
536
|
-
async checkRoles(maybeUser, roles) {
|
|
537
|
-
if (maybeUser === NO_USER || roles.length === 0)
|
|
538
|
-
return false;
|
|
539
|
-
const userRoles = await this.rolesOf(maybeUser, this.resolveRequestCache());
|
|
540
|
-
if (userRoles.size === 0)
|
|
541
|
-
return false;
|
|
542
|
-
return roles.some((r) => userRoles.has(r));
|
|
543
|
-
}
|
|
544
|
-
/**
|
|
545
|
-
* The current user's effective role names (resolver ∪ provider). The provider's
|
|
546
|
-
* roles are fetched through `cache` when one is supplied.
|
|
547
|
-
*/
|
|
548
|
-
async rolesOf(user, cache) {
|
|
549
|
-
const out = new Set();
|
|
550
|
-
const fromResolver = await this.roleResolver(user);
|
|
551
|
-
if (Array.isArray(fromResolver)) {
|
|
552
|
-
for (const r of fromResolver)
|
|
553
|
-
if (typeof r === 'string')
|
|
554
|
-
out.add(r);
|
|
209
|
+
const before = instance.before;
|
|
210
|
+
if (typeof before === 'function') {
|
|
211
|
+
const early = await before.call(instance, user ?? null, ability);
|
|
212
|
+
if (early === true)
|
|
213
|
+
return scopeAll;
|
|
214
|
+
if (early === false)
|
|
215
|
+
return scopeNone;
|
|
555
216
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
* `reason`/`message` on the thrown `ForbiddenException`. {@link check} discards
|
|
574
|
-
* everything but the verdict.
|
|
575
|
-
*/
|
|
576
|
-
async decide(maybeUser, ability, resource, cache, prefetched) {
|
|
577
|
-
const decision = await this.resolve(maybeUser, ability, resource, cache, prefetched);
|
|
578
|
-
// Emit the decision for observers (e.g. the generic @dudousxd/nestjs-diagnostics-telescope watcher).
|
|
579
|
-
// Loosely coupled via a diagnostics channel — zero-overhead when no subscriber,
|
|
580
|
-
// and a publish failure can never affect the verdict. Only reached decisions are
|
|
581
|
-
// emitted; an unresolved/ambiguous ability throws above and is intentionally silent.
|
|
582
|
-
publishAuthzDecision(ability, decision.allowed, decision.reason, maybeUser === NO_USER ? undefined : maybeUser, resource);
|
|
217
|
+
if (user == null)
|
|
218
|
+
return scopeNone;
|
|
219
|
+
return normalizeScope(await method.call(instance, user, entity));
|
|
220
|
+
}
|
|
221
|
+
// --- resolution ---
|
|
222
|
+
/** Decide, publish the decision, and (in `authorize` mode) let the official service emit its denial. */
|
|
223
|
+
async decide(user, first, rest, cache, authorizing, prefetched) {
|
|
224
|
+
const ability = typeof first === 'string' ? first : String(rest[0]);
|
|
225
|
+
const resource = typeof first === 'string'
|
|
226
|
+
? rest[0]
|
|
227
|
+
: rest[1];
|
|
228
|
+
const decision = typeof first === 'string'
|
|
229
|
+
? await this.decideString(user, first, resource, cache, authorizing, prefetched)
|
|
230
|
+
: await this.decidePolicy(user, first, ability, rest.slice(1), authorizing);
|
|
231
|
+
publishAuthzDecision(ability, decision.allowed, decision.reason, user, resource, {
|
|
232
|
+
policy: decision.policy,
|
|
233
|
+
});
|
|
583
234
|
return decision;
|
|
584
235
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
async resolve(maybeUser, ability, resource, cache, prefetched) {
|
|
593
|
-
const user = maybeUser === NO_USER ? undefined : maybeUser;
|
|
594
|
-
const base = await this.resolveBase(maybeUser, ability, resource, cache, prefetched);
|
|
595
|
-
// Global `after` hook (Laravel `Gate::after`). It may OVERRIDE only when the
|
|
596
|
-
// base path produced no explicit verdict (`base.allowed === undefined`);
|
|
597
|
-
// otherwise it is observe-only. A nullish/void return never changes anything.
|
|
598
|
-
if (this.after) {
|
|
599
|
-
const observed = base.allowed; // undefined ⇒ policy/gate had no opinion
|
|
600
|
-
const after = normalizeResult(await this.after(user, ability, observed, resource));
|
|
601
|
-
if (observed === undefined && after.allowed !== undefined) {
|
|
602
|
-
return withMessage({ allowed: after.allowed, reason: 'after' }, after.message);
|
|
236
|
+
async decidePolicy(user, policy, ability, args, authorizing) {
|
|
237
|
+
const service = this.authz;
|
|
238
|
+
let allowed;
|
|
239
|
+
if (authorizing) {
|
|
240
|
+
try {
|
|
241
|
+
await service.authorize(policy, ability, user, ...args);
|
|
242
|
+
allowed = true;
|
|
603
243
|
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (!(error instanceof AuthorizationError))
|
|
246
|
+
throw error;
|
|
247
|
+
allowed = false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
allowed = await service.can(policy, ability, user, ...args);
|
|
604
252
|
}
|
|
605
|
-
|
|
606
|
-
return withMessage({ allowed: base.allowed === true, reason: base.reason }, base.message);
|
|
253
|
+
return { allowed, reason: 'policy', policy: policy.name };
|
|
607
254
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const sa = normalizeResult(await this.superAdmin?.(user, ability, resource));
|
|
617
|
-
if (sa.allowed === true)
|
|
618
|
-
return withMessage({ allowed: true, reason: 'super-admin' }, sa.message);
|
|
619
|
-
if (sa.allowed === false)
|
|
620
|
-
return withMessage({ allowed: false, reason: 'super-admin' }, sa.message);
|
|
621
|
-
// External decision point (Cerbos/OPA/...): authoritative allow OR deny; `undefined` abstains.
|
|
622
|
-
const decisionProvider = this.resolveDecisionProvider();
|
|
623
|
-
if (decisionProvider) {
|
|
624
|
-
const verdict = normalizeResult(prefetched ? prefetched.verdict : await decisionProvider.decide(user, ability, resource));
|
|
255
|
+
async decideString(user, ability, resource, cache, authorizing, prefetched) {
|
|
256
|
+
if ((await this.superAdmin?.(user, ability, resource)) === true) {
|
|
257
|
+
return { allowed: true, reason: 'super-admin', policy: null };
|
|
258
|
+
}
|
|
259
|
+
// External decision point (Cerbos/OPA/...): its allow or deny is final; `undefined` abstains.
|
|
260
|
+
const provider = this.rbac.decisions();
|
|
261
|
+
if (provider) {
|
|
262
|
+
const verdict = normalizeVerdict(prefetched ? prefetched.verdict : await provider.decide(user, ability, resource));
|
|
625
263
|
if (verdict.allowed !== undefined) {
|
|
626
|
-
return
|
|
264
|
+
return { allowed: verdict.allowed, reason: 'decision-provider', policy: null };
|
|
627
265
|
}
|
|
628
266
|
}
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
// so this never *denies* an ability a policy/gate would otherwise allow.
|
|
633
|
-
if (maybeUser !== NO_USER &&
|
|
634
|
-
(await this.permissionProviderGrants(user, ability, resource, cache))) {
|
|
635
|
-
return { allowed: true, reason: 'permission-provider' };
|
|
636
|
-
}
|
|
637
|
-
const policy = this.resolvePolicy(ability, resource);
|
|
267
|
+
// A policy that defines the ability has the final word: a persisted permission
|
|
268
|
+
// named like the ability does not bypass it.
|
|
269
|
+
const policy = this.policyFor(ability, resource);
|
|
638
270
|
if (policy) {
|
|
639
|
-
const
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if (typeof before === 'function') {
|
|
647
|
-
const result = normalizeResult(await before.call(policy, user, ability));
|
|
648
|
-
if (result.allowed === true)
|
|
649
|
-
return withMessage({ allowed: true, reason: 'policy-before' }, result.message);
|
|
650
|
-
if (result.allowed === false)
|
|
651
|
-
return withMessage({ allowed: false, reason: 'policy-before' }, result.message);
|
|
652
|
-
}
|
|
653
|
-
// Anonymous users are denied unless a hook granted access above.
|
|
654
|
-
if (maybeUser === NO_USER)
|
|
655
|
-
return { allowed: false, reason: 'anonymous' };
|
|
656
|
-
const result = normalizeResult((await method.call(policy, user, resource)));
|
|
657
|
-
// A nullish policy return is "no opinion" → leave `allowed` undefined so
|
|
658
|
-
// an `after` hook may decide; otherwise it default-denies in `resolve`.
|
|
659
|
-
return withMessage({ allowed: result.allowed, reason: 'policy' }, result.message);
|
|
660
|
-
}
|
|
271
|
+
const args = resource !== undefined && typeof resource !== 'function' ? [resource] : [];
|
|
272
|
+
// superAdmin and the decision point were asked above: an `AuthzPolicy` `before()`
|
|
273
|
+
// must not ask them again for this same check.
|
|
274
|
+
return withAskedCheck(user, ability, args, () => this.decidePolicy(user, policy, ability, args, authorizing));
|
|
275
|
+
}
|
|
276
|
+
if (await this.rbac.grants(user, ability, resource, cache)) {
|
|
277
|
+
return { allowed: true, reason: 'permission-provider', policy: null };
|
|
661
278
|
}
|
|
662
|
-
// Fall back to an ad-hoc gate.
|
|
663
279
|
const gate = this.gates.get(ability);
|
|
664
280
|
if (gate) {
|
|
665
|
-
if (
|
|
666
|
-
return { allowed: false, reason: 'anonymous' };
|
|
667
|
-
|
|
668
|
-
return withMessage({ allowed: result.allowed, reason: 'gate' }, result.message);
|
|
281
|
+
if (user == null)
|
|
282
|
+
return { allowed: false, reason: 'anonymous', policy: null };
|
|
283
|
+
return { allowed: (await gate(user, resource)) === true, reason: 'gate', policy: null };
|
|
669
284
|
}
|
|
670
285
|
throw new AbilityNotResolvedException(ability);
|
|
671
286
|
}
|
|
672
|
-
|
|
287
|
+
/** The linked policy defining `ability` for `resource` (or for no resource, unambiguously). */
|
|
288
|
+
policyFor(ability, resource) {
|
|
673
289
|
if (resource === undefined) {
|
|
674
|
-
|
|
675
|
-
// method. If MORE THAN ONE policy defines it, picking by Map-insertion
|
|
676
|
-
// order would be a silent, arbitrary (and likely wrong) choice — throw so
|
|
677
|
-
// the caller disambiguates by passing the resource class explicitly.
|
|
678
|
-
const matches = this.policies
|
|
679
|
-
.all()
|
|
680
|
-
.filter((policy) => typeof policy[ability] === 'function');
|
|
681
|
-
if (matches.length === 0)
|
|
682
|
-
return undefined;
|
|
290
|
+
const matches = this.index.linkedPoliciesWith(ability);
|
|
683
291
|
if (matches.length > 1) {
|
|
684
|
-
throw new AmbiguousAbilityException(ability, matches.map((p) => p.
|
|
292
|
+
throw new AmbiguousAbilityException(ability, matches.map((p) => p.name));
|
|
685
293
|
}
|
|
686
294
|
return matches[0];
|
|
687
295
|
}
|
|
688
|
-
|
|
689
|
-
if (
|
|
690
|
-
return
|
|
691
|
-
|
|
692
|
-
|
|
296
|
+
const policy = this.index.policyOf(resource);
|
|
297
|
+
if (!policy)
|
|
298
|
+
return undefined;
|
|
299
|
+
return typeof policy.prototype[ability] === 'function'
|
|
300
|
+
? policy
|
|
301
|
+
: undefined;
|
|
693
302
|
}
|
|
694
303
|
};
|
|
695
304
|
Gate = __decorate([
|
|
696
305
|
Injectable(),
|
|
697
|
-
__param(
|
|
698
|
-
__param(
|
|
699
|
-
__param(2, InjectCapability('context', 'accessor')),
|
|
306
|
+
__param(0, Optional()),
|
|
307
|
+
__param(0, Inject(AuthorizationService)),
|
|
700
308
|
__param(3, Optional()),
|
|
701
|
-
__param(
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
__param(5, Inject(ROLE_PROVIDER)),
|
|
705
|
-
__param(6, Optional()),
|
|
706
|
-
__param(6, Inject(DECISION_PROVIDER)),
|
|
707
|
-
__metadata("design:paramtypes", [PolicyRegistry, Object, Object, ModuleRef, Object, Object, Object])
|
|
309
|
+
__param(3, Inject(AUTHZ_MODULE_OPTIONS)),
|
|
310
|
+
__metadata("design:paramtypes", [Object, PolicyIndex,
|
|
311
|
+
AuthzRbac, Object])
|
|
708
312
|
], Gate);
|
|
709
313
|
export { Gate };
|
|
710
|
-
/**
|
|
711
|
-
* A {@link Gate} bound to an explicit user. Returned by {@link Gate.forUser}.
|
|
712
|
-
*/
|
|
314
|
+
/** A {@link Gate} bound to an explicit user. Returned by {@link Gate.forUser}. */
|
|
713
315
|
export class BoundGate {
|
|
714
316
|
gate;
|
|
715
317
|
user;
|
|
@@ -717,32 +319,28 @@ export class BoundGate {
|
|
|
717
319
|
this.gate = gate;
|
|
718
320
|
this.user = user;
|
|
719
321
|
}
|
|
720
|
-
allows(
|
|
721
|
-
return this.gate.
|
|
322
|
+
allows(first, ...rest) {
|
|
323
|
+
return this.gate.allowsFor(this.user, first, rest);
|
|
324
|
+
}
|
|
325
|
+
async denies(first, ...rest) {
|
|
326
|
+
return !(await this.gate.allowsFor(this.user, first, rest));
|
|
722
327
|
}
|
|
723
|
-
|
|
724
|
-
return
|
|
328
|
+
authorize(first, ...rest) {
|
|
329
|
+
return this.gate.authorizeFor(this.user, first, rest);
|
|
725
330
|
}
|
|
726
|
-
/** Batch-
|
|
331
|
+
/** Batch-check for the bound user (see {@link Gate.allowsMany}). */
|
|
727
332
|
allowsMany(items) {
|
|
728
|
-
return this.gate.
|
|
333
|
+
return this.gate.allowsManyFor(this.user, items);
|
|
729
334
|
}
|
|
730
|
-
/**
|
|
335
|
+
/** The query scope for the bound user (see {@link Gate.scope}). */
|
|
731
336
|
scope(entity, ability = 'viewAny') {
|
|
732
|
-
return this.gate.
|
|
733
|
-
}
|
|
734
|
-
async authorize(ability, resource) {
|
|
735
|
-
const decision = await this.gate.decideForUser(this.user, ability, resource);
|
|
736
|
-
if (!decision.allowed)
|
|
737
|
-
throw forbidden(ability, decision);
|
|
337
|
+
return this.gate.scopeFor(this.user, entity, ability);
|
|
738
338
|
}
|
|
739
|
-
/** True when the bound user holds `role`. */
|
|
740
339
|
hasRole(role) {
|
|
741
|
-
return this.gate.
|
|
340
|
+
return this.gate.hasAnyRoleFor(this.user, [role]);
|
|
742
341
|
}
|
|
743
|
-
/** True when the bound user holds ANY of `roles`. */
|
|
744
342
|
hasAnyRole(roles) {
|
|
745
|
-
return this.gate.
|
|
343
|
+
return this.gate.hasAnyRoleFor(this.user, roles);
|
|
746
344
|
}
|
|
747
345
|
}
|
|
748
346
|
//# sourceMappingURL=gate.js.map
|