@dudousxd/nestjs-authz 0.8.0 → 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.
Files changed (132) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +279 -0
  3. package/dist/authz.guard.d.ts +48 -0
  4. package/dist/authz.guard.d.ts.map +1 -0
  5. package/dist/authz.guard.js +144 -0
  6. package/dist/authz.guard.js.map +1 -0
  7. package/dist/can-endpoint.controller.d.ts +0 -19
  8. package/dist/can-endpoint.controller.d.ts.map +1 -1
  9. package/dist/can-endpoint.controller.js +18 -9
  10. package/dist/can-endpoint.controller.js.map +1 -1
  11. package/dist/decision-provider.d.ts +25 -16
  12. package/dist/decision-provider.d.ts.map +1 -1
  13. package/dist/decision-provider.js +11 -1
  14. package/dist/decision-provider.js.map +1 -1
  15. package/dist/decisions.d.ts +24 -0
  16. package/dist/decisions.d.ts.map +1 -0
  17. package/dist/decisions.js +87 -0
  18. package/dist/decisions.js.map +1 -0
  19. package/dist/decorator/roles.decorator.d.ts +6 -12
  20. package/dist/decorator/roles.decorator.d.ts.map +1 -1
  21. package/dist/decorator/roles.decorator.js +13 -15
  22. package/dist/decorator/roles.decorator.js.map +1 -1
  23. package/dist/diagnostics.d.ts +15 -5
  24. package/dist/diagnostics.d.ts.map +1 -1
  25. package/dist/diagnostics.js +3 -1
  26. package/dist/diagnostics.js.map +1 -1
  27. package/dist/errors/exceptions.d.ts +0 -8
  28. package/dist/errors/exceptions.d.ts.map +1 -1
  29. package/dist/errors/exceptions.js +0 -14
  30. package/dist/errors/exceptions.js.map +1 -1
  31. package/dist/gate.d.ts +83 -209
  32. package/dist/gate.d.ts.map +1 -1
  33. package/dist/gate.js +209 -577
  34. package/dist/gate.js.map +1 -1
  35. package/dist/helpers.d.ts +46 -0
  36. package/dist/helpers.d.ts.map +1 -0
  37. package/dist/helpers.js +89 -0
  38. package/dist/helpers.js.map +1 -0
  39. package/dist/index.d.ts +28 -21
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +30 -13
  42. package/dist/index.js.map +1 -1
  43. package/dist/links.d.ts +39 -0
  44. package/dist/links.d.ts.map +1 -0
  45. package/dist/links.js +57 -0
  46. package/dist/links.js.map +1 -0
  47. package/dist/module.d.ts +23 -22
  48. package/dist/module.d.ts.map +1 -1
  49. package/dist/module.js +129 -158
  50. package/dist/module.js.map +1 -1
  51. package/dist/official.d.ts +39 -0
  52. package/dist/official.d.ts.map +1 -0
  53. package/dist/official.js +44 -0
  54. package/dist/official.js.map +1 -0
  55. package/dist/permission-cache.d.ts +18 -9
  56. package/dist/permission-cache.d.ts.map +1 -1
  57. package/dist/permission-cache.js +87 -24
  58. package/dist/permission-cache.js.map +1 -1
  59. package/dist/permission-matcher.d.ts +13 -0
  60. package/dist/permission-matcher.d.ts.map +1 -1
  61. package/dist/permission-matcher.js +38 -2
  62. package/dist/permission-matcher.js.map +1 -1
  63. package/dist/permission-provider.d.ts +11 -0
  64. package/dist/permission-provider.d.ts.map +1 -1
  65. package/dist/policies/authz-policy.d.ts +42 -0
  66. package/dist/policies/authz-policy.d.ts.map +1 -0
  67. package/dist/policies/authz-policy.js +80 -0
  68. package/dist/policies/authz-policy.js.map +1 -0
  69. package/dist/policies/gate.policy.d.ts +18 -0
  70. package/dist/policies/gate.policy.d.ts.map +1 -0
  71. package/dist/policies/gate.policy.js +37 -0
  72. package/dist/policies/gate.policy.js.map +1 -0
  73. package/dist/policies/index.d.ts +5 -0
  74. package/dist/policies/index.d.ts.map +1 -0
  75. package/dist/policies/index.js +5 -0
  76. package/dist/policies/index.js.map +1 -0
  77. package/dist/policies/permission.policy.d.ts +27 -0
  78. package/dist/policies/permission.policy.d.ts.map +1 -0
  79. package/dist/policies/permission.policy.js +61 -0
  80. package/dist/policies/permission.policy.js.map +1 -0
  81. package/dist/policies/role.policy.d.ts +27 -0
  82. package/dist/policies/role.policy.d.ts.map +1 -0
  83. package/dist/policies/role.policy.js +51 -0
  84. package/dist/policies/role.policy.js.map +1 -0
  85. package/dist/policy-index.d.ts +51 -0
  86. package/dist/policy-index.d.ts.map +1 -0
  87. package/dist/policy-index.js +135 -0
  88. package/dist/policy-index.js.map +1 -0
  89. package/dist/rbac.d.ts +92 -0
  90. package/dist/rbac.d.ts.map +1 -0
  91. package/dist/rbac.js +293 -0
  92. package/dist/rbac.js.map +1 -0
  93. package/dist/resource-resolver.d.ts +15 -17
  94. package/dist/resource-resolver.d.ts.map +1 -1
  95. package/dist/resource-resolver.js +8 -8
  96. package/dist/resource-resolver.js.map +1 -1
  97. package/dist/runtime.d.ts +31 -0
  98. package/dist/runtime.d.ts.map +1 -0
  99. package/dist/runtime.js +40 -0
  100. package/dist/runtime.js.map +1 -0
  101. package/dist/tokens.d.ts +4 -8
  102. package/dist/tokens.d.ts.map +1 -1
  103. package/dist/tokens.js +4 -8
  104. package/dist/tokens.js.map +1 -1
  105. package/dist/types.d.ts +52 -169
  106. package/dist/types.d.ts.map +1 -1
  107. package/package.json +19 -10
  108. package/skills/authz-enforcement/SKILL.md +67 -131
  109. package/skills/authz-policies/SKILL.md +43 -117
  110. package/skills/authz-query-scopes/SKILL.md +16 -9
  111. package/skills/authz-rbac-seams/SKILL.md +39 -12
  112. package/skills/authz-setup/SKILL.md +45 -106
  113. package/dist/decorator/can.decorator.d.ts +0 -29
  114. package/dist/decorator/can.decorator.d.ts.map +0 -1
  115. package/dist/decorator/can.decorator.js +0 -23
  116. package/dist/decorator/can.decorator.js.map +0 -1
  117. package/dist/decorator/policy.decorator.d.ts +0 -18
  118. package/dist/decorator/policy.decorator.d.ts.map +0 -1
  119. package/dist/decorator/policy.decorator.js +0 -28
  120. package/dist/decorator/policy.decorator.js.map +0 -1
  121. package/dist/guard/can.guard.d.ts +0 -31
  122. package/dist/guard/can.guard.d.ts.map +0 -1
  123. package/dist/guard/can.guard.js +0 -92
  124. package/dist/guard/can.guard.js.map +0 -1
  125. package/dist/guard/roles.guard.d.ts +0 -21
  126. package/dist/guard/roles.guard.d.ts.map +0 -1
  127. package/dist/guard/roles.guard.js +0 -50
  128. package/dist/guard/roles.guard.js.map +0 -1
  129. package/dist/policy-registry.d.ts +0 -46
  130. package/dist/policy-registry.d.ts.map +0 -1
  131. package/dist/policy-registry.js +0 -101
  132. package/dist/policy-registry.js.map +0 -1
@@ -1,167 +1,103 @@
1
1
  ---
2
2
  name: authz-enforcement
3
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.
4
+ Enforce authorization with @dudousxd/nestjs-authz 0.9 on top of @nestjs/authorization.
5
+ Routes use the official @Can(Policy, 'ability', argsResolver) and its global guard; our
6
+ helpers go inside it: byId(Post) (loads the :id record with the ResourceResolver, kept for
7
+ the handler via authorizedResource), roles(), permission(), ability(), staticCanArgs()
8
+ (static, marked with Symbol.for('@dudousxd/static-can-args')), and the sugar @Roles /
9
+ @Permission. Services use the Gate: allows/denies/authorize with a policy class or a string
10
+ ability, allowsMany, forUser, define, hasRole. Covers migrating the 0.8 string @Can.
12
11
  metadata:
13
12
  type: core
14
13
  library: "@dudousxd/nestjs-authz"
15
- library_version: "0.6.3"
14
+ library_version: "0.9.0"
16
15
  framework: nestjs
17
16
  ---
18
17
 
19
18
  # Enforcing authorization
20
19
 
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.
20
+ ## Routes
54
21
 
55
22
  ```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) {}
23
+ import { Can } from '@nestjs/authorization';
24
+ import { GatePolicy, PermissionPolicy, Roles, ability, authorizedResource, byId, permission } from '@dudousxd/nestjs-authz';
62
25
 
63
- async update(post: Post) {
64
- await this.gate.authorize('update', post); // throws on deny
65
- if (await this.gate.allows('delete', post)) { /* ... */ }
66
- }
26
+ @Patch(':id')
27
+ @Can(PostPolicy, 'update', byId(Post)) // 403 if the Post is not found
28
+ update(@Req() req: Request) {
29
+ const post = authorizedResource(req, Post); // the record byId loaded
67
30
  }
68
- ```
69
31
 
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:
32
+ @Post(':id/publish')
33
+ @Can(PermissionPolicy, 'has', permission('posts.publish')) // or @Permission('posts.publish')
34
+ publish() {}
72
35
 
73
- ```ts
74
- await this.gate.forUser(someUser).authorize('update', post);
75
- ```
36
+ @Get('admin')
37
+ @Roles('admin', 'editor') // any of; RolePolicy.hasAll for all of
38
+ admin() {}
76
39
 
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):
40
+ @Get('reports')
41
+ @Can(GatePolicy, 'allows', ability('access-admin')) // a gate.define() gate
42
+ reports() {}
79
43
 
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)
44
+ @Get('me')
45
+ @Can.Anyone() // the official way to open a route
46
+ me() {}
85
47
  ```
86
48
 
87
- ### Ad-hoc gates
49
+ Class-level and method-level requirements all apply (AND); a method's `Can.Anyone()` lifts the
50
+ class's. Argument resolvers run in the guard, before pipes, with no DI: `byId` reads the
51
+ `ResourceResolver` `AuthzModule` registered at boot.
88
52
 
89
- Register a model-less, named ability with `define`, then guard or check it:
53
+ ## Services
90
54
 
91
55
  ```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)
56
+ await gate.forUser(user).authorize(PostPolicy, 'update', post); // = AuthorizationService.authorize
57
+ await gate.forUser(user).allows('update', post); // policy linked with @PolicyFor(Post)
58
+ await gate.forUser(user).allows('posts.publish'); // persisted permission
59
+ await gate.forUser(user).allowsMany([{ ability: 'update', resource: post }]);
60
+ gate.define('access-admin', (user) => user.isStaff);
95
61
  ```
96
62
 
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
- ```
63
+ String abilities ask, in order: `superAdmin` (it receives the resource too), the external
64
+ decision point (`DECISION_PROVIDER`, e.g. the Cerbos adapter — its allow or deny is final,
65
+ `undefined` abstains), the linked policy (official evaluation), the persisted permissions,
66
+ the ad-hoc gate; else `AbilityNotResolvedException`. The policy has the final word: a
67
+ persisted permission named like one of its abilities (`update`) does not bypass it — only
68
+ `superAdmin` short-circuits a policy. `authorize()` throws the official `AuthorizationError`.
69
+
70
+ Without `forUser()`, the user is `@nestjs/authentication`'s `AuthenticationContext.user`
71
+ when that package is installed and its scope is active for the call (its answer is final,
72
+ so an anonymous call there is a guest), else nestjs-context's user ref
73
+ (hydrated once per request by `resolveUser`), else a guest.
74
+
75
+ Inside a nestjs-context request, every check shares one `resolveUser`, one permission fetch
76
+ and one role fetch per user for the whole request — the official guard's `RolePolicy` /
77
+ `PermissionPolicy` and the handler's `Gate` calls alike (keyed by the row the user names —
78
+ its class or `type` plus `id`; a plain object without `type` is keyed by identity, so two
79
+ users that only share an `id` never share an entry). `allowsMany` shares one fetch per
80
+ batch even without nestjs-context, and asks a `DECISION_PROVIDER`'s `decideMany` once.
116
81
 
117
82
  ## Common mistakes
118
83
 
119
- ### 1. Class-level ability without `{ classLevel: true }`
84
+ ### 1. Writing the 0.8 string form
120
85
 
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
- ```
86
+ 0.9 removed it: `@Can` comes from `@nestjs/authorization` and names the policy.
133
87
 
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`.
88
+ | 0.8 | 0.9 |
89
+ | --- | --- |
90
+ | `@Can('update', Post)` | `@Can(PostPolicy, 'update', byId(Post))` |
91
+ | `@Can('create', Post, { classLevel: true })` | `@Can(PostPolicy, 'create')` |
92
+ | `@Can('access-admin')` | `@Can(GatePolicy, 'allows', ability('access-admin'))` |
137
93
 
138
- ### 2. Using the default resolver and expecting real entity fields
94
+ ### 2. Loading the record again in the handler
139
95
 
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] });
96
+ Read it with `authorizedResource(req, Post)`.
144
97
 
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
98
+ ### 3. Writing an inline resolver for constant args
154
99
 
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
- ```
100
+ `@Can(FlagPolicy, 'on', () => ['billing'])` works, but tools cannot read it without a call.
101
+ Use `staticCanArgs('billing')`.
164
102
 
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`).
103
+ Source: `packages/core/src/helpers.ts`, `packages/core/src/gate.ts`, `packages/core/src/decorator/roles.decorator.ts`.
@@ -1,150 +1,76 @@
1
1
  ---
2
2
  name: authz-policies
3
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).
4
+ Write policies for @dudousxd/nestjs-authz 0.9: they are official @nestjs/authorization
5
+ policies (@Policy(), abilities taking `user | null`, optional before(), only a literal true
6
+ allows). Our additions: @PolicyFor(Resource) to link a resource to its policy (for
7
+ gate.allows('update', post), gate.scope(Post), client ability maps), the AuthzPolicy base
8
+ class / superAdminBefore() that honor the global superAdmin hook, and a scope(user) method
9
+ for query scoping. Covers migrating the 0.8 @Policy(Resource).
12
10
  metadata:
13
11
  type: core
14
12
  library: "@dudousxd/nestjs-authz"
15
- library_version: "0.6.3"
13
+ library_version: "0.9.0"
16
14
  framework: nestjs
17
15
  ---
18
16
 
19
17
  # Writing policies
20
18
 
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
19
  ```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;
20
+ import { Policy } from '@nestjs/authorization';
21
+ import { AuthzPolicy, PolicyFor, eq } from '@dudousxd/nestjs-authz';
22
+
23
+ @Policy()
24
+ @PolicyFor(Post) // optional link; also applies @Policy() itself
25
+ export class PostPolicy extends AuthzPolicy { // optional: superAdmin grants every ability
26
+ update(user: User | null, post: Post) {
27
+ return post.authorId === user?.id;
42
28
  }
43
-
44
- update(user: User, post: Post) {
45
- return post.authorId === user.id;
29
+ create(user: User | null) {
30
+ return user?.verified === true; // no record: a class-level ability
46
31
  }
47
-
48
- // class-level ability: no resource instance — receives only the user
49
- create(user: User) {
50
- return user.verified;
32
+ scope(user: User) {
33
+ return eq('authorId', user.id); // not an ability; used by gate.scope(Post)
51
34
  }
52
35
  }
53
36
  ```
54
37
 
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
38
  ## Core patterns
60
39
 
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.
40
+ - Abilities are the official contract: `ability(user | null, ...args)`, `true` allows,
41
+ anything else denies. A guest denial is a 401, a signed-in one a 403.
42
+ - `AuthzPolicy.before()` asks `superAdmin(user, ability, resource)`, then the
43
+ `DECISION_PROVIDER` when one is registered (its allow/deny is final; `undefined` abstains).
44
+ Own `before()`: override and call `super.before(user, ability, ...args)` first to keep both.
45
+ Policies that cannot extend `AuthzPolicy` call `superAdminBefore(user, ability, resource)`
46
+ and `decisionProviderBefore(user, ability, resource)`.
47
+ - A persisted permission named like an ability does not bypass the policy: the policy has the
48
+ final word.
49
+ - Policies must be singletons (official rule).
93
50
 
94
51
  ## Common mistakes
95
52
 
96
- ### 1. Falling off the end of a policy method (implicit deny)
53
+ ### 1. Typing the user as `User`
97
54
 
98
55
  ```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
- }
56
+ update(user: User, post: Post) {} // Wrong — guests reach it as null; @Can rejects it
57
+ update(user: User | null, post: Post) {} // Correct
109
58
  ```
110
59
 
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`).
60
+ ### 2. Returning `{ allowed, message }` or a truthy value
114
61
 
115
- ### 2. Putting a `before` grant for an ability the policy does not define
62
+ 0.x accepted `PolicyResponse`. The official evaluator allows only a literal `true`; a
63
+ truthy object denies. Return booleans.
116
64
 
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
- ```
65
+ ### 3. Expecting superAdmin everywhere
132
66
 
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").
67
+ The official guard runs only the policy's own `before()`. A policy that does not extend
68
+ `AuthzPolicy` (or call `superAdminBefore`) never sees superAdmin on routes.
136
69
 
137
- ### 3. Expecting `after` to overturn an explicit policy verdict
70
+ ### 4. Writing the 0.8 `@Policy(Post)`
138
71
 
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
- ```
72
+ 0.9 removed it: this package no longer exports `Policy`. Use the official `@Policy()` from
73
+ `@nestjs/authorization` plus `@PolicyFor(Post)` (or `@PolicyFor(Post)` alone, which applies
74
+ `@Policy()` itself).
147
75
 
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`).
76
+ Source: `packages/core/src/links.ts`, `packages/core/src/policies/authz-policy.ts`.
@@ -8,12 +8,14 @@ description: >
8
8
  helpers (operators eq, ne, gt, gte, lt, lte, in, nin, isNull, isNotNull). A @Policy scope(
9
9
  user, Entity) method produces it (returning true=allow-all, false/nullish=deny-all sugar);
10
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.
11
+ compiles it to a parameterized, identifier-safe WHERE. Mirrors the Gate's order:
12
+ super-admin → allow-all; a DECISION_PROVIDER planScope; the policy's scope (a persisted
13
+ permission never widens it); anonymous → deny-all; no policy method → a permission grant
14
+ allows all, else deny-all.
13
15
  metadata:
14
16
  type: core
15
17
  library: "@dudousxd/nestjs-authz"
16
- library_version: "0.6.3"
18
+ library_version: "0.9.0"
17
19
  framework: nestjs
18
20
  ---
19
21
 
@@ -30,11 +32,13 @@ Add a `scope(user, Entity)` method to a `@Policy` class, returning a `ScopeConst
30
32
  built from the core helpers:
31
33
 
32
34
  ```ts
33
- import { Policy, eq, and, where, scopeAll } from '@dudousxd/nestjs-authz';
35
+ import { Policy } from '@nestjs/authorization';
36
+ import { PolicyFor, eq, and, where, scopeAll } from '@dudousxd/nestjs-authz';
34
37
  import { Post } from './post.entity';
35
38
  import { User } from './user.entity';
36
39
 
37
- @Policy(Post)
40
+ @Policy()
41
+ @PolicyFor(Post)
38
42
  export class PostPolicy {
39
43
  scope(user: User, _entity: typeof Post) {
40
44
  if (user.isAdmin) return scopeAll; // sugar: every row
@@ -75,10 +79,13 @@ await applyScope(qb, this.gate, Post);
75
79
 
76
80
  ### Grant order mirrors the single-resource Gate
77
81
 
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
+ `gate.scope` resolves consistently with `gate.allows`: a `superAdmin` grant (it receives the
83
+ entity class) → `scopeAll`; a `DECISION_PROVIDER`'s `planScope` (e.g. Cerbos `PlanResources`),
84
+ when it does not abstain, is used as-is; a policy `before` returning `true`/`false` scopes to
85
+ allow-all/deny-all; an anonymous user → `scopeNone`; otherwise the policy's `scope` constraint.
86
+ The policy has the final word: a persisted `viewAny` permission does not widen it. Only when
87
+ there is no policy `scope`/`viewAny` method does a permission grant for the ability give
88
+ `scopeAll` (else `scopeNone`).
82
89
 
83
90
  ### Return-value sugar
84
91
 
@@ -3,10 +3,11 @@ name: authz-rbac-seams
3
3
  description: >
4
4
  Add persisted roles & permissions to @dudousxd/nestjs-authz through its optional,
5
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
6
+ optional getPermissions) grants named abilities no policy defines (posts.publish) — it never
7
+ bypasses a policy that defines the ability (the policy has the final word; only superAdmin
8
+ short-circuits it); getPermissions enables
8
9
  segment wildcard matching (granted posts.* satisfies posts.update, * satisfies anything).
9
- ROLE_PROVIDER (a RoleProvider.getRoles) feeds coarse checks gate.hasRole / @Roles, unioned
10
+ ROLE_PROVIDER (a RoleProvider.getRoles) feeds coarse checks gate.hasRole / @Roles (RolePolicy), unioned
10
11
  with the user-object defaultRoleResolver (reads user.roles / user.role). Both tokens are
11
12
  capability symbols the ORM adapters (@dudousxd/nestjs-authz-typeorm / -prisma / -mikro-orm / -drizzle)
12
13
  register via AuthzRbacModule; seams are grant-only — a false result never DENIES, it falls
@@ -14,7 +15,7 @@ description: >
14
15
  metadata:
15
16
  type: core
16
17
  library: "@dudousxd/nestjs-authz"
17
- library_version: "0.6.3"
18
+ library_version: "0.9.0"
18
19
  framework: nestjs
19
20
  ---
20
21
 
@@ -23,7 +24,8 @@ metadata:
23
24
  The core stays zero-DB. Persisted roles/permissions plug in through two **optional,
24
25
  grant-only** seams the Gate consults via `@Optional()` capability tokens:
25
26
 
26
- - `PERMISSION_PROVIDER` → a `PermissionProvider` (the Laravel/spatie `Gate::before` grant).
27
+ - `PERMISSION_PROVIDER` → a `PermissionProvider` (feeds `PermissionPolicy` / `@Permission` and
28
+ the `Gate`'s named abilities that no policy defines).
27
29
  - `ROLE_PROVIDER` → a `RoleProvider` (feeds `gate.hasRole` / `@Roles`).
28
30
 
29
31
  When neither is registered, the Gate behaves exactly as before. **Grant-only** means a
@@ -32,11 +34,13 @@ provider returning `false`/nullish never DENIES — it falls through to normal r
32
34
  ## Setup
33
35
 
34
36
  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
+ registers both seams. Example with the Prisma adapter (which takes `store` or `client`;
38
+ MikroORM takes `store`; the TypeORM and Drizzle siblings also accept `connectionName` /
39
+ `storeOptions` to build the store from the official connection instead of a prebuilt `store`):
37
40
 
38
41
  ```ts
39
42
  import { Module } from '@nestjs/common';
43
+ import { AuthorizationModule } from '@nestjs/authorization';
40
44
  import { AuthzModule } from '@dudousxd/nestjs-authz';
41
45
  import { AuthzRbacModule } from '@dudousxd/nestjs-authz-prisma';
42
46
  import { PrismaClient } from '@prisma/client';
@@ -45,7 +49,8 @@ const prisma = new PrismaClient();
45
49
 
46
50
  @Module({
47
51
  imports: [
48
- AuthzModule.forRoot({ policies: [PostPolicy] }),
52
+ AuthorizationModule.forRoot(),
53
+ AuthzModule.forRoot(),
49
54
  AuthzRbacModule.forRoot({ client: prisma }), // builds the store, registers the seams
50
55
  ],
51
56
  })
@@ -62,6 +67,17 @@ await gate.forUser(user).allows('posts.publish'); // true via PERMISSION_PROVIDE
62
67
  await gate.forUser(user).hasRole('editor'); // true via ROLE_PROVIDER
63
68
  ```
64
69
 
70
+ On routes, the same seams answer through the official guard as the core's official policies:
71
+
72
+ ```ts
73
+ @Permission('posts.publish') // = @Can(PermissionPolicy, 'has', permission('posts.publish'))
74
+ @Roles('editor') // = @Can(RolePolicy, 'has', roles('editor'))
75
+ @Can(PermissionPolicy, 'hasAll', permission('posts.publish', 'posts.delete'))
76
+ ```
77
+
78
+ Both extend `AuthzPolicy`, so `superAdmin` grants them; `gate.hasRole()` is a plain fact and
79
+ ignores it.
80
+
65
81
  Admin-UI management lives on every adapter's store too (same semantics everywhere):
66
82
  `syncRolePermissions(role, names)` replaces a role's permission set, `getRolePermissions(roles)`
67
83
  returns `{ [role]: permissionNames }` (missing roles absent), `deleteRole(role)` removes the role
@@ -95,6 +111,16 @@ The core runs segment-based wildcard matching against `getPermissions`: a grante
95
111
  `posts.*` satisfies `posts.update`; `*` satisfies anything. `hasPermission` is the
96
112
  exact-match contract for providers that do not list grants.
97
113
 
114
+ Within one request (nestjs-context) the Gate fetches `getPermissions` and
115
+ `RoleProvider.getRoles` once per user and reuses them for every check (keyed by the
116
+ row the user names — its class or `type` plus `id`, so ORMs without an identity map
117
+ share entries; a plain object without `type` is keyed by identity, so two users that
118
+ only share an `id` never share an entry); a grant made
119
+ mid-request applies from the next request. When the set misses, the Gate still calls
120
+ `hasPermission` (it may grant on the resource). Set `readonly exhaustivePermissions = true`
121
+ when `getPermissions` already lists everything `hasPermission` can grant, so a miss is
122
+ answered without another lookup; the ORM adapters' providers do.
123
+
98
124
  ### Zero-table role checks (no provider)
99
125
 
100
126
  `gate.hasRole` / `@Roles` work with **no** RBAC tables: the `defaultRoleResolver` reads
@@ -126,10 +152,11 @@ hasPermission(user: User, permission: string) {
126
152
  }
127
153
  ```
128
154
 
129
- Mechanism: the provider grant mirrors `Gate::before` — `true` short-circuits to allow;
130
- `false`/`undefined` falls through to policy/gate resolution and never denies.
131
- Source: `packages/core/src/permission-provider.ts` (grant semantics),
132
- `packages/core/src/gate.ts` (`permissionProviderGrants`, `resolveBase`).
155
+ Mechanism: the provider only grants — `false`/`undefined` falls through to the ad-hoc gate and
156
+ never denies. It is asked after the policy: a string ability a linked policy defines is the
157
+ policy's to decide, whatever the user's permissions; only `superAdmin` (and a
158
+ `DECISION_PROVIDER`) come first. Source: `packages/core/src/permission-provider.ts` (grant
159
+ semantics), `packages/core/src/rbac.ts` (`grants`), `packages/core/src/gate.ts` (`decideString`).
133
160
 
134
161
  ### 2. Re-implementing wildcard matching in your adapter
135
162