@yunsoft/yuncms-core 0.1.16 → 0.1.18
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/package.json +1 -1
- package/src/dynamic-variables.js +110 -0
- package/src/index.js +6 -0
- package/src/permission-validation.js +19 -11
- package/src/query.js +12 -2
- package/src/relation-expansion.js +7 -1
- package/src/services/auth-service.js +2 -0
- package/src/services/external-auth-service.js +1 -0
- package/src/services/files-service.js +7 -1
- package/src/services/items-service.js +90 -25
- package/src/services/permissions-service.js +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const NOW_PATTERN = /^\$NOW(?:\(([+-]\d+)\s+(second|minute|hour|day|week|month|year)s?\))?$/i;
|
|
2
|
+
const MAX_ADJUSTMENT = 10_000;
|
|
3
|
+
|
|
4
|
+
function dynamicVariableError(code, message, path = null) {
|
|
5
|
+
const error = new Error(message);
|
|
6
|
+
error.code = code;
|
|
7
|
+
if (path) error.path = path;
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizedNow(value) {
|
|
12
|
+
const date = value == null ? new Date() : new Date(value);
|
|
13
|
+
if (Number.isNaN(date.getTime())) {
|
|
14
|
+
throw dynamicVariableError('INVALID_QUERY', 'Dynamic variable context contains an invalid current timestamp');
|
|
15
|
+
}
|
|
16
|
+
return date;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function adjustNow(now, amount, unit, path) {
|
|
20
|
+
if (!Number.isSafeInteger(amount) || Math.abs(amount) > MAX_ADJUSTMENT) {
|
|
21
|
+
throw dynamicVariableError(
|
|
22
|
+
'INVALID_QUERY',
|
|
23
|
+
`$NOW adjustment must be a signed integer between -${MAX_ADJUSTMENT} and ${MAX_ADJUSTMENT}`,
|
|
24
|
+
path,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = new Date(now.getTime());
|
|
29
|
+
switch (unit.toLowerCase()) {
|
|
30
|
+
case 'second': result.setUTCSeconds(result.getUTCSeconds() + amount); break;
|
|
31
|
+
case 'minute': result.setUTCMinutes(result.getUTCMinutes() + amount); break;
|
|
32
|
+
case 'hour': result.setUTCHours(result.getUTCHours() + amount); break;
|
|
33
|
+
case 'day': result.setUTCDate(result.getUTCDate() + amount); break;
|
|
34
|
+
case 'week': result.setUTCDate(result.getUTCDate() + (amount * 7)); break;
|
|
35
|
+
case 'month': result.setUTCMonth(result.getUTCMonth() + amount); break;
|
|
36
|
+
case 'year': result.setUTCFullYear(result.getUTCFullYear() + amount); break;
|
|
37
|
+
default: throw dynamicVariableError('INVALID_QUERY', `Unsupported $NOW adjustment unit: ${unit}`, path);
|
|
38
|
+
}
|
|
39
|
+
if (Number.isNaN(result.getTime())) {
|
|
40
|
+
throw dynamicVariableError('INVALID_QUERY', '$NOW adjustment produced an invalid timestamp', path);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveDynamicVariable(value, context = {}, { allowUnresolved = false, path = null } = {}) {
|
|
46
|
+
if (typeof value !== 'string') return value;
|
|
47
|
+
|
|
48
|
+
if (value === '$CURRENT_USER' || value === '$CURRENT_ROLE') {
|
|
49
|
+
if (allowUnresolved) return value;
|
|
50
|
+
const key = value === '$CURRENT_USER' ? 'user' : 'role';
|
|
51
|
+
const resolved = context?.[key];
|
|
52
|
+
if (resolved == null || resolved === '') {
|
|
53
|
+
throw dynamicVariableError(
|
|
54
|
+
'FORBIDDEN',
|
|
55
|
+
`${value} is unavailable for the current accountability context`,
|
|
56
|
+
path,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return String(resolved);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (value === '$NOW' || value.toUpperCase().startsWith('$NOW(')) {
|
|
63
|
+
const match = value.match(NOW_PATTERN);
|
|
64
|
+
if (!match) {
|
|
65
|
+
throw dynamicVariableError(
|
|
66
|
+
'INVALID_QUERY',
|
|
67
|
+
'Invalid $NOW value; use $NOW or a signed adjustment such as $NOW(-1 day)',
|
|
68
|
+
path,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (allowUnresolved) return value;
|
|
72
|
+
const now = normalizedNow(context?.now);
|
|
73
|
+
return match[1] ? adjustNow(now, Number(match[1]), match[2], path) : now;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (value.startsWith('$CURRENT_USER.') || value.startsWith('$CURRENT_ROLE.')) {
|
|
77
|
+
throw dynamicVariableError(
|
|
78
|
+
'INVALID_QUERY',
|
|
79
|
+
'Nested current-user and current-role fields are not supported in YunCMS V1',
|
|
80
|
+
path,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function resolveDynamicVariables(value, context = {}, options = {}) {
|
|
88
|
+
const path = options.path ?? 'filter';
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
return value.map((entry, index) => resolveDynamicVariables(entry, context, {
|
|
91
|
+
...options,
|
|
92
|
+
path: `${path}.${index}`,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
if (value && typeof value === 'object' && !(value instanceof Date)) {
|
|
96
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
97
|
+
key,
|
|
98
|
+
resolveDynamicVariables(entry, context, { ...options, path: `${path}.${key}` }),
|
|
99
|
+
]));
|
|
100
|
+
}
|
|
101
|
+
return resolveDynamicVariable(value, context, options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const DYNAMIC_FILTER_VALUES = Object.freeze([
|
|
105
|
+
'$CURRENT_USER',
|
|
106
|
+
'$CURRENT_ROLE',
|
|
107
|
+
'$NOW',
|
|
108
|
+
'$NOW(-1 day)',
|
|
109
|
+
'$NOW(+1 day)',
|
|
110
|
+
]);
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { DEFAULT_SERVER_PORT, loadConfig, loadEnvFileIfPresent } from './config.js';
|
|
2
2
|
export { isCacheStore, MemoryCacheStore } from './cache.js';
|
|
3
|
+
export {
|
|
4
|
+
DYNAMIC_FILTER_VALUES,
|
|
5
|
+
resolveDynamicVariable,
|
|
6
|
+
resolveDynamicVariables,
|
|
7
|
+
} from './dynamic-variables.js';
|
|
8
|
+
|
|
3
9
|
export {
|
|
4
10
|
RedisClient,
|
|
5
11
|
RedisCacheStore,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compileFilter } from './query.js';
|
|
2
|
+
import { resolveDynamicVariables } from './dynamic-variables.js';
|
|
2
3
|
|
|
3
4
|
function validationError(message, path = null) {
|
|
4
5
|
const error = new Error(message);
|
|
@@ -8,13 +9,19 @@ function validationError(message, path = null) {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
function compare(operator, actual, expected) {
|
|
12
|
+
const temporalExpected = expected instanceof Date ? expected.getTime() : null;
|
|
13
|
+
const temporalActual = temporalExpected == null
|
|
14
|
+
? actual
|
|
15
|
+
: actual instanceof Date ? actual.getTime() : new Date(actual).getTime();
|
|
16
|
+
const comparableActual = temporalExpected == null ? actual : temporalActual;
|
|
17
|
+
const comparableExpected = temporalExpected == null ? expected : temporalExpected;
|
|
11
18
|
switch (operator) {
|
|
12
|
-
case '_eq': return
|
|
13
|
-
case '_neq': return
|
|
14
|
-
case '_lt': return
|
|
15
|
-
case '_lte': return
|
|
16
|
-
case '_gt': return
|
|
17
|
-
case '_gte': return
|
|
19
|
+
case '_eq': return comparableActual === comparableExpected;
|
|
20
|
+
case '_neq': return comparableActual !== comparableExpected;
|
|
21
|
+
case '_lt': return comparableActual != null && comparableActual < comparableExpected;
|
|
22
|
+
case '_lte': return comparableActual != null && comparableActual <= comparableExpected;
|
|
23
|
+
case '_gt': return comparableActual != null && comparableActual > comparableExpected;
|
|
24
|
+
case '_gte': return comparableActual != null && comparableActual >= comparableExpected;
|
|
18
25
|
case '_in': return expected.some((candidate) => actual === candidate);
|
|
19
26
|
case '_nin': return !expected.some((candidate) => actual === candidate);
|
|
20
27
|
case '_null': return expected ? actual == null : actual != null;
|
|
@@ -47,17 +54,18 @@ function evaluateNode(record, node) {
|
|
|
47
54
|
|
|
48
55
|
export function assertPermissionValidationRule(rule, schema) {
|
|
49
56
|
if (rule == null) return null;
|
|
50
|
-
compileFilter(rule, schema);
|
|
57
|
+
compileFilter(rule, schema, { allowUnresolvedDynamicVariables: true });
|
|
51
58
|
return rule;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
export function evaluatePermissionValidation(record, rule, schema) {
|
|
61
|
+
export function evaluatePermissionValidation(record, rule, schema, { dynamicVariables = {} } = {}) {
|
|
55
62
|
if (rule == null) return true;
|
|
56
63
|
assertPermissionValidationRule(rule, schema);
|
|
57
|
-
|
|
64
|
+
const resolvedRule = resolveDynamicVariables(rule, dynamicVariables, { path: 'validation' });
|
|
65
|
+
return evaluateNode(record, resolvedRule);
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
export function enforcePermissionValidation(record, rule, schema, { path = 'validation' } = {}) {
|
|
61
|
-
if (evaluatePermissionValidation(record, rule, schema)) return record;
|
|
68
|
+
export function enforcePermissionValidation(record, rule, schema, { path = 'validation', dynamicVariables = {} } = {}) {
|
|
69
|
+
if (evaluatePermissionValidation(record, rule, schema, { dynamicVariables })) return record;
|
|
62
70
|
throw validationError('Record does not satisfy the permission validation rule', path);
|
|
63
71
|
}
|
package/src/query.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { quoteIdentifier } from './identifier.js';
|
|
2
|
+
import { resolveDynamicVariables } from './dynamic-variables.js';
|
|
2
3
|
|
|
3
4
|
const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset', 'search', 'aggregate', 'groupBy']);
|
|
4
5
|
const FILTER_OPERATORS = new Set([
|
|
@@ -289,7 +290,16 @@ function compileFilterObject(filter, schema, path, limits, state, depth) {
|
|
|
289
290
|
|
|
290
291
|
export function compileFilter(filter, schema, options = {}) {
|
|
291
292
|
if (!filter) return { sql: '', params: [] };
|
|
292
|
-
const
|
|
293
|
-
|
|
293
|
+
const {
|
|
294
|
+
dynamicVariables = {},
|
|
295
|
+
allowUnresolvedDynamicVariables = false,
|
|
296
|
+
...limitOptions
|
|
297
|
+
} = options;
|
|
298
|
+
const limits = { ...QUERY_LIMITS, ...limitOptions };
|
|
299
|
+
const resolvedFilter = resolveDynamicVariables(filter, dynamicVariables, {
|
|
300
|
+
allowUnresolved: allowUnresolvedDynamicVariables,
|
|
301
|
+
path: 'filter',
|
|
302
|
+
});
|
|
303
|
+
const compiled = compileFilterObject(resolvedFilter, schema, 'filter', limits, { nodes: 0 }, 1);
|
|
294
304
|
return { sql: ` WHERE ${compiled.sql}`, params: compiled.params };
|
|
295
305
|
}
|
|
@@ -419,7 +419,13 @@ async function readRowsByLookup({
|
|
|
419
419
|
},
|
|
420
420
|
};
|
|
421
421
|
const selection = compileSelectFields([...new Set([...visibleFields, ...trustedInternal])], internalSchema);
|
|
422
|
-
const permissionFilter = compileFilter(permission.filter, schema
|
|
422
|
+
const permissionFilter = compileFilter(permission.filter, schema, {
|
|
423
|
+
dynamicVariables: {
|
|
424
|
+
user: service.accountability?.user ?? null,
|
|
425
|
+
role: service.accountability?.role ?? null,
|
|
426
|
+
now: new Date(),
|
|
427
|
+
},
|
|
428
|
+
});
|
|
423
429
|
const table = quoteIdentifier(service.collection, 'collection name');
|
|
424
430
|
const data = [];
|
|
425
431
|
|
|
@@ -38,6 +38,7 @@ function publicUser(user) {
|
|
|
38
38
|
email: user.email,
|
|
39
39
|
role: user.role ?? null,
|
|
40
40
|
role_name: user.role_name ?? null,
|
|
41
|
+
admin: Boolean(user.role_admin ?? user.admin),
|
|
41
42
|
status: user.status,
|
|
42
43
|
email_verified_at: user.email_verified_at ?? null,
|
|
43
44
|
};
|
|
@@ -191,6 +192,7 @@ export class AuthService extends BaseService {
|
|
|
191
192
|
email: result.email,
|
|
192
193
|
role: result.role,
|
|
193
194
|
role_name: result.role_name ?? null,
|
|
195
|
+
admin: result.admin === true,
|
|
194
196
|
},
|
|
195
197
|
access_token: result.access_token,
|
|
196
198
|
access_expires_at: result.access_expires_at,
|
|
@@ -101,7 +101,13 @@ export class FilesService extends BaseService {
|
|
|
101
101
|
'System schema is required to enforce a filtered Files permission',
|
|
102
102
|
);
|
|
103
103
|
}
|
|
104
|
-
const compiled = compileFilter(permission.filter, collectionSchema
|
|
104
|
+
const compiled = compileFilter(permission.filter, collectionSchema, {
|
|
105
|
+
dynamicVariables: {
|
|
106
|
+
user: this.accountability.user,
|
|
107
|
+
role: this.accountability.role,
|
|
108
|
+
now: new Date(),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
105
111
|
sql = compiled.sql;
|
|
106
112
|
params = [...compiled.params];
|
|
107
113
|
}
|
|
@@ -65,7 +65,7 @@ function combineCompiledFilters(...filters) {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
function createCandidateRecord(schema, id, entries) {
|
|
68
|
+
function createCandidateRecord(schema, id, entries, now = new Date()) {
|
|
69
69
|
const provided = Object.fromEntries(entries);
|
|
70
70
|
const candidate = {};
|
|
71
71
|
|
|
@@ -80,7 +80,7 @@ function createCandidateRecord(schema, id, entries) {
|
|
|
80
80
|
}
|
|
81
81
|
const metadata = parseMetadata(fieldSchema.schema_metadata);
|
|
82
82
|
if (metadata.defaultPreset === 'now') {
|
|
83
|
-
candidate[fieldSchema.field] =
|
|
83
|
+
candidate[fieldSchema.field] = now;
|
|
84
84
|
continue;
|
|
85
85
|
}
|
|
86
86
|
candidate[fieldSchema.field] = Object.hasOwn(metadata, 'defaultValue')
|
|
@@ -95,6 +95,38 @@ function mergeSystemEntries(entries, schema, accountability, operation, now = ne
|
|
|
95
95
|
return [...entries, ...systemMutationEntries(schema, accountability, operation, now)];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
function databaseMutationValue(field, value, schema) {
|
|
99
|
+
if (value == null) return value;
|
|
100
|
+
const type = schema.fields[field]?.type;
|
|
101
|
+
|
|
102
|
+
if (type === 'json') {
|
|
103
|
+
if (typeof value === 'string') {
|
|
104
|
+
try {
|
|
105
|
+
JSON.parse(value);
|
|
106
|
+
return value;
|
|
107
|
+
} catch {
|
|
108
|
+
return JSON.stringify(value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return JSON.stringify(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
(type === 'datetime' || type === 'timestamp')
|
|
116
|
+
&& typeof value === 'string'
|
|
117
|
+
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value)
|
|
118
|
+
) {
|
|
119
|
+
const date = new Date(value);
|
|
120
|
+
if (!Number.isNaN(date.getTime())) return date;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function databaseMutationParams(entries, schema) {
|
|
127
|
+
return entries.map(([field, value]) => databaseMutationValue(field, value, schema));
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
export class ItemsService extends BaseService {
|
|
99
131
|
constructor(collection, options = {}) {
|
|
100
132
|
super(options);
|
|
@@ -150,6 +182,14 @@ export class ItemsService extends BaseService {
|
|
|
150
182
|
return permissions.resolve(action, this.collection);
|
|
151
183
|
}
|
|
152
184
|
|
|
185
|
+
dynamicVariables(now = new Date()) {
|
|
186
|
+
return {
|
|
187
|
+
user: this.accountability.user,
|
|
188
|
+
role: this.accountability.role,
|
|
189
|
+
now,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
153
193
|
validatePayload(payload, schema, permission, { creating = false } = {}) {
|
|
154
194
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
155
195
|
throw serviceError('INVALID_PAYLOAD', 'Payload must be an object');
|
|
@@ -179,9 +219,9 @@ export class ItemsService extends BaseService {
|
|
|
179
219
|
return entries;
|
|
180
220
|
}
|
|
181
221
|
|
|
182
|
-
compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema, search = null) {
|
|
183
|
-
const permissionSql = compileFilter(permissionFilter, fullSchema);
|
|
184
|
-
const userSql = compileFilter(userFilter, userSchema);
|
|
222
|
+
compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema, search = null, dynamicVariables = this.dynamicVariables()) {
|
|
223
|
+
const permissionSql = compileFilter(permissionFilter, fullSchema, { dynamicVariables });
|
|
224
|
+
const userSql = compileFilter(userFilter, userSchema, { dynamicVariables });
|
|
185
225
|
const searchSql = compileSearch(search, userSchema);
|
|
186
226
|
return combineCompiledFilters(permissionSql, userSql, searchSql);
|
|
187
227
|
}
|
|
@@ -221,7 +261,14 @@ export class ItemsService extends BaseService {
|
|
|
221
261
|
const query = await this.normalizeReadQuery(rawQuery);
|
|
222
262
|
const permission = await this.resolvePermission('read');
|
|
223
263
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
224
|
-
const filter = this.compileActionFilters(
|
|
264
|
+
const filter = this.compileActionFilters(
|
|
265
|
+
query.filter,
|
|
266
|
+
permission.filter,
|
|
267
|
+
accessSchema,
|
|
268
|
+
schema,
|
|
269
|
+
query.search,
|
|
270
|
+
this.dynamicVariables(),
|
|
271
|
+
);
|
|
225
272
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
226
273
|
const aggregate = compileAggregate(query.aggregate, query.groupBy, accessSchema);
|
|
227
274
|
|
|
@@ -288,7 +335,8 @@ export class ItemsService extends BaseService {
|
|
|
288
335
|
fields: { ...accessSchema.fields, [trustedLookupField]: schema.fields[trustedLookupField] },
|
|
289
336
|
};
|
|
290
337
|
const internalSelection = compileSelectFields([...visibleSelection.fields, trustedLookupField], internalSchema);
|
|
291
|
-
const
|
|
338
|
+
const dynamicVariables = this.dynamicVariables();
|
|
339
|
+
const permissionFilter = compileFilter(permission.filter, schema, { dynamicVariables });
|
|
292
340
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
293
341
|
const data = [];
|
|
294
342
|
|
|
@@ -315,8 +363,9 @@ export class ItemsService extends BaseService {
|
|
|
315
363
|
const selected = compileSelectFields(normalizeFields(fields), accessSchema);
|
|
316
364
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
317
365
|
const primaryKey = schema.primary_key;
|
|
366
|
+
const dynamicVariables = this.dynamicVariables();
|
|
318
367
|
const filter = combineCompiledFilters(
|
|
319
|
-
compileFilter(permission.filter, schema),
|
|
368
|
+
compileFilter(permission.filter, schema, { dynamicVariables }),
|
|
320
369
|
compileFilter({ [primaryKey]: { _eq: id } }, schema),
|
|
321
370
|
);
|
|
322
371
|
const [rows] = await this.database.query(
|
|
@@ -342,17 +391,20 @@ export class ItemsService extends BaseService {
|
|
|
342
391
|
const permission = await this.resolvePermission('create');
|
|
343
392
|
const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create' });
|
|
344
393
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
|
|
345
|
-
const
|
|
394
|
+
const now = new Date();
|
|
395
|
+
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create', now);
|
|
346
396
|
const id = randomUUID();
|
|
347
|
-
const candidate = createCandidateRecord(schema, id, entries);
|
|
348
|
-
enforcePermissionValidation(candidate, permission.validation, schema
|
|
397
|
+
const candidate = createCandidateRecord(schema, id, entries, now);
|
|
398
|
+
enforcePermissionValidation(candidate, permission.validation, schema, {
|
|
399
|
+
dynamicVariables: this.dynamicVariables(now),
|
|
400
|
+
});
|
|
349
401
|
|
|
350
402
|
const values = { [schema.primary_key]: id, ...Object.fromEntries(entries) };
|
|
351
403
|
const fields = Object.keys(values);
|
|
352
404
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
353
405
|
await this.database.query(
|
|
354
406
|
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
|
|
355
|
-
fields.map((field) => values[field]),
|
|
407
|
+
databaseMutationParams(fields.map((field) => [field, values[field]]), schema),
|
|
356
408
|
);
|
|
357
409
|
|
|
358
410
|
const record = await this.returnCreatedOrUpdated(id, schema);
|
|
@@ -368,14 +420,17 @@ export class ItemsService extends BaseService {
|
|
|
368
420
|
const schema = await this.getCollectionSchema();
|
|
369
421
|
const permission = await this.resolvePermission('create');
|
|
370
422
|
const staged = [];
|
|
423
|
+
const now = new Date();
|
|
371
424
|
|
|
372
425
|
for (const payload of payloads) {
|
|
373
426
|
const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create', bulk: true });
|
|
374
427
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
|
|
375
|
-
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
|
|
428
|
+
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create', now);
|
|
376
429
|
const id = randomUUID();
|
|
377
|
-
const candidate = createCandidateRecord(schema, id, entries);
|
|
378
|
-
enforcePermissionValidation(candidate, permission.validation, schema
|
|
430
|
+
const candidate = createCandidateRecord(schema, id, entries, now);
|
|
431
|
+
enforcePermissionValidation(candidate, permission.validation, schema, {
|
|
432
|
+
dynamicVariables: this.dynamicVariables(now),
|
|
433
|
+
});
|
|
379
434
|
staged.push({ id, values: { [schema.primary_key]: id, ...Object.fromEntries(entries) } });
|
|
380
435
|
}
|
|
381
436
|
|
|
@@ -385,7 +440,10 @@ export class ItemsService extends BaseService {
|
|
|
385
440
|
const fields = Object.keys(entry.values);
|
|
386
441
|
await connection.query(
|
|
387
442
|
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
|
|
388
|
-
|
|
443
|
+
databaseMutationParams(
|
|
444
|
+
fields.map((field) => [field, entry.values[field]]),
|
|
445
|
+
schema,
|
|
446
|
+
),
|
|
389
447
|
);
|
|
390
448
|
}
|
|
391
449
|
});
|
|
@@ -405,11 +463,13 @@ export class ItemsService extends BaseService {
|
|
|
405
463
|
const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', key: id });
|
|
406
464
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission);
|
|
407
465
|
if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
408
|
-
const
|
|
466
|
+
const now = new Date();
|
|
467
|
+
const dynamicVariables = this.dynamicVariables(now);
|
|
468
|
+
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update', now);
|
|
409
469
|
const effectiveChanges = Object.fromEntries(entries);
|
|
410
470
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
411
471
|
const filter = combineCompiledFilters(
|
|
412
|
-
compileFilter(permission.filter, schema),
|
|
472
|
+
compileFilter(permission.filter, schema, { dynamicVariables }),
|
|
413
473
|
compileFilter({ [schema.primary_key]: { _eq: id } }, schema),
|
|
414
474
|
);
|
|
415
475
|
|
|
@@ -417,13 +477,13 @@ export class ItemsService extends BaseService {
|
|
|
417
477
|
const [currentRows] = await this.database.query(`SELECT * FROM ${table}${filter.sql} LIMIT 1`, filter.params);
|
|
418
478
|
const current = currentRows[0];
|
|
419
479
|
if (!current) return null;
|
|
420
|
-
enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
|
|
480
|
+
enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema, { dynamicVariables });
|
|
421
481
|
}
|
|
422
482
|
|
|
423
483
|
const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
|
|
424
484
|
const [result] = await this.database.query(
|
|
425
485
|
`UPDATE ${table} SET ${setSql}${filter.sql}`,
|
|
426
|
-
[...entries
|
|
486
|
+
[...databaseMutationParams(entries, schema), ...filter.params],
|
|
427
487
|
);
|
|
428
488
|
if (result.affectedRows === 0) return null;
|
|
429
489
|
const record = await this.returnCreatedOrUpdated(id, schema);
|
|
@@ -442,9 +502,11 @@ export class ItemsService extends BaseService {
|
|
|
442
502
|
const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', bulk: true, filter: filterInput });
|
|
443
503
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission);
|
|
444
504
|
if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
445
|
-
const
|
|
505
|
+
const now = new Date();
|
|
506
|
+
const dynamicVariables = this.dynamicVariables(now);
|
|
507
|
+
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update', now);
|
|
446
508
|
const effectiveChanges = Object.fromEntries(entries);
|
|
447
|
-
const filter = this.compileActionFilters(filterInput, permission.filter, accessSchema, schema);
|
|
509
|
+
const filter = this.compileActionFilters(filterInput, permission.filter, accessSchema, schema, null, dynamicVariables);
|
|
448
510
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
449
511
|
|
|
450
512
|
if (permission.validation) {
|
|
@@ -455,13 +517,15 @@ export class ItemsService extends BaseService {
|
|
|
455
517
|
if (rows.length > MAX_BULK_VALIDATION_ROWS) {
|
|
456
518
|
throw serviceError('VALIDATION_BULK_LIMIT', `Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`);
|
|
457
519
|
}
|
|
458
|
-
for (const row of rows)
|
|
520
|
+
for (const row of rows) {
|
|
521
|
+
enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema, { dynamicVariables });
|
|
522
|
+
}
|
|
459
523
|
}
|
|
460
524
|
|
|
461
525
|
const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
|
|
462
526
|
const [result] = await this.database.query(
|
|
463
527
|
`UPDATE ${table} SET ${setSql}${filter.sql}`,
|
|
464
|
-
[...entries
|
|
528
|
+
[...databaseMutationParams(entries, schema), ...filter.params],
|
|
465
529
|
);
|
|
466
530
|
await this.actionMutation('items.update', { filter: filterInput, changes: effectiveChanges, affected: result.affectedRows }, { operation: 'update', bulk: true });
|
|
467
531
|
return result.affectedRows;
|
|
@@ -473,8 +537,9 @@ export class ItemsService extends BaseService {
|
|
|
473
537
|
const filtered = await this.filterMutation('items.delete', { key: id }, { operation: 'delete', key: id });
|
|
474
538
|
const key = filtered?.key ?? id;
|
|
475
539
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
540
|
+
const dynamicVariables = this.dynamicVariables();
|
|
476
541
|
const filter = combineCompiledFilters(
|
|
477
|
-
compileFilter(permission.filter, schema),
|
|
542
|
+
compileFilter(permission.filter, schema, { dynamicVariables }),
|
|
478
543
|
compileFilter({ [schema.primary_key]: { _eq: key } }, schema),
|
|
479
544
|
);
|
|
480
545
|
const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
|
|
@@ -223,7 +223,7 @@ export class PermissionsService extends BaseService {
|
|
|
223
223
|
const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
|
|
224
224
|
const filter = input.filter ?? null;
|
|
225
225
|
const validation = input.validation ?? null;
|
|
226
|
-
if (filter !== null) compileFilter(filter, collectionSchema);
|
|
226
|
+
if (filter !== null) compileFilter(filter, collectionSchema, { allowUnresolvedDynamicVariables: true });
|
|
227
227
|
assertPermissionValidationRule(validation, collectionSchema);
|
|
228
228
|
|
|
229
229
|
const [roleRows] = await this.database.query(
|
|
@@ -290,7 +290,7 @@ export class PermissionsService extends BaseService {
|
|
|
290
290
|
params.push(fields == null ? null : JSON.stringify(fields));
|
|
291
291
|
}
|
|
292
292
|
if (Object.hasOwn(patch, 'filter')) {
|
|
293
|
-
if (patch.filter != null) compileFilter(patch.filter, collectionSchema);
|
|
293
|
+
if (patch.filter != null) compileFilter(patch.filter, collectionSchema, { allowUnresolvedDynamicVariables: true });
|
|
294
294
|
assignments.push('filter = ?');
|
|
295
295
|
params.push(patch.filter == null ? null : JSON.stringify(patch.filter));
|
|
296
296
|
}
|