@appxdigital/appx-core 0.1.121-beta.9 → 0.1.121
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 +32 -1
- package/PRERELEASES.md +107 -0
- package/dist/common/config/permissionsConfigTypes.d.ts +19 -3
- package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
- package/dist/config/generate-dtos.js +101 -15
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/modules/auth/auth.module.d.ts +16 -0
- package/dist/modules/auth/auth.module.d.ts.map +1 -1
- package/dist/modules/auth/auth.module.js +45 -16
- package/dist/modules/core/core.controller.d.ts.map +1 -1
- package/dist/modules/core/core.controller.js +3 -23
- package/dist/prisma/permissions-validator.d.ts +51 -0
- package/dist/prisma/permissions-validator.d.ts.map +1 -0
- package/dist/prisma/permissions-validator.js +142 -0
- package/dist/prisma/prisma.service.d.ts +61 -3
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +245 -33
- package/docs/README.md +2 -0
- package/docs/authentication.md +220 -0
- package/docs/dtos.md +158 -0
- package/docs/limitations.md +7 -1
- package/docs/permissions.md +58 -3
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ exports.PrismaService = exports.CorePrismaContext = void 0;
|
|
|
17
17
|
const common_1 = require("@nestjs/common");
|
|
18
18
|
const client_1 = require("@prisma/client");
|
|
19
19
|
const permissionsConfigTypes_1 = require("../common/config/permissionsConfigTypes");
|
|
20
|
+
const permissions_validator_1 = require("./permissions-validator");
|
|
20
21
|
const nestjs_request_context_1 = require("nestjs-request-context");
|
|
21
22
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
22
23
|
exports.CorePrismaContext = new node_async_hooks_1.AsyncLocalStorage();
|
|
@@ -26,8 +27,40 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
26
27
|
this.fieldConfigs = {};
|
|
27
28
|
this.prismaClient = prismaClient;
|
|
28
29
|
this.parseSchema();
|
|
30
|
+
this.validatePermissions();
|
|
29
31
|
this.proxyModels();
|
|
30
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Validates the permissions config against the schema at boot. Warnings are
|
|
35
|
+
* logged; errors (a create condition that crosses a relation, or a required
|
|
36
|
+
* FK whose target has no `connect` rule) reject boot — the misconfiguration
|
|
37
|
+
* would otherwise surface as a runtime 403.
|
|
38
|
+
*/
|
|
39
|
+
validatePermissions() {
|
|
40
|
+
const relations = {};
|
|
41
|
+
for (const model of Object.keys(this.fieldConfigs)) {
|
|
42
|
+
const relationFields = this.fieldConfigs[model].relationFields || {};
|
|
43
|
+
const noWrite = this.fieldConfigs[model].noWriteFields || [];
|
|
44
|
+
relations[model] = Object.keys(relationFields).map((field) => ({
|
|
45
|
+
field,
|
|
46
|
+
model: relationFields[field].model,
|
|
47
|
+
referencingColumn: relationFields[field].referencingColumn,
|
|
48
|
+
isRequired: relationFields[field].isRequired,
|
|
49
|
+
kind: relationFields[field].relation,
|
|
50
|
+
// The scalar FK is client-writable unless the column is @NoWrite / @Role(none).
|
|
51
|
+
fkWritable: !noWrite.includes(relationFields[field].referencingColumn),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
const issues = (0, permissions_validator_1.validatePermissionsConfig)(this.permissionsConfig, relations);
|
|
55
|
+
for (const issue of issues.filter((i) => i.level === 'warning')) {
|
|
56
|
+
console.warn('\x1b[33m', `[APPX-CORE PERMISSIONS] ${issue.message}`, '\x1b[0m');
|
|
57
|
+
}
|
|
58
|
+
const errors = issues.filter((i) => i.level === 'error');
|
|
59
|
+
if (errors.length) {
|
|
60
|
+
throw new Error(`[APPX-CORE PERMISSIONS] Invalid permissions configuration — ${errors.length} error(s):\n` +
|
|
61
|
+
errors.map((e) => ` • ${e.message}`).join('\n'));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
31
64
|
debugQueries(enable) {
|
|
32
65
|
nestjs_request_context_1.RequestContext.currentContext.req.corePrismaDebug = enable;
|
|
33
66
|
}
|
|
@@ -98,9 +131,17 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
98
131
|
// Conditions can't be pushed into a WHERE on insert, so
|
|
99
132
|
// validate the incoming data satisfies them before insert.
|
|
100
133
|
await this.enforceCreateConditions(String(propKey), userRole, params, user, methodKey.toString());
|
|
134
|
+
// Authorize any relation the write establishes (nested
|
|
135
|
+
// create/connect, or a raw foreign key).
|
|
136
|
+
await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
|
|
101
137
|
}
|
|
102
138
|
else {
|
|
103
139
|
params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
|
|
140
|
+
// An update can re-point a foreign key (scalar column);
|
|
141
|
+
// authorize those changes with the same connect rules as create.
|
|
142
|
+
if (['update', 'updateMany'].includes(methodKey.toString())) {
|
|
143
|
+
await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
|
|
144
|
+
}
|
|
104
145
|
}
|
|
105
146
|
}
|
|
106
147
|
else {
|
|
@@ -175,6 +216,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
175
216
|
const fieldTypes = {};
|
|
176
217
|
const scalarFields = [];
|
|
177
218
|
const relationFields = {};
|
|
219
|
+
const noWriteFields = [];
|
|
178
220
|
for (const field of fields) {
|
|
179
221
|
let field_name = field.name;
|
|
180
222
|
const fieldType = field.type;
|
|
@@ -184,6 +226,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
184
226
|
if (roleMatch) {
|
|
185
227
|
fieldConfig[field_name] = roleMatch[1].split(',').map((role) => role.trim());
|
|
186
228
|
}
|
|
229
|
+
// Fields excluded from write DTOs — never client-writable.
|
|
230
|
+
if (/@NoWrite\b/.test(commentPart) || /@Role\(\s*none\s*\)/i.test(commentPart)) {
|
|
231
|
+
noWriteFields.push(field_name);
|
|
232
|
+
}
|
|
187
233
|
}
|
|
188
234
|
allFields.push(field_name);
|
|
189
235
|
fieldTypes[field_name] = fieldType;
|
|
@@ -195,6 +241,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
195
241
|
model: field.type,
|
|
196
242
|
relation: field.isList ? 'hasMany' : 'belongsTo',
|
|
197
243
|
isRequired: field.isRequired,
|
|
244
|
+
// Disambiguates multiple relations between the same two models
|
|
245
|
+
// (e.g. Task.assignee / Task.reviewer, self-relations) so the
|
|
246
|
+
// inverse (back-reference) side can be paired reliably.
|
|
247
|
+
relationName: field.relationName,
|
|
198
248
|
};
|
|
199
249
|
if (!field.isList && field.relationToFields && field.relationFromFields) {
|
|
200
250
|
relationFields[field_name].foreignKey = field.relationToFields[0];
|
|
@@ -208,6 +258,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
208
258
|
fieldTypes,
|
|
209
259
|
scalarFields,
|
|
210
260
|
relationFields,
|
|
261
|
+
noWriteFields,
|
|
211
262
|
};
|
|
212
263
|
}
|
|
213
264
|
}
|
|
@@ -450,7 +501,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
450
501
|
* Fails **closed**: throws on a violation and on any condition shape it
|
|
451
502
|
* cannot evaluate, rather than silently allowing the insert.
|
|
452
503
|
*/
|
|
453
|
-
async enforceCreateConditions(modelName, userRole, params, user, action) {
|
|
504
|
+
async enforceCreateConditions(modelName, userRole, params, user, action, trustedFk) {
|
|
454
505
|
const normalizedName = modelName.toLowerCase().trim();
|
|
455
506
|
const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
|
|
456
507
|
if (exposedModels.includes(normalizedName))
|
|
@@ -473,12 +524,159 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
473
524
|
const data = params?.data;
|
|
474
525
|
const rows = Array.isArray(data) ? data : [data ?? {}];
|
|
475
526
|
for (const row of rows) {
|
|
476
|
-
const
|
|
527
|
+
const effectiveRow = this.resolveConnectForeignKeys(modelName, row || {});
|
|
528
|
+
const ok = await this.matchesCreateConditions(resolved, effectiveRow, modelName, trustedFk);
|
|
477
529
|
if (!ok) {
|
|
478
530
|
throw new common_1.ForbiddenException(`Not allowed to create ${modelName}: the record does not satisfy the '${action}' permission conditions for role ${userRole}.`);
|
|
479
531
|
}
|
|
480
532
|
}
|
|
481
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Authorizes every relation a write establishes. Relationship authorization
|
|
536
|
+
* lives entirely in the `connect` action — create conditions judge a model's
|
|
537
|
+
* own scalar fields only.
|
|
538
|
+
*
|
|
539
|
+
* Runs on create AND update: a `create` can nest relation writes or set raw
|
|
540
|
+
* FKs; an `updateMany` (scalar-only) can re-point a raw FK. Both go through
|
|
541
|
+
* the same `connect` authorization.
|
|
542
|
+
*
|
|
543
|
+
* - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
|
|
544
|
+
* - `relation: { connect }` → the target's `connect` rule. `connect` is a
|
|
545
|
+
* DEDICATED action: being allowed to READ a record does not authorize
|
|
546
|
+
* associating it.
|
|
547
|
+
* - `relation: { create }` → the target's `create` rule (recurses, so deeper
|
|
548
|
+
* nesting is authorized too). Every other nested operator is rejected.
|
|
549
|
+
*
|
|
550
|
+
* The one exception is `trustedFk`: the scalar FK that points back to the row
|
|
551
|
+
* we're nested under. Prisma fills it at write time and the parent already
|
|
552
|
+
* authorized itself, so it is not re-checked.
|
|
553
|
+
*/
|
|
554
|
+
async enforceNestedWrites(modelName, data, user, userRole, trustedFk) {
|
|
555
|
+
if (!data || typeof data !== 'object')
|
|
556
|
+
return;
|
|
557
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
558
|
+
for (const row of rows) {
|
|
559
|
+
if (!row || typeof row !== 'object')
|
|
560
|
+
continue;
|
|
561
|
+
for (const key of Object.keys(row)) {
|
|
562
|
+
const relation = this.getRelation(modelName, key);
|
|
563
|
+
if (relation) {
|
|
564
|
+
const ops = row[key];
|
|
565
|
+
if (!ops || typeof ops !== 'object')
|
|
566
|
+
continue; // e.g. an explicit `null` disconnect-by-omission
|
|
567
|
+
await this.enforceNestedRelationOps(relation.model, key, ops, user, userRole, modelName, relation.relationName);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
// A plain scalar that happens to be a belongsTo foreign key still
|
|
571
|
+
// establishes an association — authorize it against the target's
|
|
572
|
+
// `connect` rule, exactly as the `connect` form would be.
|
|
573
|
+
const fk = this.foreignKeyColumn(modelName, key);
|
|
574
|
+
if (!fk)
|
|
575
|
+
continue; // ordinary scalar
|
|
576
|
+
if (key === trustedFk)
|
|
577
|
+
continue; // back-reference to the nesting parent — trusted
|
|
578
|
+
let value = row[key];
|
|
579
|
+
if (value && typeof value === 'object' && 'set' in value)
|
|
580
|
+
value = value.set; // update form: { set: x }
|
|
581
|
+
if (value === undefined || value === null)
|
|
582
|
+
continue;
|
|
583
|
+
await this.assertConnectAllowed(modelName, fk.field, fk.model, { [fk.foreignKey]: value }, user, userRole);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** Authorizes each operator inside one relation's nested-write object. */
|
|
588
|
+
async enforceNestedRelationOps(relatedModel, field, ops, user, userRole, parentModel, parentRelationName) {
|
|
589
|
+
const ALLOWED = ['create', 'connect'];
|
|
590
|
+
// The child's FK back to the parent it's nested under is filled by Prisma
|
|
591
|
+
// and trusted; skip it when authorizing the child's own relations.
|
|
592
|
+
const backFk = this.backReferenceFk(relatedModel, parentModel, parentRelationName);
|
|
593
|
+
for (const op of Object.keys(ops)) {
|
|
594
|
+
if (!ALLOWED.includes(op)) {
|
|
595
|
+
throw new common_1.ForbiddenException(`Nested '${op}' on relation '${field}' of ${parentModel} is not supported through the data-access proxy. ` +
|
|
596
|
+
`Only ${ALLOWED.join(' / ')} are permitted; perform other nested writes in an explicit endpoint with its own authorization.`);
|
|
597
|
+
}
|
|
598
|
+
const value = ops[op];
|
|
599
|
+
const items = Array.isArray(value) ? value : [value];
|
|
600
|
+
if (op === 'create') {
|
|
601
|
+
for (const child of items) {
|
|
602
|
+
await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create', backFk);
|
|
603
|
+
await this.enforceNestedWrites(relatedModel, child, user, userRole, backFk);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
for (const target of items) {
|
|
608
|
+
await this.assertConnectAllowed(parentModel, field, relatedModel, target, user, userRole);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/** If `column` is the scalar FK backing a belongsTo relation, returns that relation's field + target. */
|
|
614
|
+
foreignKeyColumn(modelName, column) {
|
|
615
|
+
const rels = this.fieldConfigs[modelName.toLowerCase()]?.relationFields || {};
|
|
616
|
+
for (const key of Object.keys(rels)) {
|
|
617
|
+
const r = rels[key];
|
|
618
|
+
if (r.relation === 'belongsTo' && r.referencingColumn === column && r.foreignKey) {
|
|
619
|
+
return { field: key, model: r.model, foreignKey: r.foreignKey };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
/** The child's scalar FK column pointing back to the parent it's nested under (trusted). */
|
|
625
|
+
backReferenceFk(childModel, parentModel, parentRelationName) {
|
|
626
|
+
const rels = this.fieldConfigs[childModel.toLowerCase()]?.relationFields || {};
|
|
627
|
+
const candidates = Object.keys(rels)
|
|
628
|
+
.map(key => rels[key])
|
|
629
|
+
.filter((r) => r.relation === 'belongsTo' && r.referencingColumn && r.model.toLowerCase() === parentModel.toLowerCase());
|
|
630
|
+
// Prisma pairs the two sides of a relation by `relationName`; use it to
|
|
631
|
+
// disambiguate when the child has several relations to the same parent.
|
|
632
|
+
if (parentRelationName) {
|
|
633
|
+
const exact = candidates.find((r) => r.relationName === parentRelationName);
|
|
634
|
+
if (exact)
|
|
635
|
+
return exact.referencingColumn;
|
|
636
|
+
}
|
|
637
|
+
return candidates.length === 1 ? candidates[0].referencingColumn : undefined;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Authorizes attaching `relatedModel` (identified by `where`) through the
|
|
641
|
+
* `field` relation on `sourceModel`. Two rules can apply, evaluated against
|
|
642
|
+
* the candidate target row:
|
|
643
|
+
* - a relation-scoped rule on the source (`sourceModel[role].relations[field].connect`)
|
|
644
|
+
* - the target model's own rule (`relatedModel[role].connect`)
|
|
645
|
+
* When both exist they are ANDed (the relation rule can only strengthen).
|
|
646
|
+
* When only one exists it stands alone; when neither exists, default-deny.
|
|
647
|
+
*/
|
|
648
|
+
async assertConnectAllowed(sourceModel, field, relatedModel, where, user, userRole) {
|
|
649
|
+
const normalizedName = relatedModel.toLowerCase().trim();
|
|
650
|
+
const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
|
|
651
|
+
if (exposedModels.includes(normalizedName))
|
|
652
|
+
return;
|
|
653
|
+
const relationRule = this.normalizedPermissionsConfig[sourceModel.toLowerCase().trim()]?.[userRole]?.['relations']?.[field]?.connect;
|
|
654
|
+
const targetRule = this.normalizedPermissionsConfig[normalizedName]?.[userRole]?.['connect'];
|
|
655
|
+
if (relationRule === undefined && targetRule === undefined) {
|
|
656
|
+
throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): role ${userRole} has no 'connect' permission. ` +
|
|
657
|
+
`Declare it on ${relatedModel}.${userRole}.connect, or scope it to this relation with ${sourceModel}.${userRole}.relations.${field}.connect.`);
|
|
658
|
+
}
|
|
659
|
+
// Collect the conditions each applicable rule imposes; an 'ALL' or
|
|
660
|
+
// condition-less rule constrains nothing. The target must satisfy every
|
|
661
|
+
// collected condition (AND — the relation rule strengthens the target's).
|
|
662
|
+
const conditionSets = [];
|
|
663
|
+
for (const rule of [relationRule, targetRule]) {
|
|
664
|
+
if (rule === undefined || rule === 'ALL')
|
|
665
|
+
continue;
|
|
666
|
+
const conditions = rule.conditions;
|
|
667
|
+
if (conditions) {
|
|
668
|
+
conditionSets.push(PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (conditionSets.length === 0)
|
|
672
|
+
return; // every applicable rule is unconditional
|
|
673
|
+
const delegate = relatedModel.charAt(0).toLowerCase() + relatedModel.slice(1);
|
|
674
|
+
const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
|
|
675
|
+
const found = await client[delegate].findFirst({ where: { AND: [where || {}, ...conditionSets] } });
|
|
676
|
+
if (!found) {
|
|
677
|
+
throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): the target does not satisfy the 'connect' permission conditions for role ${userRole}.`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
482
680
|
/**
|
|
483
681
|
* True only in an explicit local-development environment. Deliberately NOT
|
|
484
682
|
* `!== 'production'` — staging and other envs should behave like production
|
|
@@ -512,14 +710,50 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
512
710
|
// Dev diagnostic only — never let it affect the create.
|
|
513
711
|
}
|
|
514
712
|
}
|
|
515
|
-
/**
|
|
516
|
-
|
|
713
|
+
/**
|
|
714
|
+
* A view of a create `row` where a belongsTo relation given via `connect`
|
|
715
|
+
* (`owner: { connect: { id } }`) is also surfaced as its scalar FK (`ownerId`),
|
|
716
|
+
* so scalar-FK create conditions match either payload form. Metadata-driven
|
|
717
|
+
* (`referencingColumn`/`foreignKey`); resolvable only when the connect targets
|
|
718
|
+
* the referenced column. Explicit scalar wins; row is not mutated.
|
|
719
|
+
*/
|
|
720
|
+
resolveConnectForeignKeys(modelName, row) {
|
|
721
|
+
if (!row || typeof row !== 'object')
|
|
722
|
+
return row;
|
|
723
|
+
let effective = row;
|
|
724
|
+
for (const key of Object.keys(row)) {
|
|
725
|
+
const relation = this.getRelation(modelName, key);
|
|
726
|
+
if (!relation || relation.relation !== 'belongsTo')
|
|
727
|
+
continue;
|
|
728
|
+
if (!relation.referencingColumn || !relation.foreignKey)
|
|
729
|
+
continue;
|
|
730
|
+
if (row[relation.referencingColumn] !== undefined)
|
|
731
|
+
continue; // explicit scalar wins
|
|
732
|
+
const connectWhere = row[key]?.connect;
|
|
733
|
+
const fk = connectWhere?.[relation.foreignKey];
|
|
734
|
+
if (fk === undefined)
|
|
735
|
+
continue;
|
|
736
|
+
if (effective === row)
|
|
737
|
+
effective = { ...row };
|
|
738
|
+
effective[relation.referencingColumn] = fk;
|
|
739
|
+
}
|
|
740
|
+
return effective;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Evaluates a (placeholder-resolved) create condition against a to-be-created
|
|
744
|
+
* row. Create conditions judge the model's OWN scalar fields only — a clause
|
|
745
|
+
* that reaches into a relation is not a create concern (relationships are
|
|
746
|
+
* authorized by the target's `connect` rule) and is skipped here; the config
|
|
747
|
+
* validator rejects such clauses at boot. `trustedFk` (the back-reference to a
|
|
748
|
+
* same-request nesting parent) is likewise skipped — its value doesn't exist yet.
|
|
749
|
+
*/
|
|
750
|
+
async matchesCreateConditions(cond, row, modelName, trustedFk) {
|
|
517
751
|
for (const key of Object.keys(cond)) {
|
|
518
752
|
const val = cond[key];
|
|
519
753
|
if (key === 'AND') {
|
|
520
754
|
const arr = Array.isArray(val) ? val : [val];
|
|
521
755
|
for (const c of arr)
|
|
522
|
-
if (!(await this.matchesCreateConditions(c, row, modelName)))
|
|
756
|
+
if (!(await this.matchesCreateConditions(c, row, modelName, trustedFk)))
|
|
523
757
|
return false;
|
|
524
758
|
continue;
|
|
525
759
|
}
|
|
@@ -527,7 +761,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
527
761
|
const arr = Array.isArray(val) ? val : [val];
|
|
528
762
|
let any = false;
|
|
529
763
|
for (const c of arr)
|
|
530
|
-
if (await this.matchesCreateConditions(c, row, modelName)) {
|
|
764
|
+
if (await this.matchesCreateConditions(c, row, modelName, trustedFk)) {
|
|
531
765
|
any = true;
|
|
532
766
|
break;
|
|
533
767
|
}
|
|
@@ -538,16 +772,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
538
772
|
if (key === 'NOT') {
|
|
539
773
|
const arr = Array.isArray(val) ? val : [val];
|
|
540
774
|
for (const c of arr)
|
|
541
|
-
if (await this.matchesCreateConditions(c, row, modelName))
|
|
775
|
+
if (await this.matchesCreateConditions(c, row, modelName, trustedFk))
|
|
542
776
|
return false;
|
|
543
777
|
continue;
|
|
544
778
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
779
|
+
if (key === trustedFk)
|
|
780
|
+
continue; // parent link, filled by Prisma — trusted
|
|
781
|
+
if (this.getRelation(modelName, key))
|
|
782
|
+
continue; // relation clause — authorized via `connect`, not here
|
|
551
783
|
if (!this.matchesCreateScalar(row[key], val, key, modelName))
|
|
552
784
|
return false;
|
|
553
785
|
}
|
|
@@ -600,26 +832,6 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
600
832
|
}
|
|
601
833
|
return true;
|
|
602
834
|
}
|
|
603
|
-
/** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
|
|
604
|
-
async matchesCreateRelation(relation, field, nestedCond, row, modelName) {
|
|
605
|
-
if (relation.relation !== 'belongsTo' || !relation.referencingColumn) {
|
|
606
|
-
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.`);
|
|
607
|
-
}
|
|
608
|
-
const fkValue = row[relation.referencingColumn];
|
|
609
|
-
if (fkValue === undefined || fkValue === null)
|
|
610
|
-
return false;
|
|
611
|
-
const delegate = relation.model.charAt(0).toLowerCase() + relation.model.slice(1);
|
|
612
|
-
// Use the request's transaction client if present (to see rows created
|
|
613
|
-
// earlier in the same request), else the raw client. Either way, access
|
|
614
|
-
// filtering on the parent is intentionally NOT re-applied — we check
|
|
615
|
-
// only the condition.
|
|
616
|
-
const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
|
|
617
|
-
const found = await client[delegate].findFirst({
|
|
618
|
-
where: { AND: [{ [relation.foreignKey]: fkValue }, nestedCond] },
|
|
619
|
-
select: { [relation.foreignKey]: true },
|
|
620
|
-
});
|
|
621
|
-
return !!found;
|
|
622
|
-
}
|
|
623
835
|
/**
|
|
624
836
|
* Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
|
|
625
837
|
* Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
|
package/docs/README.md
CHANGED
|
@@ -5,7 +5,9 @@ Authoritative usage documentation for `@appxdigital/appx-core`. **This folder is
|
|
|
5
5
|
## Contents
|
|
6
6
|
|
|
7
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`), how to add validation, and why nested relation writes don't belong on generic CRUD.
|
|
8
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
|
+
- **[authentication.md](./authentication.md)** — the built-in `/auth` surface, and **how to override framework internals** (extend the exported `AuthController`/`AuthService`/`RegisterDto`/Passport strategies and register your own at the same prefix via `AuthModule.forRoot({ controller: false })`) — e.g. adding `name` to registration.
|
|
9
11
|
- **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
|
|
10
12
|
- **[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
13
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Authentication & overriding framework internals
|
|
2
|
+
|
|
3
|
+
The framework ships a working auth surface. When you need to change part of it —
|
|
4
|
+
add a field to registration, add endpoints, swap a Passport strategy — you don't
|
|
5
|
+
fork it: you **extend the exported classes and register your own versions**. This
|
|
6
|
+
page shows the pattern (auth is the main example, but it applies to any exported
|
|
7
|
+
framework class).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What `AuthModule` provides
|
|
12
|
+
|
|
13
|
+
**Always wire it as `AuthModule.forRoot()`** — importing the bare `AuthModule`
|
|
14
|
+
registers nothing (all wiring lives in `forRoot()` so the controller can be
|
|
15
|
+
conditionally omitted; see the override section). `AuthModule.forRoot()` registers,
|
|
16
|
+
under the `/auth` prefix:
|
|
17
|
+
|
|
18
|
+
| Route | Guard | Body |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `POST /auth/register` | — | `RegisterDto` (`email`, `password`) |
|
|
21
|
+
| `POST /auth/login` | `LocalAuthGuard` | credentials |
|
|
22
|
+
| `POST /auth/login/jwt` | `LocalAuthGuard` | credentials → tokens |
|
|
23
|
+
| `POST /auth/refresh` | `RefreshTokenGuard` | refresh token |
|
|
24
|
+
| `POST /auth/logout` · `logout/jwt` | `AuthenticatedGuard` | — |
|
|
25
|
+
| `GET /auth/me`, `GET/POST /auth/sessions…` | `AuthenticatedGuard` | — |
|
|
26
|
+
|
|
27
|
+
plus the providers (all **global**): `AuthService`, the Passport strategies
|
|
28
|
+
(`LocalStrategy`, `JwtStrategy`, `RefreshTokenStrategy`), the guards
|
|
29
|
+
(`LocalAuthGuard`, `JwtAuthGuard`, `AuthenticatedGuard`, `RefreshTokenGuard`,
|
|
30
|
+
`SessionAuthGuard`), and `SessionSerializer`.
|
|
31
|
+
|
|
32
|
+
**Registration is intentionally narrow.** `RegisterDto` accepts only `email` +
|
|
33
|
+
`password`, and `AuthService.register` creates the user with `BYPASS_FILTERING`
|
|
34
|
+
(it's an unauthenticated flow, so ABAC create-enforcement can't gate it). The
|
|
35
|
+
`RegisterDto` whitelist is therefore the *only* thing controlling which fields a
|
|
36
|
+
sign-up may set — which is exactly why widening it is a deliberate override, not
|
|
37
|
+
a config toggle.
|
|
38
|
+
|
|
39
|
+
All of these are exported from the package root — **import from
|
|
40
|
+
`@appxdigital/appx-core`, never from `@appxdigital/appx-core/dist/...`** (deep
|
|
41
|
+
paths are internal and can move between versions):
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import {
|
|
45
|
+
AuthModule, AuthController, AuthService,
|
|
46
|
+
RegisterDto, UserDto, AuthField,
|
|
47
|
+
LocalStrategy, JwtStrategy, RefreshTokenStrategy,
|
|
48
|
+
} from '@appxdigital/appx-core';
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## The override pattern
|
|
54
|
+
|
|
55
|
+
To replace or extend an auth endpoint **at the same `/auth` prefix**:
|
|
56
|
+
|
|
57
|
+
1. **Extend** the framework class (controller / service / strategy).
|
|
58
|
+
2. Put your version in **your own module**.
|
|
59
|
+
3. Import **`AuthModule.forRoot({ controller: false })`** — this keeps every auth
|
|
60
|
+
provider global but drops the built-in `AuthController`, so *your* controller
|
|
61
|
+
owns `/auth` with no duplicate route.
|
|
62
|
+
4. Register your module in `AppModule` (in place of a bare `AuthModule`).
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// app.module.ts
|
|
66
|
+
@Module({
|
|
67
|
+
imports: [
|
|
68
|
+
AppxCoreModule.forRoot(PermissionsConfig),
|
|
69
|
+
AuthModule.forRoot({ controller: false }), // providers only, no built-in controller
|
|
70
|
+
MyAuthModule, // your controller/service live here
|
|
71
|
+
// …
|
|
72
|
+
],
|
|
73
|
+
})
|
|
74
|
+
export class AppModule {}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// my-auth.module.ts
|
|
79
|
+
@Module({
|
|
80
|
+
controllers: [MyAuthController],
|
|
81
|
+
providers: [/* MyAuthService, MyLocalStrategy, … as needed */],
|
|
82
|
+
})
|
|
83
|
+
export class MyAuthModule {}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> **Why `forRoot({ controller: false })`?** If the built-in controller *and* your
|
|
87
|
+
> own `/auth` controller are both registered, you get two handlers for the same
|
|
88
|
+
> routes (Express runs the first-registered, so the base `RegisterDto` would win
|
|
89
|
+
> and reject your extra fields). `forRoot({ controller: false })` drops the built-in
|
|
90
|
+
> controller while keeping every provider global, so your controller takes over
|
|
91
|
+
> cleanly. Use plain `AuthModule.forRoot()` (no options) when you're *not*
|
|
92
|
+
> overriding the controller.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Recipe 1 — add a field to registration (e.g. `name`)
|
|
97
|
+
|
|
98
|
+
Extend `RegisterDto`, then override the inherited `register` handler:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// register-local.dto.ts
|
|
102
|
+
import { RegisterDto } from '@appxdigital/appx-core';
|
|
103
|
+
import { IsOptional, IsString } from 'class-validator';
|
|
104
|
+
|
|
105
|
+
export class RegisterLocalDto extends RegisterDto {
|
|
106
|
+
@IsOptional()
|
|
107
|
+
@IsString()
|
|
108
|
+
name?: string;
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// my-auth.controller.ts
|
|
114
|
+
import { Body, Controller, Post } from '@nestjs/common';
|
|
115
|
+
import { AuthController } from '@appxdigital/appx-core';
|
|
116
|
+
import { RegisterLocalDto } from './register-local.dto';
|
|
117
|
+
|
|
118
|
+
@Controller('auth')
|
|
119
|
+
export class MyAuthController extends AuthController {
|
|
120
|
+
@Post('register')
|
|
121
|
+
async register(@Body() dto: RegisterLocalDto) {
|
|
122
|
+
return this.authService.register(dto); // authService is `protected` on AuthController
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`AuthService.register` spreads the DTO straight into the user create, so `name`
|
|
128
|
+
persists (your `User` model must have the column). Everything else on
|
|
129
|
+
`AuthController` — login, refresh, sessions — is inherited unchanged. The global
|
|
130
|
+
validation pipe now validates the body against `RegisterLocalDto`, so `name` is
|
|
131
|
+
accepted and any *other* unknown field still `400`s.
|
|
132
|
+
|
|
133
|
+
> Keep registration a **narrow allowlist** — only add fields a self-service
|
|
134
|
+
> sign-up should legitimately set. Never widen it to privileged columns (`role`,
|
|
135
|
+
> tenant, ownership ids); mark those `/// @NoWrite` and set them server-side.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Recipe 2 — extend the service (add methods / change behaviour)
|
|
140
|
+
|
|
141
|
+
Extend `AuthService` and forward its constructor. Its signature is
|
|
142
|
+
`(userService, prisma, jwtService, configService)`:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { Injectable } from '@nestjs/common';
|
|
146
|
+
import { AuthService, PrismaService } from '@appxdigital/appx-core';
|
|
147
|
+
import { UserService } from '../user/user.service';
|
|
148
|
+
import { ConfigService } from '@nestjs/config';
|
|
149
|
+
import { JwtService } from '@nestjs/jwt';
|
|
150
|
+
|
|
151
|
+
@Injectable()
|
|
152
|
+
export class MyAuthService extends AuthService {
|
|
153
|
+
constructor(
|
|
154
|
+
userService: UserService,
|
|
155
|
+
prisma: PrismaService,
|
|
156
|
+
jwtService: JwtService,
|
|
157
|
+
configService: ConfigService,
|
|
158
|
+
) {
|
|
159
|
+
super(userService, prisma, jwtService, configService);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async handleForgotPassword(email: string) { /* … */ }
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Provide `MyAuthService` in your module and inject it in your controller
|
|
167
|
+
(`super(myAuthService)` if the controller extends `AuthController`). Because you
|
|
168
|
+
imported `AuthModule.forRoot({ controller: false })`, `UserService`, `JwtService`,
|
|
169
|
+
`PrismaService`, and `ConfigService` are all already available to inject.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Recipe 3 — override a Passport strategy
|
|
174
|
+
|
|
175
|
+
The built-in `LocalStrategy` reads the username/password field names from
|
|
176
|
+
`@AuthField(...)` metadata on `UserDto` and delegates to
|
|
177
|
+
`AuthService.validateUser`. To change credential validation, extend it (or
|
|
178
|
+
`JwtStrategy` / `RefreshTokenStrategy`) and provide your version:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { Injectable } from '@nestjs/common';
|
|
182
|
+
import { LocalStrategy } from '@appxdigital/appx-core';
|
|
183
|
+
|
|
184
|
+
@Injectable()
|
|
185
|
+
export class MyLocalStrategy extends LocalStrategy {
|
|
186
|
+
async validate(username: string, password: string) {
|
|
187
|
+
// custom checks, then fall back to the framework behaviour:
|
|
188
|
+
return super.validate(username, password);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
@Module({
|
|
195
|
+
controllers: [MyAuthController],
|
|
196
|
+
providers: [MyLocalStrategy], // Passport picks up the last-registered strategy of this name
|
|
197
|
+
})
|
|
198
|
+
export class MyAuthModule {}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
> Passport registers strategies by name. Providing your own `LocalStrategy`
|
|
202
|
+
> subclass in your module (with `AuthModule.forRoot({ controller: false })` for
|
|
203
|
+
> the rest) makes yours the active `local` strategy.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## The general principle
|
|
208
|
+
|
|
209
|
+
This isn't auth-specific. Every framework class you might need to specialise is
|
|
210
|
+
exported from `@appxdigital/appx-core`. To override inner functionality:
|
|
211
|
+
|
|
212
|
+
1. **Extend** the exported class.
|
|
213
|
+
2. **Provide** your subclass in your own module (a subclass provider shadows the
|
|
214
|
+
base where you inject it).
|
|
215
|
+
3. When the framework module would *also* register the thing you're replacing
|
|
216
|
+
(a controller, a route), import it in a mode that omits that piece — e.g.
|
|
217
|
+
`AuthModule.forRoot({ controller: false })` — so there's no duplicate.
|
|
218
|
+
|
|
219
|
+
If a class you need to extend isn't exported, that's a framework gap — open it
|
|
220
|
+
rather than deep-importing from `dist/`.
|