@yunsoft/yuncms-core 0.1.3 → 0.1.6
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/auth/external-state.js +76 -0
- package/src/bootstrap.js +6 -0
- package/src/cache.js +85 -0
- package/src/config.js +115 -20
- package/src/context.js +5 -2
- package/src/hooks.js +117 -32
- package/src/index.js +32 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/migrations.js +113 -3
- package/src/query.js +156 -54
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +564 -142
- package/src/services/auth-service.js +40 -2
- package/src/services/core-services.js +2 -0
- package/src/services/external-auth-service.js +323 -0
- package/src/services/files-service.js +85 -7
- package/src/services/items-service.js +121 -81
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
|
@@ -3,10 +3,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
4
4
|
import { enforcePermissionValidation } from '../permission-validation.js';
|
|
5
5
|
import {
|
|
6
|
+
assertQueryCost,
|
|
7
|
+
compileAggregate,
|
|
6
8
|
compileFilter,
|
|
9
|
+
compileSearch,
|
|
7
10
|
compileSelectFields,
|
|
8
11
|
compileSort,
|
|
9
12
|
parseItemsQuery,
|
|
13
|
+
QUERY_LIMITS,
|
|
10
14
|
} from '../query.js';
|
|
11
15
|
import { SchemaCache } from '../schema.js';
|
|
12
16
|
import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
|
|
@@ -175,10 +179,37 @@ export class ItemsService extends BaseService {
|
|
|
175
179
|
return entries;
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema) {
|
|
182
|
+
compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema, search = null) {
|
|
179
183
|
const permissionSql = compileFilter(permissionFilter, fullSchema);
|
|
180
184
|
const userSql = compileFilter(userFilter, userSchema);
|
|
181
|
-
|
|
185
|
+
const searchSql = compileSearch(search, userSchema);
|
|
186
|
+
return combineCompiledFilters(permissionSql, userSql, searchSql);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async normalizeReadQuery(rawQuery) {
|
|
190
|
+
const parsed = parseItemsQuery(rawQuery);
|
|
191
|
+
const filtered = this.emitter
|
|
192
|
+
? await this.emitter.filter('items.query', parsed, this.hookContext({ operation: 'read' }))
|
|
193
|
+
: parsed;
|
|
194
|
+
const query = parseItemsQuery(filtered);
|
|
195
|
+
assertQueryCost(query);
|
|
196
|
+
return query;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async emitReadAction(query, rows, schema, { single = false } = {}) {
|
|
200
|
+
if (!this.emitter) return;
|
|
201
|
+
const primaryKey = schema.primary_key;
|
|
202
|
+
const keys = rows
|
|
203
|
+
.filter((row) => row && Object.hasOwn(row, primaryKey))
|
|
204
|
+
.map((row) => row[primaryKey])
|
|
205
|
+
.slice(0, QUERY_LIMITS.maxLimit);
|
|
206
|
+
await this.emitter.action('items.read', {
|
|
207
|
+
collection: this.collection,
|
|
208
|
+
query,
|
|
209
|
+
keys,
|
|
210
|
+
count: rows.length,
|
|
211
|
+
single,
|
|
212
|
+
}, this.hookContext({ operation: 'read' }));
|
|
182
213
|
}
|
|
183
214
|
|
|
184
215
|
async readMany(rawQuery = {}) {
|
|
@@ -187,15 +218,34 @@ export class ItemsService extends BaseService {
|
|
|
187
218
|
|
|
188
219
|
async readManyWithMeta(rawQuery = {}) {
|
|
189
220
|
const schema = await this.getCollectionSchema();
|
|
221
|
+
const query = await this.normalizeReadQuery(rawQuery);
|
|
190
222
|
const permission = await this.resolvePermission('read');
|
|
191
223
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
192
|
-
const
|
|
224
|
+
const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema, query.search);
|
|
225
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
226
|
+
const aggregate = compileAggregate(query.aggregate, query.groupBy, accessSchema);
|
|
227
|
+
|
|
228
|
+
if (aggregate) {
|
|
229
|
+
const sortSql = compileSort(query.sort, accessSchema);
|
|
230
|
+
const [rows] = await this.database.query(
|
|
231
|
+
`SELECT ${aggregate.sql} FROM ${table}${filter.sql}${aggregate.groupSql}${sortSql} LIMIT ? OFFSET ?`,
|
|
232
|
+
[...filter.params, query.limit, query.offset],
|
|
233
|
+
);
|
|
234
|
+
await this.emitReadAction(query, rows, schema);
|
|
235
|
+
return {
|
|
236
|
+
data: rows,
|
|
237
|
+
meta: {
|
|
238
|
+
total_count: rows.length,
|
|
239
|
+
limit: query.limit,
|
|
240
|
+
offset: query.offset,
|
|
241
|
+
aggregate: true,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
193
246
|
const requestedFields = normalizeFields(query.fields);
|
|
194
247
|
const selected = compileSelectFields(requestedFields, accessSchema);
|
|
195
|
-
const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema);
|
|
196
248
|
const sortSql = compileSort(query.sort, accessSchema);
|
|
197
|
-
const table = quoteIdentifier(this.collection, 'collection name');
|
|
198
|
-
|
|
199
249
|
const [rows] = await this.database.query(
|
|
200
250
|
`SELECT ${selected.sql} FROM ${table}${filter.sql}${sortSql} LIMIT ? OFFSET ?`,
|
|
201
251
|
[...filter.params, query.limit, query.offset],
|
|
@@ -204,6 +254,7 @@ export class ItemsService extends BaseService {
|
|
|
204
254
|
`SELECT COUNT(*) AS total_count FROM ${table}${filter.sql}`,
|
|
205
255
|
filter.params,
|
|
206
256
|
);
|
|
257
|
+
await this.emitReadAction(query, rows, schema);
|
|
207
258
|
|
|
208
259
|
return {
|
|
209
260
|
data: rows,
|
|
@@ -215,6 +266,48 @@ export class ItemsService extends BaseService {
|
|
|
215
266
|
};
|
|
216
267
|
}
|
|
217
268
|
|
|
269
|
+
async readManyForRelation({ fields = null, lookupField, values = [] } = {}) {
|
|
270
|
+
const schema = await this.getCollectionSchema();
|
|
271
|
+
const trustedLookupField = assertIdentifier(lookupField, 'relation lookup field');
|
|
272
|
+
if (!schema.fields[trustedLookupField]) {
|
|
273
|
+
throw serviceError('INVALID_QUERY', `Unknown relation lookup field: ${trustedLookupField}`, trustedLookupField);
|
|
274
|
+
}
|
|
275
|
+
if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxLimit) {
|
|
276
|
+
throw serviceError(
|
|
277
|
+
'INVALID_QUERY',
|
|
278
|
+
`Relation lookup values must contain between 1 and ${QUERY_LIMITS.maxLimit} entries`,
|
|
279
|
+
trustedLookupField,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const permission = await this.resolvePermission('read');
|
|
284
|
+
const accessSchema = schemaForFields(schema, permission.fields);
|
|
285
|
+
const visibleSelection = compileSelectFields(normalizeFields(fields), accessSchema);
|
|
286
|
+
const internalSchema = {
|
|
287
|
+
...accessSchema,
|
|
288
|
+
fields: { ...accessSchema.fields, [trustedLookupField]: schema.fields[trustedLookupField] },
|
|
289
|
+
};
|
|
290
|
+
const internalSelection = compileSelectFields([...visibleSelection.fields, trustedLookupField], internalSchema);
|
|
291
|
+
const permissionFilter = compileFilter(permission.filter, schema);
|
|
292
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
293
|
+
const data = [];
|
|
294
|
+
|
|
295
|
+
for (let offset = 0; offset < values.length; offset += QUERY_LIMITS.maxInValues) {
|
|
296
|
+
const chunk = values.slice(offset, offset + QUERY_LIMITS.maxInValues);
|
|
297
|
+
const filter = combineCompiledFilters(
|
|
298
|
+
permissionFilter,
|
|
299
|
+
compileFilter({ [trustedLookupField]: { _in: chunk } }, schema),
|
|
300
|
+
);
|
|
301
|
+
const [rows] = await this.database.query(
|
|
302
|
+
`SELECT ${internalSelection.sql} FROM ${table}${filter.sql} LIMIT ?`,
|
|
303
|
+
[...filter.params, chunk.length],
|
|
304
|
+
);
|
|
305
|
+
data.push(...rows);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { data, visibleFields: visibleSelection.fields };
|
|
309
|
+
}
|
|
310
|
+
|
|
218
311
|
async readOne(id, { fields = null } = {}) {
|
|
219
312
|
const schema = await this.getCollectionSchema();
|
|
220
313
|
const permission = await this.resolvePermission('read');
|
|
@@ -230,7 +323,9 @@ export class ItemsService extends BaseService {
|
|
|
230
323
|
`SELECT ${selected.sql} FROM ${table}${filter.sql} LIMIT 1`,
|
|
231
324
|
filter.params,
|
|
232
325
|
);
|
|
233
|
-
|
|
326
|
+
const record = rows[0] ?? null;
|
|
327
|
+
if (record) await this.emitReadAction({ fields: normalizeFields(fields) }, [record], schema, { single: true });
|
|
328
|
+
return record;
|
|
234
329
|
}
|
|
235
330
|
|
|
236
331
|
async returnCreatedOrUpdated(id, schema) {
|
|
@@ -255,10 +350,8 @@ export class ItemsService extends BaseService {
|
|
|
255
350
|
const values = { [schema.primary_key]: id, ...Object.fromEntries(entries) };
|
|
256
351
|
const fields = Object.keys(values);
|
|
257
352
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
258
|
-
|
|
259
353
|
await this.database.query(
|
|
260
|
-
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
|
|
261
|
-
VALUES (${fields.map(() => '?').join(', ')})`,
|
|
354
|
+
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
|
|
262
355
|
fields.map((field) => values[field]),
|
|
263
356
|
);
|
|
264
357
|
|
|
@@ -277,29 +370,21 @@ export class ItemsService extends BaseService {
|
|
|
277
370
|
const staged = [];
|
|
278
371
|
|
|
279
372
|
for (const payload of payloads) {
|
|
280
|
-
const filteredPayload = await this.filterMutation('items.create', payload, {
|
|
281
|
-
operation: 'create',
|
|
282
|
-
bulk: true,
|
|
283
|
-
});
|
|
373
|
+
const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create', bulk: true });
|
|
284
374
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
|
|
285
375
|
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
|
|
286
376
|
const id = randomUUID();
|
|
287
377
|
const candidate = createCandidateRecord(schema, id, entries);
|
|
288
378
|
enforcePermissionValidation(candidate, permission.validation, schema);
|
|
289
|
-
staged.push({
|
|
290
|
-
id,
|
|
291
|
-
values: { [schema.primary_key]: id, ...Object.fromEntries(entries) },
|
|
292
|
-
});
|
|
379
|
+
staged.push({ id, values: { [schema.primary_key]: id, ...Object.fromEntries(entries) } });
|
|
293
380
|
}
|
|
294
381
|
|
|
295
382
|
await withTransaction(this.database, async (connection) => {
|
|
296
383
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
297
|
-
|
|
298
384
|
for (const entry of staged) {
|
|
299
385
|
const fields = Object.keys(entry.values);
|
|
300
386
|
await connection.query(
|
|
301
|
-
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
|
|
302
|
-
VALUES (${fields.map(() => '?').join(', ')})`,
|
|
387
|
+
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
|
|
303
388
|
fields.map((field) => entry.values[field]),
|
|
304
389
|
);
|
|
305
390
|
}
|
|
@@ -309,10 +394,7 @@ export class ItemsService extends BaseService {
|
|
|
309
394
|
for (const entry of staged) {
|
|
310
395
|
const record = await this.returnCreatedOrUpdated(entry.id, schema);
|
|
311
396
|
records.push(record);
|
|
312
|
-
await this.actionMutation('items.create', { key: entry.id, item: record }, {
|
|
313
|
-
operation: 'create',
|
|
314
|
-
bulk: true,
|
|
315
|
-
});
|
|
397
|
+
await this.actionMutation('items.create', { key: entry.id, item: record }, { operation: 'create', bulk: true });
|
|
316
398
|
}
|
|
317
399
|
return records;
|
|
318
400
|
}
|
|
@@ -320,15 +402,11 @@ export class ItemsService extends BaseService {
|
|
|
320
402
|
async updateOne(id, payload = {}) {
|
|
321
403
|
const schema = await this.getCollectionSchema();
|
|
322
404
|
const permission = await this.resolvePermission('update');
|
|
323
|
-
const filteredPayload = await this.filterMutation('items.update', payload, {
|
|
324
|
-
operation: 'update',
|
|
325
|
-
key: id,
|
|
326
|
-
});
|
|
405
|
+
const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', key: id });
|
|
327
406
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission);
|
|
328
407
|
if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
329
408
|
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
|
|
330
409
|
const effectiveChanges = Object.fromEntries(entries);
|
|
331
|
-
|
|
332
410
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
333
411
|
const filter = combineCompiledFilters(
|
|
334
412
|
compileFilter(permission.filter, schema),
|
|
@@ -336,10 +414,7 @@ export class ItemsService extends BaseService {
|
|
|
336
414
|
);
|
|
337
415
|
|
|
338
416
|
if (permission.validation) {
|
|
339
|
-
const [currentRows] = await this.database.query(
|
|
340
|
-
`SELECT * FROM ${table}${filter.sql} LIMIT 1`,
|
|
341
|
-
filter.params,
|
|
342
|
-
);
|
|
417
|
+
const [currentRows] = await this.database.query(`SELECT * FROM ${table}${filter.sql} LIMIT 1`, filter.params);
|
|
343
418
|
const current = currentRows[0];
|
|
344
419
|
if (!current) return null;
|
|
345
420
|
enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
|
|
@@ -350,14 +425,9 @@ export class ItemsService extends BaseService {
|
|
|
350
425
|
`UPDATE ${table} SET ${setSql}${filter.sql}`,
|
|
351
426
|
[...entries.map(([, value]) => value), ...filter.params],
|
|
352
427
|
);
|
|
353
|
-
|
|
354
428
|
if (result.affectedRows === 0) return null;
|
|
355
429
|
const record = await this.returnCreatedOrUpdated(id, schema);
|
|
356
|
-
await this.actionMutation('items.update', {
|
|
357
|
-
key: id,
|
|
358
|
-
item: record,
|
|
359
|
-
changes: effectiveChanges,
|
|
360
|
-
}, { operation: 'update' });
|
|
430
|
+
await this.actionMutation('items.update', { key: id, item: record, changes: effectiveChanges }, { operation: 'update' });
|
|
361
431
|
return record;
|
|
362
432
|
}
|
|
363
433
|
|
|
@@ -369,11 +439,7 @@ export class ItemsService extends BaseService {
|
|
|
369
439
|
const schema = await this.getCollectionSchema();
|
|
370
440
|
const permission = await this.resolvePermission('update');
|
|
371
441
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
372
|
-
const filteredPayload = await this.filterMutation('items.update', payload, {
|
|
373
|
-
operation: 'update',
|
|
374
|
-
bulk: true,
|
|
375
|
-
filter: filterInput,
|
|
376
|
-
});
|
|
442
|
+
const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', bulk: true, filter: filterInput });
|
|
377
443
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission);
|
|
378
444
|
if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
379
445
|
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
|
|
@@ -387,14 +453,9 @@ export class ItemsService extends BaseService {
|
|
|
387
453
|
[...filter.params, MAX_BULK_VALIDATION_ROWS + 1],
|
|
388
454
|
);
|
|
389
455
|
if (rows.length > MAX_BULK_VALIDATION_ROWS) {
|
|
390
|
-
throw serviceError(
|
|
391
|
-
'VALIDATION_BULK_LIMIT',
|
|
392
|
-
`Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`,
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
for (const row of rows) {
|
|
396
|
-
enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
|
|
456
|
+
throw serviceError('VALIDATION_BULK_LIMIT', `Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`);
|
|
397
457
|
}
|
|
458
|
+
for (const row of rows) enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
|
|
398
459
|
}
|
|
399
460
|
|
|
400
461
|
const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
|
|
@@ -402,35 +463,23 @@ export class ItemsService extends BaseService {
|
|
|
402
463
|
`UPDATE ${table} SET ${setSql}${filter.sql}`,
|
|
403
464
|
[...entries.map(([, value]) => value), ...filter.params],
|
|
404
465
|
);
|
|
405
|
-
await this.actionMutation('items.update', {
|
|
406
|
-
filter: filterInput,
|
|
407
|
-
changes: effectiveChanges,
|
|
408
|
-
affected: result.affectedRows,
|
|
409
|
-
}, { operation: 'update', bulk: true });
|
|
466
|
+
await this.actionMutation('items.update', { filter: filterInput, changes: effectiveChanges, affected: result.affectedRows }, { operation: 'update', bulk: true });
|
|
410
467
|
return result.affectedRows;
|
|
411
468
|
}
|
|
412
469
|
|
|
413
470
|
async deleteOne(id) {
|
|
414
471
|
const schema = await this.getCollectionSchema();
|
|
415
472
|
const permission = await this.resolvePermission('delete');
|
|
416
|
-
const filtered = await this.filterMutation('items.delete', { key: id }, {
|
|
417
|
-
operation: 'delete',
|
|
418
|
-
key: id,
|
|
419
|
-
});
|
|
473
|
+
const filtered = await this.filterMutation('items.delete', { key: id }, { operation: 'delete', key: id });
|
|
420
474
|
const key = filtered?.key ?? id;
|
|
421
475
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
422
476
|
const filter = combineCompiledFilters(
|
|
423
477
|
compileFilter(permission.filter, schema),
|
|
424
478
|
compileFilter({ [schema.primary_key]: { _eq: key } }, schema),
|
|
425
479
|
);
|
|
426
|
-
const [result] = await this.database.query(
|
|
427
|
-
`DELETE FROM ${table}${filter.sql}`,
|
|
428
|
-
filter.params,
|
|
429
|
-
);
|
|
480
|
+
const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
|
|
430
481
|
const deleted = result.affectedRows > 0;
|
|
431
|
-
if (deleted) {
|
|
432
|
-
await this.actionMutation('items.delete', { key }, { operation: 'delete' });
|
|
433
|
-
}
|
|
482
|
+
if (deleted) await this.actionMutation('items.delete', { key }, { operation: 'delete' });
|
|
434
483
|
return deleted;
|
|
435
484
|
}
|
|
436
485
|
|
|
@@ -442,26 +491,17 @@ export class ItemsService extends BaseService {
|
|
|
442
491
|
const schema = await this.getCollectionSchema();
|
|
443
492
|
const permission = await this.resolvePermission('delete');
|
|
444
493
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
445
|
-
const filtered = await this.filterMutation('items.delete', { filter: filterInput }, {
|
|
446
|
-
operation: 'delete',
|
|
447
|
-
bulk: true,
|
|
448
|
-
});
|
|
494
|
+
const filtered = await this.filterMutation('items.delete', { filter: filterInput }, { operation: 'delete', bulk: true });
|
|
449
495
|
const effectiveFilter = filtered?.filter ?? filterInput;
|
|
450
496
|
if (!effectiveFilter || typeof effectiveFilter !== 'object' || Array.isArray(effectiveFilter) || Object.keys(effectiveFilter).length === 0) {
|
|
451
497
|
throw serviceError('FILTER_REQUIRED', 'deleteMany hook result must preserve a non-empty filter');
|
|
452
498
|
}
|
|
453
499
|
const filter = this.compileActionFilters(effectiveFilter, permission.filter, accessSchema, schema);
|
|
454
500
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
455
|
-
const [result] = await this.database.query(
|
|
456
|
-
|
|
457
|
-
filter.params,
|
|
458
|
-
);
|
|
459
|
-
await this.actionMutation('items.delete', {
|
|
460
|
-
filter: effectiveFilter,
|
|
461
|
-
affected: result.affectedRows,
|
|
462
|
-
}, { operation: 'delete', bulk: true });
|
|
501
|
+
const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
|
|
502
|
+
await this.actionMutation('items.delete', { filter: effectiveFilter, affected: result.affectedRows }, { operation: 'delete', bulk: true });
|
|
463
503
|
return result.affectedRows;
|
|
464
504
|
}
|
|
465
505
|
}
|
|
466
506
|
|
|
467
|
-
export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
|
|
507
|
+
export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
|
|
@@ -4,7 +4,7 @@ import { assertPermissionValidationRule } from '../permission-validation.js';
|
|
|
4
4
|
import { compileFilter } from '../query.js';
|
|
5
5
|
import { SchemaCache } from '../schema.js';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
assertSystemPermissionPayload,
|
|
8
8
|
assertSystemResourceAction,
|
|
9
9
|
isPermissionManagedSystemResource,
|
|
10
10
|
} from '../system-permissions.js';
|
|
@@ -86,6 +86,15 @@ export class PermissionsService extends BaseService {
|
|
|
86
86
|
this.schemaCache = options.schemaCache ?? defaultSchemaCache;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
async action(event, payload) {
|
|
90
|
+
if (!this.emitter) return;
|
|
91
|
+
await this.emitter.action(event, payload, {
|
|
92
|
+
accountability: this.accountability,
|
|
93
|
+
requestId: this.requestId,
|
|
94
|
+
collection: 'yuncms_permissions',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
89
98
|
async #collectionSchema(collection) {
|
|
90
99
|
const snapshot = this.schema ?? await this.schemaCache.get(this.database);
|
|
91
100
|
const collectionSchema = snapshot.collections?.[collection];
|
|
@@ -100,7 +109,10 @@ export class PermissionsService extends BaseService {
|
|
|
100
109
|
async resolve(action, collection) {
|
|
101
110
|
assertAction(action);
|
|
102
111
|
const key = cacheKey(this.accountability, action, collection);
|
|
103
|
-
if (this.permissionCache
|
|
112
|
+
if (this.permissionCache) {
|
|
113
|
+
const cached = await this.permissionCache.get(key);
|
|
114
|
+
if (cached !== undefined) return cached;
|
|
115
|
+
}
|
|
104
116
|
|
|
105
117
|
let permission;
|
|
106
118
|
if (this.accountability.admin === true || this.accountability.system === true) {
|
|
@@ -159,7 +171,7 @@ export class PermissionsService extends BaseService {
|
|
|
159
171
|
};
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
this.permissionCache
|
|
174
|
+
if (this.permissionCache) await this.permissionCache.set(key, permission);
|
|
163
175
|
return permission;
|
|
164
176
|
}
|
|
165
177
|
|
|
@@ -205,7 +217,7 @@ export class PermissionsService extends BaseService {
|
|
|
205
217
|
throw error;
|
|
206
218
|
}
|
|
207
219
|
assertSystemResourceAction(collectionSchema, action);
|
|
208
|
-
|
|
220
|
+
assertSystemPermissionPayload(collectionSchema, action, input);
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
|
|
@@ -224,11 +236,6 @@ export class PermissionsService extends BaseService {
|
|
|
224
236
|
error.code = 'ROLE_NOT_FOUND';
|
|
225
237
|
throw error;
|
|
226
238
|
}
|
|
227
|
-
if (collectionSchema.system && role.public) {
|
|
228
|
-
const error = new Error('Public role cannot be granted access to protected system resources');
|
|
229
|
-
error.code = 'PUBLIC_SYSTEM_ACCESS_FORBIDDEN';
|
|
230
|
-
throw error;
|
|
231
|
-
}
|
|
232
239
|
|
|
233
240
|
const id = randomUUID();
|
|
234
241
|
await this.database.query(
|
|
@@ -244,8 +251,10 @@ export class PermissionsService extends BaseService {
|
|
|
244
251
|
validation == null ? null : JSON.stringify(validation),
|
|
245
252
|
],
|
|
246
253
|
);
|
|
247
|
-
this.permissionCache
|
|
248
|
-
|
|
254
|
+
if (this.permissionCache) await this.permissionCache.clear();
|
|
255
|
+
const permission = await this.readOne(id);
|
|
256
|
+
await this.action('permissions.create', { key: id, item: permission });
|
|
257
|
+
return permission;
|
|
249
258
|
}
|
|
250
259
|
|
|
251
260
|
async updateOne(id, patch = {}) {
|
|
@@ -269,7 +278,9 @@ export class PermissionsService extends BaseService {
|
|
|
269
278
|
throw error;
|
|
270
279
|
}
|
|
271
280
|
const collectionSchema = await this.#collectionSchema(existing.collection);
|
|
272
|
-
if (collectionSchema.system)
|
|
281
|
+
if (collectionSchema.system) {
|
|
282
|
+
assertSystemPermissionPayload(collectionSchema, existing.action, patch);
|
|
283
|
+
}
|
|
273
284
|
|
|
274
285
|
const assignments = [];
|
|
275
286
|
const params = [];
|
|
@@ -294,12 +305,20 @@ export class PermissionsService extends BaseService {
|
|
|
294
305
|
`UPDATE yuncms_permissions SET ${assignments.join(', ')} WHERE id = ?`,
|
|
295
306
|
params,
|
|
296
307
|
);
|
|
297
|
-
this.permissionCache
|
|
298
|
-
|
|
308
|
+
if (this.permissionCache) await this.permissionCache.clear();
|
|
309
|
+
const permission = await this.readOne(id);
|
|
310
|
+
await this.action('permissions.update', {
|
|
311
|
+
key: id,
|
|
312
|
+
before: existing,
|
|
313
|
+
item: permission,
|
|
314
|
+
changes: patch,
|
|
315
|
+
});
|
|
316
|
+
return permission;
|
|
299
317
|
}
|
|
300
318
|
|
|
301
319
|
async deleteOne(id) {
|
|
302
320
|
assertPermissionManager(this.accountability);
|
|
321
|
+
const before = this.emitter ? await this.readOne(id) : null;
|
|
303
322
|
const [result] = await this.database.query(
|
|
304
323
|
'DELETE FROM yuncms_permissions WHERE id = ?',
|
|
305
324
|
[id],
|
|
@@ -309,7 +328,8 @@ export class PermissionsService extends BaseService {
|
|
|
309
328
|
error.code = 'PERMISSION_NOT_FOUND';
|
|
310
329
|
throw error;
|
|
311
330
|
}
|
|
312
|
-
this.permissionCache
|
|
331
|
+
if (this.permissionCache) await this.permissionCache.clear();
|
|
332
|
+
await this.action('permissions.delete', { key: id, before });
|
|
313
333
|
return true;
|
|
314
334
|
}
|
|
315
335
|
}
|
|
@@ -3,13 +3,6 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { BaseService } from './base-service.js';
|
|
4
4
|
import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
5
5
|
|
|
6
|
-
function assertRoleManager(accountability) {
|
|
7
|
-
if (accountability.admin === true || accountability.system === true) return;
|
|
8
|
-
const error = new Error('Role management requires administrator accountability');
|
|
9
|
-
error.code = 'FORBIDDEN';
|
|
10
|
-
throw error;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
6
|
function normalizeRoleName(name) {
|
|
14
7
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
15
8
|
const error = new Error('Role name is required');
|
|
@@ -25,7 +18,36 @@ function normalizeRoleName(name) {
|
|
|
25
18
|
return normalized;
|
|
26
19
|
}
|
|
27
20
|
|
|
21
|
+
function assertSpecialRoleCreation(accountability, { admin = false, public: publicRole = false } = {}) {
|
|
22
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
23
|
+
if (admin || publicRole) {
|
|
24
|
+
const error = new Error('Delegated role managers cannot create administrator or public roles');
|
|
25
|
+
error.code = 'FORBIDDEN';
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
28
30
|
export class RolesService extends BaseService {
|
|
31
|
+
async action(event, payload) {
|
|
32
|
+
if (!this.emitter) return;
|
|
33
|
+
await this.emitter.action(event, payload, {
|
|
34
|
+
accountability: this.accountability,
|
|
35
|
+
requestId: this.requestId,
|
|
36
|
+
collection: 'yuncms_roles',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async #readOneUnsafe(id) {
|
|
41
|
+
const [rows] = await this.database.query(
|
|
42
|
+
`SELECT id, name, description, admin, public, created_at, updated_at
|
|
43
|
+
FROM yuncms_roles
|
|
44
|
+
WHERE id = ?
|
|
45
|
+
LIMIT 1`,
|
|
46
|
+
[id],
|
|
47
|
+
);
|
|
48
|
+
return rows[0] ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
async readMany() {
|
|
30
52
|
await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
|
|
31
53
|
const [rows] = await this.database.query(
|
|
@@ -38,18 +60,11 @@ export class RolesService extends BaseService {
|
|
|
38
60
|
|
|
39
61
|
async readOne(id) {
|
|
40
62
|
await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
|
|
41
|
-
|
|
42
|
-
`SELECT id, name, description, admin, public, created_at, updated_at
|
|
43
|
-
FROM yuncms_roles
|
|
44
|
-
WHERE id = ?
|
|
45
|
-
LIMIT 1`,
|
|
46
|
-
[id],
|
|
47
|
-
);
|
|
48
|
-
return rows[0] ?? null;
|
|
63
|
+
return this.#readOneUnsafe(id);
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
async createOne(input = {}) {
|
|
52
|
-
|
|
67
|
+
await resolveSystemResourceAccess(this, 'create', 'yuncms_roles');
|
|
53
68
|
const name = normalizeRoleName(input.name);
|
|
54
69
|
|
|
55
70
|
const admin = input.admin === true;
|
|
@@ -59,6 +74,7 @@ export class RolesService extends BaseService {
|
|
|
59
74
|
error.code = 'INVALID_ROLE';
|
|
60
75
|
throw error;
|
|
61
76
|
}
|
|
77
|
+
assertSpecialRoleCreation(this.accountability, { admin, public: publicRole });
|
|
62
78
|
|
|
63
79
|
if (publicRole) {
|
|
64
80
|
const [rows] = await this.database.query(
|
|
@@ -83,11 +99,13 @@ export class RolesService extends BaseService {
|
|
|
83
99
|
publicRole ? 1 : 0,
|
|
84
100
|
],
|
|
85
101
|
);
|
|
86
|
-
|
|
102
|
+
const role = await this.#readOneUnsafe(id);
|
|
103
|
+
await this.action('roles.create', { key: id, item: role });
|
|
104
|
+
return role;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
async updateOne(id, patch = {}) {
|
|
90
|
-
|
|
108
|
+
await resolveSystemResourceAccess(this, 'update', 'yuncms_roles');
|
|
91
109
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
92
110
|
const error = new Error('Role patch must be an object');
|
|
93
111
|
error.code = 'INVALID_PAYLOAD';
|
|
@@ -100,7 +118,7 @@ export class RolesService extends BaseService {
|
|
|
100
118
|
throw error;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
const existing = await this
|
|
121
|
+
const existing = await this.#readOneUnsafe(id);
|
|
104
122
|
if (!existing) {
|
|
105
123
|
const error = new Error(`Unknown role: ${id}`);
|
|
106
124
|
error.code = 'ROLE_NOT_FOUND';
|
|
@@ -123,12 +141,14 @@ export class RolesService extends BaseService {
|
|
|
123
141
|
`UPDATE yuncms_roles SET ${assignments.join(', ')} WHERE id = ?`,
|
|
124
142
|
params,
|
|
125
143
|
);
|
|
126
|
-
|
|
144
|
+
const role = await this.#readOneUnsafe(id);
|
|
145
|
+
await this.action('roles.update', { key: id, item: role, before: existing, changes: patch });
|
|
146
|
+
return role;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
async deleteOne(id) {
|
|
130
|
-
|
|
131
|
-
const role = await this
|
|
150
|
+
await resolveSystemResourceAccess(this, 'delete', 'yuncms_roles');
|
|
151
|
+
const role = await this.#readOneUnsafe(id);
|
|
132
152
|
if (!role) {
|
|
133
153
|
const error = new Error(`Unknown role: ${id}`);
|
|
134
154
|
error.code = 'ROLE_NOT_FOUND';
|
|
@@ -156,6 +176,7 @@ export class RolesService extends BaseService {
|
|
|
156
176
|
error.code = 'ROLE_NOT_FOUND';
|
|
157
177
|
throw error;
|
|
158
178
|
}
|
|
179
|
+
await this.action('roles.delete', { key: id, before: role });
|
|
159
180
|
return true;
|
|
160
181
|
}
|
|
161
|
-
}
|
|
182
|
+
}
|