@appxdigital/appx-core 0.1.120 → 0.1.121-beta.10
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 +123 -0
- package/LICENSE +21 -21
- package/PRERELEASES.md +142 -0
- package/README.md +627 -533
- package/RELEASING.md +168 -0
- package/dist/common/config/permissionsConfigTypes.d.ts +8 -0
- package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
- package/dist/common/config/permissionsConfigTypes.js +13 -1
- package/dist/common/decorators/permission.decorator.d.ts +14 -0
- package/dist/common/decorators/permission.decorator.d.ts.map +1 -1
- package/dist/common/decorators/permission.decorator.js +16 -1
- package/dist/common/guards/rbac.guard.d.ts.map +1 -1
- package/dist/common/guards/rbac.guard.js +8 -1
- package/dist/common/interceptors/prisma.interceptor.d.ts.map +1 -1
- package/dist/common/interceptors/prisma.interceptor.js +24 -8
- package/dist/common/setup/core-config.d.ts +66 -0
- package/dist/common/setup/core-config.d.ts.map +1 -0
- package/dist/common/setup/core-config.js +67 -0
- package/dist/common/setup/setup-core-security.d.ts +62 -0
- package/dist/common/setup/setup-core-security.d.ts.map +1 -0
- package/dist/common/setup/setup-core-security.js +40 -0
- package/dist/config/generate-all.js +4 -0
- package/dist/config/generate-controllers.js +19 -2
- package/dist/config/generate-dtos.d.ts +2 -0
- package/dist/config/generate-dtos.d.ts.map +1 -0
- package/dist/config/generate-dtos.js +285 -0
- package/dist/config/utils.d.ts +14 -0
- package/dist/config/utils.d.ts.map +1 -1
- package/dist/config/utils.js +35 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/modules/auth/auth.controller.d.ts.map +1 -1
- package/dist/modules/auth/auth.controller.js +6 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/core/core.controller.d.ts.map +1 -1
- package/dist/modules/core/core.controller.js +12 -5
- package/dist/modules/user/user.service.d.ts.map +1 -1
- package/dist/modules/user/user.service.js +7 -3
- package/dist/prisma/prisma.service.d.ts +94 -2
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +338 -8
- package/docs/README.md +21 -0
- package/docs/data-access.md +108 -0
- package/docs/dtos.md +147 -0
- package/docs/graphql.md +72 -0
- package/docs/limitations.md +55 -0
- package/docs/permissions.md +155 -0
- package/package.json +112 -86
|
@@ -12,11 +12,45 @@ export type CorePrismaOptions = {
|
|
|
12
12
|
BYPASS_OMISSION?: boolean;
|
|
13
13
|
BYPASS_FILTERING?: boolean;
|
|
14
14
|
};
|
|
15
|
-
|
|
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;
|
|
19
46
|
prismaClient: PrismaClient;
|
|
47
|
+
/**
|
|
48
|
+
* The un-proxied Prisma client. Used internally (e.g. create-condition
|
|
49
|
+
* checks) to look up an already-existing parent row without re-applying
|
|
50
|
+
* that model's access filtering — matching "would a findFirst with these
|
|
51
|
+
* conditions match".
|
|
52
|
+
*/
|
|
53
|
+
private rawClient;
|
|
20
54
|
constructor(prismaClient: PrismaClient, permissionsConfig: PermissionsConfigType);
|
|
21
55
|
debugQueries(enable: boolean): void;
|
|
22
56
|
withExposedModels(models: string[], callback: () => Promise<void>): Promise<void>;
|
|
@@ -29,7 +63,7 @@ export declare class PrismaService {
|
|
|
29
63
|
* @param model
|
|
30
64
|
*/
|
|
31
65
|
getModelDelegate<M extends ModelKey>(model: M): CorePrismaModel<M>;
|
|
32
|
-
get model(): RuntimeClient
|
|
66
|
+
get model(): CorePrismaClient<RuntimeClient>;
|
|
33
67
|
get user(): CorePrismaModel<'user'>;
|
|
34
68
|
get session(): CorePrismaModel<'session'>;
|
|
35
69
|
get userRefreshToken(): any;
|
|
@@ -62,6 +96,64 @@ export declare class PrismaService {
|
|
|
62
96
|
* @throws ForbiddenException if no valid `where` conditions are present after applying permissions.
|
|
63
97
|
*/
|
|
64
98
|
private applyWhereConditions;
|
|
99
|
+
/**
|
|
100
|
+
* Enforces permission `conditions` on `create` / `createMany`.
|
|
101
|
+
*
|
|
102
|
+
* Conditions cannot be pushed into a `WHERE` on insert, so instead we verify
|
|
103
|
+
* the incoming data *would* satisfy them — i.e. a `findFirst` with those
|
|
104
|
+
* conditions would return the new row. Own-scalar fields are checked against
|
|
105
|
+
* `data`; relation conditions are checked by looking up the referenced parent
|
|
106
|
+
* (which lets Prisma evaluate arbitrarily-nested relation conditions for us).
|
|
107
|
+
*
|
|
108
|
+
* Also default-denies: create is only allowed for a role/action that has an
|
|
109
|
+
* explicit permission (or 'ALL'), mirroring find/update/delete. Framework
|
|
110
|
+
* flows that must create without a permission (registration, sessions,
|
|
111
|
+
* tokens) pass BYPASS_FILTERING and never reach here.
|
|
112
|
+
*
|
|
113
|
+
* Fails **closed**: throws on a violation and on any condition shape it
|
|
114
|
+
* cannot evaluate, rather than silently allowing the insert.
|
|
115
|
+
*/
|
|
116
|
+
private enforceCreateConditions;
|
|
117
|
+
/**
|
|
118
|
+
* Recursively authorizes nested relation writes in a `create` payload. Only
|
|
119
|
+
* an explicit allowlist of operators is permitted, each checked against ABAC;
|
|
120
|
+
* every other operator is rejected (fail closed).
|
|
121
|
+
*
|
|
122
|
+
* Runs on the create path only — `updateMany` / `createMany` (what update /
|
|
123
|
+
* createMany resolve to) accept scalar data, so nested relation writes reach
|
|
124
|
+
* Prisma solely via `create()`.
|
|
125
|
+
*
|
|
126
|
+
* - `create` → the child must satisfy the related model's `create` rule
|
|
127
|
+
* (recurses, so deeper nesting is authorized too).
|
|
128
|
+
* - `connect` → the target must satisfy the related model's `connect` rule.
|
|
129
|
+
* `connect` is a DEDICATED action: being allowed to READ a record does not
|
|
130
|
+
* authorize associating it.
|
|
131
|
+
*/
|
|
132
|
+
private enforceNestedWrites;
|
|
133
|
+
/** Authorizes each operator inside one relation's nested-write object. */
|
|
134
|
+
private enforceNestedRelationOps;
|
|
135
|
+
/** A `connect` requires an explicit `connect` permission on the target model. */
|
|
136
|
+
private assertConnectAllowed;
|
|
137
|
+
/**
|
|
138
|
+
* True only in an explicit local-development environment. Deliberately NOT
|
|
139
|
+
* `!== 'production'` — staging and other envs should behave like production
|
|
140
|
+
* (no extra diagnostic queries/logging). Only `development` / `dev` opt in.
|
|
141
|
+
*/
|
|
142
|
+
private isDevMode;
|
|
143
|
+
/**
|
|
144
|
+
* Dev-only diagnostic. After a create that passed its create-conditions,
|
|
145
|
+
* checks whether the new row also satisfies the model's *find* conditions —
|
|
146
|
+
* if not, the creator can't read it back (create rule broader than find
|
|
147
|
+
* rule). Logs a warning; never affects the create. Runs only in development
|
|
148
|
+
* (gated by the caller).
|
|
149
|
+
*/
|
|
150
|
+
private warnIfCreatedRowNotFindable;
|
|
151
|
+
/** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
|
|
152
|
+
private matchesCreateConditions;
|
|
153
|
+
/** Evaluates a scalar-field condition (value or Prisma operator object) against the row's value. */
|
|
154
|
+
private matchesCreateScalar;
|
|
155
|
+
/** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
|
|
156
|
+
private matchesCreateRelation;
|
|
65
157
|
/**
|
|
66
158
|
* Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
|
|
67
159
|
* Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":";AACA,OAAO,EAAC,MAAM,EAAE,YAAY,EAAC,MAAM,gBAAgB,CAAC;AACpD,OAAO,EAAC,qBAAqB,
|
|
1
|
+
{"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":";AACA,OAAO,EAAC,MAAM,EAAE,YAAY,EAAC,MAAM,gBAAgB,CAAC;AACpD,OAAO,EAAC,qBAAqB,EAAkB,MAAM,yCAAyC,CAAC;AAE/F,OAAO,KAAK,EAAC,YAAY,IAAI,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAGlE,OAAO,EAAC,iBAAiB,EAAC,MAAM,kBAAkB,CAAC;AAEnD,eAAO,MAAM,iBAAiB;mBACX,MAAM,EAAE;EACvB,CAAC;AAEL,KAAK,QAAQ,GAAG,MAAM,aAAa,CAAC;AAEpC,oDAAoD;AACpD,MAAM,MAAM,iBAAiB,GAAG;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF,4EAA4E;AAC5E,KAAK,QAAQ,CAAC,CAAC,EAAE,CAAC,SAAS,MAAM,IAAI,CAAC,SAAS,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;AAEtE;;;;;;;;;GASG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,YAAY,GAAG,mBAAmB,GAAG,QAAQ,GAAG,QAAQ,CAAC,GAAG;IACjG,UAAU,EAAE,QAAQ,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;IACrC,iBAAiB,EAAE,QAAQ,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC;IACnD,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;IAClC,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;CACrC,CAAC;AAGF,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,QAAQ,IAAI,eAAe,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;AAEpF;;;GAGG;AACH,MAAM,MAAM,gBAAgB,CAAC,CAAC,IAAI;KAC7B,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;QAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;KAAE,GAAG,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CACpG,CAAC;AAEF,qBACa,aAAa;IAaY,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAZpE,OAAO,CAAC,YAAY,CAA2B;IAC/C,YAAY,EAAE,YAAY,CAAC;IAC3B;;;;;OAKG;IACH,OAAO,CAAC,SAAS,CAAgB;gBAG7B,YAAY,EAAE,YAAY,EACqB,iBAAiB,EAAE,qBAAqB;IAO3F,YAAY,CAAC,MAAM,EAAE,OAAO;IAO5B,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC;IAWjE,OAAO,CAAC,KAAK;IAQb,YAAY,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,iBAAiB,KAAK,OAAO,CAAC,CAAC,CAAC;IAIpE,WAAW;IA6EX;;;;OAIG;IACH,gBAAgB,CAAC,CAAC,SAAS,QAAQ,EAAE,KAAK,EAAE,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC;IAYlE,IAAI,KAAK,IAAI,gBAAgB,CAAC,aAAa,CAAC,CAU3C;IAED,IAAI,IAAI,IAAI,eAAe,CAAC,MAAM,CAAC,CAElC;IAED,IAAI,OAAO,IAAI,eAAe,CAAC,SAAS,CAAC,CAExC;IAED,IAAI,gBAAgB,QAEnB;IAED;;;OAGG;IACH,WAAW;IAqDX;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;IAkE1B,IAAI,2BAA2B,QAM9B;IAED;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,oBAAoB;IA8K5B;;;;;;;;;;;;;;;;OAgBG;YACW,uBAAuB;IAgDrC;;;;;;;;;;;;;;OAcG;YACW,mBAAmB;IAejC,0EAA0E;YAC5D,wBAAwB;IA+BtC,iFAAiF;YACnE,oBAAoB;IAkClC;;;;OAIG;IACH,OAAO,CAAC,SAAS;IAKjB;;;;;;OAMG;YACW,2BAA2B;IAqBzC,mGAAmG;YACrF,uBAAuB;IAiCrC,oGAAoG;IACpG,OAAO,CAAC,mBAAmB;IAwB3B,yGAAyG;YAC3F,qBAAqB;IAuBnC;;;;;;;OAOG;WACW,gBAAgB,CAAC,UAAU,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,GAAG,GAAG,GAAG;IAgBjE;;;;;;;OAOG;IACH,OAAO,CAAC,MAAM,CAAC,mBAAmB;IA2BlC;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe;IAUvB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,iBAAiB;IA8EzB,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,IAAI,GAAG;QAC5E,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAC3B,UAAU,EAAE,OAAO,CAAA;KACtB;IAmBD,gBAAgB,CAAC,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAyBzF"}
|
|
@@ -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();
|
|
@@ -54,6 +55,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
54
55
|
return this.prismaClient.$transaction(fn);
|
|
55
56
|
}
|
|
56
57
|
proxyModels() {
|
|
58
|
+
// Capture the un-proxied client before wrapping it (used by internal
|
|
59
|
+
// lookups that must bypass this proxy's access filtering — see
|
|
60
|
+
// enforceCreateConditions).
|
|
61
|
+
this.rawClient = this.prismaClient;
|
|
57
62
|
// Proxy client to intercept model calls
|
|
58
63
|
this.prismaClient = new Proxy(this.prismaClient, {
|
|
59
64
|
get: (target, propKey) => {
|
|
@@ -68,16 +73,19 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
68
73
|
this.debug(`Proxying ${String(propKey)}.${String(methodKey)}() for role ${userRole}`);
|
|
69
74
|
const contextModel = nestjs_request_context_1.RequestContext.currentContext?.req.prisma?.[propKey] || model;
|
|
70
75
|
return async (params = {}, options) => {
|
|
71
|
-
//
|
|
72
|
-
|
|
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 = {
|
|
73
83
|
findUnique: 'findFirst',
|
|
74
84
|
findUniqueOrThrow: 'findFirstOrThrow',
|
|
75
85
|
delete: 'deleteMany',
|
|
76
86
|
update: 'updateMany',
|
|
77
87
|
};
|
|
78
|
-
|
|
79
|
-
throw new Error(`The method ${methodKey.toString()} is not compatible with permission filtering and is not allowed. Please use ${blacklist[methodKey.toString()]}} instead.`);
|
|
80
|
-
}
|
|
88
|
+
methodKey = redirect[methodKey.toString()] ?? methodKey;
|
|
81
89
|
// delete, deleteMany, update and updateMany methods should not apply field omission because they are not selecting fields
|
|
82
90
|
if (!options?.BYPASS_OMISSION && !['delete', 'deleteMany', 'update', 'updateMany', 'create', 'createMany'].includes(methodKey.toString())) {
|
|
83
91
|
params = this.applyFieldOmission(String(propKey), userRole, params);
|
|
@@ -85,8 +93,18 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
85
93
|
else {
|
|
86
94
|
this.debug(`Skipping field omission for ${String(propKey)}.${String(methodKey)}()`);
|
|
87
95
|
}
|
|
88
|
-
if (!options?.BYPASS_FILTERING
|
|
89
|
-
|
|
96
|
+
if (!options?.BYPASS_FILTERING) {
|
|
97
|
+
if (['create', 'createMany'].includes(methodKey.toString())) {
|
|
98
|
+
// Conditions can't be pushed into a WHERE on insert, so
|
|
99
|
+
// validate the incoming data satisfies them before insert.
|
|
100
|
+
await this.enforceCreateConditions(String(propKey), userRole, params, user, methodKey.toString());
|
|
101
|
+
// Authorize any nested relation writes (create/connect) in
|
|
102
|
+
// the payload; reject every other nested operator.
|
|
103
|
+
await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
|
|
107
|
+
}
|
|
90
108
|
}
|
|
91
109
|
else {
|
|
92
110
|
this.debug(`Skipping permission filtering for ${String(propKey)}.${String(methodKey)}()`);
|
|
@@ -95,7 +113,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
95
113
|
delete params.select;
|
|
96
114
|
}
|
|
97
115
|
this.debug(`Executing ${String(propKey)}.${String(methodKey)}() with params: ${JSON.stringify(params)}`);
|
|
98
|
-
|
|
116
|
+
const result = await contextModel[methodKey](params);
|
|
117
|
+
// Dev-only: warn if a just-created row wouldn't be readable by its
|
|
118
|
+
// creator (create conditions broader than find conditions). Skipped
|
|
119
|
+
// outside development so no extra query runs on the hot path.
|
|
120
|
+
if (methodKey === 'create' && !options?.BYPASS_FILTERING && this.isDevMode()) {
|
|
121
|
+
await this.warnIfCreatedRowNotFindable(String(propKey), userRole, result);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
99
124
|
};
|
|
100
125
|
}
|
|
101
126
|
return model[methodKey];
|
|
@@ -208,6 +233,37 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
208
233
|
omitFields.forEach((field) => {
|
|
209
234
|
delete args.select[field];
|
|
210
235
|
});
|
|
236
|
+
// Omission must also apply to nested relation selections. Without this,
|
|
237
|
+
// a caller reads an omitted field on a RELATED model by nesting the
|
|
238
|
+
// request (e.g. `select: { rel: { select: { secret: true } } }`) — the
|
|
239
|
+
// top-level delete above only covers this model's own fields.
|
|
240
|
+
for (const key of Object.keys(args.select)) {
|
|
241
|
+
const relation = this.getRelation(modelName, key);
|
|
242
|
+
if (!relation) {
|
|
243
|
+
continue; // scalar field (`true`) or `_count` — nothing to recurse
|
|
244
|
+
}
|
|
245
|
+
const relatedOmit = this.getFieldsToOmit(relation.model, userRole);
|
|
246
|
+
const value = args.select[key];
|
|
247
|
+
if (value === true) {
|
|
248
|
+
// `rel: true` returns every scalar of the related model — narrow
|
|
249
|
+
// it to the readable ones.
|
|
250
|
+
args.select[key] = {
|
|
251
|
+
select: this.buildSelectFields(relation.model, relatedOmit, null, null, userRole),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
else if (value && typeof value === 'object') {
|
|
255
|
+
if (value.select || value.include) {
|
|
256
|
+
// Recurse — strips the related model's omitted fields (and
|
|
257
|
+
// deeper) while preserving any where/orderBy/take on `value`.
|
|
258
|
+
args.select[key] = this.applyFieldOmission(relation.model, userRole, value);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// Relation args with no explicit projection (e.g. `{ where }`)
|
|
262
|
+
// — inject a select that excludes omitted fields.
|
|
263
|
+
value.select = this.buildSelectFields(relation.model, relatedOmit, null, null, userRole);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
211
267
|
}
|
|
212
268
|
else if (args.include) {
|
|
213
269
|
this.debug(`Found included model '${modelName}', generating select fields`);
|
|
@@ -380,6 +436,272 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
380
436
|
}
|
|
381
437
|
return args;
|
|
382
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Enforces permission `conditions` on `create` / `createMany`.
|
|
441
|
+
*
|
|
442
|
+
* Conditions cannot be pushed into a `WHERE` on insert, so instead we verify
|
|
443
|
+
* the incoming data *would* satisfy them — i.e. a `findFirst` with those
|
|
444
|
+
* conditions would return the new row. Own-scalar fields are checked against
|
|
445
|
+
* `data`; relation conditions are checked by looking up the referenced parent
|
|
446
|
+
* (which lets Prisma evaluate arbitrarily-nested relation conditions for us).
|
|
447
|
+
*
|
|
448
|
+
* Also default-denies: create is only allowed for a role/action that has an
|
|
449
|
+
* explicit permission (or 'ALL'), mirroring find/update/delete. Framework
|
|
450
|
+
* flows that must create without a permission (registration, sessions,
|
|
451
|
+
* tokens) pass BYPASS_FILTERING and never reach here.
|
|
452
|
+
*
|
|
453
|
+
* Fails **closed**: throws on a violation and on any condition shape it
|
|
454
|
+
* cannot evaluate, rather than silently allowing the insert.
|
|
455
|
+
*/
|
|
456
|
+
async enforceCreateConditions(modelName, userRole, params, user, action) {
|
|
457
|
+
const normalizedName = modelName.toLowerCase().trim();
|
|
458
|
+
const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
|
|
459
|
+
if (exposedModels.includes(normalizedName))
|
|
460
|
+
return;
|
|
461
|
+
const permissions = this.normalizedPermissionsConfig[normalizedName]?.[userRole];
|
|
462
|
+
if (!permissions) {
|
|
463
|
+
throw new common_1.HttpException(`No permissions defined for role ${userRole} on model '${modelName}' — create is denied by default. Define a '${action}' permission (or 'ALL').`, common_1.HttpStatus.FORBIDDEN);
|
|
464
|
+
}
|
|
465
|
+
const actionPermissions = permissions[action] ?? permissions['create'];
|
|
466
|
+
if (!actionPermissions) {
|
|
467
|
+
throw new common_1.HttpException(`Role ${userRole} has no '${action}' permission on model '${modelName}' — create is denied. Define it (or 'ALL').`, common_1.HttpStatus.FORBIDDEN);
|
|
468
|
+
}
|
|
469
|
+
if (actionPermissions === 'ALL')
|
|
470
|
+
return;
|
|
471
|
+
const conditions = actionPermissions.conditions;
|
|
472
|
+
if (!conditions)
|
|
473
|
+
return; // permission defined but no conditions (e.g. setUserIdField only) → allow
|
|
474
|
+
const condObj = Array.isArray(conditions) ? { AND: conditions } : conditions;
|
|
475
|
+
const resolved = PrismaService_1._buildConditions(condObj, user);
|
|
476
|
+
const data = params?.data;
|
|
477
|
+
const rows = Array.isArray(data) ? data : [data ?? {}];
|
|
478
|
+
for (const row of rows) {
|
|
479
|
+
const ok = await this.matchesCreateConditions(resolved, row || {}, modelName);
|
|
480
|
+
if (!ok) {
|
|
481
|
+
throw new common_1.ForbiddenException(`Not allowed to create ${modelName}: the record does not satisfy the '${action}' permission conditions for role ${userRole}.`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Recursively authorizes nested relation writes in a `create` payload. Only
|
|
487
|
+
* an explicit allowlist of operators is permitted, each checked against ABAC;
|
|
488
|
+
* every other operator is rejected (fail closed).
|
|
489
|
+
*
|
|
490
|
+
* Runs on the create path only — `updateMany` / `createMany` (what update /
|
|
491
|
+
* createMany resolve to) accept scalar data, so nested relation writes reach
|
|
492
|
+
* Prisma solely via `create()`.
|
|
493
|
+
*
|
|
494
|
+
* - `create` → the child must satisfy the related model's `create` rule
|
|
495
|
+
* (recurses, so deeper nesting is authorized too).
|
|
496
|
+
* - `connect` → the target must satisfy the related model's `connect` rule.
|
|
497
|
+
* `connect` is a DEDICATED action: being allowed to READ a record does not
|
|
498
|
+
* authorize associating it.
|
|
499
|
+
*/
|
|
500
|
+
async enforceNestedWrites(modelName, data, user, userRole) {
|
|
501
|
+
if (!data || typeof data !== 'object')
|
|
502
|
+
return;
|
|
503
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
504
|
+
for (const row of rows) {
|
|
505
|
+
if (!row || typeof row !== 'object')
|
|
506
|
+
continue;
|
|
507
|
+
for (const key of Object.keys(row)) {
|
|
508
|
+
const relation = this.getRelation(modelName, key);
|
|
509
|
+
if (!relation)
|
|
510
|
+
continue; // scalar / FK field — not a relation nav key
|
|
511
|
+
const ops = row[key];
|
|
512
|
+
if (!ops || typeof ops !== 'object')
|
|
513
|
+
continue;
|
|
514
|
+
await this.enforceNestedRelationOps(relation.model, key, ops, user, userRole, modelName);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/** Authorizes each operator inside one relation's nested-write object. */
|
|
519
|
+
async enforceNestedRelationOps(relatedModel, field, ops, user, userRole, parentModel) {
|
|
520
|
+
const ALLOWED = ['create', 'connect'];
|
|
521
|
+
for (const op of Object.keys(ops)) {
|
|
522
|
+
if (!ALLOWED.includes(op)) {
|
|
523
|
+
throw new common_1.ForbiddenException(`Nested '${op}' on relation '${field}' of ${parentModel} is not supported through the data-access proxy. ` +
|
|
524
|
+
`Only ${ALLOWED.join(' / ')} are permitted; perform other nested writes in an explicit endpoint with its own authorization.`);
|
|
525
|
+
}
|
|
526
|
+
const value = ops[op];
|
|
527
|
+
const items = Array.isArray(value) ? value : [value];
|
|
528
|
+
if (op === 'create') {
|
|
529
|
+
for (const child of items) {
|
|
530
|
+
await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create');
|
|
531
|
+
await this.enforceNestedWrites(relatedModel, child, user, userRole);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
for (const target of items) {
|
|
536
|
+
await this.assertConnectAllowed(relatedModel, target, user, userRole, field);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** A `connect` requires an explicit `connect` permission on the target model. */
|
|
542
|
+
async assertConnectAllowed(relatedModel, where, user, userRole, field) {
|
|
543
|
+
const normalizedName = relatedModel.toLowerCase().trim();
|
|
544
|
+
const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
|
|
545
|
+
if (exposedModels.includes(normalizedName))
|
|
546
|
+
return;
|
|
547
|
+
const connectPerm = this.normalizedPermissionsConfig[normalizedName]?.[userRole]?.['connect'];
|
|
548
|
+
if (!connectPerm) {
|
|
549
|
+
throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): role ${userRole} has no 'connect' permission on ${relatedModel}.`);
|
|
550
|
+
}
|
|
551
|
+
if (connectPerm === 'ALL')
|
|
552
|
+
return;
|
|
553
|
+
const conditions = connectPerm.conditions;
|
|
554
|
+
const resolved = conditions
|
|
555
|
+
? PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user)
|
|
556
|
+
: {};
|
|
557
|
+
const delegate = relatedModel.charAt(0).toLowerCase() + relatedModel.slice(1);
|
|
558
|
+
const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
|
|
559
|
+
const found = await client[delegate].findFirst({ where: { AND: [where || {}, resolved] } });
|
|
560
|
+
if (!found) {
|
|
561
|
+
throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): the target does not satisfy the 'connect' permission conditions for role ${userRole}.`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* True only in an explicit local-development environment. Deliberately NOT
|
|
566
|
+
* `!== 'production'` — staging and other envs should behave like production
|
|
567
|
+
* (no extra diagnostic queries/logging). Only `development` / `dev` opt in.
|
|
568
|
+
*/
|
|
569
|
+
isDevMode() {
|
|
570
|
+
const env = process.env.NODE_ENV;
|
|
571
|
+
return env === 'development' || env === 'dev';
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Dev-only diagnostic. After a create that passed its create-conditions,
|
|
575
|
+
* checks whether the new row also satisfies the model's *find* conditions —
|
|
576
|
+
* if not, the creator can't read it back (create rule broader than find
|
|
577
|
+
* rule). Logs a warning; never affects the create. Runs only in development
|
|
578
|
+
* (gated by the caller).
|
|
579
|
+
*/
|
|
580
|
+
async warnIfCreatedRowNotFindable(modelKey, userRole, created) {
|
|
581
|
+
try {
|
|
582
|
+
if (!created || created.id === undefined || created.id === null)
|
|
583
|
+
return;
|
|
584
|
+
const permissions = this.normalizedPermissionsConfig[modelKey.toLowerCase()]?.[userRole];
|
|
585
|
+
const findPerm = permissions?.['findFirst'] ?? permissions?.['findMany'];
|
|
586
|
+
if (!findPerm || findPerm === 'ALL' || !findPerm.conditions)
|
|
587
|
+
return;
|
|
588
|
+
const found = await this.prismaClient[modelKey].findFirst({ where: { id: created.id } });
|
|
589
|
+
if (!found) {
|
|
590
|
+
console.warn('\x1b[33m', `[APPX-CORE PRISMA] Created ${modelKey} id=${created.id} as role ${userRole}, but it does not satisfy the model's find conditions — the creator cannot read it back. Ensure your find conditions are at least as permissive as your create conditions.`, '\x1b[0m');
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Dev diagnostic only — never let it affect the create.
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
|
|
598
|
+
async matchesCreateConditions(cond, row, modelName) {
|
|
599
|
+
for (const key of Object.keys(cond)) {
|
|
600
|
+
const val = cond[key];
|
|
601
|
+
if (key === 'AND') {
|
|
602
|
+
const arr = Array.isArray(val) ? val : [val];
|
|
603
|
+
for (const c of arr)
|
|
604
|
+
if (!(await this.matchesCreateConditions(c, row, modelName)))
|
|
605
|
+
return false;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (key === 'OR') {
|
|
609
|
+
const arr = Array.isArray(val) ? val : [val];
|
|
610
|
+
let any = false;
|
|
611
|
+
for (const c of arr)
|
|
612
|
+
if (await this.matchesCreateConditions(c, row, modelName)) {
|
|
613
|
+
any = true;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
if (!any)
|
|
617
|
+
return false;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (key === 'NOT') {
|
|
621
|
+
const arr = Array.isArray(val) ? val : [val];
|
|
622
|
+
for (const c of arr)
|
|
623
|
+
if (await this.matchesCreateConditions(c, row, modelName))
|
|
624
|
+
return false;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const relation = this.getRelation(modelName, key);
|
|
628
|
+
if (relation) {
|
|
629
|
+
if (!(await this.matchesCreateRelation(relation, key, val, row, modelName)))
|
|
630
|
+
return false;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (!this.matchesCreateScalar(row[key], val, key, modelName))
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
/** Evaluates a scalar-field condition (value or Prisma operator object) against the row's value. */
|
|
639
|
+
matchesCreateScalar(actual, cond, key, modelName) {
|
|
640
|
+
if (cond === null)
|
|
641
|
+
return actual === null || actual === undefined;
|
|
642
|
+
if (cond instanceof Date || typeof cond !== 'object')
|
|
643
|
+
return actual === cond;
|
|
644
|
+
for (const op of Object.keys(cond)) {
|
|
645
|
+
const opv = cond[op];
|
|
646
|
+
switch (op) {
|
|
647
|
+
case 'equals':
|
|
648
|
+
if (actual !== opv)
|
|
649
|
+
return false;
|
|
650
|
+
break;
|
|
651
|
+
case 'not':
|
|
652
|
+
if (actual === opv)
|
|
653
|
+
return false;
|
|
654
|
+
break;
|
|
655
|
+
case 'in':
|
|
656
|
+
if (!Array.isArray(opv) || !opv.includes(actual))
|
|
657
|
+
return false;
|
|
658
|
+
break;
|
|
659
|
+
case 'notIn':
|
|
660
|
+
if (Array.isArray(opv) && opv.includes(actual))
|
|
661
|
+
return false;
|
|
662
|
+
break;
|
|
663
|
+
case 'lt':
|
|
664
|
+
if (!(actual < opv))
|
|
665
|
+
return false;
|
|
666
|
+
break;
|
|
667
|
+
case 'lte':
|
|
668
|
+
if (!(actual <= opv))
|
|
669
|
+
return false;
|
|
670
|
+
break;
|
|
671
|
+
case 'gt':
|
|
672
|
+
if (!(actual > opv))
|
|
673
|
+
return false;
|
|
674
|
+
break;
|
|
675
|
+
case 'gte':
|
|
676
|
+
if (!(actual >= opv))
|
|
677
|
+
return false;
|
|
678
|
+
break;
|
|
679
|
+
default:
|
|
680
|
+
throw new common_1.ForbiddenException(`create on ${modelName}: unsupported operator '${op}' on field '${key}' in a create condition. Use a simpler condition or setUserIdField.`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
/** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
|
|
686
|
+
async matchesCreateRelation(relation, field, nestedCond, row, modelName) {
|
|
687
|
+
if (relation.relation !== 'belongsTo' || !relation.referencingColumn) {
|
|
688
|
+
throw new common_1.ForbiddenException(`create on ${modelName}: unsupported relation condition on '${field}' — a new row has no related '${field}' yet to match. Constrain the scalar foreign key instead.`);
|
|
689
|
+
}
|
|
690
|
+
const fkValue = row[relation.referencingColumn];
|
|
691
|
+
if (fkValue === undefined || fkValue === null)
|
|
692
|
+
return false;
|
|
693
|
+
const delegate = relation.model.charAt(0).toLowerCase() + relation.model.slice(1);
|
|
694
|
+
// Use the request's transaction client if present (to see rows created
|
|
695
|
+
// earlier in the same request), else the raw client. Either way, access
|
|
696
|
+
// filtering on the parent is intentionally NOT re-applied — we check
|
|
697
|
+
// only the condition.
|
|
698
|
+
const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
|
|
699
|
+
const found = await client[delegate].findFirst({
|
|
700
|
+
where: { AND: [{ [relation.foreignKey]: fkValue }, nestedCond] },
|
|
701
|
+
select: { [relation.foreignKey]: true },
|
|
702
|
+
});
|
|
703
|
+
return !!found;
|
|
704
|
+
}
|
|
383
705
|
/**
|
|
384
706
|
* Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
|
|
385
707
|
* Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
|
|
@@ -532,6 +854,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
532
854
|
actionPermissions = permissions['findFirst'];
|
|
533
855
|
}
|
|
534
856
|
}
|
|
857
|
+
// A `*Many` action inherits the singular rule when not set explicitly
|
|
858
|
+
// (updateMany → update, deleteMany → delete). `update()` / `delete()`
|
|
859
|
+
// redirect to their `*Many` form, so this is what makes a config that
|
|
860
|
+
// declares only `update` / `delete` apply to them.
|
|
861
|
+
if (!actionPermissions && permissionsConfigTypes_1.SINGULAR_ACTION[action] && permissions[permissionsConfigTypes_1.SINGULAR_ACTION[action]]) {
|
|
862
|
+
this.debug(`Using '${permissionsConfigTypes_1.SINGULAR_ACTION[action]}' permissions for '${action}' action on model '${modelName}' and role ${userRole}`, 'info');
|
|
863
|
+
actionPermissions = permissions[permissionsConfigTypes_1.SINGULAR_ACTION[action]];
|
|
864
|
+
}
|
|
535
865
|
return actionPermissions;
|
|
536
866
|
}
|
|
537
867
|
};
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
- **[dtos.md](./dtos.md)** — the generated CRUD validation DTOs: their two-file structure, **how to remove a field** (`OmitType` / `@NoWrite` / `restrictedFields`), how to add validation, and why nested relation writes don't belong on generic CRUD.
|
|
9
|
+
- **[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.
|
|
10
|
+
- **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
|
|
11
|
+
- **[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.
|
|
12
|
+
|
|
13
|
+
## The one-paragraph mental model
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
## Conventions used in these docs
|
|
18
|
+
|
|
19
|
+
- `$USER_ID` refers to `PermissionPlaceholder.USER_ID` — a placeholder replaced with the current user's id at query time.
|
|
20
|
+
- "the proxy" means the `PrismaService` client returned by `prismaService.model` (and the `prisma.user` / `prisma.session` / `prisma.userRefreshToken` accessors).
|
|
21
|
+
- "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.
|