@solidxai/core 0.1.12 → 0.1.13-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controllers/view-metadata.controller.d.ts +1 -1
- package/dist/decorators/workflow-field-data-provider.decorator.d.ts +3 -0
- package/dist/decorators/workflow-field-data-provider.decorator.d.ts.map +1 -0
- package/dist/decorators/workflow-field-data-provider.decorator.js +11 -0
- package/dist/decorators/workflow-field-data-provider.decorator.js.map +1 -0
- package/dist/helpers/solid-registry.d.ts +5 -1
- package/dist/helpers/solid-registry.d.ts.map +1 -1
- package/dist/helpers/solid-registry.js +17 -0
- package/dist/helpers/solid-registry.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces.d.ts +24 -0
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/interfaces.js.map +1 -1
- package/dist/repository/security-rule.repository.d.ts +1 -0
- package/dist/repository/security-rule.repository.d.ts.map +1 -1
- package/dist/repository/security-rule.repository.js +28 -7
- package/dist/repository/security-rule.repository.js.map +1 -1
- package/dist/services/chatter-message.service.d.ts.map +1 -1
- package/dist/services/chatter-message.service.js +1 -1
- package/dist/services/chatter-message.service.js.map +1 -1
- package/dist/services/crud-helper.service.d.ts +16 -1
- package/dist/services/crud-helper.service.d.ts.map +1 -1
- package/dist/services/crud-helper.service.js +99 -22
- package/dist/services/crud-helper.service.js.map +1 -1
- package/dist/services/crud.service.d.ts +1 -0
- package/dist/services/crud.service.d.ts.map +1 -1
- package/dist/services/crud.service.js +15 -0
- package/dist/services/crud.service.js.map +1 -1
- package/dist/services/solid-introspect.service.d.ts +1 -0
- package/dist/services/solid-introspect.service.d.ts.map +1 -1
- package/dist/services/solid-introspect.service.js +12 -0
- package/dist/services/solid-introspect.service.js.map +1 -1
- package/dist/services/view-metadata.service.d.ts +5 -1
- package/dist/services/view-metadata.service.d.ts.map +1 -1
- package/dist/services/view-metadata.service.js +63 -14
- package/dist/services/view-metadata.service.js.map +1 -1
- package/package.json +1 -1
- package/src/decorators/workflow-field-data-provider.decorator.ts +7 -0
- package/src/helpers/solid-registry.ts +21 -1
- package/src/index.ts +1 -0
- package/src/interfaces.ts +29 -0
- package/src/repository/security-rule.repository.ts +52 -10
- package/src/services/chatter-message.service.ts +5 -1
- package/src/services/crud-helper.service.ts +220 -22
- package/src/services/crud.service.ts +30 -0
- package/src/services/solid-introspect.service.ts +19 -0
- package/src/services/view-metadata.service.ts +87 -21
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Injectable } from '@nestjs/common';
|
|
1
|
+
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
|
2
2
|
import { CreateSecurityRuleDto } from 'src/dtos/create-security-rule.dto';
|
|
3
3
|
import { SecurityRuleConfig } from 'src/dtos/security-rule-config.dto';
|
|
4
4
|
import { UpdateSecurityRuleDto } from 'src/dtos/update-security-rule.dto';
|
|
@@ -64,16 +64,37 @@ export class SecurityRuleRepository extends SolidBaseRepository<SecurityRule> {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
// A rule whose filters contribute no conditions is dangerous here: TypeORM renders an empty
|
|
68
|
+
// Brackets as "1=1" (always true), so such a rule would be OR'd in as "match everything" and
|
|
69
|
+
// silently void the entire security filter. Test emptiness explicitly -- {} and [] are both
|
|
70
|
+
// truthy, so the previous `if (evaluatedRule.filters)` check let them through.
|
|
71
|
+
const applicableRules = evaluatedRules.filter(rule => rule && this.hasFilterConditions(rule.filters));
|
|
72
|
+
|
|
73
|
+
if (!applicableRules.length) {
|
|
74
|
+
// We only reach here when security rules exist for this model/role (there is an early
|
|
75
|
+
// return above otherwise), so a restriction WAS intended. Fail closed and be loud:
|
|
76
|
+
// denying silently would surface as a mysteriously empty list, and allowing would be a
|
|
77
|
+
// full row-level-security bypass.
|
|
78
|
+
const message = `Security rules for model '${modelSingularName}' produced no filter conditions. `
|
|
79
|
+
+ `Check the securityRuleConfig / securityRuleConfigProvider for rules with empty filters. `
|
|
80
|
+
+ `Denying access rather than returning unrestricted results.`;
|
|
81
|
+
this.logger.error(message);
|
|
82
|
+
throw new InternalServerErrorException(message);
|
|
83
|
+
}
|
|
84
|
+
|
|
67
85
|
// Apply each security rule to the query builder. The rules are combined with OR logic at the top level.
|
|
68
|
-
qb.andWhere(new Brackets(
|
|
69
|
-
for (const evaluatedRule of
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
qb.andWhere(new Brackets((outerQb) => {
|
|
87
|
+
for (const evaluatedRule of applicableRules) {
|
|
88
|
+
outerQb.orWhere( // combine each rule-group with OR at the outer level
|
|
89
|
+
new Brackets((innerQb) => {
|
|
90
|
+
// NOTE: do NOT wrap this in try/catch. Field validation inside applyFilters
|
|
91
|
+
// throws on an invalid rule field, and letting it propagate aborts the
|
|
92
|
+
// request with no rows -- i.e. it fails closed. Swallowing it would leave
|
|
93
|
+
// this Brackets empty, which TypeORM renders as "1=1", turning the rule into
|
|
94
|
+
// "match everything" and voiding row-level security entirely.
|
|
95
|
+
this.crudHelperService.applyFilters(innerQb, evaluatedRule.filters, securityRuleAlias, qb); // AND within a rule
|
|
96
|
+
})
|
|
97
|
+
);
|
|
77
98
|
}
|
|
78
99
|
}));
|
|
79
100
|
|
|
@@ -84,6 +105,27 @@ export class SecurityRuleRepository extends SolidBaseRepository<SecurityRule> {
|
|
|
84
105
|
return configString.replace('$activeUserId', activeUser.sub.toString());
|
|
85
106
|
}
|
|
86
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Does this filter object actually produce at least one WHERE condition?
|
|
110
|
+
*
|
|
111
|
+
* Truthiness is not enough: `{}` and `[]` are truthy but contribute nothing, and an empty
|
|
112
|
+
* Brackets is rendered by TypeORM as "1=1" -- which inside the security rules' OR chain means
|
|
113
|
+
* "match every row". Recurses through $and/$or so `{ $and: [] }` is treated as empty too.
|
|
114
|
+
*/
|
|
115
|
+
private hasFilterConditions(filters: any): boolean {
|
|
116
|
+
if (filters === null || filters === undefined) return false;
|
|
117
|
+
if (Array.isArray(filters)) return filters.some(filter => this.hasFilterConditions(filter));
|
|
118
|
+
if (typeof filters !== 'object') return false;
|
|
119
|
+
|
|
120
|
+
return Object.keys(filters).some(key => {
|
|
121
|
+
const normalizedKey = key.replace(/^\[(.*)\]$/, '$1');
|
|
122
|
+
if (normalizedKey === '$and' || normalizedKey === '$or') {
|
|
123
|
+
return this.hasFilterConditions(filters[key]);
|
|
124
|
+
}
|
|
125
|
+
return true; // any other key is a field condition
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
87
129
|
async toDto(securityRule: SecurityRule): Promise<UpdateSecurityRuleDto> {
|
|
88
130
|
// load the role and model relations for the security rule
|
|
89
131
|
let populatedSecurityRule: SecurityRule = securityRule;
|
|
@@ -883,7 +883,11 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
|
|
|
883
883
|
}
|
|
884
884
|
}
|
|
885
885
|
|
|
886
|
-
|
|
886
|
+
// SECURITY: must be andWhere. `where()` REPLACES every previously registered condition,
|
|
887
|
+
// which would discard the row-level security rules applied by
|
|
888
|
+
// createSecurityRuleAwareQueryBuilder above. (The inner `where` is safe: it is the first
|
|
889
|
+
// condition on a fresh Brackets sub-builder.)
|
|
890
|
+
qb.andWhere(new Brackets(qb => {
|
|
887
891
|
qb.where(orConditions.join(' OR '), parameters);
|
|
888
892
|
}));
|
|
889
893
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from "typeorm";
|
|
1
|
+
import { Brackets, EntityMetadata, SelectQueryBuilder, WhereExpressionBuilder } from "typeorm";
|
|
2
2
|
import { BasicFilterDto } from "../dtos/basic-filters.dto";
|
|
3
3
|
import { classify } from '../helpers/string.helper';
|
|
4
4
|
import { ActiveUserData } from "src/interfaces/active-user-data.interface";
|
|
@@ -17,11 +17,130 @@ export enum UserIdFields {
|
|
|
17
17
|
UPDATED_BY = 'updatedBy'
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Aggregate functions permitted in the `fields` `fn(field)` syntax. */
|
|
21
|
+
const SUPPORTED_FIELD_FUNCTIONS = ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'];
|
|
22
|
+
|
|
23
|
+
/** Date granularities permitted in `groupBy` (`field:granularity`) and filter func-aliases. */
|
|
24
|
+
const SUPPORTED_GRANULARITIES = ['day', 'week', 'month', 'year'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Outcome of resolving a user-supplied dotted path against real entity metadata.
|
|
28
|
+
* Every string here originates from TypeORM metadata, never from request input.
|
|
29
|
+
*/
|
|
30
|
+
export interface ResolvedFieldPath {
|
|
31
|
+
/** Canonical relation property names for each hop traversed (excludes a column leaf). */
|
|
32
|
+
relationSegments: string[];
|
|
33
|
+
/** Canonical column property name, when the leaf is a column. */
|
|
34
|
+
leafProperty?: string;
|
|
35
|
+
/** True when the leaf itself is a relation (populate / nested-filter join). */
|
|
36
|
+
leafIsRelation: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
export class CrudHelperService {
|
|
21
40
|
constructor(
|
|
22
41
|
) { }
|
|
23
42
|
private readonly logger = new Logger(CrudHelperService.name);
|
|
24
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a user-supplied dotted path (e.g. "customer.name") against real TypeORM metadata.
|
|
46
|
+
*
|
|
47
|
+
* SECURITY — this is the choke point that makes the filtering vocabulary safe.
|
|
48
|
+
* SQL can bind *values* as parameters but never *identifiers* (column names, aliases,
|
|
49
|
+
* ORDER BY expressions), so a client-chosen field name must be allow-listed instead.
|
|
50
|
+
* The caller's string is used ONLY as a lookup key; every value returned comes from
|
|
51
|
+
* ColumnMetadata/RelationMetadata, so callers build SQL exclusively from strings this
|
|
52
|
+
* codebase produced, never from request input.
|
|
53
|
+
*
|
|
54
|
+
* Throws BadRequestException on any segment that is not a real column/relation.
|
|
55
|
+
*/
|
|
56
|
+
resolveFieldPathFromMetadata(
|
|
57
|
+
rootMetadata: EntityMetadata,
|
|
58
|
+
pathParts: string[],
|
|
59
|
+
{ allowRelationLeaf = false }: { allowRelationLeaf?: boolean } = {}
|
|
60
|
+
): ResolvedFieldPath {
|
|
61
|
+
// Fail closed: without metadata we cannot prove the identifier is safe.
|
|
62
|
+
if (!rootMetadata) throw new BadRequestException(`Cannot resolve field '${this.describeInvalidField(pathParts?.join('.'))}'`);
|
|
63
|
+
if (!pathParts?.length || pathParts.some(part => !part)) {
|
|
64
|
+
throw new BadRequestException(`Invalid field path '${this.describeInvalidField(pathParts?.join('.'))}'`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let metadata = rootMetadata;
|
|
68
|
+
const relationSegments: string[] = [];
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < pathParts.length; i++) {
|
|
71
|
+
const isLeaf = i === pathParts.length - 1;
|
|
72
|
+
// `metadata` is the entity currently being walked; it advances one hop per segment.
|
|
73
|
+
// These lookups key on propertyName/propertyPath (NOT database name), so they are
|
|
74
|
+
// naming-strategy agnostic and resolve implicit relation FK columns correctly.
|
|
75
|
+
const relation = metadata.findRelationWithPropertyPath(pathParts[i]);
|
|
76
|
+
const column = metadata.findColumnWithPropertyName(pathParts[i]);
|
|
77
|
+
|
|
78
|
+
if (!isLeaf) {
|
|
79
|
+
// Interior segments must be relations so we can keep walking.
|
|
80
|
+
if (!relation) {
|
|
81
|
+
throw new BadRequestException(`Invalid relation '${this.describeInvalidField(pathParts[i])}' in '${this.describeInvalidField(pathParts.join('.'))}'`);
|
|
82
|
+
}
|
|
83
|
+
relationSegments.push(relation.propertyName); // canonical, from metadata
|
|
84
|
+
// THE HOP: advance to the entity on the other side so the NEXT iteration validates
|
|
85
|
+
// against that entity rather than the root. Consumed at the top of the next pass —
|
|
86
|
+
// it only looks unused here because `continue` follows immediately.
|
|
87
|
+
// e.g. "shop.customer.mcc": NbfTransaction -> CustomerLocation -> Customer.
|
|
88
|
+
// Without this, "customer" would be checked on NbfTransaction (wrongly accepted)
|
|
89
|
+
// and "mcc" on NbfTransaction (wrongly rejected).
|
|
90
|
+
metadata = relation.inverseEntityMetadata;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Leaf ordering matters: a many-to-one name can match BOTH a relation and its FK column.
|
|
95
|
+
// populate (allowRelationLeaf) must treat it as a relation so it can be joined;
|
|
96
|
+
// sort/filter must treat it as a column so it compares on the FK, as today.
|
|
97
|
+
if (allowRelationLeaf && relation) {
|
|
98
|
+
relationSegments.push(relation.propertyName);
|
|
99
|
+
return { relationSegments, leafIsRelation: true };
|
|
100
|
+
}
|
|
101
|
+
if (column) {
|
|
102
|
+
return { relationSegments, leafProperty: column.propertyName, leafIsRelation: false };
|
|
103
|
+
}
|
|
104
|
+
throw new BadRequestException(`Invalid field '${this.describeInvalidField(pathParts[i])}' in '${this.describeInvalidField(pathParts.join('.'))}'`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
throw new BadRequestException(`Invalid field path '${this.describeInvalidField(pathParts.join('.'))}'`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Query-builder flavoured wrapper around {@link resolveFieldPathFromMetadata}.
|
|
112
|
+
*
|
|
113
|
+
* `startAlias` matters for nested filters: applyFilters recurses into joined relations
|
|
114
|
+
* carrying that relation's alias, so the leaf must resolve against the joined entity
|
|
115
|
+
* rather than always the root.
|
|
116
|
+
*/
|
|
117
|
+
resolveFieldPath(
|
|
118
|
+
qb: SelectQueryBuilder<any>,
|
|
119
|
+
pathParts: string[],
|
|
120
|
+
{ allowRelationLeaf = false, startAlias }: { allowRelationLeaf?: boolean; startAlias?: string } = {}
|
|
121
|
+
): ResolvedFieldPath {
|
|
122
|
+
const aliasMetadata = startAlias ? this.findAliasMetadata(qb, startAlias) : undefined;
|
|
123
|
+
const rootMetadata = aliasMetadata ?? qb?.expressionMap?.mainAlias?.metadata;
|
|
124
|
+
return this.resolveFieldPathFromMetadata(rootMetadata, pathParts, { allowRelationLeaf });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private findAliasMetadata(qb: SelectQueryBuilder<any>, alias: string): EntityMetadata | undefined {
|
|
128
|
+
const found = qb?.expressionMap?.aliases?.find(a => a.name === alias);
|
|
129
|
+
return found?.hasMetadata ? found.metadata : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Echo a rejected identifier back in a bounded way.
|
|
134
|
+
*
|
|
135
|
+
* Naming the bad field is what makes a 400 actionable for a legitimate typo, but reflecting an
|
|
136
|
+
* unbounded attacker-supplied string is needless: it hands an attacker a reliable echo channel
|
|
137
|
+
* and bloats logs. 80 characters is ample for a real field name.
|
|
138
|
+
*/
|
|
139
|
+
private describeInvalidField(value: string): string {
|
|
140
|
+
const text = String(value ?? '');
|
|
141
|
+
return text.length > 80 ? `${text.slice(0, 80)}...` : text;
|
|
142
|
+
}
|
|
143
|
+
|
|
25
144
|
private orderOptions(sort: any[] = []) {
|
|
26
145
|
const orderOptions = {};
|
|
27
146
|
sort.forEach((s: string) => {
|
|
@@ -72,24 +191,38 @@ export class CrudHelperService {
|
|
|
72
191
|
// if the key is an operator, then build the query based on the operator
|
|
73
192
|
if (operatorOrField.startsWith('$')) {
|
|
74
193
|
const operator = operatorOrField;
|
|
194
|
+
// SECURITY: resolve against the CURRENT alias (this method recurses into joined
|
|
195
|
+
// relations), so a nested filter key is checked on the joined entity rather than
|
|
196
|
+
// the root. Resolution is driven off `selectQb` because `qb` is a
|
|
197
|
+
// WhereExpressionBuilder and exposes no expressionMap.
|
|
198
|
+
const { leafProperty } = this.resolveFieldPath(selectQb, [rawField], { startAlias: alias });
|
|
75
199
|
let columnExpression: string | undefined;
|
|
76
200
|
if (funcAlias) {
|
|
77
201
|
try {
|
|
78
|
-
columnExpression = this.buildDateGranularityExpression(this.getDriver(selectQb), `${alias}.${
|
|
79
|
-
} catch {
|
|
80
|
-
|
|
202
|
+
columnExpression = this.buildDateGranularityExpression(this.getDriver(selectQb), `${alias}.${leafProperty}`, funcAlias);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Surface the precise granularity message; keep the original fallback
|
|
205
|
+
// for driver-level failures.
|
|
206
|
+
if (error instanceof BadRequestException) throw error;
|
|
207
|
+
throw new BadRequestException(`Unsupported field function '${this.describeInvalidField(funcAlias)}'. Supported functions are: ${SUPPORTED_GRANULARITIES.join(', ')}.`);
|
|
81
208
|
}
|
|
82
209
|
}
|
|
83
|
-
this.buildOperatorQuery(qb, alias,
|
|
210
|
+
this.buildOperatorQuery(qb, alias, leafProperty, normalizedPrimaryFilterObj, operator, columnExpression);
|
|
84
211
|
return;
|
|
85
212
|
}
|
|
86
213
|
else { // Recursively call the applyFilters method to handle nested conditions
|
|
87
214
|
if (funcAlias) {
|
|
88
215
|
throw new BadRequestException(`Function alias ':${funcAlias}' is not valid on relation field '${rawField}'. It can only be applied to scalar fields.`);
|
|
89
216
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
217
|
+
// SECURITY: the nested key must be a real relation on the current entity.
|
|
218
|
+
const resolvedRelation = this.resolveFieldPath(selectQb, [rawField], { allowRelationLeaf: true, startAlias: alias });
|
|
219
|
+
if (!resolvedRelation.leafIsRelation) {
|
|
220
|
+
throw new BadRequestException(`'${this.describeInvalidField(rawField)}' is not a relation and cannot contain nested filters.`);
|
|
221
|
+
}
|
|
222
|
+
const relationName = resolvedRelation.relationSegments[resolvedRelation.relationSegments.length - 1];
|
|
223
|
+
const joinField = `${alias}.${relationName}`;
|
|
224
|
+
if (!this.isRelationJoined(selectQb, joinField)) selectQb.leftJoin(joinField, relationName);
|
|
225
|
+
this.applyFilters(qb, primaryFilterObj, relationName, selectQb);
|
|
93
226
|
}
|
|
94
227
|
});
|
|
95
228
|
}
|
|
@@ -284,7 +417,7 @@ export class CrudHelperService {
|
|
|
284
417
|
if (normalizedFields && normalizedFields.length) {
|
|
285
418
|
qb.select(normalizedFields.map(field => {
|
|
286
419
|
// If the field contains a (, do not prefix the entity alias
|
|
287
|
-
return this.wrapFieldWithAlias(field, entityAlias);
|
|
420
|
+
return this.wrapFieldWithAlias(qb, field, entityAlias);
|
|
288
421
|
}));
|
|
289
422
|
}
|
|
290
423
|
|
|
@@ -306,7 +439,10 @@ export class CrudHelperService {
|
|
|
306
439
|
qb.addOrderBy(orderColumn, value);
|
|
307
440
|
if (created) qb.addSelect(orderColumn);
|
|
308
441
|
} else {
|
|
309
|
-
|
|
442
|
+
// SECURITY: resolve against real metadata and order by the canonical column
|
|
443
|
+
// name, never the raw input (addOrderBy accepts arbitrary SQL text).
|
|
444
|
+
const { leafProperty } = this.resolveFieldPath(qb, [field]);
|
|
445
|
+
qb.addOrderBy(`${entityAlias}.${leafProperty}`, value);
|
|
310
446
|
}
|
|
311
447
|
});
|
|
312
448
|
if (!hasExplicitIdSort) {
|
|
@@ -321,7 +457,10 @@ export class CrudHelperService {
|
|
|
321
457
|
|
|
322
458
|
if (showSoftDeleted === 'exclusive') {
|
|
323
459
|
qb.withDeleted();
|
|
324
|
-
|
|
460
|
+
// SECURITY: must be andWhere. `where()` REPLACES every previously registered condition,
|
|
461
|
+
// which discarded the user's filters, the locale/status predicates and — critically —
|
|
462
|
+
// the row-level security rules applied by createSecurityRuleAwareQueryBuilder.
|
|
463
|
+
qb.andWhere(`${entityAlias}.deletedAt IS NOT NULL`);
|
|
325
464
|
}
|
|
326
465
|
|
|
327
466
|
// Apply the pagination options & handle the case when the query has joins
|
|
@@ -364,14 +503,18 @@ export class CrudHelperService {
|
|
|
364
503
|
}
|
|
365
504
|
|
|
366
505
|
private ensureRelationPathJoined(qb: SelectQueryBuilder<any>, rootAlias: string, pathParts: string[]) {
|
|
506
|
+
// SECURITY: validate the whole path against real metadata before any segment reaches SQL,
|
|
507
|
+
// and build from the canonical names returned rather than the caller's strings. Covers
|
|
508
|
+
// dotted `sort`, `groupBy` and `aggregates`, which all route through here.
|
|
509
|
+
const resolved = this.resolveFieldPath(qb, pathParts);
|
|
367
510
|
const mainAlias =
|
|
368
511
|
qb.expressionMap?.mainAlias?.name ||
|
|
369
512
|
qb.expressionMap?.aliases?.find(a => a.metadata)?.name ||
|
|
370
513
|
qb.expressionMap?.aliases?.[0]?.name;
|
|
371
514
|
let parentAlias = mainAlias || rootAlias;
|
|
372
515
|
let leafJoinCreated = false;
|
|
373
|
-
for (let i = 0; i <
|
|
374
|
-
const part =
|
|
516
|
+
for (let i = 0; i < resolved.relationSegments.length; i++) {
|
|
517
|
+
const part = resolved.relationSegments[i];
|
|
375
518
|
const joinProperty = `${parentAlias}.${part}`;
|
|
376
519
|
const existingAlias = this.getExistingJoinAlias(qb, joinProperty);
|
|
377
520
|
const joinAlias = existingAlias ?? this.sanitizeAlias(`${parentAlias}_${part}`);
|
|
@@ -383,7 +526,7 @@ export class CrudHelperService {
|
|
|
383
526
|
}
|
|
384
527
|
parentAlias = joinAlias;
|
|
385
528
|
}
|
|
386
|
-
return { alias: parentAlias, property:
|
|
529
|
+
return { alias: parentAlias, property: resolved.leafProperty, created: leafJoinCreated };
|
|
387
530
|
}
|
|
388
531
|
|
|
389
532
|
private getDriver(qb: SelectQueryBuilder<any>) {
|
|
@@ -391,6 +534,16 @@ export class CrudHelperService {
|
|
|
391
534
|
}
|
|
392
535
|
|
|
393
536
|
private buildDateGranularityExpression(driver: string, columnExpr: string, granularity: string) {
|
|
537
|
+
// SECURITY: `granularity` is caller-supplied (groupBy `field:granularity`, or a filter
|
|
538
|
+
// func-alias) and is interpolated into a quoted SQL string literal in the postgres branch
|
|
539
|
+
// below. Validate it for EVERY driver here rather than per-driver: the whitelist used to be
|
|
540
|
+
// duplicated inside the mysql/mssql switches and simply absent for postgres, which is
|
|
541
|
+
// exactly how a quote break-out (`x'||(SELECT version())||'`) slipped through.
|
|
542
|
+
if (!SUPPORTED_GRANULARITIES.includes(granularity)) {
|
|
543
|
+
throw new BadRequestException(
|
|
544
|
+
`Unsupported granularity '${this.describeInvalidField(granularity)}'. Supported granularities are: ${SUPPORTED_GRANULARITIES.join(', ')}.`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
394
547
|
switch (driver) {
|
|
395
548
|
case 'postgres':
|
|
396
549
|
case 'cockroachdb':
|
|
@@ -507,7 +660,15 @@ export class CrudHelperService {
|
|
|
507
660
|
const orderOptions = this.orderOptions(normalizedSort);
|
|
508
661
|
const orderOptionKeys = Object.keys(orderOptions) as Array<keyof typeof orderOptions>;
|
|
509
662
|
orderOptionKeys.forEach((key) => {
|
|
510
|
-
|
|
663
|
+
// SECURITY: in a grouped query the only legally sortable names are the declared
|
|
664
|
+
// group/aggregate aliases. Falling back to the raw key spliced it into a quoted
|
|
665
|
+
// identifier, which an embedded double-quote could break out of.
|
|
666
|
+
const resolvedKey = aliasMap[key];
|
|
667
|
+
if (!resolvedKey) {
|
|
668
|
+
throw new BadRequestException(
|
|
669
|
+
`Cannot sort by '${this.describeInvalidField(String(key))}' on a grouped query. Sort only by a groupBy or aggregate field.`
|
|
670
|
+
);
|
|
671
|
+
}
|
|
511
672
|
const value = orderOptions[key] as 'ASC' | 'DESC';
|
|
512
673
|
qb.addOrderBy(`"${resolvedKey}"`, value);
|
|
513
674
|
});
|
|
@@ -536,12 +697,18 @@ export class CrudHelperService {
|
|
|
536
697
|
private buildJoinQueryForRelation(qb: SelectQueryBuilder<any>, entityAlias: string, relation: string) {
|
|
537
698
|
// We split the joinProperty to get the alias of the entity we are joining
|
|
538
699
|
const relationParts = relation.split('.');
|
|
700
|
+
// SECURITY: every segment must be a real relation on the entity being walked. The canonical
|
|
701
|
+
// names returned here are what get spliced below, so no request string reaches the join.
|
|
702
|
+
const { relationSegments, leafIsRelation } = this.resolveFieldPath(qb, relationParts, { allowRelationLeaf: true });
|
|
703
|
+
if (!leafIsRelation) {
|
|
704
|
+
throw new BadRequestException(`'${this.describeInvalidField(relation)}' is not a relation and cannot be populated.`);
|
|
705
|
+
}
|
|
539
706
|
let parentAlias = entityAlias;
|
|
540
|
-
|
|
707
|
+
relationSegments.forEach((part, i) => {
|
|
541
708
|
const joinProperty = `${parentAlias}.${part}`;
|
|
542
709
|
// Check if the relation is already joined, if not then join it
|
|
543
710
|
if (!this.isRelationJoined(qb, joinProperty)) {
|
|
544
|
-
const joinAlias =
|
|
711
|
+
const joinAlias = this.sanitizeAlias(relationSegments.slice(0, i + 1).join('_'));
|
|
545
712
|
qb.leftJoinAndSelect(joinProperty, joinAlias);
|
|
546
713
|
}
|
|
547
714
|
else {
|
|
@@ -549,18 +716,49 @@ export class CrudHelperService {
|
|
|
549
716
|
//If the join is already present, it is probably because of the relation being passed in the where filter i.e applyFilters method
|
|
550
717
|
qb.addSelect(`${part}`);
|
|
551
718
|
}
|
|
719
|
+
// NOTE: deliberately left as `part` rather than the joinAlias. That is a pre-existing
|
|
720
|
+
// alias-chaining bug which breaks 3+ level populate; fixing it is tracked separately so
|
|
721
|
+
// this security change stays behaviour-preserving for queries that work today.
|
|
552
722
|
parentAlias = part; // Update the parent alias for the next iteration
|
|
553
723
|
});
|
|
554
724
|
return qb;
|
|
555
725
|
}
|
|
556
726
|
|
|
557
|
-
private wrapFieldWithAlias(field: string, entityAlias: string): string {
|
|
558
|
-
|
|
727
|
+
private wrapFieldWithAlias(qb: SelectQueryBuilder<any>, field: string, entityAlias: string): string {
|
|
728
|
+
// SECURITY: `qb.select()` accepts arbitrary SQL text, so the field name must be resolved
|
|
729
|
+
// against real metadata and the canonical column name spliced instead of the raw input.
|
|
730
|
+
if (!this.isAggregateField(field)) {
|
|
731
|
+
return `${entityAlias}.${this.resolveOwnColumn(qb, field, 'fields')}`;
|
|
732
|
+
}
|
|
559
733
|
// For aggregate fields, extract the field name from the aggregate function & wrap it with the entity alias, if it is not already wrapped
|
|
560
734
|
const fieldParts = field.split('(');
|
|
561
|
-
const aggregateFunction = fieldParts[0];
|
|
562
|
-
|
|
563
|
-
|
|
735
|
+
const aggregateFunction = fieldParts[0].trim().toUpperCase();
|
|
736
|
+
// Whitelist the function name too, mirroring buildAggregateExpression.
|
|
737
|
+
if (!SUPPORTED_FIELD_FUNCTIONS.includes(aggregateFunction)) {
|
|
738
|
+
throw new BadRequestException(
|
|
739
|
+
`Unsupported field function '${this.describeInvalidField(fieldParts[0])}'. Supported functions are: ${SUPPORTED_FIELD_FUNCTIONS.join(', ')}.`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
const fieldName = fieldParts[1].replace(')', '').trim();
|
|
743
|
+
return `${aggregateFunction}(${entityAlias}.${this.resolveOwnColumn(qb, fieldName, 'fields')})`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Resolve a single-segment column on the root entity and return its canonical name.
|
|
748
|
+
*
|
|
749
|
+
* Related (dotted) paths are rejected rather than resolved: the caller emits
|
|
750
|
+
* `<rootAlias>.<column>` and builds no joins, so resolving a dotted path here would splice the
|
|
751
|
+
* leaf against the root alias and silently read the wrong column. Such paths previously
|
|
752
|
+
* produced invalid SQL, so rejecting them is the honest behaviour.
|
|
753
|
+
*/
|
|
754
|
+
private resolveOwnColumn(qb: SelectQueryBuilder<any>, field: string, context: string): string {
|
|
755
|
+
const resolved = this.resolveFieldPath(qb, field.split('.'));
|
|
756
|
+
if (resolved.relationSegments.length) {
|
|
757
|
+
throw new BadRequestException(
|
|
758
|
+
`Related field '${this.describeInvalidField(field)}' is not supported in '${context}'. Use 'populate' to fetch related records.`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
return resolved.leafProperty;
|
|
564
762
|
}
|
|
565
763
|
|
|
566
764
|
isAggregateField(field: string): boolean {
|
|
@@ -697,6 +697,12 @@ private async prepareManyToManyAuditSnapshot(entity: T,id: number,modelSingularN
|
|
|
697
697
|
}
|
|
698
698
|
}
|
|
699
699
|
|
|
700
|
+
// Validate populate/fields against real entity metadata before handing them to TypeORM.
|
|
701
|
+
// This path is not SQL-injectable (setFindOptions is metadata driven), but without this an
|
|
702
|
+
// unknown name surfaces as an unhandled TypeORM error — a 500 leaking schema detail — and
|
|
703
|
+
// behaves inconsistently with find(), which returns a 400.
|
|
704
|
+
this.validateFindOneQueryFields(normalizedPopulate, this.crudHelperService.normalize(fields));
|
|
705
|
+
|
|
700
706
|
let entity = await this.repo.findOne({
|
|
701
707
|
where: {
|
|
702
708
|
id: id,
|
|
@@ -715,6 +721,30 @@ private async prepareManyToManyAuditSnapshot(entity: T,id: number,modelSingularN
|
|
|
715
721
|
return entity;
|
|
716
722
|
}
|
|
717
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Validate findOne's `populate` / `fields` against the entity's real TypeORM metadata.
|
|
726
|
+
*
|
|
727
|
+
* `findOne` goes through repo.findOne({ relations, select }) -> setFindOptions, which is
|
|
728
|
+
* metadata driven and therefore not SQL-injectable. Validating here is about turning an
|
|
729
|
+
* unknown name into a clean 400 instead of an unhandled TypeORM error (a 500 that leaks
|
|
730
|
+
* schema detail), and keeping behaviour consistent with find().
|
|
731
|
+
*/
|
|
732
|
+
private validateFindOneQueryFields(populate: string[], fields: string[]) {
|
|
733
|
+
const metadata = this.repo.metadata;
|
|
734
|
+
if (!metadata) return;
|
|
735
|
+
for (const relationPath of populate) {
|
|
736
|
+
const resolved = this.crudHelperService.resolveFieldPathFromMetadata(
|
|
737
|
+
metadata, relationPath.split('.'), { allowRelationLeaf: true }
|
|
738
|
+
);
|
|
739
|
+
if (!resolved.leafIsRelation) {
|
|
740
|
+
throw new BadRequestException(`'${relationPath}' is not a relation and cannot be populated.`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
for (const field of fields) {
|
|
744
|
+
this.crudHelperService.resolveFieldPathFromMetadata(metadata, field.split('.'));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
718
748
|
async createMany(createDtos: any[], solidRequestContext: any = {}): Promise<T[]> {
|
|
719
749
|
const loadedmodel = await this.loadModel();
|
|
720
750
|
|
|
@@ -15,6 +15,7 @@ import { IS_DASHBOARD_WIDGET_DATA_PROVIDER } from 'src/decorators/dashboard-widg
|
|
|
15
15
|
import { IS_EXTENSION_USER_CREATION_PROVIDER } from 'src/decorators/extension-user-creation-provider.decorator';
|
|
16
16
|
import { IS_SOLID_DATABASE_MODULE } from 'src/decorators/solid-database-module.decorator';
|
|
17
17
|
import { IS_WA_PROVIDER } from 'src/decorators/whatsapp-provider.decorator';
|
|
18
|
+
import { IS_WORKFLOW_FIELD_DATA_PROVIDER } from 'src/decorators/workflow-field-data-provider.decorator';
|
|
18
19
|
import { SolidRegistry } from 'src/helpers/solid-registry';
|
|
19
20
|
import { AuditSubscriber } from 'src/subscribers/audit.subscriber';
|
|
20
21
|
import { ComputedEntityFieldSubscriber } from 'src/subscribers/computed-entity-field.subscriber';
|
|
@@ -69,6 +70,12 @@ export class SolidIntrospectService implements OnApplicationBootstrap {
|
|
|
69
70
|
this.solidRegistry.registerSelectionProvider(selectionProvider);
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
// Register all IWorkflowFieldDataProvider implementations
|
|
74
|
+
const workflowFieldDataProviders = this.discoveryService.getProviders().filter((provider) => this.isWorkflowFieldDataProvider(provider));
|
|
75
|
+
workflowFieldDataProviders.forEach((provider) => {
|
|
76
|
+
this.solidRegistry.registerWorkflowFieldDataProvider(provider);
|
|
77
|
+
});
|
|
78
|
+
|
|
72
79
|
// Register all IDashboardWidgetDataProvider implementations
|
|
73
80
|
const dashboardWidgetDataProviders = this.discoveryService.getProviders().filter((provider) => this.isDashboardWidgetDataProvider(provider));
|
|
74
81
|
dashboardWidgetDataProviders.forEach((provider) => {
|
|
@@ -277,6 +284,18 @@ export class SolidIntrospectService implements OnApplicationBootstrap {
|
|
|
277
284
|
return !!isSelectionProvider;
|
|
278
285
|
}
|
|
279
286
|
|
|
287
|
+
private isWorkflowFieldDataProvider(provider: InstanceWrapper) {
|
|
288
|
+
const { instance } = provider;
|
|
289
|
+
if (!instance) return false;
|
|
290
|
+
|
|
291
|
+
const isWorkflowFieldDataProvider = this.reflector.get<boolean>(
|
|
292
|
+
IS_WORKFLOW_FIELD_DATA_PROVIDER,
|
|
293
|
+
instance.constructor,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
return !!isWorkflowFieldDataProvider;
|
|
297
|
+
}
|
|
298
|
+
|
|
280
299
|
private isDashboardWidgetDataProvider(provider: InstanceWrapper) {
|
|
281
300
|
const { instance } = provider;
|
|
282
301
|
if (!instance) return false;
|