@dudousxd/nestjs-authz 0.6.2 → 0.6.4

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @dudousxd/nestjs-authz
2
2
 
3
+ ## 0.6.4
4
+
5
+ ### Patch Changes
6
+
7
+ - [#15](https://github.com/DavideCarvalho/nestjs-authz/pull/15) [`10fb1d1`](https://github.com/DavideCarvalho/nestjs-authz/commit/10fb1d154f4dc7a8294b366f213ec38c26a9c678) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Ship TanStack Intent agent skills (SKILL.md) inside the package.
8
+
9
+ - [#15](https://github.com/DavideCarvalho/nestjs-authz/pull/15) [`7294ff5`](https://github.com/DavideCarvalho/nestjs-authz/commit/7294ff5c01454d9fea6d42a6c3f80eff2f00dc48) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Fix the exported `VERSION` const shipping as `'0.0.0'`.
10
+
11
+ The build is plain `tsc` with no version injection, and `changeset version` only bumps `package.json` — leaving the `export const VERSION` in `src/index.ts` stale, so the published dist reported `'0.0.0'`. The const is now corrected to match each package's `package.json`, and a new `scripts/sync-version.mjs` (chained into the `version-packages` script) re-syncs it on every release bump to prevent future drift. Run with `--check` to fail a build on mismatch.
12
+
13
+ ## 0.6.3
14
+
15
+ ### Patch Changes
16
+
17
+ - [`c4a1fe8`](https://github.com/DavideCarvalho/nestjs-authz/commit/c4a1fe876f6539446a1dec3bc241164058045bcb) - Derive authz's own DI tokens (`AUTHZ_MODULE_OPTIONS`, `RESOURCE_RESOLVER`, `RESOURCE_HYDRATOR`, `PERMISSION_PROVIDER`, `ROLE_PROVIDER`) from the `capability()` factory instead of hand-rolled `Symbol.for` strings, and add a conformance test (`assertCapabilityNaming`) that locks their canonical naming. Resolves to byte-identical symbols — fully non-breaking.
18
+
3
19
  ## 0.6.2
4
20
 
5
21
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.0.0";
1
+ export declare const VERSION = "0.6.4";
2
2
  export { Gate, BoundGate } from './gate.js';
3
3
  export type { BatchAbility, BatchResult } from './gate.js';
4
4
  export { PermissionCache } from './permission-cache.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = '0.0.0';
1
+ export const VERSION = '0.6.4';
2
2
  export { Gate, BoundGate } from './gate.js';
3
3
  export { PermissionCache } from './permission-cache.js';
4
4
  export { authzDecisionChannel, publishAuthzDecision } from './diagnostics.js';
package/dist/tokens.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Injection token holding the resolved {@link AuthzModuleOptions}. */
2
- export declare const AUTHZ_MODULE_OPTIONS: unique symbol;
2
+ export declare const AUTHZ_MODULE_OPTIONS: symbol;
3
3
  /** Injection token for the optional {@link ResourceResolver} registered by the app. */
4
- export declare const RESOURCE_RESOLVER: unique symbol;
4
+ export declare const RESOURCE_RESOLVER: symbol;
5
5
  /**
6
6
  * Injection token holding the optional resource-loader map consulted by the opt-in
7
7
  * `POST /authz/can` endpoint to rehydrate a `{ type, id }` shim into the REAL entity
@@ -16,7 +16,7 @@ export declare const RESOURCE_RESOLVER: unique symbol;
16
16
  * same key resolves to the SAME symbol instance. Consulted with `@Optional()` — absent
17
17
  * or empty, the endpoint behaves exactly as before (class-level / ad-hoc only).
18
18
  */
19
- export declare const RESOURCE_HYDRATOR: unique symbol;
19
+ export declare const RESOURCE_HYDRATOR: symbol;
20
20
  /** Metadata key for `@Policy(Resource)` — stores the resource class on the policy. */
21
21
  export declare const POLICY_RESOURCE_METADATA = "nestjs-authz:policy-resource";
22
22
  /** Metadata key for `@Can(ability, Resource?)` — stores the ability descriptor on a route. */
@@ -34,7 +34,7 @@ export declare const ROLES_METADATA = "nestjs-authz:roles";
34
34
  * The token is consulted with `@Optional()` — when absent, the Gate behaves exactly
35
35
  * as before (backward-compatible: unknown abilities still throw).
36
36
  */
37
- export declare const PERMISSION_PROVIDER: unique symbol;
37
+ export declare const PERMISSION_PROVIDER: symbol;
38
38
  /**
39
39
  * Cross-lib injection token for an optional {@link RoleProvider} — the seam an RBAC
40
40
  * adapter (`@dudousxd/nestjs-authz-typeorm`) registers so coarse role-checks
@@ -46,7 +46,7 @@ export declare const PERMISSION_PROVIDER: unique symbol;
46
46
  * `@Optional()`: when absent, only the default {@link RoleResolver} (roles read off
47
47
  * the user object) supplies roles. When BOTH yield roles, the Gate unions them.
48
48
  */
49
- export declare const ROLE_PROVIDER: unique symbol;
49
+ export declare const ROLE_PROVIDER: symbol;
50
50
  /**
51
51
  * Cross-lib injection token for the current-request context accessor, owned by
52
52
  * `@dudousxd/nestjs-context`. Resolved via the ecosystem protocol's typed
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAEA,uEAAuE;AACvE,eAAO,MAAM,oBAAoB,eAA+C,CAAC;AAEjF,uFAAuF;AACvF,eAAO,MAAM,iBAAiB,eAAyD,CAAC;AAExF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,eAAyD,CAAC;AAExF,sFAAsF;AACtF,eAAO,MAAM,wBAAwB,iCAAiC,CAAC;AAEvE,8FAA8F;AAC9F,eAAO,MAAM,YAAY,qBAAqB,CAAC;AAE/C,sFAAsF;AACtF,eAAO,MAAM,cAAc,uBAAuB,CAAC;AAEnD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,eAA2D,CAAC;AAE5F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,eAAqD,CAAC;AAEhF;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,QAAoC,CAAC"}
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAEA,uEAAuE;AACvE,eAAO,MAAM,oBAAoB,QAAiC,CAAC;AAEnE,uFAAuF;AACvF,eAAO,MAAM,iBAAiB,QAA2C,CAAC;AAE1E;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,QAA2C,CAAC;AAE1E,sFAAsF;AACtF,eAAO,MAAM,wBAAwB,iCAAiC,CAAC;AAEvE,8FAA8F;AAC9F,eAAO,MAAM,YAAY,qBAAqB,CAAC;AAE/C,sFAAsF;AACtF,eAAO,MAAM,cAAc,uBAAuB,CAAC;AAEnD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,QAA6C,CAAC;AAE9E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,QAAuC,CAAC;AAElE;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,QAAoC,CAAC"}
package/dist/tokens.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { capability } from '@dudousxd/nestjs-diagnostics';
2
2
  /** Injection token holding the resolved {@link AuthzModuleOptions}. */
3
- export const AUTHZ_MODULE_OPTIONS = Symbol.for('@dudousxd/nestjs-authz:options');
3
+ export const AUTHZ_MODULE_OPTIONS = capability('authz', 'options');
4
4
  /** Injection token for the optional {@link ResourceResolver} registered by the app. */
5
- export const RESOURCE_RESOLVER = Symbol.for('@dudousxd/nestjs-authz:resource-resolver');
5
+ export const RESOURCE_RESOLVER = capability('authz', 'resource-resolver');
6
6
  /**
7
7
  * Injection token holding the optional resource-loader map consulted by the opt-in
8
8
  * `POST /authz/can` endpoint to rehydrate a `{ type, id }` shim into the REAL entity
@@ -17,7 +17,7 @@ export const RESOURCE_RESOLVER = Symbol.for('@dudousxd/nestjs-authz:resource-res
17
17
  * same key resolves to the SAME symbol instance. Consulted with `@Optional()` — absent
18
18
  * or empty, the endpoint behaves exactly as before (class-level / ad-hoc only).
19
19
  */
20
- export const RESOURCE_HYDRATOR = Symbol.for('@dudousxd/nestjs-authz:resource-hydrator');
20
+ export const RESOURCE_HYDRATOR = capability('authz', 'resource-hydrator');
21
21
  /** Metadata key for `@Policy(Resource)` — stores the resource class on the policy. */
22
22
  export const POLICY_RESOURCE_METADATA = 'nestjs-authz:policy-resource';
23
23
  /** Metadata key for `@Can(ability, Resource?)` — stores the ability descriptor on a route. */
@@ -35,7 +35,7 @@ export const ROLES_METADATA = 'nestjs-authz:roles';
35
35
  * The token is consulted with `@Optional()` — when absent, the Gate behaves exactly
36
36
  * as before (backward-compatible: unknown abilities still throw).
37
37
  */
38
- export const PERMISSION_PROVIDER = Symbol.for('@dudousxd/nestjs-authz:permission-provider');
38
+ export const PERMISSION_PROVIDER = capability('authz', 'permission-provider');
39
39
  /**
40
40
  * Cross-lib injection token for an optional {@link RoleProvider} — the seam an RBAC
41
41
  * adapter (`@dudousxd/nestjs-authz-typeorm`) registers so coarse role-checks
@@ -47,7 +47,7 @@ export const PERMISSION_PROVIDER = Symbol.for('@dudousxd/nestjs-authz:permission
47
47
  * `@Optional()`: when absent, only the default {@link RoleResolver} (roles read off
48
48
  * the user object) supplies roles. When BOTH yield roles, the Gate unions them.
49
49
  */
50
- export const ROLE_PROVIDER = Symbol.for('@dudousxd/nestjs-authz:role-provider');
50
+ export const ROLE_PROVIDER = capability('authz', 'role-provider');
51
51
  /**
52
52
  * Cross-lib injection token for the current-request context accessor, owned by
53
53
  * `@dudousxd/nestjs-context`. Resolved via the ecosystem protocol's typed
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAE1D,uEAAuE;AACvE,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAC;AAEjF,uFAAuF;AACvF,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC,GAAG,CAAC,0CAA0C,CAAC,CAAC;AAExF;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC,GAAG,CAAC,0CAA0C,CAAC,CAAC;AAExF,sFAAsF;AACtF,MAAM,CAAC,MAAM,wBAAwB,GAAG,8BAA8B,CAAC;AAEvE,8FAA8F;AAC9F,MAAM,CAAC,MAAM,YAAY,GAAG,kBAAkB,CAAC;AAE/C,sFAAsF;AACtF,MAAM,CAAC,MAAM,cAAc,GAAG,oBAAoB,CAAC;AAEnD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAC,4CAA4C,CAAC,CAAC;AAE5F;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC,GAAG,CAAC,sCAAsC,CAAC,CAAC;AAEhF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,UAAU,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC"}
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAE1D,uEAAuE;AACvE,MAAM,CAAC,MAAM,oBAAoB,GAAG,UAAU,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;AAEnE,uFAAuF;AACvF,MAAM,CAAC,MAAM,iBAAiB,GAAG,UAAU,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC;AAE1E;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,UAAU,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC;AAE1E,sFAAsF;AACtF,MAAM,CAAC,MAAM,wBAAwB,GAAG,8BAA8B,CAAC;AAEvE,8FAA8F;AAC9F,MAAM,CAAC,MAAM,YAAY,GAAG,kBAAkB,CAAC;AAE/C,sFAAsF;AACtF,MAAM,CAAC,MAAM,cAAc,GAAG,oBAAoB,CAAC;AAEnD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,UAAU,CAAC,OAAO,EAAE,qBAAqB,CAAC,CAAC;AAE9E;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,UAAU,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC;AAElE;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,UAAU,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-authz",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "NestJS authorization — Laravel-style Gates & Policies, @Can guard, resource resolver (zero DB).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,7 +27,8 @@
27
27
  "files": [
28
28
  "dist/",
29
29
  "README.md",
30
- "CHANGELOG.md"
30
+ "CHANGELOG.md",
31
+ "skills"
31
32
  ],
32
33
  "dependencies": {
33
34
  "@dudousxd/nestjs-diagnostics": "^0.5.0"
@@ -45,6 +46,7 @@
45
46
  },
46
47
  "devDependencies": {
47
48
  "@nestjs/common": "^11.0.0",
49
+ "@tanstack/intent": "^0.3.2",
48
50
  "@nestjs/core": "^11.0.0",
49
51
  "@nestjs/platform-express": "^11.0.0",
50
52
  "@nestjs/testing": "^11.0.0",
@@ -68,7 +70,8 @@
68
70
  "gate",
69
71
  "policy",
70
72
  "acl",
71
- "rbac"
73
+ "rbac",
74
+ "tanstack-intent"
72
75
  ],
73
76
  "scripts": {
74
77
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,167 @@
1
+ ---
2
+ name: authz-enforcement
3
+ description: >
4
+ Enforce authorization in @dudousxd/nestjs-authz with the @Can and @Roles guards and the
5
+ programmatic Gate. @Can('update', Post) resolves a Post instance via the ResourceResolver
6
+ and runs PostPolicy.update; @Can('create', Post, { classLevel: true }) skips loading;
7
+ @Can('access-admin') hits an ad-hoc gate. @Roles('admin','teacher') is the coarse role
8
+ check. Both guards are auto-registered as APP_GUARD and are inert on un-annotated routes.
9
+ Covers the ResourceResolver seam (default IdParamResourceResolver reads :id vs a real ORM
10
+ resolver bound to RESOURCE_RESOLVER) and the Gate API: authorize/allows/denies/allowsMany,
11
+ forUser(user) BoundGate, define(ability, fn), hasRole/hasAnyRole.
12
+ metadata:
13
+ type: core
14
+ library: "@dudousxd/nestjs-authz"
15
+ library_version: "0.6.3"
16
+ framework: nestjs
17
+ ---
18
+
19
+ # Enforcing authorization
20
+
21
+ Two paths: **declarative** guards (`@Can`, `@Roles`) and the **programmatic** `Gate`.
22
+ Both guards are registered as `APP_GUARD` by `AuthzModule.forRoot`, and both are **inert**
23
+ on routes without their decorator (no metadata → allow).
24
+
25
+ ## Setup
26
+
27
+ ```ts
28
+ import { Controller, Param, Patch, Post as HttpPost } from '@nestjs/common';
29
+ import { Can, Roles } from '@dudousxd/nestjs-authz';
30
+ import { Post } from './post.entity';
31
+
32
+ @Controller('posts')
33
+ export class PostController {
34
+ @Patch(':id')
35
+ @Can('update', Post) // load Post by :id, run PostPolicy.update(currentUser, post)
36
+ update(@Param('id') id: string) {}
37
+
38
+ @HttpPost()
39
+ @Can('create', Post, { classLevel: true }) // run PostPolicy.create(currentUser) — no instance
40
+ create() {}
41
+
42
+ @HttpPost('purge')
43
+ @Roles('admin', 'teacher') // allow if the user holds ANY of these roles
44
+ purge() {}
45
+ }
46
+ ```
47
+
48
+ ## Core patterns
49
+
50
+ ### Programmatic Gate
51
+
52
+ Inject `Gate` for checks inside services. `authorize` throws `ForbiddenException` on deny;
53
+ `allows`/`denies` return a boolean.
54
+
55
+ ```ts
56
+ import { Injectable } from '@nestjs/common';
57
+ import { Gate } from '@dudousxd/nestjs-authz';
58
+
59
+ @Injectable()
60
+ export class PostService {
61
+ constructor(private readonly gate: Gate) {}
62
+
63
+ async update(post: Post) {
64
+ await this.gate.authorize('update', post); // throws on deny
65
+ if (await this.gate.allows('delete', post)) { /* ... */ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ `forUser(user)` returns a `BoundGate` that bypasses the context accessor — use it when no
71
+ nestjs-context is wired, or to check a user other than the current one:
72
+
73
+ ```ts
74
+ await this.gate.forUser(someUser).authorize('update', post);
75
+ ```
76
+
77
+ `allowsMany` batch-checks one user in a single pass (resolves the user once, shares a
78
+ permission cache — kills the N+1 on a list page):
79
+
80
+ ```ts
81
+ const results = await this.gate.allowsMany([
82
+ { ability: 'update', resource: post },
83
+ { ability: 'delete', resource: post },
84
+ ]); // → [{ ability:'update', resource, allowed }, ...] (order preserved)
85
+ ```
86
+
87
+ ### Ad-hoc gates
88
+
89
+ Register a model-less, named ability with `define`, then guard or check it:
90
+
91
+ ```ts
92
+ this.gate.define('access-admin', (user) => (user as User).role === 'staff');
93
+ await this.gate.allows('access-admin'); // programmatic
94
+ // @Can('access-admin') // declarative (no resource class)
95
+ ```
96
+
97
+ ### Custom ResourceResolver
98
+
99
+ `@Can('update', Post)` needs an instance. The default `IdParamResourceResolver` builds a
100
+ `{ id }` shim from the route `:id`. For real entities, implement `ResourceResolver` and
101
+ bind `RESOURCE_RESOLVER` (or pass `resourceResolver` to `forRoot`):
102
+
103
+ ```ts
104
+ import type { ResourceResolver } from '@dudousxd/nestjs-authz';
105
+ import { RESOURCE_RESOLVER } from '@dudousxd/nestjs-authz';
106
+ import type { ExecutionContext, Type } from '@nestjs/common';
107
+
108
+ class OrmResourceResolver implements ResourceResolver {
109
+ async resolve(resource: Type<unknown>, ctx: ExecutionContext) {
110
+ const req = ctx.switchToHttp().getRequest<{ params: { id: string } }>();
111
+ return this.repos.get(resource).findOneBy({ id: Number(req.params.id) });
112
+ }
113
+ }
114
+ // provide: { provide: RESOURCE_RESOLVER, useClass: OrmResourceResolver }
115
+ ```
116
+
117
+ ## Common mistakes
118
+
119
+ ### 1. Class-level ability without `{ classLevel: true }`
120
+
121
+ ```ts
122
+ // Wrong — the guard tries to LOAD a Post (by :id) for a creation route that has none,
123
+ // so the resolver returns undefined and the request is denied.
124
+ @HttpPost()
125
+ @Can('create', Post)
126
+ create() {}
127
+
128
+ // Correct — flag it class-level so the guard skips resource loading.
129
+ @HttpPost()
130
+ @Can('create', Post, { classLevel: true })
131
+ create() {}
132
+ ```
133
+
134
+ Mechanism: with a `resource` and no `classLevel`, the guard loads an instance via the
135
+ resolver; a `undefined` instance is treated as not-found → deny.
136
+ Source: `packages/core/src/guard/can.guard.ts` (`canActivate`), `packages/core/src/decorator/can.decorator.ts`.
137
+
138
+ ### 2. Using the default resolver and expecting real entity fields
139
+
140
+ ```ts
141
+ // Wrong — IdParamResourceResolver only sets `{ id }`; `post.authorId` is undefined,
142
+ // so an ownership policy reading post.authorId can never match.
143
+ AuthzModule.forRoot({ policies: [PostPolicy] });
144
+
145
+ // Correct — register an ORM-backed resolver so the policy receives a hydrated row.
146
+ AuthzModule.forRoot({ policies: [PostPolicy], resourceResolver: new OrmResourceResolver(...) });
147
+ ```
148
+
149
+ Mechanism: the default resolver produces a `Object.create(resource.prototype)` shim with
150
+ only `id` set (enough for `instance.constructor` matching, not for column reads).
151
+ Source: `packages/core/src/resource-resolver.ts` (`IdParamResourceResolver`).
152
+
153
+ ### 3. Ambiguous class-level ability across multiple policies
154
+
155
+ ```ts
156
+ // Wrong — both PostPolicy and CommentPolicy define create(); with no resource the Gate
157
+ // cannot tell which to run and throws AmbiguousAbilityException.
158
+ await this.gate.allows('create');
159
+
160
+ // Correct — pass the resource class so the Gate selects the right policy.
161
+ await this.gate.allows('create', Post); // programmatic
162
+ // @Can('create', Post, { classLevel: true }) // declarative
163
+ ```
164
+
165
+ Mechanism: with no resource the Gate scans all policies for the method; >1 match is an
166
+ ambiguous (arbitrary) choice, so it throws instead of guessing.
167
+ Source: `packages/core/src/gate.ts` (`resolvePolicy`, `AmbiguousAbilityException`).
@@ -0,0 +1,150 @@
1
+ ---
2
+ name: authz-policies
3
+ description: >
4
+ Write authorization policies for @dudousxd/nestjs-authz the Laravel way. Use the
5
+ @Policy(Resource) decorator to map a class to a resource; its methods are abilities
6
+ dispatched by name (view/update/create...) receiving (user, resource). Covers the
7
+ optional before(user, ability) short-circuit hook (true=allow, false=deny,
8
+ undefined=fall through), class-level abilities that omit the resource, returning a
9
+ deny message via PolicyResponse ({ allowed, message }) instead of a bare boolean, and
10
+ the global superAdmin / after hooks with their override semantics (after can only fill
11
+ in when the policy returned nullish; default-deny on no opinion).
12
+ metadata:
13
+ type: core
14
+ library: "@dudousxd/nestjs-authz"
15
+ library_version: "0.6.3"
16
+ framework: nestjs
17
+ ---
18
+
19
+ # Writing policies
20
+
21
+ A **policy** is a class decorated with `@Policy(Resource)`. Its methods are **abilities**
22
+ dispatched by name: `update(user, post)` answers `gate.authorize('update', post)` and
23
+ `@Can('update', Post)`.
24
+
25
+ ## Setup
26
+
27
+ ```ts
28
+ import { Policy } from '@dudousxd/nestjs-authz';
29
+ import { Post } from './post.entity';
30
+ import { User } from './user.entity';
31
+
32
+ @Policy(Post)
33
+ export class PostPolicy {
34
+ // before runs first for any ability this policy defines:
35
+ // true → allow (short-circuit), false → deny (short-circuit), undefined → fall through
36
+ before(user: User, ability: string) {
37
+ if (user.isAdmin) return true;
38
+ }
39
+
40
+ view(user: User, post: Post) {
41
+ return post.published || post.authorId === user.id;
42
+ }
43
+
44
+ update(user: User, post: Post) {
45
+ return post.authorId === user.id;
46
+ }
47
+
48
+ // class-level ability: no resource instance — receives only the user
49
+ create(user: User) {
50
+ return user.verified;
51
+ }
52
+ }
53
+ ```
54
+
55
+ `@Policy` also makes the class `@Injectable()`, so it is auto-discovered from the Nest
56
+ container (you do not have to list it in `policies: []`, though you may). Register it via
57
+ `AuthzModule.forRoot({ policies: [PostPolicy] })` or rely on auto-discovery.
58
+
59
+ ## Core patterns
60
+
61
+ ### Deny messages with PolicyResponse
62
+
63
+ Return `{ allowed, message }` instead of a bare boolean to surface a reason. The message
64
+ rides the thrown `ForbiddenException` body and the diagnostics decision.
65
+
66
+ ```ts
67
+ update(user: User, post: Post) {
68
+ if (post.locked) return { allowed: false, message: 'Post is locked' };
69
+ return post.authorId === user.id;
70
+ }
71
+ ```
72
+
73
+ ### Global superAdmin and after hooks
74
+
75
+ Configured on the module, not the policy. `superAdmin` runs **before** any policy/gate;
76
+ truthy grants. `after` (Laravel `Gate::after`) runs **after** resolution:
77
+
78
+ ```ts
79
+ AuthzModule.forRoot({
80
+ superAdmin: (user, ability) => (user as User).role === 'root', // truthy → allow
81
+ after: (user, ability, result) => {
82
+ // result is the policy/gate verdict, or undefined when it had "no opinion".
83
+ if (result === undefined) return ability.startsWith('public.'); // only fills in a gap
84
+ },
85
+ });
86
+ ```
87
+
88
+ ### Inheritance
89
+
90
+ A subclass of a `@Policy`-decorated class still resolves the inherited resource
91
+ (`getPolicyResource` uses `getMetadata`, walking the prototype chain), so you can share a
92
+ base policy and override individual ability methods.
93
+
94
+ ## Common mistakes
95
+
96
+ ### 1. Falling off the end of a policy method (implicit deny)
97
+
98
+ ```ts
99
+ // Wrong — no return on the non-owner branch yields `undefined` = "no opinion" = DENY,
100
+ // even for a published post you meant to allow.
101
+ view(user: User, post: Post) {
102
+ if (post.authorId === user.id) return true;
103
+ }
104
+
105
+ // Correct — return an explicit boolean for every path.
106
+ view(user: User, post: Post) {
107
+ return post.published || post.authorId === user.id;
108
+ }
109
+ ```
110
+
111
+ Mechanism: a nullish policy return is normalized to `allowed: undefined`, which
112
+ default-denies in `resolve` (after giving an `after` hook a chance).
113
+ Source: `packages/core/src/gate.ts` (`resolveBase` / `resolve`, `normalizeResult`).
114
+
115
+ ### 2. Putting a `before` grant for an ability the policy does not define
116
+
117
+ ```ts
118
+ // Wrong — `before` grants 'publish', but there is no publish() method, so the ability
119
+ // is unresolved and throws AbilityNotResolvedException instead of being allowed.
120
+ @Policy(Post)
121
+ class PostPolicy {
122
+ before(user: User, ability: string) { if (ability === 'publish') return true; }
123
+ }
124
+
125
+ // Correct — define the method; `before` may then short-circuit it.
126
+ @Policy(Post)
127
+ class PostPolicy {
128
+ before(user: User, ability: string) { if (ability === 'publish') return true; }
129
+ publish(user: User, post: Post) { return post.authorId === user.id; }
130
+ }
131
+ ```
132
+
133
+ Mechanism: the Gate gates the `before` hook on method existence FIRST — a `before` cannot
134
+ answer an ability the policy never declared.
135
+ Source: `packages/core/src/gate.ts` (`resolveBase`, "gate it on method existence FIRST").
136
+
137
+ ### 3. Expecting `after` to overturn an explicit policy verdict
138
+
139
+ ```ts
140
+ // Wrong — the policy already returned `false`; `after` is observe-only here and its
141
+ // return is ignored, so this does NOT re-grant access.
142
+ after: (user, ability, result) => true;
143
+
144
+ // Correct — `after` can only supply a verdict when the policy/gate had no opinion.
145
+ after: (user, ability, result) => (result === undefined ? true : undefined);
146
+ ```
147
+
148
+ Mechanism: `after` overrides ONLY when `result === undefined` (the base produced no
149
+ explicit verdict); when the policy decided, `after` is purely an observer.
150
+ Source: `packages/core/src/types.ts` (`AfterHook` docs), `packages/core/src/gate.ts` (`resolve`).
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: authz-query-scopes
3
+ description: >
4
+ Filter collections to the rows a user may access with @dudousxd/nestjs-authz query
5
+ scopes — the accessibleBy / Pundit policy_scope / Cerbos query-plan concept. gate.scope(
6
+ Entity, ability='viewAny') returns an ORM-neutral ScopeConstraint AST built from scopeAll
7
+ / scopeNone and the where(field, op, value) / eq(field, value) / and(...) / or(...)
8
+ helpers (operators eq, ne, gt, gte, lt, lte, in, nin, isNull, isNotNull). A @Policy scope(
9
+ user, Entity) method produces it (returning true=allow-all, false/nullish=deny-all sugar);
10
+ an ORM adapter such as @dudousxd/nestjs-authz-typeorm's applyScope / applyScopeConstraint
11
+ compiles it to a parameterized, identifier-safe WHERE. Mirrors the Gate's grant order:
12
+ super-admin / permission-provider grant → allow-all; anonymous / no scope → deny-all.
13
+ metadata:
14
+ type: core
15
+ library: "@dudousxd/nestjs-authz"
16
+ library_version: "0.6.3"
17
+ framework: nestjs
18
+ ---
19
+
20
+ # Query scopes (filtering collections)
21
+
22
+ A policy **method** decides yes/no for ONE resource. A policy **scope** produces a
23
+ constraint that filters a COLLECTION at the DB layer — instead of over-fetch-then-filter.
24
+ This is the `accessibleBy` / Pundit `policy_scope` / Cerbos query-plan idea, represented
25
+ as a small, serializable condition AST (`ScopeConstraint`).
26
+
27
+ ## Setup
28
+
29
+ Add a `scope(user, Entity)` method to a `@Policy` class, returning a `ScopeConstraint`
30
+ built from the core helpers:
31
+
32
+ ```ts
33
+ import { Policy, eq, and, where, scopeAll } from '@dudousxd/nestjs-authz';
34
+ import { Post } from './post.entity';
35
+ import { User } from './user.entity';
36
+
37
+ @Policy(Post)
38
+ export class PostPolicy {
39
+ scope(user: User, _entity: typeof Post) {
40
+ if (user.isAdmin) return scopeAll; // sugar: every row
41
+ // editors see published OR their own posts:
42
+ return and(
43
+ eq('tenantId', user.tenantId),
44
+ where('authorId', 'eq', user.id),
45
+ );
46
+ }
47
+ }
48
+ ```
49
+
50
+ Resolve it for the current (context) user and apply it with an adapter:
51
+
52
+ ```ts
53
+ import { Gate } from '@dudousxd/nestjs-authz';
54
+ import { applyScopeConstraint, applyScope } from '@dudousxd/nestjs-authz-typeorm';
55
+
56
+ const constraint = await this.gate.scope(Post); // ScopeConstraint (ability defaults to 'viewAny')
57
+ const qb = repo.createQueryBuilder('post');
58
+ applyScopeConstraint(qb, constraint); // adds a parameterized WHERE
59
+ const posts = await qb.getMany();
60
+
61
+ // or fold both steps for the current user:
62
+ await applyScope(qb, this.gate, Post);
63
+ ```
64
+
65
+ ## Core patterns
66
+
67
+ ### The constraint vocabulary
68
+
69
+ - `scopeAll` (`{ kind: 'all' }`) — every row; no WHERE added.
70
+ - `scopeNone` (`{ kind: 'none' }`) — no rows; an always-false predicate.
71
+ - `where(field, op, value)` / `eq(field, value)` — a leaf condition. Operators: `eq`,
72
+ `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `isNull`, `isNotNull`.
73
+ - `and(...nodes)` / `or(...nodes)` — boolean groups. Empty `and()` → allow-all; empty
74
+ `or()` → deny-all; a single node is returned as-is.
75
+
76
+ ### Grant order mirrors the single-resource Gate
77
+
78
+ `gate.scope` resolves consistently with `gate.allows`: a `superAdmin` grant or a
79
+ permission-provider grant for the ability → `scopeAll`; an anonymous user, or no
80
+ `scope`/`viewAny` method → `scopeNone`; otherwise the policy's `scope` constraint. A
81
+ policy `before` returning `true`/`false` scopes to allow-all/deny-all too.
82
+
83
+ ### Return-value sugar
84
+
85
+ A `scope` method may return `true` (→ `scopeAll`), `false`/`null`/`undefined` (→
86
+ `scopeNone`), or a `ScopeConstraint`. `normalizeScope` handles the coercion.
87
+
88
+ ## Common mistakes
89
+
90
+ ### 1. Hand-filtering after fetching instead of scoping the query
91
+
92
+ ```ts
93
+ // Wrong — loads every row then filters in memory: leaks counts, paginates wrong, slow.
94
+ const all = await repo.find();
95
+ const visible = all.filter((p) => p.authorId === user.id);
96
+
97
+ // Correct — push the constraint into the SQL WHERE.
98
+ const qb = repo.createQueryBuilder('post');
99
+ await applyScope(qb, this.gate, Post);
100
+ const visible = await qb.getMany();
101
+ ```
102
+
103
+ Mechanism: `gate.scope` returns a pure-data AST the adapter compiles to a parameterized
104
+ WHERE, so the DB does the filtering and pagination/counts stay correct.
105
+ Source: `packages/core/src/scope.ts` (`ScopeConstraint`), `packages/typeorm/src/scope.ts`
106
+ (`applyScope` / `applyScopeConstraint`).
107
+
108
+ ### 2. Interpolating user input into a field name
109
+
110
+ ```ts
111
+ // Wrong — concatenating a dynamic column name risks SQL injection and is rejected.
112
+ return where(`${userSuppliedColumn}` as string, 'eq', value);
113
+
114
+ // Correct — use known, static field names; values are always bound parameters.
115
+ return eq('authorId', user.id);
116
+ ```
117
+
118
+ Mechanism: adapters validate every interpolated identifier against `SAFE_IDENTIFIER`
119
+ (`/^[A-Za-z_][A-Za-z0-9_]*$/`) and **bind** values as parameters — never concatenate them.
120
+ Source: `packages/core/src/scope.ts` (`SAFE_IDENTIFIER`, `assertSafeIdentifier`,
121
+ `ScopeCondition` "value is bound as a parameter").
122
+
123
+ ### 3. Returning `undefined` from a scope and expecting "all rows"
124
+
125
+ ```ts
126
+ // Wrong — a nullish return is deny-all (no rows), not allow-all.
127
+ scope(user: User) {
128
+ if (user.isAdmin) return; // oops
129
+ }
130
+
131
+ // Correct — be explicit; nullish/false = deny-all, true/scopeAll = allow-all.
132
+ scope(user: User) {
133
+ return user.isAdmin ? scopeAll : eq('authorId', user.id);
134
+ }
135
+ ```
136
+
137
+ Mechanism: `normalizeScope` maps `null`/`undefined`/`false` to `scopeNone` (default-deny),
138
+ consistent with the Gate's deny-by-default posture.
139
+ Source: `packages/core/src/scope.ts` (`normalizeScope`, `ScopeResult`).
@@ -0,0 +1,157 @@
1
+ ---
2
+ name: authz-rbac-seams
3
+ description: >
4
+ Add persisted roles & permissions to @dudousxd/nestjs-authz through its optional,
5
+ grant-only seams. PERMISSION_PROVIDER (a PermissionProvider with hasPermission and an
6
+ optional getPermissions) is the Laravel/spatie Gate::before grant — when the user holds
7
+ a named permission the ability is allowed regardless of policies; getPermissions enables
8
+ segment wildcard matching (granted posts.* satisfies posts.update, * satisfies anything).
9
+ ROLE_PROVIDER (a RoleProvider.getRoles) feeds coarse checks gate.hasRole / @Roles, unioned
10
+ with the user-object defaultRoleResolver (reads user.roles / user.role). Both tokens are
11
+ capability symbols the ORM adapters (@dudousxd/nestjs-authz-typeorm / -prisma / -mikro-orm)
12
+ register via AuthzRbacModule; seams are grant-only — a false result never DENIES, it falls
13
+ through. Covers resolveRoles override and zero-table role checks.
14
+ metadata:
15
+ type: core
16
+ library: "@dudousxd/nestjs-authz"
17
+ library_version: "0.6.3"
18
+ framework: nestjs
19
+ ---
20
+
21
+ # Persisted RBAC seams
22
+
23
+ The core stays zero-DB. Persisted roles/permissions plug in through two **optional,
24
+ grant-only** seams the Gate consults via `@Optional()` capability tokens:
25
+
26
+ - `PERMISSION_PROVIDER` → a `PermissionProvider` (the Laravel/spatie `Gate::before` grant).
27
+ - `ROLE_PROVIDER` → a `RoleProvider` (feeds `gate.hasRole` / `@Roles`).
28
+
29
+ When neither is registered, the Gate behaves exactly as before. **Grant-only** means a
30
+ provider returning `false`/nullish never DENIES — it falls through to normal resolution.
31
+
32
+ ## Setup
33
+
34
+ The simplest path is an ORM adapter's `AuthzRbacModule`, which builds a store and
35
+ registers both seams. Example with the Prisma adapter (the TypeORM and MikroORM siblings
36
+ have an identical `AuthzRbacModule` surface):
37
+
38
+ ```ts
39
+ import { Module } from '@nestjs/common';
40
+ import { AuthzModule } from '@dudousxd/nestjs-authz';
41
+ import { AuthzRbacModule } from '@dudousxd/nestjs-authz-prisma';
42
+ import { PrismaClient } from '@prisma/client';
43
+
44
+ const prisma = new PrismaClient();
45
+
46
+ @Module({
47
+ imports: [
48
+ AuthzModule.forRoot({ policies: [PostPolicy] }),
49
+ AuthzRbacModule.forRoot({ client: prisma }), // builds the store, registers the seams
50
+ ],
51
+ })
52
+ export class AppModule {}
53
+ ```
54
+
55
+ Grant permissions/roles through the store, then the Gate consults them automatically:
56
+
57
+ ```ts
58
+ await store.givePermissionToRole('editor', 'posts.publish');
59
+ await store.assignRole({ type: 'user', id: 7 }, 'editor');
60
+
61
+ await gate.forUser(user).allows('posts.publish'); // true via PERMISSION_PROVIDER
62
+ await gate.forUser(user).hasRole('editor'); // true via ROLE_PROVIDER
63
+ ```
64
+
65
+ ## Core patterns
66
+
67
+ ### A hand-rolled PermissionProvider
68
+
69
+ Implement the interface and bind `PERMISSION_PROVIDER` yourself when you do not use an
70
+ adapter. Implement `getPermissions` to opt into wildcard matching:
71
+
72
+ ```ts
73
+ import type { PermissionProvider, User } from '@dudousxd/nestjs-authz';
74
+ import { PERMISSION_PROVIDER } from '@dudousxd/nestjs-authz';
75
+
76
+ class MyPermissions implements PermissionProvider {
77
+ hasPermission(user: User, permission: string) {
78
+ return this.exactGrants(user).includes(permission); // exact-match fast path
79
+ }
80
+ getPermissions(user: User) {
81
+ return this.grantsFor(user); // e.g. ['posts.*'] — core matches posts.update against it
82
+ }
83
+ }
84
+ // provide: { provide: PERMISSION_PROVIDER, useClass: MyPermissions }
85
+ ```
86
+
87
+ The core runs segment-based wildcard matching against `getPermissions`: a granted
88
+ `posts.*` satisfies `posts.update`; `*` satisfies anything. `hasPermission` is the
89
+ exact-match contract for providers that do not list grants.
90
+
91
+ ### Zero-table role checks (no provider)
92
+
93
+ `gate.hasRole` / `@Roles` work with **no** RBAC tables: the `defaultRoleResolver` reads
94
+ `user.roles` (`string[]`) OR `user.role` (`string | string[]`) off the user object.
95
+ Override the shape with `resolveRoles`:
96
+
97
+ ```ts
98
+ AuthzModule.forRoot({
99
+ resolveRoles: (user) => (user as { groups: string[] }).groups, // derive roles differently
100
+ });
101
+ ```
102
+
103
+ When BOTH a `ROLE_PROVIDER` and the resolver yield roles, the Gate takes their **union**.
104
+
105
+ ## Common mistakes
106
+
107
+ ### 1. Expecting a PermissionProvider to DENY
108
+
109
+ ```ts
110
+ // Wrong — returning false to "block" an ability does nothing: the seam is grant-only,
111
+ // so a policy/gate can still allow it. This is not how you revoke access.
112
+ hasPermission(user: User, permission: string) {
113
+ return this.isBanned(user) ? false : this.grants(user).includes(permission);
114
+ }
115
+
116
+ // Correct — denial belongs in the policy/superAdmin; the provider only GRANTS.
117
+ hasPermission(user: User, permission: string) {
118
+ return this.grants(user).includes(permission); // false simply falls through
119
+ }
120
+ ```
121
+
122
+ Mechanism: the provider grant mirrors `Gate::before` — `true` short-circuits to allow;
123
+ `false`/`undefined` falls through to policy/gate resolution and never denies.
124
+ Source: `packages/core/src/permission-provider.ts` (grant semantics),
125
+ `packages/core/src/gate.ts` (`permissionProviderGrants`, `resolveBase`).
126
+
127
+ ### 2. Re-implementing wildcard matching in your adapter
128
+
129
+ ```ts
130
+ // Wrong — manually expanding wildcards inside hasPermission duplicates core logic and
131
+ // drifts (e.g. handling 'posts.*' but not '*').
132
+ hasPermission(user, permission) {
133
+ return this.grants(user).some((g) => g === '*' || permission.startsWith(g.replace('.*','')));
134
+ }
135
+
136
+ // Correct — expose getPermissions and let the core do segment matching.
137
+ getPermissions(user) { return this.grants(user); } // 'posts.*', '*' handled by core
138
+ ```
139
+
140
+ Mechanism: when `getPermissions` is present the core applies `permissionSatisfied`
141
+ segment matching, so wildcard semantics are identical across every adapter.
142
+ Source: `packages/core/src/permission-provider.ts` (getPermissions docs),
143
+ `packages/core/src/permission-matcher.ts` (`permissionSatisfied`).
144
+
145
+ ### 3. Granting permissions for an anonymous request
146
+
147
+ ```ts
148
+ // Wrong — assuming the permission seam runs for unauthenticated callers.
149
+ await gate.allows('posts.publish'); // anonymous → seam is skipped, denies
150
+
151
+ // Correct — the seam only grants for an authenticated user; resolve/pass one.
152
+ await gate.forUser(user).allows('posts.publish');
153
+ ```
154
+
155
+ Mechanism: the permission-provider grant is gated on `maybeUser !== NO_USER`, so an
156
+ anonymous request never reaches it and follows the default-deny path.
157
+ Source: `packages/core/src/gate.ts` (`resolveBase` — `maybeUser !== NO_USER && permissionProviderGrants(...)`).
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: authz-setup
3
+ description: >
4
+ Set up @dudousxd/nestjs-authz in a NestJS app — install, peer deps, and wire
5
+ AuthzModule.forRoot / forRootAsync (global module that registers the Gate,
6
+ CanGuard + RolesGuard as APP_GUARD, PolicyRegistry, default IdParamResourceResolver,
7
+ and the opt-in POST /authz/can endpoint). Covers the optional @dudousxd/nestjs-context
8
+ peer (CONTEXT_ACCESSOR) for the current user, and the load-bearing resolveUser hook
9
+ that hydrates the full user entity from a UserRef ({type,id}) so superAdmin and
10
+ policy checks like user.isAdmin / post.authorId === user.id actually see real columns.
11
+ metadata:
12
+ type: core
13
+ library: "@dudousxd/nestjs-authz"
14
+ library_version: "0.6.3"
15
+ framework: nestjs
16
+ ---
17
+
18
+ # Setting up @dudousxd/nestjs-authz
19
+
20
+ `@dudousxd/nestjs-authz` is **authorization, not authentication**: it reads the
21
+ current user and decides what they may do, Laravel Gate/Policy style. The core has
22
+ **zero database** dependencies.
23
+
24
+ ## Setup
25
+
26
+ ```sh
27
+ pnpm add @dudousxd/nestjs-authz
28
+ ```
29
+
30
+ Peer deps: `@nestjs/common`, `@nestjs/core`, `reflect-metadata`.
31
+ `@dudousxd/nestjs-context` is an **optional** peer — when present the Gate reads the
32
+ current user for free; when absent you pass the user explicitly via `gate.forUser(...)`.
33
+
34
+ Register the module once (it is `global: true`, so every module sees the `Gate` and the
35
+ guards):
36
+
37
+ ```ts
38
+ import { Module } from '@nestjs/common';
39
+ import { AuthzModule } from '@dudousxd/nestjs-authz';
40
+ import { PostPolicy } from './post.policy';
41
+
42
+ @Module({
43
+ imports: [
44
+ AuthzModule.forRoot({
45
+ policies: [PostPolicy], // optional — @Policy classes are also auto-discovered
46
+ superAdmin: (user) => (user as { isAdmin?: boolean }).isAdmin === true,
47
+ }),
48
+ ],
49
+ })
50
+ export class AppModule {}
51
+ ```
52
+
53
+ `forRoot` registers: `Gate`, `PolicyRegistry`, `CanGuard` **and** `RolesGuard` (both as
54
+ `APP_GUARD`, so `@Can` / `@Roles` enforce app-wide), a default `IdParamResourceResolver`
55
+ bound to `RESOURCE_RESOLVER`, and a bootstrap provider that fills the `PolicyRegistry` on
56
+ init from `policies: []` **and** auto-discovered `@Policy` providers.
57
+
58
+ ## Core patterns
59
+
60
+ ### forRootAsync (config from DI)
61
+
62
+ Use when policy lists or hooks depend on injected config. Policies returned from the
63
+ factory are registered too (the bootstrap reads the **resolved** options).
64
+
65
+ ```ts
66
+ AuthzModule.forRootAsync({
67
+ imports: [ConfigModule],
68
+ inject: [ConfigService],
69
+ useFactory: (config: ConfigService) => ({
70
+ policies: [PostPolicy],
71
+ superAdmin: (user) => (user as { role?: string }).role === config.get('SUPER_ROLE'),
72
+ }),
73
+ });
74
+ ```
75
+
76
+ `canEndpoint` and `resourceLoaders` may be declared **statically** on the async options
77
+ too (controllers are registered at module-definition time, before the factory resolves).
78
+
79
+ ### Wiring the current user via nestjs-context
80
+
81
+ The Gate resolves the current user structurally through the well-known capability token
82
+ `CONTEXT_ACCESSOR` (`capability('context','accessor')` ===
83
+ `Symbol.for('@dudousxd/nestjs-context:accessor')`). Install and configure
84
+ `@dudousxd/nestjs-context` so requests carry a user; the Gate then needs no wiring. With
85
+ **no** accessor and no explicit user, checks treat the request as anonymous and **deny**.
86
+
87
+ ### resolveUser — hydrate the real entity (the critical hook)
88
+
89
+ On the **context path**, the value handed to `superAdmin`, `after`, `resolveRoles`, and
90
+ your policy methods is the raw **`UserRef`** (`{ type, id }`) from nestjs-context — **not**
91
+ your hydrated user row. Provide `resolveUser` to load the entity before those run:
92
+
93
+ ```ts
94
+ AuthzModule.forRoot({
95
+ resolveUser: (ref) => userRepo.findOneBy({ id: Number(ref.id) }), // ref: { type, id }
96
+ superAdmin: (user) => (user as User).isAdmin, // now sees a real column
97
+ });
98
+ ```
99
+
100
+ `resolveUser` returning nullish is treated as "no user" (deny). It is **only** applied on
101
+ the context path — `gate.forUser(entity)` always uses the value you pass verbatim.
102
+
103
+ ## Common mistakes
104
+
105
+ ### 1. Expecting `superAdmin: u => u.isAdmin` to work without `resolveUser`
106
+
107
+ ```ts
108
+ // Wrong — on the context path `user` is just { type, id }; `isAdmin` is undefined,
109
+ // so the super-admin grant never fires from a route or gate.allows(...).
110
+ AuthzModule.forRoot({ superAdmin: (u) => (u as User).isAdmin });
111
+
112
+ // Correct — hydrate the entity first.
113
+ AuthzModule.forRoot({
114
+ resolveUser: (ref) => userRepo.findOneBy({ id: Number(ref.id) }),
115
+ superAdmin: (u) => (u as User).isAdmin,
116
+ });
117
+ ```
118
+
119
+ Mechanism: the context accessor yields only a `UserRef` (`{ type, id }`); `resolveUser`
120
+ is the hook that turns that ref into your `User` entity before hooks/policies run.
121
+ Source: `packages/core/src/types.ts` (`AuthzModuleOptions.superAdmin` / `resolveUser` docs),
122
+ `packages/core/src/gate.ts` (`currentUser`).
123
+
124
+ ### 2. Importing AuthzModule in every feature module
125
+
126
+ ```ts
127
+ // Wrong — forRoot is global; re-importing creates duplicate providers/options.
128
+ @Module({ imports: [AuthzModule.forRoot({ policies: [PostPolicy] })] })
129
+ export class PostsModule {}
130
+
131
+ // Correct — register once in the root module; the Gate is exported globally.
132
+ @Module({ imports: [AuthzModule.forRoot({ policies: [PostPolicy] })] })
133
+ export class AppModule {}
134
+ ```
135
+
136
+ Mechanism: `forRoot`/`forRootAsync` return `{ global: true }`, so a single root
137
+ registration exports `Gate`, the guards, and `RESOURCE_RESOLVER` to the whole app.
138
+ Source: `packages/core/src/module.ts` (`forRoot` returns `global: true`).
139
+
140
+ ### 3. Reaching for the `canEndpoint` before it is enabled
141
+
142
+ ```ts
143
+ // Wrong — POST /authz/can returns 404; the endpoint is OFF by default.
144
+ AuthzModule.forRoot({ policies: [PostPolicy] });
145
+
146
+ // Correct — opt in (true = default path 'authz/can', or pass a custom string).
147
+ AuthzModule.forRoot({ policies: [PostPolicy], canEndpoint: true });
148
+ ```
149
+
150
+ Mechanism: `canControllers` registers the fallback controller only when `canEndpoint` is
151
+ truthy; it is the last-resort path for abilities not already hydrated on the client.
152
+ Source: `packages/core/src/module.ts` (`canControllers`), `packages/core/src/types.ts`
153
+ (`canEndpoint`).