@yunsoft/yuncms-core 0.1.2 → 0.1.5
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/bootstrap.js +10 -0
- package/src/cache.js +85 -0
- package/src/config.js +50 -0
- package/src/context.js +5 -2
- package/src/index.js +12 -1
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0008-studio-logo-file.js +10 -0
- package/src/migrations/0009-schema-display-names.js +19 -0
- package/src/migrations/0010-studio-favicon-file.js +12 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations.js +113 -3
- package/src/query.js +54 -12
- package/src/relation-expansion.js +191 -57
- package/src/schema-key.js +54 -0
- package/src/schema-metadata-repository.js +20 -8
- package/src/services/collections-service.js +14 -2
- package/src/services/core-services.js +2 -0
- package/src/services/fields-service.js +11 -2
- package/src/services/files-service.js +85 -7
- package/src/services/items-service.js +54 -1
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/studio-settings-service.js +83 -11
- package/src/services/system-collection-fields-service.js +122 -0
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
|
@@ -3,13 +3,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { withAdvisoryLock } from '../advisory-lock.js';
|
|
4
4
|
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
5
5
|
import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
|
|
6
|
+
import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
|
|
6
7
|
import { incrementSchemaVersion } from '../schema-version.js';
|
|
7
8
|
import { compileCollectionSystemFields, normalizeCollectionSystemFields } from '../system-fields.js';
|
|
8
9
|
import { withConnectionTransaction } from '../transaction.js';
|
|
9
10
|
import { BaseService } from './base-service.js';
|
|
10
11
|
import { assertSchemaManager } from './schema-access.js';
|
|
11
12
|
|
|
12
|
-
const COLLECTION_METADATA_KEYS = new Set(['note', 'singleton', 'hidden', 'metadata']);
|
|
13
|
+
const COLLECTION_METADATA_KEYS = new Set(['name', 'note', 'singleton', 'hidden', 'metadata']);
|
|
13
14
|
|
|
14
15
|
function assertUserCollectionName(collection) {
|
|
15
16
|
assertIdentifier(collection, 'collection name');
|
|
@@ -47,6 +48,9 @@ function assertCollectionMetadataPatch(patch) {
|
|
|
47
48
|
throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
if (Object.hasOwn(patch, 'name')) {
|
|
52
|
+
patch.name = normalizeDisplayName(patch.name);
|
|
53
|
+
}
|
|
50
54
|
if (Object.hasOwn(patch, 'note') && patch.note != null && typeof patch.note !== 'string') {
|
|
51
55
|
throw invalidSchemaPayload('Collection note must be a string or null');
|
|
52
56
|
}
|
|
@@ -93,7 +97,13 @@ export class CollectionsService extends BaseService {
|
|
|
93
97
|
async createOne(input = {}) {
|
|
94
98
|
assertSchemaManager(this.accountability);
|
|
95
99
|
assertCollectionCreateMetadata(input);
|
|
96
|
-
const
|
|
100
|
+
const resolvedName = resolveSchemaName({
|
|
101
|
+
displayName: input.name ?? input.collection,
|
|
102
|
+
key: input.collection,
|
|
103
|
+
prefix: 'collection',
|
|
104
|
+
});
|
|
105
|
+
const collection = assertUserCollectionName(resolvedName.key);
|
|
106
|
+
const name = resolvedName.name;
|
|
97
107
|
const primaryKey = input.primaryKey ?? 'id';
|
|
98
108
|
|
|
99
109
|
if (primaryKey !== 'id') {
|
|
@@ -132,6 +142,7 @@ export class CollectionsService extends BaseService {
|
|
|
132
142
|
return await withConnectionTransaction(connection, async () => {
|
|
133
143
|
const created = await metadata.createCollection({
|
|
134
144
|
collection,
|
|
145
|
+
name,
|
|
135
146
|
primaryKey,
|
|
136
147
|
note: input.note ?? null,
|
|
137
148
|
singleton: input.singleton === true,
|
|
@@ -145,6 +156,7 @@ export class CollectionsService extends BaseService {
|
|
|
145
156
|
await metadata.createField({
|
|
146
157
|
collection,
|
|
147
158
|
field: 'id',
|
|
159
|
+
name: 'ID',
|
|
148
160
|
type: 'uuid',
|
|
149
161
|
required: true,
|
|
150
162
|
readonly: true,
|
|
@@ -11,6 +11,7 @@ import { PermissionsService } from './permissions-service.js';
|
|
|
11
11
|
import { RelationsService } from './relations-service.js';
|
|
12
12
|
import { RolesService } from './roles-service.js';
|
|
13
13
|
import { StudioSettingsService } from './studio-settings-service.js';
|
|
14
|
+
import { SystemCollectionFieldsService } from './system-collection-fields-service.js';
|
|
14
15
|
import { UsersService } from './users-service.js';
|
|
15
16
|
import { createServiceRegistry } from './service-registry.js';
|
|
16
17
|
|
|
@@ -23,6 +24,7 @@ export function createCoreServiceRegistry() {
|
|
|
23
24
|
ItemsService,
|
|
24
25
|
CollectionsService,
|
|
25
26
|
FieldsService,
|
|
27
|
+
SystemCollectionFieldsService,
|
|
26
28
|
RelationsService,
|
|
27
29
|
UsersService,
|
|
28
30
|
RolesService,
|
|
@@ -4,12 +4,13 @@ import { withAdvisoryLock } from '../advisory-lock.js';
|
|
|
4
4
|
import { compileFieldColumn } from '../field-types.js';
|
|
5
5
|
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
6
6
|
import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
|
|
7
|
+
import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
|
|
7
8
|
import { incrementSchemaVersion } from '../schema-version.js';
|
|
8
9
|
import { withConnectionTransaction } from '../transaction.js';
|
|
9
10
|
import { BaseService } from './base-service.js';
|
|
10
11
|
import { assertSchemaManager } from './schema-access.js';
|
|
11
12
|
|
|
12
|
-
const FIELD_METADATA_KEYS = new Set(['readonly', 'hidden', 'sort', 'interface', 'options']);
|
|
13
|
+
const FIELD_METADATA_KEYS = new Set(['name', 'readonly', 'hidden', 'sort', 'interface', 'options']);
|
|
13
14
|
const FIELD_PHYSICAL_KEYS = new Set([
|
|
14
15
|
'required',
|
|
15
16
|
'defaultValue',
|
|
@@ -46,6 +47,7 @@ function assertFieldMetadataPatch(patch) {
|
|
|
46
47
|
if (Object.keys(patch).length === 0) {
|
|
47
48
|
throw invalidSchemaPayload('Field metadata patch cannot be empty');
|
|
48
49
|
}
|
|
50
|
+
if (Object.hasOwn(patch, 'name')) patch.name = normalizeDisplayName(patch.name);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
function assertPhysicalPatch(patch) {
|
|
@@ -150,7 +152,13 @@ export class FieldsService extends BaseService {
|
|
|
150
152
|
async createOne(collection, input = {}) {
|
|
151
153
|
assertSchemaManager(this.accountability);
|
|
152
154
|
assertIdentifier(collection, 'collection name');
|
|
153
|
-
const
|
|
155
|
+
const resolvedName = resolveSchemaName({
|
|
156
|
+
displayName: input.name ?? input.field,
|
|
157
|
+
key: input.field,
|
|
158
|
+
prefix: 'field',
|
|
159
|
+
});
|
|
160
|
+
const field = assertFieldName(resolvedName.key);
|
|
161
|
+
const name = resolvedName.name;
|
|
154
162
|
|
|
155
163
|
if (field === 'id') {
|
|
156
164
|
const error = new Error('The id field is created with the collection and cannot be added again');
|
|
@@ -196,6 +204,7 @@ export class FieldsService extends BaseService {
|
|
|
196
204
|
const created = await metadata.createField({
|
|
197
205
|
collection,
|
|
198
206
|
field,
|
|
207
|
+
name,
|
|
199
208
|
type: input.type,
|
|
200
209
|
required: input.required === true,
|
|
201
210
|
readonly: input.readonly === true,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
import { compileFilter } from '../query.js';
|
|
3
4
|
import { BaseService } from './base-service.js';
|
|
4
5
|
import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
5
6
|
|
|
@@ -27,6 +28,38 @@ function normalizeMimeType(value) {
|
|
|
27
28
|
return mimetype;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
function startsWithBytes(buffer, bytes, offset = 0) {
|
|
32
|
+
if (buffer.byteLength < offset + bytes.length) return false;
|
|
33
|
+
return bytes.every((byte, index) => buffer[offset + index] === byte);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasKnownMimeSignature(contents, mimetype) {
|
|
37
|
+
const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
38
|
+
switch (mimetype) {
|
|
39
|
+
case 'application/pdf':
|
|
40
|
+
return startsWithBytes(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
|
|
41
|
+
case 'image/png':
|
|
42
|
+
return startsWithBytes(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
43
|
+
case 'image/jpeg':
|
|
44
|
+
return startsWithBytes(buffer, [0xff, 0xd8, 0xff]);
|
|
45
|
+
case 'image/gif':
|
|
46
|
+
return buffer.subarray(0, 6).toString('ascii') === 'GIF87a'
|
|
47
|
+
|| buffer.subarray(0, 6).toString('ascii') === 'GIF89a';
|
|
48
|
+
case 'image/webp':
|
|
49
|
+
return buffer.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
50
|
+
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP';
|
|
51
|
+
default:
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertMimeSignature(contents, mimetype) {
|
|
57
|
+
const matches = hasKnownMimeSignature(contents, mimetype);
|
|
58
|
+
if (matches === false) {
|
|
59
|
+
throw fileError('FILE_MIME_MISMATCH', `File contents do not match declared MIME type: ${mimetype}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
function decodeJson(value) {
|
|
31
64
|
if (value == null || typeof value === 'object') return value ?? null;
|
|
32
65
|
try {
|
|
@@ -56,6 +89,31 @@ export class FilesService extends BaseService {
|
|
|
56
89
|
});
|
|
57
90
|
}
|
|
58
91
|
|
|
92
|
+
#compileReadScope(permission, id = null) {
|
|
93
|
+
let sql = '';
|
|
94
|
+
let params = [];
|
|
95
|
+
|
|
96
|
+
if (permission?.filter) {
|
|
97
|
+
const collectionSchema = this.schema?.collections?.yuncms_files;
|
|
98
|
+
if (!collectionSchema) {
|
|
99
|
+
throw fileError(
|
|
100
|
+
'SYSTEM_SCHEMA_REQUIRED',
|
|
101
|
+
'System schema is required to enforce a filtered Files permission',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const compiled = compileFilter(permission.filter, collectionSchema);
|
|
105
|
+
sql = compiled.sql;
|
|
106
|
+
params = [...compiled.params];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (id != null) {
|
|
110
|
+
sql = sql ? `${sql} AND id = ?` : ' WHERE id = ?';
|
|
111
|
+
params.push(id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { sql, params };
|
|
115
|
+
}
|
|
116
|
+
|
|
59
117
|
async #readOneUnsafe(id) {
|
|
60
118
|
const [rows] = await this.database.query(
|
|
61
119
|
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
@@ -68,20 +126,34 @@ export class FilesService extends BaseService {
|
|
|
68
126
|
return normalizeRow(rows[0]);
|
|
69
127
|
}
|
|
70
128
|
|
|
129
|
+
async #readOneAuthorized(id, permission) {
|
|
130
|
+
const scope = this.#compileReadScope(permission, id);
|
|
131
|
+
const [rows] = await this.database.query(
|
|
132
|
+
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
133
|
+
uploaded_by, uploaded_at, metadata
|
|
134
|
+
FROM yuncms_files${scope.sql}
|
|
135
|
+
LIMIT 1`,
|
|
136
|
+
scope.params,
|
|
137
|
+
);
|
|
138
|
+
return normalizeRow(rows[0]);
|
|
139
|
+
}
|
|
140
|
+
|
|
71
141
|
async readMany() {
|
|
72
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
142
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
143
|
+
const scope = this.#compileReadScope(permission);
|
|
73
144
|
const [rows] = await this.database.query(
|
|
74
145
|
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
75
146
|
uploaded_by, uploaded_at, metadata
|
|
76
|
-
FROM yuncms_files
|
|
147
|
+
FROM yuncms_files${scope.sql}
|
|
77
148
|
ORDER BY uploaded_at DESC, id DESC`,
|
|
149
|
+
scope.params,
|
|
78
150
|
);
|
|
79
151
|
return rows.map(normalizeRow);
|
|
80
152
|
}
|
|
81
153
|
|
|
82
154
|
async readOne(id) {
|
|
83
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
84
|
-
return this.#
|
|
155
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
156
|
+
return this.#readOneAuthorized(id, permission);
|
|
85
157
|
}
|
|
86
158
|
|
|
87
159
|
async createOne({
|
|
@@ -100,6 +172,7 @@ export class FilesService extends BaseService {
|
|
|
100
172
|
|
|
101
173
|
const filename = normalizeFilename(filenameDownload);
|
|
102
174
|
const normalizedMime = normalizeMimeType(mimetype);
|
|
175
|
+
assertMimeSignature(contents, normalizedMime);
|
|
103
176
|
const driver = this.storage.get(storage);
|
|
104
177
|
const id = randomUUID();
|
|
105
178
|
const filenameDisk = id;
|
|
@@ -144,8 +217,8 @@ export class FilesService extends BaseService {
|
|
|
144
217
|
}
|
|
145
218
|
|
|
146
219
|
async readContent(id) {
|
|
147
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
148
|
-
const file = await this.#
|
|
220
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
221
|
+
const file = await this.#readOneAuthorized(id, permission);
|
|
149
222
|
if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
|
|
150
223
|
const driver = this.storage.get(file.storage);
|
|
151
224
|
const contents = await driver.get(file.filename_disk);
|
|
@@ -225,4 +298,9 @@ export class FilesService extends BaseService {
|
|
|
225
298
|
}
|
|
226
299
|
}
|
|
227
300
|
|
|
228
|
-
export {
|
|
301
|
+
export {
|
|
302
|
+
assertMimeSignature,
|
|
303
|
+
hasKnownMimeSignature,
|
|
304
|
+
normalizeFilename,
|
|
305
|
+
normalizeMimeType,
|
|
306
|
+
};
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
compileSelectFields,
|
|
8
8
|
compileSort,
|
|
9
9
|
parseItemsQuery,
|
|
10
|
+
QUERY_LIMITS,
|
|
10
11
|
} from '../query.js';
|
|
11
12
|
import { SchemaCache } from '../schema.js';
|
|
12
13
|
import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
|
|
@@ -215,6 +216,58 @@ export class ItemsService extends BaseService {
|
|
|
215
216
|
};
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
async readManyForRelation({ fields = null, lookupField, values = [] } = {}) {
|
|
220
|
+
const schema = await this.getCollectionSchema();
|
|
221
|
+
const trustedLookupField = assertIdentifier(lookupField, 'relation lookup field');
|
|
222
|
+
if (!schema.fields[trustedLookupField]) {
|
|
223
|
+
throw serviceError(
|
|
224
|
+
'INVALID_QUERY',
|
|
225
|
+
`Unknown relation lookup field: ${trustedLookupField}`,
|
|
226
|
+
trustedLookupField,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxLimit) {
|
|
230
|
+
throw serviceError(
|
|
231
|
+
'INVALID_QUERY',
|
|
232
|
+
`Relation lookup values must contain between 1 and ${QUERY_LIMITS.maxLimit} entries`,
|
|
233
|
+
trustedLookupField,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const permission = await this.resolvePermission('read');
|
|
238
|
+
const accessSchema = schemaForFields(schema, permission.fields);
|
|
239
|
+
const visibleSelection = compileSelectFields(normalizeFields(fields), accessSchema);
|
|
240
|
+
const internalSchema = {
|
|
241
|
+
...accessSchema,
|
|
242
|
+
fields: {
|
|
243
|
+
...accessSchema.fields,
|
|
244
|
+
[trustedLookupField]: schema.fields[trustedLookupField],
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
const internalSelection = compileSelectFields(
|
|
248
|
+
[...visibleSelection.fields, trustedLookupField],
|
|
249
|
+
internalSchema,
|
|
250
|
+
);
|
|
251
|
+
const permissionFilter = compileFilter(permission.filter, schema);
|
|
252
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
253
|
+
const data = [];
|
|
254
|
+
|
|
255
|
+
for (let offset = 0; offset < values.length; offset += QUERY_LIMITS.maxInValues) {
|
|
256
|
+
const chunk = values.slice(offset, offset + QUERY_LIMITS.maxInValues);
|
|
257
|
+
const filter = combineCompiledFilters(
|
|
258
|
+
permissionFilter,
|
|
259
|
+
compileFilter({ [trustedLookupField]: { _in: chunk } }, schema),
|
|
260
|
+
);
|
|
261
|
+
const [rows] = await this.database.query(
|
|
262
|
+
`SELECT ${internalSelection.sql} FROM ${table}${filter.sql} LIMIT ?`,
|
|
263
|
+
[...filter.params, chunk.length],
|
|
264
|
+
);
|
|
265
|
+
data.push(...rows);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { data, visibleFields: visibleSelection.fields };
|
|
269
|
+
}
|
|
270
|
+
|
|
218
271
|
async readOne(id, { fields = null } = {}) {
|
|
219
272
|
const schema = await this.getCollectionSchema();
|
|
220
273
|
const permission = await this.resolvePermission('read');
|
|
@@ -464,4 +517,4 @@ export class ItemsService extends BaseService {
|
|
|
464
517
|
}
|
|
465
518
|
}
|
|
466
519
|
|
|
467
|
-
export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
|
|
520
|
+
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
|
+
}
|