@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 +16 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/tokens.d.ts +5 -5
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +5 -5
- package/dist/tokens.js.map +1 -1
- package/package.json +6 -3
- package/skills/authz-enforcement/SKILL.md +167 -0
- package/skills/authz-policies/SKILL.md +150 -0
- package/skills/authz-query-scopes/SKILL.md +139 -0
- package/skills/authz-rbac-seams/SKILL.md +157 -0
- package/skills/authz-setup/SKILL.md +153 -0
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
package/dist/index.js
CHANGED
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
package/dist/tokens.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAEA,uEAAuE;AACvE,eAAO,MAAM,oBAAoB,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
package/dist/tokens.js.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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`).
|