@rytass/wms-module-graphql 0.2.0 → 0.4.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 (53) hide show
  1. package/README.md +75 -29
  2. package/adapters/member-base.d.ts +35 -0
  3. package/adapters/member-base.js +47 -0
  4. package/docs/README.md +6 -3
  5. package/docs/api-reference.md +143 -11
  6. package/docs/authorization.md +349 -0
  7. package/docs/integration-guide.md +155 -84
  8. package/index.cjs.js +177 -6
  9. package/index.d.ts +2 -0
  10. package/index.js +4 -0
  11. package/lib/auth/host-skip.constant.d.ts +27 -0
  12. package/lib/auth/host-skip.constant.js +29 -0
  13. package/lib/auth/index.d.ts +4 -0
  14. package/lib/auth/wms-auth.guard.d.ts +19 -0
  15. package/lib/auth/wms-auth.guard.js +41 -0
  16. package/lib/auth/wms-auth.types.d.ts +36 -0
  17. package/lib/auth/wms-auth.types.js +6 -0
  18. package/lib/auth/wms-operation.decorator.d.ts +23 -0
  19. package/lib/auth/wms-operation.decorator.js +29 -0
  20. package/lib/graphql.module.d.ts +8 -1
  21. package/lib/graphql.module.js +12 -3
  22. package/lib/helpers/create-wms-loaders.d.ts +3 -1
  23. package/lib/helpers/create-wms-loaders.js +5 -3
  24. package/lib/mutations/allocate-inventory-order.mutation.js +5 -0
  25. package/lib/mutations/hold-inventory-order.mutation.js +5 -0
  26. package/lib/mutations/loader.mutation.js +7 -0
  27. package/lib/mutations/location.mutation.js +4 -0
  28. package/lib/mutations/material.mutation.js +4 -0
  29. package/lib/mutations/merge-batch.mutation.js +3 -0
  30. package/lib/mutations/quality-inspection-order.mutation.js +3 -0
  31. package/lib/mutations/receive-inventory-order.mutation.js +5 -0
  32. package/lib/mutations/reclassify-inventory-order.mutation.js +3 -0
  33. package/lib/mutations/scrape-order.mutation.js +3 -0
  34. package/lib/mutations/ship-inventory-order.mutation.js +6 -0
  35. package/lib/mutations/split-batch.mutation.js +3 -0
  36. package/lib/mutations/transfer-customer-order.mutation.js +5 -0
  37. package/lib/mutations/transfer-order.mutation.js +6 -0
  38. package/lib/mutations/transfer-vendor-order.mutation.js +5 -0
  39. package/lib/mutations/warehouse-map.mutation.js +3 -0
  40. package/lib/queries/allocate-inventory-order.query.js +3 -0
  41. package/lib/queries/batch.query.js +3 -0
  42. package/lib/queries/hold-inventory-order.query.js +3 -0
  43. package/lib/queries/loader.query.js +8 -0
  44. package/lib/queries/location.query.js +4 -0
  45. package/lib/queries/material.query.js +4 -0
  46. package/lib/queries/recieve-inventory-order.query.js +4 -0
  47. package/lib/queries/sale-order.query.js +4 -0
  48. package/lib/queries/ship-inventory-order.query.js +4 -0
  49. package/lib/queries/stock.query.js +7 -0
  50. package/lib/queries/transfer-order.query.js +4 -0
  51. package/lib/queries/unit.query.js +3 -0
  52. package/lib/queries/warehouse-map.query.js +3 -0
  53. package/package.json +5 -1
@@ -0,0 +1,349 @@
1
+ # Authorization
2
+
3
+ How `@rytass/wms-module-graphql` lets **you** authorize its resolvers with **your**
4
+ permission system — without the library knowing anything about it, and without
5
+ your global auth guard rejecting every WMS operation.
6
+
7
+ - [1. The problem](#1-the-problem)
8
+ - [2. The design](#2-the-design)
9
+ - [3. Execution order — why it composes](#3-execution-order--why-it-composes)
10
+ - [4. Recipe A — member-base / Casbin](#4-recipe-a--member-base--casbin)
11
+ - [5. Recipe B — your own JWT / RBAC](#5-recipe-b--your-own-jwt--rbac)
12
+ - [6. Recipe C — no global guard / fully open](#6-recipe-c--no-global-guard--fully-open)
13
+ - [7. The single coupling point](#7-the-single-coupling-point)
14
+ - [8. Public API](#8-public-api)
15
+ - [9. FAQ & gotchas](#9-faq--gotchas)
16
+
17
+ ---
18
+
19
+ ## 1. The problem
20
+
21
+ The library exposes ~66 root `@Query` / `@Mutation` operations. Like any
22
+ publishable NestJS GraphQL module, it cannot know how *your* application
23
+ authorizes requests — RBAC? ABAC? Casbin? JWT scopes? a custom session table?
24
+ So historically its resolvers were **bare** (`@Query()` with no guard metadata).
25
+
26
+ That collides badly with a **globally registered, deny-by-default guard**. The
27
+ canonical example is the `CasbinGuard` that `@rytass/member-base-nestjs-module`
28
+ registers as `APP_GUARD`:
29
+
30
+ ```
31
+ Host App
32
+ APP_GUARD = CasbinGuard <- one net over EVERY resolver in the schema
33
+ |
34
+ v
35
+ +------------------------------+
36
+ | compiled GraphQL schema |
37
+ | - your resolvers | carry @AllowActions -> authorized OK
38
+ | - WMS resolvers (bare) | no metadata -> guard returns false -> ALL denied
39
+ +------------------------------+
40
+ ```
41
+
42
+ `CasbinGuard` is white-list: a handler with no `@AllowActions` / `@Authenticated`
43
+ / `@IsPublic` metadata is **rejected**. WMS resolvers had none, so every WMS
44
+ query and mutation came back *forbidden*. You could not fix it from the consumer
45
+ side either — you do not own the WMS resolver classes, so you cannot decorate
46
+ them.
47
+
48
+ **Conclusion:** the library itself must participate in authorization, while
49
+ staying ignorant of which auth system you use.
50
+
51
+ ---
52
+
53
+ ## 2. The design
54
+
55
+ A host-neutral, opt-in authorization protocol with three moving parts, all
56
+ shipped by the library:
57
+
58
+ | Part | What it is | Who supplies it |
59
+ |------|------------|-----------------|
60
+ | `@WmsOperation({ resource, action })` | Method decorator on every WMS root op. Declares a **neutral** permission requirement (just two strings) and wires the guard. | Library (already applied) |
61
+ | `WmsAuthGuard` | Route-level guard (NOT global) that reads the requirement and delegates the decision. | Library |
62
+ | `authChecker` | Your function `(ctx, { resource, action }) => boolean`. Maps WMS's neutral requirement onto your real permission system. | **You**, via `forRootAsync` |
63
+
64
+ ```
65
+ WMS resolver
66
+ |
67
+ +- @WmsOperation({ resource: 'material', action: 'read' })
68
+ | |
69
+ | +- stamps neutral metadata ............ read by WmsAuthGuard
70
+ | +- stamps a host-skip marker ........... tells your global guard to defer
71
+ | +- @UseGuards(WmsAuthGuard) ............ the guard that actually enforces
72
+ |
73
+ v
74
+ WmsAuthGuard.canActivate(ctx)
75
+ |
76
+ +- reads { resource, action } from the handler
77
+ +- calls YOUR authChecker(ctx, { resource, action })
78
+ |
79
+ v
80
+ true -> resolve / false -> ForbiddenError
81
+ ```
82
+
83
+ Key properties:
84
+
85
+ - **The library never imports your auth system.** It only knows two strings.
86
+ - **`authChecker` is optional.** Omit it and the module stays fully permissive
87
+ (handy for local dev / demos) — identical to the pre-auth releases.
88
+ - **`resource` / `action` are internal metadata**, not part of the GraphQL
89
+ schema. Refining them later is *not* a breaking change for GraphQL consumers.
90
+ - The host-skip marker is what lets WMS coexist with your global guard — see §3.
91
+
92
+ ---
93
+
94
+ ## 3. Execution order — why it composes
95
+
96
+ NestJS runs guards in the order **global -> controller -> route**. So a global
97
+ `APP_GUARD` (your `CasbinGuard`) runs *before* WMS's route-level `WmsAuthGuard`.
98
+ That is exactly why `@WmsOperation` stamps a host-skip marker: it makes the
99
+ global guard *defer* on WMS operations so it never blanket-rejects them.
100
+
101
+ ```
102
+ request
103
+ |
104
+ v [1] APP_GUARD = CasbinGuard (global, runs first)
105
+ | - verifies token, attaches payload/enforcer to the request
106
+ | - sees host-skip marker (IS_ROUTE_PUBLIC) -> returns true (defers)
107
+ |
108
+ v [2] @UseGuards(WmsAuthGuard) (route-level, runs next)
109
+ | - reads { resource, action }
110
+ | - calls your authChecker -> real allow/deny decision
111
+ |
112
+ v resolver executes
113
+ ```
114
+
115
+ With member-base specifically there is a bonus: before `CasbinGuard` defers on
116
+ the skip marker, it has *already* attached the verified `payload`, the Casbin
117
+ `enforcer`, and your `casbinPermissionChecker` to the request. The member-base
118
+ adapter (§4) reuses those — so WMS enforces with the **exact same** policy
119
+ engine and rules as your own resolvers.
120
+
121
+ > Your global guard configuration does **not** change. You do not whitelist WMS
122
+ > routes, you do not enumerate operations, you do not weaken your default-deny
123
+ > posture. WMS makes itself transparent to the global guard and self-enforces.
124
+
125
+ ---
126
+
127
+ ## 4. Recipe A — member-base / Casbin
128
+
129
+ The common case: you use `@rytass/member-base-nestjs-module` with its global
130
+ `CasbinGuard`. Use the ready-made adapter from the dedicated subpath.
131
+
132
+ ```ts
133
+ // app.module.ts
134
+ import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
135
+ import { createMemberBaseWmsAuthChecker } from '@rytass/wms-module-graphql/adapters/member-base';
136
+
137
+ @Module({
138
+ imports: [
139
+ // ...MemberBaseModule.forRoot({ ... }) with its global guard enabled (default)...
140
+ WmsModuleGraphqlModule.forRootAsync({
141
+ // existing core options:
142
+ useFactory: async () => ({}),
143
+ defaultLoaderSerialId: 'DEFAULT',
144
+ defaultLoaderTypeKey: 'DEFAULT',
145
+ defaultLocationKey: 'DEFAULT',
146
+
147
+ // authorization:
148
+ authChecker: createMemberBaseWmsAuthChecker({
149
+ // Map WMS resource/action onto YOUR Casbin [subject, action] tuples.
150
+ // This is the one place your permission naming convention lives.
151
+ // Return several tuples to mean "allow if ANY passes" (OR semantics).
152
+ map: ({ resource, action }) => [[`wms:${resource}`, action]],
153
+ // material + read -> enforce(memberId, domain, 'wms:material', 'read')
154
+ }),
155
+ }),
156
+ // ...your own GraphQLModule.forRootAsync (must expose `req` on context)...
157
+ ],
158
+ })
159
+ export class AppModule {}
160
+ ```
161
+
162
+ What the adapter does, step by step:
163
+
164
+ 1. Pulls the request off the GraphQL `ExecutionContext`
165
+ (`GqlExecutionContext.create(ctx).getContext().req` — member-base's own
166
+ convention).
167
+ 2. Reads `request.payload` (the JWT member, attached by `CasbinGuard`). Missing
168
+ payload -> **deny** (unauthenticated).
169
+ 3. Reads `request.enforcer` and `request.casbinPermissionChecker` (also attached
170
+ by `CasbinGuard`). Missing -> **deny** (fail closed: the global guard was not
171
+ actually enabled).
172
+ 4. Runs `your map({ resource, action })` to get the Casbin tuples.
173
+ 5. Calls the **consumer's own** `casbinPermissionChecker({ enforcer, payload,
174
+ actions })` and returns its verdict.
175
+
176
+ The adapter imports **nothing** from member-base — it relies only on the runtime
177
+ contract `CasbinGuard` already established on the request. So member-base is not
178
+ even a dependency of `@rytass/wms-module-graphql`.
179
+
180
+ **Prerequisites**
181
+
182
+ - Keep member-base's global guard enabled (`enableGlobalGuard: true`, the
183
+ default).
184
+ - Your `GraphQLModule` context must expose the request as `req` (Apollo's
185
+ default): `context: ({ req }) => ({ ...createWmsLoaders(loaderService), req })`.
186
+
187
+ **Permission grants.** Make sure your Casbin policy grants the mapped subjects.
188
+ With the example `map` above, a warehouse operator role might be granted
189
+ `wms:material:read`, `wms:receive-inventory-order:create`, etc. The full list of
190
+ `resource` values mirrors the WMS domains (`material`, `loader`, `stock`,
191
+ `location`, `unit`, `batch`, `warehouse-map`, `transfer-order`,
192
+ `receive-inventory-order`, `ship-inventory-order`, `allocate-inventory-order`,
193
+ `hold-inventory-order`, `reclassify-inventory-order`, `transfer-customer-order`,
194
+ `transfer-vendor-order`, `sale-order`, `scrape-order`,
195
+ `quality-inspection-order`). `action` is `read` for queries, and the leading
196
+ verb (`create` / `update` / `delete` / `ship` / `hold` / `allocate` / `split` /
197
+ `merge` / `transfer` / ...) for mutations.
198
+
199
+ ---
200
+
201
+ ## 5. Recipe B — your own JWT / RBAC
202
+
203
+ `authChecker` is a plain function — you do not need Casbin or member-base. Read
204
+ whatever you need off the `ExecutionContext` and decide.
205
+
206
+ ```ts
207
+ import { GqlExecutionContext } from '@nestjs/graphql';
208
+ import type { WmsAuthChecker } from '@rytass/wms-module-graphql';
209
+
210
+ const myWmsAuthChecker: WmsAuthChecker = (ctx, { resource, action }) => {
211
+ const { req } = GqlExecutionContext.create(ctx).getContext<{ req: { user?: { scopes: string[] } } }>();
212
+ const user = req.user;
213
+
214
+ if (!user) return false; // unauthenticated -> deny
215
+
216
+ // your own convention; e.g. scopes like "wms:material:read" or "wms:*"
217
+ return user.scopes.includes('wms:*')
218
+ || user.scopes.includes(`wms:${resource}:${action}`)
219
+ || user.scopes.includes(`wms:${resource}:*`);
220
+ };
221
+
222
+ WmsModuleGraphqlModule.forRootAsync({
223
+ useFactory: async () => ({}),
224
+ defaultLoaderSerialId: 'DEFAULT',
225
+ defaultLoaderTypeKey: 'DEFAULT',
226
+ defaultLocationKey: 'DEFAULT',
227
+ authChecker: myWmsAuthChecker,
228
+ });
229
+ ```
230
+
231
+ `authChecker` may be sync or async (return `Promise<boolean>`). Throwing also
232
+ works — the exception propagates exactly like any guard rejection (use a
233
+ `ForbiddenException` if you want a clean 403-style GraphQL error).
234
+
235
+ If you *also* run a global deny-by-default guard that is **not** member-base, you
236
+ must make it skip WMS routes too — see §7 to change the host-skip key.
237
+
238
+ ---
239
+
240
+ ## 6. Recipe C — no global guard / fully open
241
+
242
+ - **No `authChecker`.** The module is permissive: `WmsAuthGuard` lets every
243
+ operation through. The host-skip marker is inert. Good for local dev, internal
244
+ tooling, or demos.
245
+
246
+ ```ts
247
+ WmsModuleGraphqlModule.forRootAsync({
248
+ useFactory: async () => ({}),
249
+ defaultLoaderSerialId: 'DEFAULT',
250
+ defaultLoaderTypeKey: 'DEFAULT',
251
+ defaultLocationKey: 'DEFAULT',
252
+ // no authChecker -> open
253
+ });
254
+ ```
255
+
256
+ - **You guard your own resolvers explicitly** (`@UseGuards(...)` per resolver
257
+ instead of a global `APP_GUARD`). Then there is no net over WMS at all; the
258
+ skip marker does nothing and WMS still self-enforces through your
259
+ `authChecker` if you supply one.
260
+
261
+ ---
262
+
263
+ ## 7. The single coupling point
264
+
265
+ `@WmsOperation` stamps a host-skip metadata key so your global guard defers. A
266
+ global guard reads its skip key *statically* (at decoration time), so the key
267
+ must be a fixed value baked into the decorator. The default matches
268
+ member-base's:
269
+
270
+ ```ts
271
+ // libs/graphql/src/lib/auth/host-skip.constant.ts
272
+ export const HOST_SKIP_METADATA_KEY = 'IS_ROUTE_PUBLIC';
273
+ ```
274
+
275
+ This is the **only** place WMS assumes anything about a specific host guard. If
276
+ your global guard uses a different "public / skip" metadata key, change this one
277
+ constant (when building from source) — nothing else moves. If you do not run a
278
+ global guard, it is irrelevant.
279
+
280
+ ---
281
+
282
+ ## 8. Public API
283
+
284
+ Exported from the package root (`@rytass/wms-module-graphql`):
285
+
286
+ | Export | Kind | Purpose |
287
+ |--------|------|---------|
288
+ | `WmsOperation(requirement)` | decorator | Annotate a WMS root op (already applied across the library). |
289
+ | `WmsAuthGuard` | class | Route-level guard that delegates to `authChecker`. |
290
+ | `WmsAuthChecker` | type | `(ctx: ExecutionContext, req: WmsOperationRequirement) => boolean \| Promise<boolean>` |
291
+ | `WmsOperationRequirement` | type | `{ readonly resource: string; readonly action: string }` |
292
+ | `WmsAuthModuleOptions` | type | `{ readonly authChecker?: WmsAuthChecker }` |
293
+ | `WmsModuleGraphqlModuleAsyncOptions` | type | Core async options **+** `WmsAuthModuleOptions`. |
294
+ | `WMS_AUTH_OPTIONS` | symbol | DI token holding the resolved auth options. |
295
+ | `WMS_OPERATION_METADATA` | string | Reflector key carrying the requirement. |
296
+ | `HOST_SKIP_METADATA_KEY` | string | The host-skip metadata key (default `'IS_ROUTE_PUBLIC'`). |
297
+
298
+ Exported from the adapter subpath (`@rytass/wms-module-graphql/adapters/member-base`):
299
+
300
+ | Export | Kind | Purpose |
301
+ |--------|------|---------|
302
+ | `createMemberBaseWmsAuthChecker(options)` | factory | Builds a `WmsAuthChecker` for member-base / Casbin. |
303
+ | `MemberBaseWmsAuthCheckerOptions` | type | `{ readonly map: (req: WmsOperationRequirement) => CasbinAction[] }` |
304
+ | `CasbinAction` | type | `[string, string]` — a `[subject, action]` tuple. |
305
+
306
+ `forRootAsync` accepts the optional `authChecker` on its single options object:
307
+
308
+ ```ts
309
+ static forRootAsync(options: WmsModuleGraphqlModuleAsyncOptions): DynamicModule;
310
+ ```
311
+
312
+ ---
313
+
314
+ ## 9. FAQ & gotchas
315
+
316
+ **"Every WMS query/mutation is forbidden after I added member-base."**
317
+ Your global `CasbinGuard` is rejecting bare resolvers. Upgrade to a WMS version
318
+ that ships `@WmsOperation` (this protocol) and wire an `authChecker` per §4. You
319
+ do not need to touch the global guard.
320
+
321
+ **Does the GraphQL context need anything special?**
322
+ Yes — expose the request as `req` on the context (Apollo's default). Both the
323
+ member-base adapter and most custom checkers read `GqlExecutionContext.create(ctx).getContext().req`.
324
+
325
+ **Is member-base a dependency now?**
326
+ No. The adapter reads runtime fields the host's guard attaches to the request;
327
+ it imports nothing from member-base. member-base is not in this package's
328
+ `dependencies` or `peerDependencies`.
329
+
330
+ **What about `@ResolveField` resolvers?**
331
+ They are intentionally *not* annotated. A field resolver is only ever reached
332
+ through a root `@Query`/`@Mutation` that already passed `WmsAuthGuard`, so
333
+ authorizing the root op authorizes the whole selection. `WmsAuthGuard` returns
334
+ `true` for any handler without a requirement.
335
+
336
+ **Fail-open or fail-closed?**
337
+ The library is permissive only when *you* opt out (no `authChecker`). When an
338
+ `authChecker` is configured, missing identity / missing enforcer -> **deny**. The
339
+ member-base adapter fails closed.
340
+
341
+ **Can I change `resource`/`action` strings?**
342
+ Yes, freely — they are internal metadata, never exposed in the GraphQL schema.
343
+ Changing them only affects your `authChecker` / `map`, never your GraphQL
344
+ clients.
345
+
346
+ ---
347
+
348
+ See also: [Integration Guide §8.5](./integration-guide.md#85-backend-authorization-resolver-level)
349
+ · [API Reference](./api-reference.md#rytasswms-module-graphql)