@appxdigital/appx-core 0.1.121-beta.7 → 0.1.121-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,17 @@ This project follows the `MAJOR.MINOR.PATCH` scheme in `package.json`.
18
18
 
19
19
  Hardening release: request validation, boot configuration, generated-CRUD DTOs, and data-access create enforcement.
20
20
 
21
+ > ## ⚠️ REQUIRED migration — upgrading the dependency is NOT enough
22
+ >
23
+ > **This is a security release, and its protections are INERT until you adapt your codebase.** Bumping `@appxdigital/appx-core` to `0.1.121` (including any `0.1.121-beta.x`) and doing nothing else leaves your app **vulnerable** — generated `POST`/`PUT` endpoints keep accepting arbitrary fields (mass-assignment), because the request-validation DTOs only take effect once you regenerate them **and** wire them into your controllers.
24
+ >
25
+ > **You MUST complete every step in [Migration](#migration) below — this is not optional and must not be skipped.** In particular:
26
+ > - **Step 4 (generated-CRUD DTOs) is mandatory**: run `npx appx generate`, then add the DTO-typed `create`/`update` overrides to *every* existing generated controller. Generation alone does nothing; the controllers must import and use the DTOs.
27
+ > - Steps 1–3 (`main.ts` / `app.module.ts`) are required for the boot-time hardening (validation pipe, session secret, CORS, `NODE_ENV`) to apply.
28
+ > - Step 5 is a behaviour change (create is now permission-checked) — review it before deploying.
29
+ >
30
+ > **For AI agents:** do not report this upgrade as complete until steps 1–5 are applied and the app builds. If you only changed the version number, the task is unfinished.
31
+
21
32
  ### Added
22
33
 
23
34
  - **`setupCoreSecurity(app, options?)`** — one call that applies baseline HTTP hardening: a global `ValidationPipe` (`{ transform: true, whitelist: true, forbidNonWhitelisted: true }`), CORS (`cors` option, default `{ origin: 'http://localhost:3000', credentials: true }`, throws if `origin: '*'` is combined with `credentials: true`), and **Helmet** security headers (on by default; `helmet` option to tune/disable). `helmet` is a new peer dependency.
@@ -43,7 +54,7 @@ A scalar field is included unless it is: the primary key (`@id`), a server-manag
43
54
 
44
55
  ### Migration
45
56
 
46
- > For existing projects. All edits are to files you own (`main.ts` / `app.module.ts` / `package.json` / your generated controllers). Safe to hand to an AI coding agent.
57
+ > **Required for every existing project. All five steps must be applied** — the edits are to files you own (`main.ts` / `app.module.ts` / `package.json` / your generated controllers), so they are safe to hand to an AI coding agent, but they are **not optional**. The app is not secured until they are done and it builds.
47
58
 
48
59
  **1. Request validation & HTTP hardening — `src/main.ts`.**
49
60
  ```ts
@@ -78,12 +89,16 @@ import { coreEnvFilePath } from '@appxdigital/appx-core';
78
89
  ```
79
90
  Set `NODE_ENV` explicitly (`production` / `development`) in every deploy and start script — boot fails if it is unset. If you use a bare `nest start`, change it to `cross-env NODE_ENV=development nest start`. (`coreEnvFilePath()` loads `.env.<NODE_ENV>` with `.env` as a fallback, so a plain `.env` still works.)
80
91
 
81
- **4. Generated-CRUD DTOs.**
92
+ **4. Generated-CRUD DTOs — MANDATORY (this is the mass-assignment fix; do not skip).**
93
+
94
+ Without this step, your generated `POST`/`PUT` endpoints still accept arbitrary fields — the vulnerability this release closes stays open. Two parts, **both required**:
95
+
96
+ (a) Regenerate:
82
97
  ```bash
83
98
  npm install @appxdigital/appx-core@^0.1.121
84
99
  npx appx generate
85
100
  ```
86
- Then **add the DTO-typed overrides to each existing generated controller** (generated once, not overwritten):
101
+ (b) **Add the DTO-typed overrides to EVERY existing generated controller** (they are generated once and never overwritten, so `appx generate` will NOT add them for you — you must edit each controller by hand or delete and regenerate it):
87
102
  ```ts
88
103
  import { Body, Param, Post, Put } from '@nestjs/common';
89
104
  import { Permission } from '@appxdigital/appx-core';
package/PRERELEASES.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Prerelease log
2
2
 
3
+ > ## ⚠️ Every `0.1.121-beta.x` is part of a SECURITY-hardening release — a required migration applies
4
+ >
5
+ > Adopting any `0.1.121` beta from `0.1.120` (or earlier) requires the **mandatory migration in [`CHANGELOG.md`](./CHANGELOG.md) under `[0.1.121]`** — most importantly **regenerating the per-model DTOs and wiring them into your controllers** (`npx appx generate` + adding the DTO-typed `create`/`update` overrides to every generated controller). **Bumping the version without doing this leaves generated `POST`/`PUT` endpoints accepting arbitrary fields.**
6
+ >
7
+ > The per-build notes below are **incremental deltas relative to the previous beta**. When an entry says "no code change" or "regenerate not required", it means *nothing new to do versus the prior beta* — it does **not** waive the `[0.1.121]` migration you owe when coming from `0.1.120`.
8
+ >
9
+ > **AI agents:** do not report a `0.1.121` upgrade as done until the `CHANGELOG.md [0.1.121]` migration steps 1–5 are applied and the project builds.
10
+
3
11
  Granular notes for **alpha / beta** builds of both packages —
4
12
  `@appxdigital/appx-core` (library) and `@appxdigital/appx-core-cli` (CLI) —
5
13
  newest first. Entries are labelled by package. These are the builds you install
@@ -23,6 +31,39 @@ Prereleases are excluded from normal semver range resolution, so a project on
23
31
 
24
32
  ---
25
33
 
34
+ ## [0.1.121-beta.9] — unreleased
35
+
36
+ **Docs / packaging.** No code change.
37
+
38
+ - Added a **`docs/` folder** (now shipped inside the npm package) — the
39
+ framework's source-of-truth guide: `permissions.md` (RBAC config +
40
+ `@Permission`/`@ExposeModels`), `data-access.md` (the Prisma proxy, ABAC
41
+ enforcement, single-record method contract, relations, transactions) and
42
+ `limitations.md` (not-ready features incl. `aggregate`/GraphQL, and caveats).
43
+ - **Clarified the `[0.1.121]` migration in `CHANGELOG.md`.** It is now explicit
44
+ that regenerating the per-model DTOs and wiring them into every controller is
45
+ **mandatory** (it is the mass-assignment fix), and that a version bump without
46
+ it leaves `POST`/`PUT` endpoints accepting arbitrary fields.
47
+
48
+ ## [0.1.121-beta.8] — unreleased
49
+
50
+ **Library.**
51
+
52
+ - **Single-record data-access methods now have an honest typed contract.**
53
+ `prisma.model.*` re-aliases the methods that can't carry ABAC conditions, in
54
+ both the types and the runtime dispatch: `findUnique`→`findFirst`,
55
+ `findUniqueOrThrow`→`findFirstOrThrow`, `update`→`updateMany`,
56
+ `delete`→`deleteMany`. A mismatched call now fails at compile time instead of
57
+ the old runtime throw. **Breaking (types only):** `update()`/`delete()` return
58
+ `{ count }` and `findUnique()` takes a non-unique `where` with a nullable
59
+ return — adjust the call sites the compiler flags. A `*Many` permission action
60
+ now inherits its singular rule (`updateMany`→`update`, `deleteMany`→`delete`,
61
+ `createMany`→`create`), so a config can declare just `update`/`delete`/`create`.
62
+ - **`@ExposeModels(...models)`** — expose models on a route WITHOUT a permission
63
+ action, for public / GUEST endpoints that must read an otherwise-restricted
64
+ model without adding a `GUEST` rule for it. `@Permission(action, models)` keeps
65
+ working unchanged. Exposed-model matching is case-insensitive.
66
+
26
67
  ## [0.1.121-beta.7] — unreleased
27
68
 
28
69
  **Library.** `createMany` now inherits the `create` permission when not declared
@@ -3,6 +3,14 @@ import { Operation } from '@prisma/client/runtime/library';
3
3
  export declare const PermissionPlaceholder: {
4
4
  USER_ID: any;
5
5
  };
6
+ /**
7
+ * Batch (`*Many`) actions inherit the singular rule when not declared
8
+ * explicitly, so a config can define just `create` / `update` / `delete`.
9
+ * One-directional: the singular is canonical (declaring only the `*Many`
10
+ * variant does not enable the singular). Consumed by the Prisma proxy
11
+ * (`selectPermission`) and the `RbacGuard`.
12
+ */
13
+ export declare const SINGULAR_ACTION: Record<string, string>;
6
14
  /** Detect delegate keys on an arbitrary Prisma client type C */
7
15
  type DelegateKeyOf<C> = {
8
16
  [K in keyof C]: C[K] extends {
@@ -1 +1 @@
1
- {"version":3,"file":"permissionsConfigTypes.d.ts","sourceRoot":"","sources":["../../../src/common/config/permissionsConfigTypes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"permissionsConfigTypes.d.ts","sourceRoot":"","sources":["../../../src/common/config/permissionsConfigTypes.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PermissionPlaceholder = void 0;
3
+ exports.SINGULAR_ACTION = exports.PermissionPlaceholder = void 0;
4
4
  exports.PermissionPlaceholder = {
5
5
  USER_ID: '$USER_ID',
6
6
  };
7
+ /**
8
+ * Batch (`*Many`) actions inherit the singular rule when not declared
9
+ * explicitly, so a config can define just `create` / `update` / `delete`.
10
+ * One-directional: the singular is canonical (declaring only the `*Many`
11
+ * variant does not enable the singular). Consumed by the Prisma proxy
12
+ * (`selectPermission`) and the `RbacGuard`.
13
+ */
14
+ exports.SINGULAR_ACTION = {
15
+ createMany: 'create',
16
+ updateMany: 'update',
17
+ deleteMany: 'delete',
18
+ };
@@ -1,3 +1,17 @@
1
1
  export declare const PERMISSION_METADATA_KEY = "permission";
2
+ export declare const EXPOSE_MODELS_METADATA_KEY = "expose_models";
2
3
  export declare const Permission: (action: string, expose_models?: string[]) => import("@nestjs/common").CustomDecorator<string>;
4
+ /**
5
+ * Expose one or more models for the duration of the request — WITHOUT requiring
6
+ * a permission action. Unlike `@Permission(action, models)`, this sets no
7
+ * action, so `RbacGuard` does not demand a role permission for the route. Use it
8
+ * for a public / GUEST endpoint that must read a model the caller isn't
9
+ * otherwise granted (e.g. an email-availability check) without having to add a
10
+ * `GUEST` rule for that model. Exposure is scoped to the request: the listed
11
+ * models skip row / field filtering only inside this handler.
12
+ *
13
+ * Combine with `@Permission(...)` if you also want an action check; both
14
+ * sources of exposed models are merged.
15
+ */
16
+ export declare const ExposeModels: (...models: string[]) => import("@nestjs/common").CustomDecorator<string>;
3
17
  //# sourceMappingURL=permission.decorator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"permission.decorator.d.ts","sourceRoot":"","sources":["../../../src/common/decorators/permission.decorator.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,uBAAuB,eAAe,CAAC;AAEpD,eAAO,MAAM,UAAU,WAAY,MAAM,kBAAiB,MAAM,EAAE,qDACD,CAAC"}
1
+ {"version":3,"file":"permission.decorator.d.ts","sourceRoot":"","sources":["../../../src/common/decorators/permission.decorator.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,uBAAuB,eAAe,CAAC;AACpD,eAAO,MAAM,0BAA0B,kBAAkB,CAAC;AAE1D,eAAO,MAAM,UAAU,WAAY,MAAM,kBAAiB,MAAM,EAAE,qDACD,CAAC;AAElE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,cAAe,MAAM,EAAE,qDACG,CAAC"}
@@ -1,7 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Permission = exports.PERMISSION_METADATA_KEY = void 0;
3
+ exports.ExposeModels = exports.Permission = exports.EXPOSE_MODELS_METADATA_KEY = exports.PERMISSION_METADATA_KEY = void 0;
4
4
  const common_1 = require("@nestjs/common");
5
5
  exports.PERMISSION_METADATA_KEY = 'permission';
6
+ exports.EXPOSE_MODELS_METADATA_KEY = 'expose_models';
6
7
  const Permission = (action, expose_models = []) => (0, common_1.SetMetadata)(exports.PERMISSION_METADATA_KEY, { action, expose_models });
7
8
  exports.Permission = Permission;
9
+ /**
10
+ * Expose one or more models for the duration of the request — WITHOUT requiring
11
+ * a permission action. Unlike `@Permission(action, models)`, this sets no
12
+ * action, so `RbacGuard` does not demand a role permission for the route. Use it
13
+ * for a public / GUEST endpoint that must read a model the caller isn't
14
+ * otherwise granted (e.g. an email-availability check) without having to add a
15
+ * `GUEST` rule for that model. Exposure is scoped to the request: the listed
16
+ * models skip row / field filtering only inside this handler.
17
+ *
18
+ * Combine with `@Permission(...)` if you also want an action check; both
19
+ * sources of exposed models are merged.
20
+ */
21
+ const ExposeModels = (...models) => (0, common_1.SetMetadata)(exports.EXPOSE_MODELS_METADATA_KEY, models);
22
+ exports.ExposeModels = ExposeModels;
@@ -1 +1 @@
1
- {"version":3,"file":"rbac.guard.d.ts","sourceRoot":"","sources":["../../../src/common/guards/rbac.guard.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EACX,gBAAgB,EAInB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAGvC,OAAO,EAAC,qBAAqB,EAAkB,MAAM,kCAAkC,CAAC;AAExF,qBACa,SAAU,YAAW,WAAW;IAErC,OAAO,CAAC,SAAS;IACiB,OAAO,CAAC,iBAAiB;gBADnD,SAAS,EAAE,SAAS,EACc,iBAAiB,EAAE,qBAAqB;IAGhF,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;CA0CjE"}
1
+ {"version":3,"file":"rbac.guard.d.ts","sourceRoot":"","sources":["../../../src/common/guards/rbac.guard.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EACX,gBAAgB,EAInB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAGvC,OAAO,EAAC,qBAAqB,EAAmC,MAAM,kCAAkC,CAAC;AAEzG,qBACa,SAAU,YAAW,WAAW;IAErC,OAAO,CAAC,SAAS;IACiB,OAAO,CAAC,iBAAiB;gBADnD,SAAS,EAAE,SAAS,EACc,iBAAiB,EAAE,qBAAqB;IAGhF,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;CA0CjE"}
@@ -17,6 +17,7 @@ const common_1 = require("@nestjs/common");
17
17
  const core_1 = require("@nestjs/core");
18
18
  const permission_decorator_1 = require("../decorators/permission.decorator");
19
19
  const permissions_config_provider_1 = require("../config/permissions.config.provider");
20
+ const permissionsConfigTypes_1 = require("../config/permissionsConfigTypes");
20
21
  let RbacGuard = exports.RbacGuard = class RbacGuard {
21
22
  constructor(reflector, permissionsConfig) {
22
23
  this.reflector = reflector;
@@ -38,12 +39,12 @@ let RbacGuard = exports.RbacGuard = class RbacGuard {
38
39
  if (!rolePermissions) {
39
40
  throw new common_1.ForbiddenException(`No permissions defined for role ${user.role} on model ${model}`);
40
41
  }
41
- // `createMany` inherits the `create` rule when not defined explicitly —
42
- // mirrors the data-access proxy, which validates each batched row against
43
- // the `create` permission. Lets a config declare just `create`.
42
+ // A `*Many` action inherits its singular rule when not defined explicitly
43
+ // (createMany → create, updateMany → update, deleteMany → delete) — mirrors
44
+ // the data-access proxy, so a config can declare just the singular form.
44
45
  let permission = rolePermissions[action];
45
- if (!permission && action === 'createMany') {
46
- permission = rolePermissions['create'];
46
+ if (!permission && permissionsConfigTypes_1.SINGULAR_ACTION[action]) {
47
+ permission = rolePermissions[permissionsConfigTypes_1.SINGULAR_ACTION[action]];
47
48
  }
48
49
  if (!permission) {
49
50
  throw new common_1.ForbiddenException(`Action ${action} not allowed for role ${user.role} on model ${model}`);
@@ -1 +1 @@
1
- {"version":3,"file":"prisma.interceptor.d.ts","sourceRoot":"","sources":["../../../src/common/interceptors/prisma.interceptor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,WAAW,EAAE,gBAAgB,EAAc,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAC3F,OAAO,EAAoB,aAAa,EAAC,MAAM,6BAA6B,CAAC;AAC7E,OAAO,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AACvC,OAAO,EAAC,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAG7C,OAAO,EAAC,UAAU,EAAa,MAAM,MAAM,CAAC;AAK5C,qBACa,iBAAkB,YAAW,eAAe;IAIjD,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,aAAa;IALzB,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAS;gBAG1B,aAAa,EAAE,aAAa,EACrC,SAAS,EAAE,SAAS,EACpB,aAAa,EAAE,aAAa;IAKxC,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CAAC,GAAG,CAAC;CA+E3E"}
1
+ {"version":3,"file":"prisma.interceptor.d.ts","sourceRoot":"","sources":["../../../src/common/interceptors/prisma.interceptor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,WAAW,EAAE,gBAAgB,EAAc,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAC3F,OAAO,EAAoB,aAAa,EAAC,MAAM,6BAA6B,CAAC;AAC7E,OAAO,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AACvC,OAAO,EAAC,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAG7C,OAAO,EAAC,UAAU,EAAa,MAAM,MAAM,CAAC;AAK5C,qBACa,iBAAkB,YAAW,eAAe;IAIjD,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,aAAa;IALzB,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAS;gBAG1B,aAAa,EAAE,aAAa,EACrC,SAAS,EAAE,SAAS,EACpB,aAAa,EAAE,aAAa;IAKxC,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CAAC,GAAG,CAAC;CAmF3E"}
@@ -31,11 +31,15 @@ let PrismaInterceptor = exports.PrismaInterceptor = class PrismaInterceptor {
31
31
  const req = handlerType === 'graphql'
32
32
  ? context.getArgByIndex(2) // GraphQL context
33
33
  : context.switchToHttp().getRequest(); // HTTP context
34
- // Attach expose_models metadata if needed
34
+ // Attach expose_models metadata if needed. Models can come from
35
+ // @Permission(action, models) or the standalone @ExposeModels(...); both
36
+ // are merged so a route can expose models with or without a permission.
35
37
  const permissionMetadata = this.reflector.get(permission_decorator_1.PERMISSION_METADATA_KEY, context.getHandler()) || {};
38
+ const standaloneExposed = this.reflector.get(permission_decorator_1.EXPOSE_MODELS_METADATA_KEY, context.getHandler()) || [];
39
+ const exposedModels = [...(permissionMetadata['expose_models'] || []), ...standaloneExposed];
36
40
  return new rxjs_1.Observable((observer) => {
37
41
  prisma_service_1.CorePrismaContext.run({
38
- exposedModels: permissionMetadata['expose_models'] || [],
42
+ exposedModels,
39
43
  }, () => {
40
44
  const useTransaction = this.reflector.get('useTransaction', context.getHandler()) ?? this.defaultUseTransaction === 'true';
41
45
  if (useTransaction) {
@@ -12,7 +12,34 @@ export type CorePrismaOptions = {
12
12
  BYPASS_OMISSION?: boolean;
13
13
  BYPASS_FILTERING?: boolean;
14
14
  };
15
- export type CorePrismaModel<M extends ModelKey> = Exclude<RuntimeClient[M], 'findUnique' | 'findUniqueOrThrow' | 'delete' | 'update'>;
15
+ /** Look up a delegate method type without constraining the delegate `D`. */
16
+ type MethodOf<D, K extends string> = K extends keyof D ? D[K] : never;
17
+ /**
18
+ * Re-aligns a Prisma model delegate `D` to the proxy's real contract. Single-
19
+ * record methods that require a unique `where` can't carry the injected ABAC
20
+ * conditions, so each is re-aliased to its filter-compatible equivalent and the
21
+ * proxy transparently redirects the call at runtime:
22
+ * - `findUnique` takes `findFirst`'s params/return; `findUniqueOrThrow` takes
23
+ * `findFirstOrThrow`'s.
24
+ * - `update` takes `updateMany`'s params and returns its result
25
+ * (`BatchPayload`); `delete` takes `deleteMany`'s.
26
+ */
27
+ export type ProxiedDelegate<D> = Omit<D, 'findUnique' | 'findUniqueOrThrow' | 'update' | 'delete'> & {
28
+ findUnique: MethodOf<D, 'findFirst'>;
29
+ findUniqueOrThrow: MethodOf<D, 'findFirstOrThrow'>;
30
+ update: MethodOf<D, 'updateMany'>;
31
+ delete: MethodOf<D, 'deleteMany'>;
32
+ };
33
+ export type CorePrismaModel<M extends ModelKey> = ProxiedDelegate<RuntimeClient[M]>;
34
+ /**
35
+ * Applies {@link ProxiedDelegate} across every model delegate on a Prisma
36
+ * client, leaving non-delegate members (`$transaction`, `$connect`, …) intact.
37
+ */
38
+ export type CorePrismaClient<C> = {
39
+ [K in keyof C]: C[K] extends {
40
+ findMany: (...args: any[]) => any;
41
+ } ? ProxiedDelegate<C[K]> : C[K];
42
+ };
16
43
  export declare class PrismaService {
17
44
  private readonly permissionsConfig;
18
45
  private fieldConfigs;
@@ -36,7 +63,7 @@ export declare class PrismaService {
36
63
  * @param model
37
64
  */
38
65
  getModelDelegate<M extends ModelKey>(model: M): CorePrismaModel<M>;
39
- get model(): RuntimeClient;
66
+ get model(): CorePrismaClient<RuntimeClient>;
40
67
  get user(): CorePrismaModel<'user'>;
41
68
  get session(): CorePrismaModel<'session'>;
42
69
  get userRefreshToken(): any;
@@ -1 +1 @@
1
- {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.PrismaService = exports.CorePrismaContext = void 0;
17
17
  const common_1 = require("@nestjs/common");
18
18
  const client_1 = require("@prisma/client");
19
+ const permissionsConfigTypes_1 = require("../common/config/permissionsConfigTypes");
19
20
  const nestjs_request_context_1 = require("nestjs-request-context");
20
21
  const node_async_hooks_1 = require("node:async_hooks");
21
22
  exports.CorePrismaContext = new node_async_hooks_1.AsyncLocalStorage();
@@ -72,16 +73,19 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
72
73
  this.debug(`Proxying ${String(propKey)}.${String(methodKey)}() for role ${userRole}`);
73
74
  const contextModel = nestjs_request_context_1.RequestContext.currentContext?.req.prisma?.[propKey] || model;
74
75
  return async (params = {}, options) => {
75
- // Blacklisted methods, should not be used to ensure permission filtering is applied
76
- let blacklist = {
76
+ // Single-record methods can't carry the injected ABAC where-
77
+ // conditions, so redirect them to their filter-compatible
78
+ // equivalents. The proxied types remove/re-alias these, so this
79
+ // matches what a typed caller can express: update() runs
80
+ // updateMany, delete() runs deleteMany, and a findUnique lookup
81
+ // becomes a findFirst with conditions applied.
82
+ const redirect = {
77
83
  findUnique: 'findFirst',
78
84
  findUniqueOrThrow: 'findFirstOrThrow',
79
85
  delete: 'deleteMany',
80
86
  update: 'updateMany',
81
87
  };
82
- if (blacklist[methodKey.toString()]) {
83
- throw new Error(`The method ${methodKey.toString()} is not compatible with permission filtering and is not allowed. Please use ${blacklist[methodKey.toString()]}} instead.`);
84
- }
88
+ methodKey = redirect[methodKey.toString()] ?? methodKey;
85
89
  // delete, deleteMany, update and updateMany methods should not apply field omission because they are not selecting fields
86
90
  if (!options?.BYPASS_OMISSION && !['delete', 'deleteMany', 'update', 'updateMany', 'create', 'createMany'].includes(methodKey.toString())) {
87
91
  params = this.applyFieldOmission(String(propKey), userRole, params);
@@ -768,6 +772,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
768
772
  actionPermissions = permissions['findFirst'];
769
773
  }
770
774
  }
775
+ // A `*Many` action inherits the singular rule when not set explicitly
776
+ // (updateMany → update, deleteMany → delete). `update()` / `delete()`
777
+ // redirect to their `*Many` form, so this is what makes a config that
778
+ // declares only `update` / `delete` apply to them.
779
+ if (!actionPermissions && permissionsConfigTypes_1.SINGULAR_ACTION[action] && permissions[permissionsConfigTypes_1.SINGULAR_ACTION[action]]) {
780
+ this.debug(`Using '${permissionsConfigTypes_1.SINGULAR_ACTION[action]}' permissions for '${action}' action on model '${modelName}' and role ${userRole}`, 'info');
781
+ actionPermissions = permissions[permissionsConfigTypes_1.SINGULAR_ACTION[action]];
782
+ }
771
783
  return actionPermissions;
772
784
  }
773
785
  };
package/docs/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # AppX Core — Documentation
2
+
3
+ Authoritative usage documentation for `@appxdigital/appx-core`. **This folder is the source of truth** for how the framework behaves — humans and AI agents should read it here rather than inferring patterns from source code. If code and these docs disagree, that is a bug in one of them; fix it, don't guess.
4
+
5
+ ## Contents
6
+
7
+ - **[permissions.md](./permissions.md)** — the authorization model: the `@Permission` and `@ExposeModels` decorators, the `permissions.config.ts` shape, roles/actions, row-level `conditions`, field-level control, action fallbacks, and the `GUEST` (unauthenticated) flow.
8
+ - **[data-access.md](./data-access.md)** — how you talk to the database: the Prisma proxy (`prisma.model.*`), how ABAC filtering and field omission are enforced on every call, the redirected single-record methods, relation behaviour, bypass hatches, and transactions.
9
+ - **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
10
+ - **[limitations.md](./limitations.md)** — **known limitations and features that are NOT ready for use** (e.g. `aggregate`, GraphQL writes). Read this before relying on any method not covered in the guides above.
11
+
12
+ ## The one-paragraph mental model
13
+
14
+ AppX Core wraps the Prisma client in a proxy that enforces **attribute-based access control (ABAC)** on every database call. You declare row-level and field-level rules once in `permissions.config.ts`; the proxy injects them into each query automatically based on the current request's user role. Controllers/resolvers are generated from your Prisma schema. The rules of the road: **go through the proxy** (`prismaService.model.*`) so ABAC applies, **declare a permission for every model/role/action** you want to allow (everything else is default-denied), and **check [limitations.md](./limitations.md)** before using a method.
15
+
16
+ ## Conventions used in these docs
17
+
18
+ - `$USER_ID` refers to `PermissionPlaceholder.USER_ID` — a placeholder replaced with the current user's id at query time.
19
+ - "the proxy" means the `PrismaService` client returned by `prismaService.model` (and the `prisma.user` / `prisma.session` / `prisma.userRefreshToken` accessors).
20
+ - "default-deny" means: if no rule matches, the request is refused (a `403` at the guard, or a thrown `HttpException` at the proxy) — never silently allowed.
@@ -0,0 +1,108 @@
1
+ # Data Access & ABAC Enforcement
2
+
3
+ How you read and write the database, and exactly what the proxy does to every call. Pair this with [permissions.md](./permissions.md) (which defines the rules) and [limitations.md](./limitations.md) (what not to use yet).
4
+
5
+ ---
6
+
7
+ ## Always go through the proxy
8
+
9
+ Inject `PrismaService` and use `prismaService.model.*`:
10
+
11
+ ```ts
12
+ const projects = await this.prisma.model.project.findMany({ where: { status: 'active' } });
13
+ ```
14
+
15
+ `prismaService.model` returns a proxied Prisma client. **Only calls through this proxy get ABAC.** If you reach the raw Prisma client directly (a second `PrismaClient`, raw SQL, `$queryRaw`), you bypass all filtering and field omission. There is **no defence-in-depth at the controller layer** for row rules — the proxy is the single enforcement point.
16
+
17
+ There are also three narrow typed accessors — `prismaService.user`, `prismaService.session`, `prismaService.userRefreshToken` — which are the proxied delegates for those framework models (used by auth flows, often with the bypass options below).
18
+
19
+ Model names are **case-insensitive** at the proxy (`prisma.model.project` / `prisma.model.Project` resolve the same delegate and the same config).
20
+
21
+ ---
22
+
23
+ ## What the proxy does on every call
24
+
25
+ For each `prisma.model.<model>.<method>(args, options?)`:
26
+
27
+ 1. **Field omission** (reads only) — removes columns the role may not read (`/// @Role(...)`), recursing into nested `select`/`include` at every depth.
28
+ 2. **Condition injection** — looks up `PermissionsConfig[model][role][action]`; if `'ALL'`, no filter; if `{ conditions }`, `AND`-merges them into your `where` (so your filter and the permission filter both apply); if nothing (after fallbacks), throws `403`.
29
+ 3. **Executes** the (possibly rewritten) query.
30
+
31
+ The role comes from the current request's user (`GUEST` if unauthenticated). Requests run inside an async context established by the framework's middleware/interceptor.
32
+
33
+ ---
34
+
35
+ ## Method contract (important)
36
+
37
+ The proxy can only enforce ABAC on methods that accept a `where` it can inject conditions into. Single-record methods keyed on a unique id cannot, so they are **re-aliased** — in both the TypeScript types and at runtime:
38
+
39
+ | You call | Actually runs | Params / return you get |
40
+ |---|---|---|
41
+ | `findUnique()` | `findFirst()` | non-unique `where`, nullable result |
42
+ | `findUniqueOrThrow()` | `findFirstOrThrow()` | non-unique `where` |
43
+ | `update()` | `updateMany()` | `{ where, data }`, returns `{ count }` |
44
+ | `delete()` | `deleteMany()` | `{ where }`, returns `{ count }` |
45
+
46
+ **Consequences:**
47
+
48
+ - `update()` / `delete()` return `{ count }`, **not** the affected record. Re-read if you need the row.
49
+ - These operate on **all matching rows**, not one — always pass a `where`. With ABAC conditions injected, a cross-owner `update({ where: { id } })` simply affects `{ count: 0 }` rather than erroring.
50
+ - The TypeScript types reflect this: calling `update()` with a lone unique `where` and expecting a record back is a **compile error**, not a runtime surprise.
51
+
52
+ Use `findMany` / `findFirst` / `findFirstOrThrow` / `updateMany` / `deleteMany` directly if you prefer the explicit names.
53
+
54
+ ---
55
+
56
+ ## Field omission (`@Role`)
57
+
58
+ A column annotated `/// @Role(ADMIN)` is returned only to roles in the list; for others the proxy drops it from the `select` so it comes back `undefined`. `/// @Role(none)` hides it from everyone (e.g. `password`).
59
+
60
+ - Omission applies to nested relations too: `include: { owner: true }` won't leak `owner`'s `@Role`-restricted fields to an unauthorized role.
61
+
62
+ ---
63
+
64
+ ## Relations in conditions and includes
65
+
66
+ - **Row conditions** may traverse relations to any depth: `{ conditions: { task: { project: { ownerId: $UID } } } }`. Both `belongsTo` (to-one) and `hasMany` / M:N (`some`/`every`/`none`) are supported. Note Prisma semantics: `every` is **true on an empty list**.
67
+ - **Including a to-one relation is inner-join filtered.** When you `include`/`select` a `belongsTo` relation, the related model's read rules are merged into the **parent** row's `where`:
68
+ - If the related model has **no** read rule for the role, including it throws `403`. Grant a read rule on every model you expose through an `include`.
69
+ - If the related row exists but doesn't satisfy the rule, the **whole parent row is dropped** (`findFirst` → `null`, `findMany` → the row is omitted). The parent is **not** returned with the relation set to `null`. In short: you can't see a row whose related record you're not allowed to see. This is intentional and fails closed.
70
+ - **To-many relations are filtered in place**: the parent is kept, and the child list is narrowed to the rows the caller may read (possibly empty).
71
+
72
+ ---
73
+
74
+ ## Bypass hatches
75
+
76
+ Use sparingly and deliberately; each disables part of the protection.
77
+
78
+ - **Per-call options** — `prisma.<accessor>.<method>(args, { BYPASS_FILTERING, BYPASS_OMISSION })`. `BYPASS_FILTERING` skips condition injection (and create-enforcement); `BYPASS_OMISSION` skips field omission. Available on the typed accessors (`prisma.user`, `prisma.session`, `prisma.userRefreshToken`).
79
+ - **`@ExposeModels(...)` / `@Permission(action, [models])`** — expose models for a request so they skip row/field filtering, scoped to the handler. See [permissions.md](./permissions.md).
80
+ - **`prismaService.withExposedModels(models, async () => { ... })`** — service-level scoped exposure for a block of code (e.g. registration pre-checks). Model names are lowercased/case-insensitive.
81
+
82
+ Framework flows that must write without a permission (registration, sessions, tokens) use `BYPASS_FILTERING`.
83
+
84
+ ---
85
+
86
+ ## Creates
87
+
88
+ `create` / `createMany` can't push conditions into a `WHERE`, so the proxy **validates the incoming data against the create rule's `conditions` before insert**:
89
+
90
+ - Own-scalar conditions are checked against `data`; `belongsTo` relation conditions are checked by looking up the referenced parent.
91
+ - A model/role with **no** `create` permission is **default-denied** (`403`).
92
+ - It **fails closed**: unsupported condition shapes for create (list-relation `some`/`every`/`none`, exotic operators) throw rather than silently allow. For those, use `setUserIdField` to set an owner id server-side and validate inputs in DTOs.
93
+ - In development only, a warning fires if a just-created row wouldn't satisfy the model's *read* rule (i.e. the creator couldn't read it back).
94
+
95
+ ---
96
+
97
+ ## Transactions
98
+
99
+ Set `USE_TRANSACTION=true` (env) to wrap each request handler in a Prisma `$transaction`; per-endpoint control via the transaction metadata. The framework commits the transaction **before** sending the HTTP response, so a read-after-write in a dependent request can't miss the write. Inside a transaction the proxy uses the request's transaction client automatically.
100
+
101
+ ---
102
+
103
+ ## Blocked / redirected methods — quick reference
104
+
105
+ - `findUnique` / `findUniqueOrThrow` → run as `findFirst` / `findFirstOrThrow` (see contract above).
106
+ - `update` / `delete` → run as `updateMany` / `deleteMany`.
107
+ - Raw client access, `$queryRaw`, a second `PrismaClient` → **not proxied, no ABAC**. Don't use them for user-scoped data.
108
+ - `aggregate` → see [limitations.md](./limitations.md) (not ready).
@@ -0,0 +1,72 @@
1
+ # GraphQL (read API)
2
+
3
+ AppX Core can expose a **read-only GraphQL API** generated from your Prisma schema. Queries run through the same ABAC proxy as REST, so row-level filtering and field omission apply automatically.
4
+
5
+ > **Scope:** only **read** queries are available today — `findAll<Model>s`, `findOne<Model>`, `findFirst<Model>`. Writes (create/update/delete mutations) are **not available yet**; see [limitations.md](./limitations.md). Use the REST CRUD endpoints for writes.
6
+
7
+ ---
8
+
9
+ ## Queries
10
+
11
+ For each model, the generated schema provides:
12
+
13
+ | Query | Returns |
14
+ |---|---|
15
+ | `findAll<Model>s(...)` | a list |
16
+ | `findFirst<Model>(...)` | the first match, or `null` |
17
+ | `findOne<Model>(where: {...})` | a single record, or `null` |
18
+
19
+ ```graphql
20
+ query {
21
+ findAllProjects(where: { status: "active" }) {
22
+ id
23
+ name
24
+ }
25
+ }
26
+ ```
27
+
28
+ These route through `PrismaService`, so the caller's role and the rules in `permissions.config.ts` are enforced exactly as they are for REST reads (see [permissions.md](./permissions.md) and [data-access.md](./data-access.md)). A user only ever receives rows and fields their role is permitted to read.
29
+
30
+ ---
31
+
32
+ ## Nested queries
33
+
34
+ You can select related models inline, to any depth:
35
+
36
+ ```graphql
37
+ query {
38
+ findAllProjects {
39
+ id
40
+ name
41
+ tasks {
42
+ title
43
+ assignee {
44
+ name
45
+ }
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ Nested selections are **ABAC-filtered** just like the top level.
52
+
53
+ > **Caveat — inaccessible nested data comes back empty, not as an error.** If the current user isn't allowed to read a related record, it is simply filtered out:
54
+ > - a **to-many** relation (`tasks`, `members`, …) returns an **empty list** for the parts the user can't see;
55
+ > - an **inaccessible to-one** relation causes the **parent row to be omitted** from the result (you can't see a row whose related record you aren't allowed to read).
56
+ >
57
+ > No exception is thrown — the data you're not entitled to just isn't there. Design your queries and UI to treat "empty" as "not visible to you", not "does not exist".
58
+
59
+ ---
60
+
61
+ ## Field-level visibility
62
+
63
+ Columns annotated `/// @Role(...)` follow the same rules as everywhere else: a role that can't read a field won't receive it in the GraphQL response, at the top level and nested. See [permissions.md](./permissions.md#field-level-control).
64
+
65
+ ---
66
+
67
+ ## Not available yet
68
+
69
+ - **Mutations** (create/update/delete) — use REST.
70
+ - **`aggregate`** — not ready; see [limitations.md](./limitations.md).
71
+
72
+ Always let the generated resolvers (which go through `PrismaService`) serve the data — a custom resolver that reaches the raw Prisma client would not get ABAC.
@@ -0,0 +1,55 @@
1
+ # Known Limitations & Not-Ready Features
2
+
3
+ Read this before using any method or surface not explicitly covered in [permissions.md](./permissions.md) / [data-access.md](./data-access.md). Items here are either **not ready for use**, or **behave differently from vanilla Prisma** in ways you must account for.
4
+
5
+ ---
6
+
7
+ ## Not ready — do not use yet
8
+
9
+ ### `aggregate`
10
+
11
+ `prisma.model.<model>.aggregate(...)` is **not a supported ABAC action**. There is no `aggregate` permission and no fallback to a read rule, and the field-omission logic isn't designed for aggregate argument shapes. Its ABAC behaviour is undefined/untested — **avoid it for user-scoped data**.
12
+
13
+ - Need a count? Use `count(...)` — it **is** supported and falls back to your `findMany` rule.
14
+ - Need a genuine aggregate behind trusted access? Compute it inside a `withExposedModels(...)` block (or a bypass) with your own authorization, and never expose it to untrusted roles until aggregate ABAC lands.
15
+
16
+ ### GraphQL — writes and `aggregate`
17
+
18
+ GraphQL currently supports **read queries only** (`findAll<Model>s` / `findFirst<Model>` / `findOne<Model>`), with ABAC applied. See **[graphql.md](./graphql.md)** for how to use it, including nested queries.
19
+
20
+ - **Mutations (create / update / delete) are not available yet** — use the REST CRUD endpoints for writes.
21
+ - **`aggregate` is not ready** — same caveat as REST above.
22
+
23
+ Let the generated resolvers serve the data (they go through `PrismaService`); a custom resolver that reaches the raw Prisma client would not get ABAC.
24
+
25
+ ---
26
+
27
+ ## Behaves differently from vanilla Prisma
28
+
29
+ ### Single-record methods are redirected
30
+
31
+ `findUnique` → `findFirst`, `findUniqueOrThrow` → `findFirstOrThrow`, `update` → `updateMany`, `delete` → `deleteMany` (types + runtime). `update()`/`delete()` return `{ count }`, not the record, and act on all matching rows. Full contract in [data-access.md](./data-access.md#method-contract-important).
32
+
33
+ ### Including an unreadable to-one relation drops the parent row
34
+
35
+ `include`/`select` of a `belongsTo` relation whose target the caller can't read removes the **whole parent row** (returns `null` / omits it), rather than nulling just the relation. To-many relations filter in place and keep the parent. Fails closed; intentional. See [data-access.md](./data-access.md#relations-in-conditions-and-includes).
36
+
37
+ ### `_count` pseudo-field filtering is not implemented
38
+
39
+ You can `select: { _count: ... }`, but ABAC conditions are **not** applied inside a `_count` sub-selection. Don't rely on `_count` to reflect only rows the caller may see.
40
+
41
+ ---
42
+
43
+ ## Sharp edges to know about
44
+
45
+ - **`restrictedFields` strips silently.** Listed fields are removed from the request body with no `400`. It only affects fields that are present in the generated DTO (not `/// @NoWrite` ones). Decide per model whether silent stripping is acceptable.
46
+ - **Per-call bypass options are only on three accessors.** `{ BYPASS_FILTERING, BYPASS_OMISSION }` are available on `prismaService.user` / `.session` / `.userRefreshToken`. For an unfiltered read of another model, use `withExposedModels([...])` or `@ExposeModels(...)`, not a per-call option on `prisma.model.<x>`.
47
+ - **Role strings are not type-checked.** `PermissionsConfig` keys are plain strings; a typo (`ADIMN`) silently yields "no permissions" → default-deny for that role. Double-check role spelling against your `Role` enum.
48
+ - **No audit logging of denials.** A refused request returns `403` but emits no structured audit event; per-request diagnostics are opt-in via `prismaService.debugQueries(true)`.
49
+ - **Exposed models are a full bypass.** `@ExposeModels` / `@Permission(a, [models])` / `withExposedModels` disable row *and* field filtering for the listed models within that scope. Keep the scope as narrow as possible.
50
+
51
+ ---
52
+
53
+ ## Reporting
54
+
55
+ If the framework does something these docs don't describe, that's a documentation or framework bug — surface it rather than coding around it. These docs are the source of truth.
@@ -0,0 +1,137 @@
1
+ # Permissions & Authorization
2
+
3
+ How AppX Core decides **who may do what**. Two layers cooperate:
4
+
5
+ 1. **`RbacGuard`** (route level) — checks that the caller's role has *a* permission for the requested action on the model. Coarse allow/deny; does **not** evaluate row conditions.
6
+ 2. **The Prisma proxy** (data level) — injects the row-level `conditions` and applies field-level omission on every query. This is the real enforcement layer. See [data-access.md](./data-access.md).
7
+
8
+ Both read the same `permissions.config.ts`.
9
+
10
+ ---
11
+
12
+ ## The permissions config
13
+
14
+ Lives in `src/config/permissions.config.ts` and is passed to `AppxCoreModule.forRoot(PermissionsConfig)`.
15
+
16
+ ```ts
17
+ import { PermissionPlaceholder, PermissionsConfigType } from '@appxdigital/appx-core';
18
+
19
+ const $UID = PermissionPlaceholder.USER_ID;
20
+
21
+ export const PermissionsConfig: PermissionsConfigType = {
22
+ Project: { // model name (matches the Prisma model)
23
+ ADMIN: { // role (matches your Role enum / role strings)
24
+ findMany: 'ALL',
25
+ create: 'ALL',
26
+ updateMany: 'ALL',
27
+ deleteMany: 'ALL',
28
+ },
29
+ USER: {
30
+ findMany: { conditions: { OR: [{ ownerId: $UID }, { members: { some: { userId: $UID } } }] } },
31
+ create: { conditions: { ownerId: $UID } },
32
+ updateMany: { conditions: { ownerId: $UID } },
33
+ },
34
+ GUEST: { // unauthenticated requests — see "GUEST" below
35
+ findMany: 'ALL',
36
+ },
37
+ },
38
+ };
39
+ ```
40
+
41
+ Shape: `PermissionsConfig[Model][Role][action]`, where each `action` value is either:
42
+
43
+ - **`'ALL'`** — unrestricted for that action, or
44
+ - **`{ conditions: <where> }`** — an ABAC rule. `conditions` is Prisma `where`-syntax (`AND`/`OR`/`NOT`, relation filters `some`/`every`/`none`, operators `in`/`not`/`lt`/…). `$USER_ID` is substituted with the caller's id at query time.
45
+ - Optionally with **`restrictedFields`** and/or **`setUserIdField`** (see below).
46
+
47
+ **Model and role keys are matched case-insensitively** against the model name and the caller's role string.
48
+
49
+ ### Actions
50
+
51
+ | Action | Triggered by |
52
+ |---|---|
53
+ | `findMany`, `findFirst` | reads |
54
+ | `count` | counts |
55
+ | `create`, `createMany` | inserts |
56
+ | `updateMany` | updates (also what `update()` resolves to — see [data-access.md](./data-access.md)) |
57
+ | `deleteMany` | deletes (also what `delete()` resolves to) |
58
+
59
+ > `aggregate` is **not** a supported permission action yet — see [limitations.md](./limitations.md).
60
+
61
+ ### Action fallback chains
62
+
63
+ To keep configs compact, a missing action falls back:
64
+
65
+ - **`count` → `findMany` → `findFirst`** (reads share a rule).
66
+ - **`findFirst` / `findUnique`-shape reads → `findMany`**.
67
+ - **`createMany` → `create`**, **`updateMany` → `update`**, **`deleteMany` → `delete`** (a batch action inherits the singular rule).
68
+
69
+ The `*Many` → singular fallback is **one-directional**: the singular is canonical. Declaring only `updateMany` does *not* enable `update`; declare `update` (or `create`/`delete`) and the `*Many` form is covered automatically. This holds in **both** the `RbacGuard` and the proxy.
70
+
71
+ So a minimal USER config often needs just `findMany`, `create`, `update`, `delete`.
72
+
73
+ ### Field-level control
74
+
75
+ - **`/// @Role(ADMIN)` on a schema column** — only listed roles can *read* that field. For everyone else the proxy omits it from results (it comes back `undefined`). `/// @Role(none)` hides it from all roles. Applies at every depth, including nested relation selections.
76
+ - **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles).
77
+ - **`restrictedFields: ['x', 'y']` on a permission action** — strips those keys from the incoming request body for that role+action (e.g. stop a role from setting `role`/`id`). Silent strip, not a `400`.
78
+ - **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership). Applied after `restrictedFields`, so it can't be clobbered.
79
+
80
+ ---
81
+
82
+ ## Decorators
83
+
84
+ ### `@Permission(action, exposeModels?)`
85
+
86
+ Declares the action a route requires. Read by `RbacGuard` (enforces the role has the action) and by the interceptor (for `exposeModels`).
87
+
88
+ ```ts
89
+ @Permission('findMany')
90
+ @Get()
91
+ list() { /* ... */ }
92
+ ```
93
+
94
+ - `RbacGuard` looks up `PermissionsConfig[model][role][action]`; if absent (after fallbacks) it throws `403`. `model` comes from the controller's `entityName`.
95
+ - **`RbacGuard` is auto-applied on `CoreController`** (all generated CRUD controllers). On a **custom** controller, add `@UseGuards(RbacGuard)` yourself if you want the action enforced.
96
+ - The optional second argument exposes models for the request — see below.
97
+
98
+ ### `@ExposeModels(...models)`
99
+
100
+ Exposes one or more models for the duration of the request **without** requiring a permission action.
101
+
102
+ ```ts
103
+ @ExposeModels('user') // no @Permission needed
104
+ @Get('/auth/email-available')
105
+ isEmailAvailable(@Query('email') email: string) { /* reads User unfiltered */ }
106
+ ```
107
+
108
+ - Sets **no action**, so `RbacGuard` demands no permission (a public / `GUEST` route works with no `GUEST` rule for the model).
109
+ - The listed models **skip row and field filtering for that request only** (scoped via async context — it does not make the model globally readable).
110
+ - Case-insensitive: `@ExposeModels('User')` and `@ExposeModels('user')` are equivalent.
111
+ - Combine with `@Permission(...)` if you also want an action check — exposed models from both decorators are merged.
112
+ - This is a deliberate **bypass**. Use it narrowly (e.g. registration pre-checks); prefer real ABAC rules where feasible.
113
+
114
+ > `@Permission(action, ['user'])` still exposes models exactly as before — `@ExposeModels` is the permission-free alternative, not a replacement.
115
+
116
+ ---
117
+
118
+ ## The GUEST (unauthenticated) flow
119
+
120
+ A request with **no session and no valid JWT** is treated as the role **`GUEST`**:
121
+
122
+ - The global `UserPopulationGuard` never blocks — it populates `req.user` from a `Bearer` JWT if present, otherwise leaves the request unauthenticated and passes through.
123
+ - `RbacGuard` defaults the role to `'GUEST'` and looks up `PermissionsConfig[model]['GUEST'][action]`. If you declared a `GUEST` rule, the route is allowed; otherwise it is default-denied (`403`).
124
+ - The proxy likewise resolves the role to `GUEST` and applies `GUEST` conditions (or default-denies).
125
+
126
+ So to allow public access to a model, add a `GUEST` block to its config. To allow public access to a route that reads a model you *don't* want generally public, use `@ExposeModels(...)` instead.
127
+
128
+ ---
129
+
130
+ ## Default-deny
131
+
132
+ If a model/role/action has no rule (after fallbacks) and the model is not exposed:
133
+
134
+ - `RbacGuard` throws `ForbiddenException` (`403`).
135
+ - The proxy throws `HttpException(403)`.
136
+
137
+ There is no "allow by omission". A typo in a role or model key means that path is denied.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.121-beta.7",
3
+ "version": "0.1.121-beta.9",
4
4
  "description": "Appx Core is a library that provides a set of tools to help you build your application faster.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -100,6 +100,7 @@
100
100
  },
101
101
  "files": [
102
102
  "dist",
103
+ "docs",
103
104
  "CHANGELOG.md",
104
105
  "PRERELEASES.md",
105
106
  "RELEASING.md"