@dudousxd/nestjs-authz 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) 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 -218
  32. package/dist/gate.d.ts.map +1 -1
  33. package/dist/gate.js +209 -611
  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/policies/authz-policy.d.ts +42 -0
  56. package/dist/policies/authz-policy.d.ts.map +1 -0
  57. package/dist/policies/authz-policy.js +80 -0
  58. package/dist/policies/authz-policy.js.map +1 -0
  59. package/dist/policies/gate.policy.d.ts +18 -0
  60. package/dist/policies/gate.policy.d.ts.map +1 -0
  61. package/dist/policies/gate.policy.js +37 -0
  62. package/dist/policies/gate.policy.js.map +1 -0
  63. package/dist/policies/index.d.ts +5 -0
  64. package/dist/policies/index.d.ts.map +1 -0
  65. package/dist/policies/index.js +5 -0
  66. package/dist/policies/index.js.map +1 -0
  67. package/dist/policies/permission.policy.d.ts +27 -0
  68. package/dist/policies/permission.policy.d.ts.map +1 -0
  69. package/dist/policies/permission.policy.js +61 -0
  70. package/dist/policies/permission.policy.js.map +1 -0
  71. package/dist/policies/role.policy.d.ts +27 -0
  72. package/dist/policies/role.policy.d.ts.map +1 -0
  73. package/dist/policies/role.policy.js +51 -0
  74. package/dist/policies/role.policy.js.map +1 -0
  75. package/dist/policy-index.d.ts +51 -0
  76. package/dist/policy-index.d.ts.map +1 -0
  77. package/dist/policy-index.js +135 -0
  78. package/dist/policy-index.js.map +1 -0
  79. package/dist/rbac.d.ts +92 -0
  80. package/dist/rbac.d.ts.map +1 -0
  81. package/dist/rbac.js +293 -0
  82. package/dist/rbac.js.map +1 -0
  83. package/dist/resource-resolver.d.ts +15 -17
  84. package/dist/resource-resolver.d.ts.map +1 -1
  85. package/dist/resource-resolver.js +8 -8
  86. package/dist/resource-resolver.js.map +1 -1
  87. package/dist/runtime.d.ts +31 -0
  88. package/dist/runtime.d.ts.map +1 -0
  89. package/dist/runtime.js +40 -0
  90. package/dist/runtime.js.map +1 -0
  91. package/dist/tokens.d.ts +4 -8
  92. package/dist/tokens.d.ts.map +1 -1
  93. package/dist/tokens.js +4 -8
  94. package/dist/tokens.js.map +1 -1
  95. package/dist/types.d.ts +52 -171
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +20 -11
  98. package/skills/authz-enforcement/SKILL.md +67 -135
  99. package/skills/authz-policies/SKILL.md +43 -117
  100. package/skills/authz-query-scopes/SKILL.md +16 -9
  101. package/skills/authz-rbac-seams/SKILL.md +29 -12
  102. package/skills/authz-setup/SKILL.md +45 -106
  103. package/dist/decorator/can.decorator.d.ts +0 -29
  104. package/dist/decorator/can.decorator.d.ts.map +0 -1
  105. package/dist/decorator/can.decorator.js +0 -23
  106. package/dist/decorator/can.decorator.js.map +0 -1
  107. package/dist/decorator/policy.decorator.d.ts +0 -18
  108. package/dist/decorator/policy.decorator.d.ts.map +0 -1
  109. package/dist/decorator/policy.decorator.js +0 -28
  110. package/dist/decorator/policy.decorator.js.map +0 -1
  111. package/dist/guard/can.guard.d.ts +0 -31
  112. package/dist/guard/can.guard.d.ts.map +0 -1
  113. package/dist/guard/can.guard.js +0 -92
  114. package/dist/guard/can.guard.js.map +0 -1
  115. package/dist/guard/roles.guard.d.ts +0 -21
  116. package/dist/guard/roles.guard.d.ts.map +0 -1
  117. package/dist/guard/roles.guard.js +0 -50
  118. package/dist/guard/roles.guard.js.map +0 -1
  119. package/dist/policy-registry.d.ts +0 -46
  120. package/dist/policy-registry.d.ts.map +0 -1
  121. package/dist/policy-registry.js +0 -101
  122. package/dist/policy-registry.js.map +0 -1
@@ -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
@@ -136,10 +152,11 @@ hasPermission(user: User, permission: string) {
136
152
  }
137
153
  ```
138
154
 
139
- Mechanism: the provider grant mirrors `Gate::before` — `true` short-circuits to allow;
140
- `false`/`undefined` falls through to policy/gate resolution and never denies.
141
- Source: `packages/core/src/permission-provider.ts` (grant semantics),
142
- `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`).
143
160
 
144
161
  ### 2. Re-implementing wildcard matching in your adapter
145
162
 
@@ -1,153 +1,92 @@
1
1
  ---
2
2
  name: authz-setup
3
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.
4
+ Set up @dudousxd/nestjs-authz 0.9, the extras on top of NestJS's first-party
5
+ @nestjs/authorization. Import AuthorizationModule.forRoot() (official: global
6
+ deny-by-default AuthorizationGuard, @Can, AuthorizationService, getUser) next to
7
+ AuthzModule.forRoot / forRootAsync (ours: Gate, RolePolicy / PermissionPolicy /
8
+ GatePolicy, the ResourceResolver byId() uses, superAdmin, the allow+deny decision event,
9
+ the opt-in POST /authz/can endpoint). Covers the optional peers: @nestjs/authentication
10
+ (AuthenticationContext.user is the ambient Gate user) and @dudousxd/nestjs-context
11
+ (fallback ambient user via resolveUser, per-request permission cache, tenant).
11
12
  metadata:
12
13
  type: core
13
14
  library: "@dudousxd/nestjs-authz"
14
- library_version: "0.6.3"
15
+ library_version: "0.9.0"
15
16
  framework: nestjs
16
17
  ---
17
18
 
18
19
  # Setting up @dudousxd/nestjs-authz
19
20
 
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.
21
+ Authorization decisions belong to [`@nestjs/authorization`](https://docs.nestjs.com/security/authorization).
22
+ This package adds RBAC, `@Can` helpers, a service-layer `Gate`, super-admin, query scopes and
23
+ frontend glue. It never authenticates.
23
24
 
24
25
  ## Setup
25
26
 
26
27
  ```sh
27
- pnpm add @dudousxd/nestjs-authz
28
+ pnpm add @nestjs/authorization @dudousxd/nestjs-authz
28
29
  ```
29
30
 
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):
31
+ NestJS 12, Node 22+. Peers: `@nestjs/common`, `@nestjs/core`, `@nestjs/authorization`,
32
+ `reflect-metadata`, `rxjs`; optional `@nestjs/authentication` and `@dudousxd/nestjs-context`.
36
33
 
37
34
  ```ts
38
- import { Module } from '@nestjs/common';
35
+ import { AuthorizationModule } from '@nestjs/authorization';
39
36
  import { AuthzModule } from '@dudousxd/nestjs-authz';
40
- import { PostPolicy } from './post.policy';
41
37
 
42
38
  @Module({
43
39
  imports: [
40
+ AuthorizationModule.forRoot({ getUser: (ctx) => ctx.switchToHttp().getRequest().user }),
44
41
  AuthzModule.forRoot({
45
- policies: [PostPolicy], // optional — @Policy classes are also auto-discovered
46
- superAdmin: (user) => (user as { isAdmin?: boolean }).isAdmin === true,
42
+ superAdmin: (user) => user?.isRoot === true,
43
+ resourceResolver: new OrmResourceResolver(), // for byId(); default builds { id } from :id
47
44
  }),
48
45
  ],
46
+ providers: [PostPolicy], // official policies are discovered wherever they are providers
49
47
  })
50
48
  export class AppModule {}
51
49
  ```
52
50
 
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.
51
+ `forRootAsync({ imports, inject, useFactory | useClass | useExisting, canEndpoint, decorators, unannotated })` takes the
52
+ same options. `canEndpoint`, `decorators` and `unannotated` are static (read from the top
53
+ level next to `useFactory`, never from the factory's return — `decorators` and `unannotated` in the factory's return throw).
57
54
 
58
55
  ## Core patterns
59
56
 
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.
57
+ - The official module must stay global (its default): the `Gate` injects `AuthorizationService`.
58
+ - `getUser` is the only user on routes. The `Gate`'s ambient user (calls without `forUser`) is
59
+ `@nestjs/authentication`'s `AuthenticationContext.user` when that package is installed and its
60
+ scope is active (its answer is final, so an anonymous call there is a guest), else nestjs-context's
61
+ `{ type, id }` ref, hydrated by `resolveUser` once per request.
62
+ - Every route needs a declaration: the official guard denies a handler with no `@Can()`,
63
+ `@Can.Anyone()` or `@Public()`.
102
64
 
103
65
  ## Common mistakes
104
66
 
105
- ### 1. Expecting `superAdmin: u => u.isAdmin` to work without `resolveUser`
67
+ ### 1. Importing only AuthzModule
106
68
 
107
69
  ```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
- ```
70
+ // Wrong — no AuthorizationService for the Gate, no guard on routes.
71
+ imports: [AuthzModule.forRoot()]
118
72
 
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`).
73
+ // Correct
74
+ imports: [AuthorizationModule.forRoot(), AuthzModule.forRoot()]
75
+ ```
123
76
 
124
- ### 2. Importing AuthzModule in every feature module
77
+ ### 2. Passing policies to AuthzModule
125
78
 
126
79
  ```ts
127
- // Wrong — forRoot is global; re-importing creates duplicate providers/options.
128
- @Module({ imports: [AuthzModule.forRoot({ policies: [PostPolicy] })] })
129
- export class PostsModule {}
80
+ // Wrong (0.x) — `policies` is gone from AuthzModuleOptions.
81
+ AuthzModule.forRoot({ policies: [PostPolicy] })
130
82
 
131
- // Correct — register once in the root module; the Gate is exported globally.
132
- @Module({ imports: [AuthzModule.forRoot({ policies: [PostPolicy] })] })
133
- export class AppModule {}
83
+ // Correct — register them as providers, or with the official module.
84
+ AuthorizationModule.forRoot({ policies: [PostPolicy] })
134
85
  ```
135
86
 
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
87
+ ### 3. Leaving routes undeclared after upgrading from 0.x
141
88
 
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
- ```
89
+ 0.x let un-annotated routes through. The official guard denies them (401 for guests, 403
90
+ otherwise). Add `@Can.Anyone()` to routes anyone may call.
149
91
 
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`).
92
+ Source: `packages/core/src/module.ts`, `packages/core/src/runtime.ts`.
@@ -1,29 +0,0 @@
1
- import 'reflect-metadata';
2
- import { type Type } from '@nestjs/common';
3
- export interface CanOptions {
4
- /**
5
- * Class-level ability (e.g. `create`): the policy method is keyed to the
6
- * resource class but receives NO instance, so the guard skips resource loading.
7
- */
8
- classLevel?: boolean;
9
- }
10
- export interface CanMetadata {
11
- ability: string;
12
- /** Resource class — to load an instance, or (for class-level) to locate the policy. */
13
- resource?: Type<unknown>;
14
- /** When true, skip resource loading and dispatch against the resource class. */
15
- classLevel?: boolean;
16
- }
17
- /**
18
- * Guard a route with an authorization check.
19
- *
20
- * @example
21
- * `@Can('update', Post)` // resolve Post by :id, run PostPolicy.update(user, post)
22
- * `@Can('create', Post, { classLevel: true })` // run PostPolicy.create(user) — no instance loaded
23
- * `@Can('access-admin')` // ad-hoc gate, no resource
24
- *
25
- * When a resource class is provided (and not `classLevel`), the guard loads an
26
- * instance via the registered `ResourceResolver` (default: by route `:id`).
27
- */
28
- export declare function Can(ability: string, resource?: Type<unknown>, options?: CanOptions): MethodDecorator & ClassDecorator;
29
- //# sourceMappingURL=can.decorator.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"can.decorator.d.ts","sourceRoot":"","sources":["../../src/decorator/can.decorator.ts"],"names":[],"mappings":"AAAA,OAAO,kBAAkB,CAAC;AAC1B,OAAO,EAAe,KAAK,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAGxD,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACzB,gFAAgF;IAChF,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,GAAG,CACjB,OAAO,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,EACxB,OAAO,CAAC,EAAE,UAAU,GACnB,eAAe,GAAG,cAAc,CAKlC"}
@@ -1,23 +0,0 @@
1
- import 'reflect-metadata';
2
- import { SetMetadata } from '@nestjs/common';
3
- import { CAN_METADATA } from '../tokens.js';
4
- /**
5
- * Guard a route with an authorization check.
6
- *
7
- * @example
8
- * `@Can('update', Post)` // resolve Post by :id, run PostPolicy.update(user, post)
9
- * `@Can('create', Post, { classLevel: true })` // run PostPolicy.create(user) — no instance loaded
10
- * `@Can('access-admin')` // ad-hoc gate, no resource
11
- *
12
- * When a resource class is provided (and not `classLevel`), the guard loads an
13
- * instance via the registered `ResourceResolver` (default: by route `:id`).
14
- */
15
- export function Can(ability, resource, options) {
16
- const meta = { ability };
17
- if (resource !== undefined)
18
- meta.resource = resource;
19
- if (options?.classLevel)
20
- meta.classLevel = true;
21
- return SetMetadata(CAN_METADATA, meta);
22
- }
23
- //# sourceMappingURL=can.decorator.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"can.decorator.js","sourceRoot":"","sources":["../../src/decorator/can.decorator.ts"],"names":[],"mappings":"AAAA,OAAO,kBAAkB,CAAC;AAC1B,OAAO,EAAE,WAAW,EAAa,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAkB5C;;;;;;;;;;GAUG;AACH,MAAM,UAAU,GAAG,CACjB,OAAe,EACf,QAAwB,EACxB,OAAoB;IAEpB,MAAM,IAAI,GAAgB,EAAE,OAAO,EAAE,CAAC;IACtC,IAAI,QAAQ,KAAK,SAAS;QAAE,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IACrD,IAAI,OAAO,EAAE,UAAU;QAAE,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;IAChD,OAAO,WAAW,CAAC,YAAY,EAAE,IAAI,CAAC,CAAC;AACzC,CAAC"}
@@ -1,18 +0,0 @@
1
- import 'reflect-metadata';
2
- import { type Type } from '@nestjs/common';
3
- /**
4
- * Marks a class as the policy for `resource`. The class methods become abilities
5
- * dispatched by name (e.g. `update(user, post)` answers `gate.authorize('update', post)`).
6
- *
7
- * Applying `@Policy` also makes the class an `@Injectable()` provider so it can
8
- * be auto-discovered and resolved from the Nest container.
9
- */
10
- export declare function Policy(resource: Type<unknown>): ClassDecorator;
11
- /**
12
- * Returns the resource class a policy was declared for, or `undefined`.
13
- *
14
- * Uses `getMetadata` (not `getOwnMetadata`) so a subclass of a `@Policy`-decorated
15
- * class still resolves the inherited resource via the prototype chain.
16
- */
17
- export declare function getPolicyResource(policy: Type<unknown> | object): Type<unknown> | undefined;
18
- //# sourceMappingURL=policy.decorator.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"policy.decorator.d.ts","sourceRoot":"","sources":["../../src/decorator/policy.decorator.ts"],"names":[],"mappings":"AAAA,OAAO,kBAAkB,CAAC;AAC1B,OAAO,EAAc,KAAK,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAIvD;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,cAAc,CAM9D;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,SAAS,CAG3F"}