@yunsoft/yuncms-core 0.1.1 → 0.1.2
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/README.md +6 -0
- package/package.json +1 -1
- package/src/auth/users-repository.js +2 -2
- package/src/bootstrap.js +2 -0
- package/src/config.js +3 -1
- package/src/field-types.js +49 -1
- package/src/index.js +17 -2
- package/src/migrations/0007-system-permission-resources.js +46 -0
- package/src/o2o-relation.js +231 -0
- package/src/schema-metadata-repository.js +17 -0
- package/src/services/auth-service.js +4 -1
- package/src/services/collections-service.js +18 -2
- package/src/services/fields-service.js +63 -27
- package/src/services/files-service.js +24 -23
- package/src/services/items-service.js +34 -12
- package/src/services/permissions-service.js +48 -13
- package/src/services/roles-service.js +4 -3
- package/src/services/sessions-service.js +3 -3
- package/src/services/system-resource-access.js +23 -0
- package/src/services/users-service.js +71 -28
- package/src/setup.js +1 -2
- package/src/system-fields.js +146 -0
- package/src/system-permissions.js +54 -0
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { hashPassword } from '../auth/password.js';
|
|
4
4
|
import { withTransaction } from '../transaction.js';
|
|
5
5
|
import { BaseService } from './base-service.js';
|
|
6
|
+
import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
6
7
|
|
|
7
8
|
const USER_STATUSES = new Set(['active', 'suspended', 'disabled']);
|
|
8
9
|
const USER_UPDATE_KEYS = new Set(['email', 'role', 'status']);
|
|
@@ -22,11 +23,10 @@ function normalizeEmail(email) {
|
|
|
22
23
|
return normalized;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
const error = new Error('User management requires administrator accountability');
|
|
26
|
+
function forbidden(message) {
|
|
27
|
+
const error = new Error(message);
|
|
28
28
|
error.code = 'FORBIDDEN';
|
|
29
|
-
|
|
29
|
+
return error;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function assertStatus(status) {
|
|
@@ -38,19 +38,65 @@ function assertStatus(status) {
|
|
|
38
38
|
return status;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
function assertCredentialManager(accountability) {
|
|
42
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
43
|
+
throw forbidden('Changing another user password requires administrator accountability');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function assertRoleAssignable(database, role, accountability) {
|
|
42
47
|
if (role == null) return;
|
|
43
|
-
const [roleRows] = await database.query(
|
|
44
|
-
|
|
48
|
+
const [roleRows] = await database.query(
|
|
49
|
+
'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
50
|
+
[role],
|
|
51
|
+
);
|
|
52
|
+
const targetRole = roleRows[0];
|
|
53
|
+
if (!targetRole) {
|
|
45
54
|
const error = new Error(`Unknown role: ${role}`);
|
|
46
55
|
error.code = 'ROLE_NOT_FOUND';
|
|
47
56
|
throw error;
|
|
48
57
|
}
|
|
58
|
+
if (targetRole.public) {
|
|
59
|
+
const error = new Error('The public role cannot be assigned to an authenticated user');
|
|
60
|
+
error.code = 'INVALID_ROLE';
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (targetRole.admin && accountability.admin !== true && accountability.system !== true) {
|
|
64
|
+
throw forbidden('Only an administrator can assign the administrator role');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function assertTargetManageable(database, id, accountability) {
|
|
69
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
70
|
+
const [rows] = await database.query(
|
|
71
|
+
`SELECT u.id, r.admin AS role_admin
|
|
72
|
+
FROM yuncms_users u
|
|
73
|
+
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
74
|
+
WHERE u.id = ?
|
|
75
|
+
LIMIT 1`,
|
|
76
|
+
[id],
|
|
77
|
+
);
|
|
78
|
+
if (!rows[0]) {
|
|
79
|
+
const error = new Error(`Unknown user: ${id}`);
|
|
80
|
+
error.code = 'USER_NOT_FOUND';
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
if (rows[0].role_admin) throw forbidden('Delegated user managers cannot modify administrator accounts');
|
|
49
84
|
}
|
|
50
85
|
|
|
51
86
|
export class UsersService extends BaseService {
|
|
87
|
+
async #readOneUnsafe(id) {
|
|
88
|
+
const [rows] = await this.database.query(
|
|
89
|
+
`SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
|
|
90
|
+
FROM yuncms_users
|
|
91
|
+
WHERE id = ?
|
|
92
|
+
LIMIT 1`,
|
|
93
|
+
[id],
|
|
94
|
+
);
|
|
95
|
+
return rows[0] ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
52
98
|
async readMany() {
|
|
53
|
-
|
|
99
|
+
await resolveSystemResourceAccess(this, 'read', 'yuncms_users');
|
|
54
100
|
const [rows] = await this.database.query(
|
|
55
101
|
`SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
|
|
56
102
|
FROM yuncms_users
|
|
@@ -61,26 +107,19 @@ export class UsersService extends BaseService {
|
|
|
61
107
|
|
|
62
108
|
async readOne(id) {
|
|
63
109
|
const self = this.accountability.user === id;
|
|
64
|
-
if (!self)
|
|
65
|
-
|
|
66
|
-
const [rows] = await this.database.query(
|
|
67
|
-
`SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
|
|
68
|
-
FROM yuncms_users
|
|
69
|
-
WHERE id = ?
|
|
70
|
-
LIMIT 1`,
|
|
71
|
-
[id],
|
|
72
|
-
);
|
|
73
|
-
return rows[0] ?? null;
|
|
110
|
+
if (!self) await resolveSystemResourceAccess(this, 'read', 'yuncms_users');
|
|
111
|
+
return this.#readOneUnsafe(id);
|
|
74
112
|
}
|
|
75
113
|
|
|
76
114
|
async createOne(input = {}) {
|
|
77
|
-
|
|
115
|
+
await resolveSystemResourceAccess(this, 'create', 'yuncms_users');
|
|
78
116
|
const email = normalizeEmail(input.email);
|
|
79
117
|
const status = assertStatus(input.status ?? 'active');
|
|
80
|
-
await
|
|
118
|
+
await assertRoleAssignable(this.database, input.role ?? null, this.accountability);
|
|
81
119
|
|
|
82
120
|
const passwordHash = await hashPassword(input.password);
|
|
83
121
|
const id = randomUUID();
|
|
122
|
+
const verifiedAt = new Date();
|
|
84
123
|
await this.database.query(
|
|
85
124
|
`INSERT INTO yuncms_users (id, email, password_hash, role, status, email_verified_at)
|
|
86
125
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
@@ -90,15 +129,16 @@ export class UsersService extends BaseService {
|
|
|
90
129
|
passwordHash,
|
|
91
130
|
input.role ?? null,
|
|
92
131
|
status,
|
|
93
|
-
|
|
132
|
+
verifiedAt,
|
|
94
133
|
],
|
|
95
134
|
);
|
|
96
135
|
|
|
97
|
-
return this
|
|
136
|
+
return this.#readOneUnsafe(id);
|
|
98
137
|
}
|
|
99
138
|
|
|
100
139
|
async updateOne(id, patch = {}) {
|
|
101
|
-
|
|
140
|
+
await resolveSystemResourceAccess(this, 'update', 'yuncms_users');
|
|
141
|
+
await assertTargetManageable(this.database, id, this.accountability);
|
|
102
142
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
103
143
|
const error = new Error('User patch must be an object');
|
|
104
144
|
error.code = 'INVALID_PAYLOAD';
|
|
@@ -114,12 +154,14 @@ export class UsersService extends BaseService {
|
|
|
114
154
|
if (Object.hasOwn(patch, 'status')) {
|
|
115
155
|
assertStatus(patch.status);
|
|
116
156
|
if (this.accountability.user === id && patch.status !== 'active') {
|
|
117
|
-
const error = new Error('
|
|
157
|
+
const error = new Error('A user cannot suspend or disable their own active session');
|
|
118
158
|
error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
|
|
119
159
|
throw error;
|
|
120
160
|
}
|
|
121
161
|
}
|
|
122
|
-
if (Object.hasOwn(patch, 'role'))
|
|
162
|
+
if (Object.hasOwn(patch, 'role')) {
|
|
163
|
+
await assertRoleAssignable(this.database, patch.role, this.accountability);
|
|
164
|
+
}
|
|
123
165
|
|
|
124
166
|
return withTransaction(this.database, async (connection) => {
|
|
125
167
|
const assignments = [];
|
|
@@ -163,9 +205,10 @@ export class UsersService extends BaseService {
|
|
|
163
205
|
}
|
|
164
206
|
|
|
165
207
|
async deleteOne(id) {
|
|
166
|
-
|
|
208
|
+
await resolveSystemResourceAccess(this, 'delete', 'yuncms_users');
|
|
209
|
+
await assertTargetManageable(this.database, id, this.accountability);
|
|
167
210
|
if (this.accountability.user === id) {
|
|
168
|
-
const error = new Error('
|
|
211
|
+
const error = new Error('A user cannot delete their own account from an active session');
|
|
169
212
|
error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
|
|
170
213
|
throw error;
|
|
171
214
|
}
|
|
@@ -180,7 +223,7 @@ export class UsersService extends BaseService {
|
|
|
180
223
|
|
|
181
224
|
async updatePassword(id, password) {
|
|
182
225
|
const self = this.accountability.user === id;
|
|
183
|
-
if (!self)
|
|
226
|
+
if (!self) assertCredentialManager(this.accountability);
|
|
184
227
|
|
|
185
228
|
const passwordHash = await hashPassword(password);
|
|
186
229
|
const connection = await this.database.getConnection();
|
package/src/setup.js
CHANGED
|
@@ -103,7 +103,6 @@ export async function createInitialAdmin(pool, { email, password } = {}) {
|
|
|
103
103
|
password,
|
|
104
104
|
role: roleId,
|
|
105
105
|
status: 'active',
|
|
106
|
-
emailVerified: true,
|
|
107
106
|
});
|
|
108
107
|
|
|
109
108
|
return {
|
|
@@ -112,4 +111,4 @@ export async function createInitialAdmin(pool, { email, password } = {}) {
|
|
|
112
111
|
role: roleId,
|
|
113
112
|
};
|
|
114
113
|
});
|
|
115
|
-
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { compileFieldColumn } from './field-types.js';
|
|
4
|
+
import { quoteIdentifier } from './identifier.js';
|
|
5
|
+
|
|
6
|
+
export const COLLECTION_SYSTEM_FIELDS = Object.freeze([
|
|
7
|
+
'created_at',
|
|
8
|
+
'updated_at',
|
|
9
|
+
'created_by',
|
|
10
|
+
'updated_by',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const SYSTEM_FIELD_DEFINITIONS = Object.freeze({
|
|
14
|
+
created_at: Object.freeze({
|
|
15
|
+
field: 'created_at',
|
|
16
|
+
type: 'timestamp',
|
|
17
|
+
required: true,
|
|
18
|
+
readonly: true,
|
|
19
|
+
interface: 'datetime',
|
|
20
|
+
special: 'date-created',
|
|
21
|
+
defaultPreset: 'now',
|
|
22
|
+
}),
|
|
23
|
+
updated_at: Object.freeze({
|
|
24
|
+
field: 'updated_at',
|
|
25
|
+
type: 'timestamp',
|
|
26
|
+
required: true,
|
|
27
|
+
readonly: true,
|
|
28
|
+
interface: 'datetime',
|
|
29
|
+
special: 'date-updated',
|
|
30
|
+
defaultPreset: 'now',
|
|
31
|
+
autoUpdate: true,
|
|
32
|
+
}),
|
|
33
|
+
created_by: Object.freeze({
|
|
34
|
+
field: 'created_by',
|
|
35
|
+
type: 'uuid',
|
|
36
|
+
required: false,
|
|
37
|
+
readonly: true,
|
|
38
|
+
interface: 'user',
|
|
39
|
+
special: 'user-created',
|
|
40
|
+
}),
|
|
41
|
+
updated_by: Object.freeze({
|
|
42
|
+
field: 'updated_by',
|
|
43
|
+
type: 'uuid',
|
|
44
|
+
required: false,
|
|
45
|
+
readonly: true,
|
|
46
|
+
interface: 'user',
|
|
47
|
+
special: 'user-updated',
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function invalidSystemFields(message) {
|
|
52
|
+
const error = new Error(message);
|
|
53
|
+
error.code = 'INVALID_SCHEMA_PAYLOAD';
|
|
54
|
+
return error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeCollectionSystemFields(value) {
|
|
58
|
+
if (value == null) return [];
|
|
59
|
+
if (!Array.isArray(value)) throw invalidSystemFields('systemFields must be an array');
|
|
60
|
+
|
|
61
|
+
const normalized = [...new Set(value)];
|
|
62
|
+
for (const field of normalized) {
|
|
63
|
+
if (!COLLECTION_SYSTEM_FIELDS.includes(field)) {
|
|
64
|
+
throw invalidSystemFields(`Unsupported system field: ${String(field)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return COLLECTION_SYSTEM_FIELDS.filter((field) => normalized.includes(field));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function systemFieldDefinition(field) {
|
|
71
|
+
return SYSTEM_FIELD_DEFINITIONS[field] ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function foreignKeyName(collection, field) {
|
|
75
|
+
const digest = createHash('sha256')
|
|
76
|
+
.update(`${collection}:${field}:system-user`)
|
|
77
|
+
.digest('hex')
|
|
78
|
+
.slice(0, 24);
|
|
79
|
+
return `ysf_${digest}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function compileCollectionSystemFields(collection, requestedFields) {
|
|
83
|
+
const fields = normalizeCollectionSystemFields(requestedFields);
|
|
84
|
+
const columns = [];
|
|
85
|
+
const constraints = [];
|
|
86
|
+
const metadata = [];
|
|
87
|
+
|
|
88
|
+
for (const field of fields) {
|
|
89
|
+
const definition = SYSTEM_FIELD_DEFINITIONS[field];
|
|
90
|
+
const compiled = compileFieldColumn(definition);
|
|
91
|
+
columns.push(`${quoteIdentifier(field, 'system field')} ${compiled.sql}`);
|
|
92
|
+
metadata.push({
|
|
93
|
+
collection,
|
|
94
|
+
field,
|
|
95
|
+
type: definition.type,
|
|
96
|
+
required: definition.required,
|
|
97
|
+
readonly: true,
|
|
98
|
+
hidden: false,
|
|
99
|
+
interface: definition.interface,
|
|
100
|
+
schemaMetadata: {
|
|
101
|
+
...compiled.schemaMetadata,
|
|
102
|
+
special: definition.special,
|
|
103
|
+
systemManaged: true,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (field === 'created_by' || field === 'updated_by') {
|
|
108
|
+
constraints.push(
|
|
109
|
+
`CONSTRAINT ${quoteIdentifier(foreignKeyName(collection, field), 'system field constraint')} `
|
|
110
|
+
+ `FOREIGN KEY (${quoteIdentifier(field, 'system field')}) REFERENCES yuncms_users (id) ON DELETE SET NULL`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { fields, columns, constraints, metadata };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseSchemaMetadata(value) {
|
|
119
|
+
if (value == null) return {};
|
|
120
|
+
if (typeof value === 'object') return value;
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(value) ?? {};
|
|
123
|
+
} catch {
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function fieldSpecial(fieldSchema) {
|
|
129
|
+
return parseSchemaMetadata(fieldSchema?.schema_metadata).special ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function isSystemManagedField(fieldSchema) {
|
|
133
|
+
return Boolean(fieldSpecial(fieldSchema));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function systemMutationEntries(schema, accountability, operation, now = new Date()) {
|
|
137
|
+
const entries = [];
|
|
138
|
+
for (const fieldSchema of Object.values(schema?.fields ?? {})) {
|
|
139
|
+
const special = fieldSpecial(fieldSchema);
|
|
140
|
+
if (operation === 'create' && special === 'date-created') entries.push([fieldSchema.field, now]);
|
|
141
|
+
if ((operation === 'create' || operation === 'update') && special === 'date-updated') entries.push([fieldSchema.field, now]);
|
|
142
|
+
if (operation === 'create' && special === 'user-created') entries.push([fieldSchema.field, accountability?.user ?? null]);
|
|
143
|
+
if ((operation === 'create' || operation === 'update') && special === 'user-updated') entries.push([fieldSchema.field, accountability?.user ?? null]);
|
|
144
|
+
}
|
|
145
|
+
return entries;
|
|
146
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const ALL_ACTIONS = Object.freeze(['read', 'create', 'update', 'delete']);
|
|
2
|
+
|
|
3
|
+
function parseMetadata(value) {
|
|
4
|
+
if (value == null) return {};
|
|
5
|
+
if (typeof value === 'object') return value;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(value) ?? {};
|
|
8
|
+
} catch {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isEnabledFlag(value) {
|
|
14
|
+
return value === true || value === 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function systemPermissionConfig(collectionSchema) {
|
|
18
|
+
if (!collectionSchema?.system) return null;
|
|
19
|
+
const metadata = parseMetadata(collectionSchema.metadata);
|
|
20
|
+
if (!isEnabledFlag(metadata.permissionManaged)) return null;
|
|
21
|
+
const allowedActions = Array.isArray(metadata.allowedActions)
|
|
22
|
+
? metadata.allowedActions.filter((action) => ALL_ACTIONS.includes(action))
|
|
23
|
+
: [];
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
resource: metadata.resource ?? collectionSchema.collection,
|
|
26
|
+
mode: metadata.permissionMode ?? 'action-only',
|
|
27
|
+
allowedActions: Object.freeze([...new Set(allowedActions)]),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isPermissionManagedSystemResource(collectionSchema) {
|
|
32
|
+
return Boolean(systemPermissionConfig(collectionSchema));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertSystemResourceAction(collectionSchema, action) {
|
|
36
|
+
const config = systemPermissionConfig(collectionSchema);
|
|
37
|
+
if (!config) return null;
|
|
38
|
+
if (!config.allowedActions.includes(action)) {
|
|
39
|
+
const error = new Error(`Action ${action} is protected for system resource ${collectionSchema.collection}`);
|
|
40
|
+
error.code = 'SYSTEM_PERMISSION_ACTION_PROTECTED';
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertActionOnlyPermissionPayload(collectionSchema, { fields, filter, validation } = {}) {
|
|
47
|
+
const config = systemPermissionConfig(collectionSchema);
|
|
48
|
+
if (!config || config.mode !== 'action-only') return;
|
|
49
|
+
if (fields != null || filter != null || validation != null) {
|
|
50
|
+
const error = new Error(`System resource ${collectionSchema.collection} supports action-level permissions only`);
|
|
51
|
+
error.code = 'SYSTEM_PERMISSION_ADVANCED_UNSUPPORTED';
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|