@yunsoft/yuncms-core 0.1.3 → 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 +4 -0
- package/src/cache.js +85 -0
- package/src/config.js +50 -0
- package/src/context.js +5 -2
- package/src/index.js +9 -0
- 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.js +113 -3
- package/src/query.js +54 -12
- package/src/relation-expansion.js +191 -57
- 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/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
|
@@ -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
|
+
}
|
|
@@ -63,6 +63,13 @@ async function assertRoleAssignable(database, role, accountability) {
|
|
|
63
63
|
if (targetRole.admin && accountability.admin !== true && accountability.system !== true) {
|
|
64
64
|
throw forbidden('Only an administrator can assign the administrator role');
|
|
65
65
|
}
|
|
66
|
+
if (
|
|
67
|
+
accountability.admin !== true
|
|
68
|
+
&& accountability.system !== true
|
|
69
|
+
&& role !== accountability.role
|
|
70
|
+
) {
|
|
71
|
+
throw forbidden('Delegated user managers may assign only their own role');
|
|
72
|
+
}
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
async function assertTargetManageable(database, id, accountability) {
|
|
@@ -84,6 +91,15 @@ async function assertTargetManageable(database, id, accountability) {
|
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
export class UsersService extends BaseService {
|
|
94
|
+
async action(event, payload) {
|
|
95
|
+
if (!this.emitter) return;
|
|
96
|
+
await this.emitter.action(event, payload, {
|
|
97
|
+
accountability: this.accountability,
|
|
98
|
+
requestId: this.requestId,
|
|
99
|
+
collection: 'yuncms_users',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
async #readOneUnsafe(id) {
|
|
88
104
|
const [rows] = await this.database.query(
|
|
89
105
|
`SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
|
|
@@ -133,7 +149,9 @@ export class UsersService extends BaseService {
|
|
|
133
149
|
],
|
|
134
150
|
);
|
|
135
151
|
|
|
136
|
-
|
|
152
|
+
const user = await this.#readOneUnsafe(id);
|
|
153
|
+
await this.action('users.create', { key: id, item: user });
|
|
154
|
+
return user;
|
|
137
155
|
}
|
|
138
156
|
|
|
139
157
|
async updateOne(id, patch = {}) {
|
|
@@ -163,7 +181,7 @@ export class UsersService extends BaseService {
|
|
|
163
181
|
await assertRoleAssignable(this.database, patch.role, this.accountability);
|
|
164
182
|
}
|
|
165
183
|
|
|
166
|
-
|
|
184
|
+
const user = await withTransaction(this.database, async (connection) => {
|
|
167
185
|
const assignments = [];
|
|
168
186
|
const params = [];
|
|
169
187
|
|
|
@@ -202,6 +220,8 @@ export class UsersService extends BaseService {
|
|
|
202
220
|
);
|
|
203
221
|
return rows[0] ?? null;
|
|
204
222
|
});
|
|
223
|
+
await this.action('users.update', { key: id, item: user, changes: patch });
|
|
224
|
+
return user;
|
|
205
225
|
}
|
|
206
226
|
|
|
207
227
|
async deleteOne(id) {
|
|
@@ -212,12 +232,14 @@ export class UsersService extends BaseService {
|
|
|
212
232
|
error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
|
|
213
233
|
throw error;
|
|
214
234
|
}
|
|
235
|
+
const before = this.emitter ? await this.#readOneUnsafe(id) : null;
|
|
215
236
|
const [result] = await this.database.query('DELETE FROM yuncms_users WHERE id = ?', [id]);
|
|
216
237
|
if (result.affectedRows !== 1) {
|
|
217
238
|
const error = new Error(`Unknown user: ${id}`);
|
|
218
239
|
error.code = 'USER_NOT_FOUND';
|
|
219
240
|
throw error;
|
|
220
241
|
}
|
|
242
|
+
await this.action('users.delete', { key: id, before });
|
|
221
243
|
return true;
|
|
222
244
|
}
|
|
223
245
|
|
|
@@ -241,7 +263,6 @@ export class UsersService extends BaseService {
|
|
|
241
263
|
}
|
|
242
264
|
await connection.query('DELETE FROM yuncms_sessions WHERE user = ?', [id]);
|
|
243
265
|
await connection.commit();
|
|
244
|
-
return true;
|
|
245
266
|
} catch (error) {
|
|
246
267
|
try {
|
|
247
268
|
await connection.rollback();
|
|
@@ -252,6 +273,9 @@ export class UsersService extends BaseService {
|
|
|
252
273
|
} finally {
|
|
253
274
|
connection.release();
|
|
254
275
|
}
|
|
276
|
+
|
|
277
|
+
await this.action('users.password.update', { key: id });
|
|
278
|
+
return true;
|
|
255
279
|
}
|
|
256
280
|
}
|
|
257
281
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const ALL_ACTIONS = Object.freeze(['read', 'create', 'update', 'delete']);
|
|
2
|
+
const PERMISSION_MODES = new Set(['action-only', 'filter-read']);
|
|
2
3
|
|
|
3
4
|
function parseMetadata(value) {
|
|
4
5
|
if (value == null) return {};
|
|
@@ -14,16 +15,28 @@ function isEnabledFlag(value) {
|
|
|
14
15
|
return value === true || value === 1;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function advancedUnsupported(collectionSchema, message) {
|
|
19
|
+
const error = new Error(message ?? `System resource ${collectionSchema.collection} does not support this advanced permission rule`);
|
|
20
|
+
error.code = 'SYSTEM_PERMISSION_ADVANCED_UNSUPPORTED';
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
export function systemPermissionConfig(collectionSchema) {
|
|
18
25
|
if (!collectionSchema?.system) return null;
|
|
19
26
|
const metadata = parseMetadata(collectionSchema.metadata);
|
|
20
27
|
if (!isEnabledFlag(metadata.permissionManaged)) return null;
|
|
28
|
+
const mode = metadata.permissionMode ?? 'action-only';
|
|
29
|
+
if (!PERMISSION_MODES.has(mode)) {
|
|
30
|
+
const error = new Error(`Unsupported system permission mode for ${collectionSchema.collection}: ${mode}`);
|
|
31
|
+
error.code = 'SYSTEM_PERMISSION_MODE_UNSUPPORTED';
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
21
34
|
const allowedActions = Array.isArray(metadata.allowedActions)
|
|
22
35
|
? metadata.allowedActions.filter((action) => ALL_ACTIONS.includes(action))
|
|
23
36
|
: [];
|
|
24
37
|
return Object.freeze({
|
|
25
38
|
resource: metadata.resource ?? collectionSchema.collection,
|
|
26
|
-
mode
|
|
39
|
+
mode,
|
|
27
40
|
allowedActions: Object.freeze([...new Set(allowedActions)]),
|
|
28
41
|
});
|
|
29
42
|
}
|
|
@@ -43,12 +56,42 @@ export function assertSystemResourceAction(collectionSchema, action) {
|
|
|
43
56
|
return config;
|
|
44
57
|
}
|
|
45
58
|
|
|
46
|
-
export function
|
|
59
|
+
export function assertSystemPermissionPayload(
|
|
60
|
+
collectionSchema,
|
|
61
|
+
action,
|
|
62
|
+
{ fields, filter, validation } = {},
|
|
63
|
+
) {
|
|
47
64
|
const config = systemPermissionConfig(collectionSchema);
|
|
48
|
-
if (!config
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
if (!config) return;
|
|
66
|
+
|
|
67
|
+
if (config.mode === 'action-only') {
|
|
68
|
+
if (fields != null || filter != null || validation != null) {
|
|
69
|
+
advancedUnsupported(
|
|
70
|
+
collectionSchema,
|
|
71
|
+
`System resource ${collectionSchema.collection} supports action-level permissions only`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
53
75
|
}
|
|
76
|
+
|
|
77
|
+
if (config.mode === 'filter-read') {
|
|
78
|
+
if (fields != null || validation != null) {
|
|
79
|
+
advancedUnsupported(
|
|
80
|
+
collectionSchema,
|
|
81
|
+
`System resource ${collectionSchema.collection} supports only a row filter on read permissions`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (filter != null && action !== 'read') {
|
|
85
|
+
advancedUnsupported(
|
|
86
|
+
collectionSchema,
|
|
87
|
+
`System resource ${collectionSchema.collection} permits row filters only for read`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function assertActionOnlyPermissionPayload(collectionSchema, payload = {}) {
|
|
94
|
+
const config = systemPermissionConfig(collectionSchema);
|
|
95
|
+
if (!config || config.mode !== 'action-only') return;
|
|
96
|
+
return assertSystemPermissionPayload(collectionSchema, 'read', payload);
|
|
54
97
|
}
|