@goplusvn/core 0.1.75 → 0.1.76
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/CHANGELOG.md +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +260 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +157 -0
- package/src/workspace/pages/workspace-list-page.tsx +430 -0
- package/src/workspace/route-handlers.ts +274 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uỷ quyền quản trị người dùng — bảy bất biến D1–D7.
|
|
3
|
+
*
|
|
4
|
+
* Đây là phần lõi và cũng là phần dễ sai nhất của cả cơ chế: cho khách hàng tự
|
|
5
|
+
* tạo tài khoản mà không rào thì họ tự cấp `SUPER_ADMIN` sau 30 giây. Mỗi hàm
|
|
6
|
+
* dưới đây là một cách leo thang cụ thể đã bị bịt, không phải "kiểm tra cho chắc".
|
|
7
|
+
*
|
|
8
|
+
* D1 chứa-trong-nhánh · D2 trần quyền lúc CHẠY · D3 không tự nâng · D4 vai trò
|
|
9
|
+
* cấm uỷ quyền · D5 sở hữu trọn · D6 tài khoản được bảo vệ · D7 không tháo rào.
|
|
10
|
+
*
|
|
11
|
+
* **Một sự thật phải chấp nhận, không vá được bằng kỹ thuật** (AWS ghi thẳng
|
|
12
|
+
* trong tài liệu permissions boundary): người được uỷ quyền tạo tài khoản vẫn có
|
|
13
|
+
* thể tạo ra một tài khoản truy cập được thứ mà chính họ không truy cập được.
|
|
14
|
+
* Uỷ quyền tạo tài khoản LUÔN là một hành vi tin tưởng; D1–D7 giới hạn thiệt hại
|
|
15
|
+
* chứ không xoá bỏ nó.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { isWorkspaceAdmin } from "./scope";
|
|
19
|
+
import type { WorkspaceScope } from "./types";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Action mà chỉ TOÀN QUYỀN mới được gán đi (D4) — kể cả khi trần quyền D2 tình
|
|
23
|
+
* cờ thoả. Keycloak phát biểu y hệt cho vai trò quản trị tổ chức: "chỉ realm
|
|
24
|
+
* administrator gán được".
|
|
25
|
+
*
|
|
26
|
+
* Ai gán được `role:update` thì gán được mọi thứ khác — nên nó phải nằm đây,
|
|
27
|
+
* không phải "cân nhắc theo ngữ cảnh".
|
|
28
|
+
*/
|
|
29
|
+
export const DANGER_ACTIONS: readonly string[] = [
|
|
30
|
+
"role:create",
|
|
31
|
+
"role:update",
|
|
32
|
+
"role:delete",
|
|
33
|
+
"resource:create",
|
|
34
|
+
"resource:update",
|
|
35
|
+
"resource:delete",
|
|
36
|
+
"action:create",
|
|
37
|
+
"action:update",
|
|
38
|
+
"action:delete",
|
|
39
|
+
"user:manage-all",
|
|
40
|
+
"permission:assign",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Hậu tố nguy hiểm bất kể resource nào mang nó. */
|
|
44
|
+
export const DANGER_SUFFIXES: readonly string[] = [
|
|
45
|
+
":view-all-workspaces",
|
|
46
|
+
":view-all-branches",
|
|
47
|
+
":manage-all",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export type DelegationCode =
|
|
51
|
+
| "D1_OUT_OF_SCOPE"
|
|
52
|
+
| "D2_EXCEEDS_CEILING"
|
|
53
|
+
| "D3_SELF_ESCALATION"
|
|
54
|
+
| "D4_DANGEROUS_ROLE"
|
|
55
|
+
| "D5_NOT_FULLY_OWNED"
|
|
56
|
+
| "D6_PROTECTED_ACCOUNT"
|
|
57
|
+
| "D7_BOUNDARY_TAMPER"
|
|
58
|
+
| "NO_DELEGATION";
|
|
59
|
+
|
|
60
|
+
export class DelegationError extends Error {
|
|
61
|
+
readonly code: DelegationCode;
|
|
62
|
+
constructor(code: DelegationCode, message: string) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "DelegationError";
|
|
65
|
+
this.code = code;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Người đang thao tác.
|
|
71
|
+
*
|
|
72
|
+
* `canManageAll` là lối thoát duy nhất khỏi D1–D7 — nhóm vận hành. Đừng suy ra
|
|
73
|
+
* nó từ "có nhiều membership": nhiều nhánh vẫn là bị giới hạn.
|
|
74
|
+
*/
|
|
75
|
+
export interface DelegationActor {
|
|
76
|
+
userId: string;
|
|
77
|
+
/** Phạm vi đã bung con cháu; `adminIds` là thứ D1/D5 dựa vào. */
|
|
78
|
+
scope: WorkspaceScope;
|
|
79
|
+
/** Toàn quyền: `user:manage-all` hoặc `*:view-all-workspaces`. Miễn mọi bất biến. */
|
|
80
|
+
canManageAll: boolean;
|
|
81
|
+
/** Tập quyền hiệu lực của chính người này, dạng `"resource:action"`. */
|
|
82
|
+
permissions: ReadonlySet<string>;
|
|
83
|
+
/** Cấp cao nhất trong các vai trò của họ — càng NHỎ càng cao. */
|
|
84
|
+
rank: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Người dùng bị tác động. */
|
|
88
|
+
export interface DelegationTarget {
|
|
89
|
+
id: string;
|
|
90
|
+
isProtected?: boolean;
|
|
91
|
+
permissionCeilingRoleId?: string | null;
|
|
92
|
+
/** TOÀN BỘ membership của họ, không phải phần giao với phạm vi người thao tác. */
|
|
93
|
+
workspaceIds: readonly string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DelegationRole {
|
|
97
|
+
id: string;
|
|
98
|
+
code: string;
|
|
99
|
+
rank: number;
|
|
100
|
+
isSystem?: boolean;
|
|
101
|
+
workspaceId?: string | null;
|
|
102
|
+
/** Quyền của vai trò, dạng `"resource:action"`. */
|
|
103
|
+
permissions: readonly string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Quyền này có nằm trong nhóm cấm uỷ quyền không (D4)? */
|
|
107
|
+
export function isDangerousPermission(permission: string): boolean {
|
|
108
|
+
if (DANGER_ACTIONS.includes(permission)) return true;
|
|
109
|
+
return DANGER_SUFFIXES.some((suffix) => permission.endsWith(suffix));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* D1 — Chứa trong nhánh. Mọi workspace gán cho người bị tạo/sửa phải nằm trong
|
|
114
|
+
* `adminIds` của người thao tác.
|
|
115
|
+
*
|
|
116
|
+
* Cố ý NÉM chứ không lặng lẽ cắt bớt danh sách: cắt bớt thì người dùng bấm lưu,
|
|
117
|
+
* thấy "thành công", rồi mới phát hiện một nửa membership không được ghi.
|
|
118
|
+
*/
|
|
119
|
+
export function assertWorkspacesInScope(
|
|
120
|
+
actor: DelegationActor,
|
|
121
|
+
requestedWorkspaceIds: readonly string[],
|
|
122
|
+
): void {
|
|
123
|
+
if (actor.canManageAll) return;
|
|
124
|
+
if (actor.scope.adminIds.length === 0) {
|
|
125
|
+
throw new DelegationError(
|
|
126
|
+
"NO_DELEGATION",
|
|
127
|
+
"Bạn không được uỷ quyền quản trị không gian nào.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const outside = requestedWorkspaceIds.filter(
|
|
131
|
+
(id) => !isWorkspaceAdmin(actor.scope, id),
|
|
132
|
+
);
|
|
133
|
+
if (outside.length > 0) {
|
|
134
|
+
throw new DelegationError(
|
|
135
|
+
"D1_OUT_OF_SCOPE",
|
|
136
|
+
`Ngoài phạm vi quản trị của bạn: ${outside.join(", ")}.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* D3 — Không tự nâng. Không ai sửa vai trò / membership của CHÍNH MÌNH.
|
|
143
|
+
*
|
|
144
|
+
* Không có luật này thì D1 vô nghĩa: admin nhánh chỉ cần tự thêm vai trò cao hơn
|
|
145
|
+
* cho bản thân là xong.
|
|
146
|
+
*/
|
|
147
|
+
export function assertNotSelf(
|
|
148
|
+
actor: DelegationActor,
|
|
149
|
+
targetUserId: string,
|
|
150
|
+
): void {
|
|
151
|
+
if (actor.canManageAll) return;
|
|
152
|
+
if (actor.userId === targetUserId) {
|
|
153
|
+
throw new DelegationError(
|
|
154
|
+
"D3_SELF_ESCALATION",
|
|
155
|
+
"Không thể tự sửa vai trò hoặc không gian của chính mình.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* D5 — Sửa được thì phải SỞ HỮU TRỌN. Chỉ sửa/xoá người dùng mà **toàn bộ**
|
|
162
|
+
* membership của họ nằm trong `adminIds`.
|
|
163
|
+
*
|
|
164
|
+
* Luật lỏng hơn ("giao nhau khác rỗng") sẽ cho admin khách hàng sửa được tài
|
|
165
|
+
* khoản của nhân viên vận hành đang vào hỗ trợ họ — đúng đường leo thang.
|
|
166
|
+
* Người vắt ra ngoài nhánh: ĐỌC được, không SỬA được.
|
|
167
|
+
*/
|
|
168
|
+
export function assertFullyOwned(
|
|
169
|
+
actor: DelegationActor,
|
|
170
|
+
target: DelegationTarget,
|
|
171
|
+
): void {
|
|
172
|
+
if (actor.canManageAll) return;
|
|
173
|
+
const outside = target.workspaceIds.filter(
|
|
174
|
+
(id) => !isWorkspaceAdmin(actor.scope, id),
|
|
175
|
+
);
|
|
176
|
+
if (outside.length > 0) {
|
|
177
|
+
throw new DelegationError(
|
|
178
|
+
"D5_NOT_FULLY_OWNED",
|
|
179
|
+
"Người dùng này còn thuộc không gian ngoài phạm vi của bạn — bạn xem được nhưng không sửa được.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (target.workspaceIds.length === 0) {
|
|
183
|
+
throw new DelegationError(
|
|
184
|
+
"D5_NOT_FULLY_OWNED",
|
|
185
|
+
"Người dùng này chưa thuộc không gian nào nên không nằm trong phạm vi quản trị của bạn.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* D6 — Tài khoản được bảo vệ. Admin nhánh KHÔNG đổi mật khẩu / vô hiệu hoá / gỡ
|
|
192
|
+
* vai trò của tài khoản `isProtected`, **dù cùng workspace và dù D5 thoả**.
|
|
193
|
+
*
|
|
194
|
+
* D5 che được ca "người này vắt ra ngoài nhánh". Nó KHÔNG che được ca nhân viên
|
|
195
|
+
* vận hành được thêm vào đúng workspace khách hàng để hỗ trợ — lúc đó target chỉ
|
|
196
|
+
* thuộc nhánh của khách, D5 thoả, và admin khách reset được mật khẩu của nhân
|
|
197
|
+
* viên vận hành. Entra phải đẻ hẳn một loại AU riêng (Restricted Management) chỉ
|
|
198
|
+
* để bịt đúng lỗ này.
|
|
199
|
+
*/
|
|
200
|
+
export function assertNotProtected(
|
|
201
|
+
actor: DelegationActor,
|
|
202
|
+
target: DelegationTarget,
|
|
203
|
+
): void {
|
|
204
|
+
if (actor.canManageAll) return;
|
|
205
|
+
if (target.isProtected) {
|
|
206
|
+
throw new DelegationError(
|
|
207
|
+
"D6_PROTECTED_ACCOUNT",
|
|
208
|
+
"Tài khoản này được bảo vệ — chỉ quản trị toàn hệ thống thao tác được.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* D2 + D4 — vai trò có gán đi được không.
|
|
215
|
+
*
|
|
216
|
+
* D2 (trần quyền): vai trò không được chứa quyền mà chính người gán không có.
|
|
217
|
+
* D4 (cấm uỷ quyền): vai trò chứa action nguy hiểm thì chỉ toàn quyền gán được,
|
|
218
|
+
* kể cả khi D2 tình cờ thoả.
|
|
219
|
+
* Kèm luật `rank`: chỉ gán được vai trò THẤP HƠN cấp của mình, và không gán được
|
|
220
|
+
* vai trò hệ thống hay vai trò thuộc nhánh khác.
|
|
221
|
+
*/
|
|
222
|
+
export function assertRoleAssignable(
|
|
223
|
+
actor: DelegationActor,
|
|
224
|
+
role: DelegationRole,
|
|
225
|
+
): void {
|
|
226
|
+
if (actor.canManageAll) return;
|
|
227
|
+
|
|
228
|
+
const dangerous = role.permissions.filter(isDangerousPermission);
|
|
229
|
+
if (dangerous.length > 0) {
|
|
230
|
+
throw new DelegationError(
|
|
231
|
+
"D4_DANGEROUS_ROLE",
|
|
232
|
+
`Vai trò "${role.code}" chứa quyền quản trị hệ thống (${dangerous.join(", ")}) — chỉ quản trị toàn hệ thống gán được.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (role.isSystem) {
|
|
237
|
+
throw new DelegationError(
|
|
238
|
+
"D4_DANGEROUS_ROLE",
|
|
239
|
+
`Vai trò "${role.code}" là vai trò hệ thống — chỉ quản trị toàn hệ thống gán được.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (role.rank <= actor.rank) {
|
|
244
|
+
throw new DelegationError(
|
|
245
|
+
"D2_EXCEEDS_CEILING",
|
|
246
|
+
`Vai trò "${role.code}" có cấp ngang hoặc cao hơn bạn — không gán được.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const exceeded = role.permissions.filter((p) => !actor.permissions.has(p));
|
|
251
|
+
if (exceeded.length > 0) {
|
|
252
|
+
throw new DelegationError(
|
|
253
|
+
"D2_EXCEEDS_CEILING",
|
|
254
|
+
`Vai trò "${role.code}" chứa quyền bạn không có: ${exceeded.slice(0, 5).join(", ")}${exceeded.length > 5 ? "…" : ""}.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (role.workspaceId && !isWorkspaceAdmin(actor.scope, role.workspaceId)) {
|
|
259
|
+
throw new DelegationError(
|
|
260
|
+
"D1_OUT_OF_SCOPE",
|
|
261
|
+
`Vai trò "${role.code}" thuộc không gian ngoài phạm vi của bạn.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Lọc danh sách vai trò còn lại những cái GÁN ĐƯỢC — dùng cho picker (D2∧D4). */
|
|
267
|
+
export function assignableRoles<T extends DelegationRole>(
|
|
268
|
+
actor: DelegationActor,
|
|
269
|
+
roles: readonly T[],
|
|
270
|
+
): T[] {
|
|
271
|
+
return roles.filter((role) => {
|
|
272
|
+
try {
|
|
273
|
+
assertRoleAssignable(actor, role);
|
|
274
|
+
return true;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* D2 bản CHẠY — quyền hiệu lực = GIAO của quyền vai trò và trần quyền.
|
|
283
|
+
*
|
|
284
|
+
* Bản chỉ-kiểm-lúc-gán là chưa đủ: người gán bị thu hồi quyền sau đó thì tài
|
|
285
|
+
* khoản họ đã tạo **vẫn giữ nguyên quyền cũ**. AWS giải bằng permissions
|
|
286
|
+
* boundary đánh giá mỗi request; đây là bản rút gọn của đúng ý đó.
|
|
287
|
+
*
|
|
288
|
+
* `ceiling === null` (không có trần) ⇒ trả nguyên quyền vai trò.
|
|
289
|
+
*/
|
|
290
|
+
export function applyPermissionCeiling(
|
|
291
|
+
rolePermissions: readonly string[],
|
|
292
|
+
ceilingPermissions: readonly string[] | null | undefined,
|
|
293
|
+
): string[] {
|
|
294
|
+
if (!ceilingPermissions) return [...rolePermissions];
|
|
295
|
+
const ceiling = new Set(ceilingPermissions);
|
|
296
|
+
return rolePermissions.filter((p) => ceiling.has(p));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Thay đổi đang được yêu cầu trên một người dùng — đầu vào của D7. */
|
|
300
|
+
export interface DelegationChanges {
|
|
301
|
+
/** Đổi trần quyền của target. */
|
|
302
|
+
permissionCeilingRoleId?: string | null;
|
|
303
|
+
/** Đổi cờ bảo vệ. */
|
|
304
|
+
isProtected?: boolean;
|
|
305
|
+
/** Danh sách membership sau khi sửa. */
|
|
306
|
+
workspaceIds?: readonly string[];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* D7 — Không tháo được rào. Deny tường minh, thắng mọi thứ khác.
|
|
311
|
+
*
|
|
312
|
+
* Ba đường vòng bị bịt ở đây, và cả ba đều là đường vòng THẬT (AWS phải viết
|
|
313
|
+
* riêng hai policy `NoBoundaryPolicyEdit` + `NoBoundaryUserDelete` cho hai cái
|
|
314
|
+
* đầu — thiếu chúng thì cái trần chỉ là gợi ý):
|
|
315
|
+
* 1. sửa / gỡ trần quyền của người khác,
|
|
316
|
+
* 2. tự bật/tắt cờ bảo vệ,
|
|
317
|
+
* 3. gỡ membership của người khác để đẩy họ ra khỏi phạm vi rồi thao tác tiếp.
|
|
318
|
+
*/
|
|
319
|
+
export function assertBoundaryIntact(
|
|
320
|
+
actor: DelegationActor,
|
|
321
|
+
target: DelegationTarget,
|
|
322
|
+
changes: DelegationChanges,
|
|
323
|
+
): void {
|
|
324
|
+
if (actor.canManageAll) return;
|
|
325
|
+
|
|
326
|
+
if (
|
|
327
|
+
changes.permissionCeilingRoleId !== undefined &&
|
|
328
|
+
changes.permissionCeilingRoleId !== target.permissionCeilingRoleId
|
|
329
|
+
) {
|
|
330
|
+
throw new DelegationError(
|
|
331
|
+
"D7_BOUNDARY_TAMPER",
|
|
332
|
+
"Không thể sửa hoặc gỡ trần quyền — chỉ quản trị toàn hệ thống làm được.",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (
|
|
337
|
+
changes.isProtected !== undefined &&
|
|
338
|
+
changes.isProtected !== Boolean(target.isProtected)
|
|
339
|
+
) {
|
|
340
|
+
throw new DelegationError(
|
|
341
|
+
"D7_BOUNDARY_TAMPER",
|
|
342
|
+
"Không thể đổi trạng thái bảo vệ của tài khoản.",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (changes.workspaceIds) {
|
|
347
|
+
// Membership NGOÀI phạm vi của người thao tác phải được giữ nguyên. Cho phép
|
|
348
|
+
// gỡ nghĩa là cho phép đẩy nạn nhân ra khỏi nhánh khác rồi D5 tự nhiên thoả.
|
|
349
|
+
const untouchable = target.workspaceIds.filter(
|
|
350
|
+
(id) => !isWorkspaceAdmin(actor.scope, id),
|
|
351
|
+
);
|
|
352
|
+
const kept = new Set(changes.workspaceIds);
|
|
353
|
+
const removed = untouchable.filter((id) => !kept.has(id));
|
|
354
|
+
if (removed.length > 0) {
|
|
355
|
+
throw new DelegationError(
|
|
356
|
+
"D7_BOUNDARY_TAMPER",
|
|
357
|
+
"Không thể gỡ người dùng khỏi không gian nằm ngoài phạm vi quản trị của bạn.",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Cổng đầy đủ cho đường SỬA một người dùng — gọi đúng một hàm này ở route thay
|
|
365
|
+
* vì nhớ gọi sáu hàm rời (quên một cái là thủng một bất biến).
|
|
366
|
+
*/
|
|
367
|
+
export function assertCanUpdateUser(
|
|
368
|
+
actor: DelegationActor,
|
|
369
|
+
target: DelegationTarget,
|
|
370
|
+
changes: DelegationChanges = {},
|
|
371
|
+
): void {
|
|
372
|
+
assertNotSelf(actor, target.id);
|
|
373
|
+
assertNotProtected(actor, target);
|
|
374
|
+
assertFullyOwned(actor, target);
|
|
375
|
+
assertBoundaryIntact(actor, target, changes);
|
|
376
|
+
if (changes.workspaceIds) {
|
|
377
|
+
assertWorkspacesInScope(actor, changes.workspaceIds);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Cổng đầy đủ cho đường TẠO người dùng. */
|
|
382
|
+
export function assertCanCreateUser(
|
|
383
|
+
actor: DelegationActor,
|
|
384
|
+
workspaceIds: readonly string[],
|
|
385
|
+
roles: readonly DelegationRole[] = [],
|
|
386
|
+
): void {
|
|
387
|
+
assertWorkspacesInScope(actor, workspaceIds);
|
|
388
|
+
if (!actor.canManageAll && workspaceIds.length === 0) {
|
|
389
|
+
throw new DelegationError(
|
|
390
|
+
"D1_OUT_OF_SCOPE",
|
|
391
|
+
"Phải chọn ít nhất một không gian trong phạm vi quản trị của bạn.",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
for (const role of roles) assertRoleAssignable(actor, role);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Có được uỷ quyền quản trị người dùng ở đâu đó không — dùng để bật/tắt nút "Thêm". */
|
|
398
|
+
export function canDelegateUsers(actor: DelegationActor): boolean {
|
|
399
|
+
return actor.canManageAll || actor.scope.adminIds.length > 0;
|
|
400
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { resolveAmbientScope } from "./context";
|
|
2
|
+
import { scopedWhere, workspaceScopeField } from "./scope";
|
|
3
|
+
import type { WorkspaceScope } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* LỚP 2 — lưới an toàn ở tầng ORM (mô hình Odoo/NocoBase): route mới quên lọc
|
|
7
|
+
* phạm vi thì vẫn không lộ dữ liệu.
|
|
8
|
+
*
|
|
9
|
+
* Chỉ chạm thao tác ĐỌC, chỉ trên model được khai, và chỉ khi có ScopeContext —
|
|
10
|
+
* ngoài context (RSC page, cron, webhook, script) extension bất động, nên tác
|
|
11
|
+
* vụ nền không bị cắt dữ liệu bất ngờ.
|
|
12
|
+
*
|
|
13
|
+
* db.$extends(createScopeGuardExtension({
|
|
14
|
+
* models: {
|
|
15
|
+
* Receipt: {}, // cột phạm vi mặc định
|
|
16
|
+
* GoodsReceipt: { relation: "warehouse" }, // scope qua quan hệ
|
|
17
|
+
* SalesOrder: { crossScopeRead: true }, // đọc chéo hợp lệ
|
|
18
|
+
* },
|
|
19
|
+
* }))
|
|
20
|
+
*
|
|
21
|
+
* CẢNH BÁO: model đưa vào `models` PHẢI có cột phạm vi, hoặc phải khai
|
|
22
|
+
* `relation`/`where`. Chèn điều kiện lên cột không tồn tại là Prisma ném
|
|
23
|
+
* validation error cho MỌI thao tác đọc của user bị scope — hỏng cả nghiệp vụ,
|
|
24
|
+
* không chỉ hỏng bộ lọc.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface ModelScopeConfig {
|
|
28
|
+
/** Cột mang phạm vi; mặc định lấy từ `configureWorkspaces({ scopeField })`. */
|
|
29
|
+
field?: string;
|
|
30
|
+
/** Model không có cột phạm vi thì đi qua quan hệ, VD `"warehouse"`. */
|
|
31
|
+
relation?: string;
|
|
32
|
+
/**
|
|
33
|
+
* **Δ2 — đọc chéo có kiểm soát.** Model có luồng liên không gian HỢP LỆ (bán
|
|
34
|
+
* lại chọn đơn nguồn chi nhánh khác, lịch sử đơn của khách) thì bật cờ này:
|
|
35
|
+
* thao tác đọc đi thẳng, thao tác **ghi hàng loạt vẫn bị kẹp**.
|
|
36
|
+
*
|
|
37
|
+
* NetSuite gọi đúng thứ này là `Allow Cross-Subsidiary Record Viewing` — xem
|
|
38
|
+
* được, sửa không được. Trước đây ta xử bằng cách **bỏ model ra khỏi danh
|
|
39
|
+
* sách guard**, tức là vừa xem chéo vừa SỬA chéo được. Cờ này thay cho cách
|
|
40
|
+
* đó: mỗi ngoại lệ có đúng một dòng khai, kèm lý do trong code.
|
|
41
|
+
*
|
|
42
|
+
* GIỚI HẠN THẬT, phải biết: `update`/`delete` một dòng dùng `where` unique
|
|
43
|
+
* nên guard KHÔNG chèn điều kiện vào được — service phải tự gọi
|
|
44
|
+
* `canAccessWorkspace(scope, record.workspaceId)` trước khi ghi. Guard chỉ
|
|
45
|
+
* kẹp được `updateMany`/`deleteMany`.
|
|
46
|
+
*/
|
|
47
|
+
crossScopeRead?: boolean;
|
|
48
|
+
/** Điều kiện guard tự viết; trả `null` để rơi về mặc định. */
|
|
49
|
+
where?: (scope: WorkspaceScope) => Record<string, unknown> | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ScopeGuardOptions {
|
|
53
|
+
/** Tên model Prisma (đúng chữ hoa/thường như trong schema) → cấu hình. */
|
|
54
|
+
models: Record<string, ModelScopeConfig> | string[];
|
|
55
|
+
/** Mặc định: các thao tác đọc nhiều dòng. */
|
|
56
|
+
operations?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Thao tác ghi hàng loạt bị kẹp `where` — kể cả model `crossScopeRead`. Ghi
|
|
59
|
+
* một dòng (`update`/`delete`) không nằm ở đây vì `where` của chúng chỉ nhận
|
|
60
|
+
* unique field.
|
|
61
|
+
*/
|
|
62
|
+
writeOperations?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* findUnique* cố ý không guard: `where` của nó chỉ nhận unique field, chèn thêm
|
|
67
|
+
* điều kiện là Prisma từ chối. Trang chi tiết guard bằng `canAccessWorkspace`.
|
|
68
|
+
*/
|
|
69
|
+
const DEFAULT_OPERATIONS = [
|
|
70
|
+
"findMany",
|
|
71
|
+
"findFirst",
|
|
72
|
+
"findFirstOrThrow",
|
|
73
|
+
"count",
|
|
74
|
+
"aggregate",
|
|
75
|
+
"groupBy",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const DEFAULT_WRITE_OPERATIONS = ["updateMany", "deleteMany"];
|
|
79
|
+
|
|
80
|
+
export function createScopeGuardExtension(options: ScopeGuardOptions) {
|
|
81
|
+
const models: Record<string, ModelScopeConfig> = Array.isArray(options.models)
|
|
82
|
+
? Object.fromEntries(options.models.map((name) => [name, {}]))
|
|
83
|
+
: options.models;
|
|
84
|
+
const operations = new Set(options.operations ?? DEFAULT_OPERATIONS);
|
|
85
|
+
const writeOperations = new Set(
|
|
86
|
+
options.writeOperations ?? DEFAULT_WRITE_OPERATIONS,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
name: "workspace-guard",
|
|
91
|
+
query: {
|
|
92
|
+
$allModels: {
|
|
93
|
+
// Chữ ký thật do Prisma sinh theo schema của từng app; khai chặt ở core
|
|
94
|
+
// sẽ không assignable ở phía app (kiểu hẹp hơn, contravariance).
|
|
95
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
96
|
+
async $allOperations({
|
|
97
|
+
model,
|
|
98
|
+
operation,
|
|
99
|
+
args,
|
|
100
|
+
query,
|
|
101
|
+
}: {
|
|
102
|
+
model?: string;
|
|
103
|
+
operation: string;
|
|
104
|
+
args: any;
|
|
105
|
+
query: (args: any) => Promise<any>;
|
|
106
|
+
}): Promise<any> {
|
|
107
|
+
const config = model ? models[model] : undefined;
|
|
108
|
+
if (!config) return query(args);
|
|
109
|
+
|
|
110
|
+
const isRead = operations.has(operation);
|
|
111
|
+
const isWrite = writeOperations.has(operation);
|
|
112
|
+
if (!isRead && !isWrite) return query(args);
|
|
113
|
+
// Đọc chéo được cho phép; GHI thì không, kể cả trên model đó.
|
|
114
|
+
if (isRead && config.crossScopeRead) return query(args);
|
|
115
|
+
|
|
116
|
+
const scopePromise = resolveAmbientScope<WorkspaceScope>();
|
|
117
|
+
if (!scopePromise) return query(args);
|
|
118
|
+
|
|
119
|
+
const scope = await scopePromise;
|
|
120
|
+
if (scope.canViewAll) return query(args);
|
|
121
|
+
|
|
122
|
+
const guard =
|
|
123
|
+
config.where?.(scope) ??
|
|
124
|
+
scopedWhere(scope, {
|
|
125
|
+
field: config.field ?? workspaceScopeField(),
|
|
126
|
+
relation: config.relation,
|
|
127
|
+
});
|
|
128
|
+
const prevWhere = args?.where;
|
|
129
|
+
return query({
|
|
130
|
+
...args,
|
|
131
|
+
where: prevWhere ? { AND: [prevWhere, guard] } : guard,
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Không gian làm việc (workspace) — phạm vi dữ liệu tổng quát.
|
|
3
|
+
*
|
|
4
|
+
* Chi nhánh / đơn vị / phòng ban / tổ chức là **một cây có nhãn `kind`**, không
|
|
5
|
+
* phải bốn cơ chế. Ba lớp cưỡng chế:
|
|
6
|
+
*
|
|
7
|
+
* Lớp 1 (tường minh): `getWorkspaceScope(session)` + `scopedWhere(scope)`
|
|
8
|
+
* trong page/route/service. Đây mới là lớp làm việc chính.
|
|
9
|
+
*
|
|
10
|
+
* Lớp 2 (lưới an toàn): `createScopeGuardExtension` cắm vào Prisma, tự AND
|
|
11
|
+
* điều kiện phạm vi cho thao tác đọc trên model được khai — cứu chỗ quên.
|
|
12
|
+
*
|
|
13
|
+
* Lớp 3 (uỷ quyền): người dùng và vai trò KHÔNG phải chứng từ, không xử được
|
|
14
|
+
* bằng `where workspaceId` — ai gán quyền được cho ai là một bài toán khác.
|
|
15
|
+
* Bộ luật riêng D1–D7 nằm ở `./delegation`.
|
|
16
|
+
*
|
|
17
|
+
* App cấu hình một lần ở composition root và import phạm vi qua đúng file đó
|
|
18
|
+
* (`src/lib/branch-scope.ts`): engine là singleton, import thẳng từ core sẽ lấy
|
|
19
|
+
* được hàm nhưng bỏ lỡ lời gọi cấu hình.
|
|
20
|
+
*
|
|
21
|
+
* `@goerp/core/branch-scope` vẫn chạy y nguyên — nó là lớp adapter mỏng của
|
|
22
|
+
* module này, dùng CHUNG một AsyncLocalStorage.
|
|
23
|
+
*/
|
|
24
|
+
export {
|
|
25
|
+
configureWorkspaces,
|
|
26
|
+
resetWorkspaceConfig,
|
|
27
|
+
getWorkspaceConfig,
|
|
28
|
+
workspaceScopeField,
|
|
29
|
+
canViewAllWorkspaces,
|
|
30
|
+
createWorkspaceScope,
|
|
31
|
+
getWorkspaceScope,
|
|
32
|
+
canAccessWorkspace,
|
|
33
|
+
isWorkspaceAdmin,
|
|
34
|
+
hasAdminScope,
|
|
35
|
+
scopedWhere,
|
|
36
|
+
memberScopeWhere,
|
|
37
|
+
scopeLevelWhere,
|
|
38
|
+
scopeLevelAtLeast,
|
|
39
|
+
clampScopeFilter,
|
|
40
|
+
clampToAdminScope,
|
|
41
|
+
} from "./scope";
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
runWithScope,
|
|
45
|
+
getScopeContext,
|
|
46
|
+
resolveAmbientScope,
|
|
47
|
+
runWithoutScope,
|
|
48
|
+
type ScopeContext,
|
|
49
|
+
} from "./context";
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
createScopeGuardExtension,
|
|
53
|
+
type ScopeGuardOptions,
|
|
54
|
+
type ModelScopeConfig,
|
|
55
|
+
} from "./guard";
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
PATH_SEPARATOR,
|
|
59
|
+
ROOT_PATH,
|
|
60
|
+
buildPath,
|
|
61
|
+
normalizePath,
|
|
62
|
+
pathSegments,
|
|
63
|
+
pathDepth,
|
|
64
|
+
selfIdFromPath,
|
|
65
|
+
parentIdFromPath,
|
|
66
|
+
isInSubtree,
|
|
67
|
+
subtreePrefix,
|
|
68
|
+
selfAndDescendants,
|
|
69
|
+
repathSubtree,
|
|
70
|
+
assertNoCycle,
|
|
71
|
+
assertDepthLimit,
|
|
72
|
+
WorkspaceTreeError,
|
|
73
|
+
} from "./tree";
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
createWorkspace,
|
|
77
|
+
moveWorkspace,
|
|
78
|
+
listSubtree,
|
|
79
|
+
deactivateSubtree,
|
|
80
|
+
buildTree,
|
|
81
|
+
type WorkspaceServiceDb,
|
|
82
|
+
type WorkspaceServiceOptions,
|
|
83
|
+
type CreateWorkspaceInput,
|
|
84
|
+
type MoveWorkspaceInput,
|
|
85
|
+
type WorkspaceTreeItem,
|
|
86
|
+
type WorkspaceTreeOf,
|
|
87
|
+
} from "./service";
|
|
88
|
+
|
|
89
|
+
export {
|
|
90
|
+
DANGER_ACTIONS,
|
|
91
|
+
DANGER_SUFFIXES,
|
|
92
|
+
DelegationError,
|
|
93
|
+
isDangerousPermission,
|
|
94
|
+
assertWorkspacesInScope,
|
|
95
|
+
assertNotSelf,
|
|
96
|
+
assertFullyOwned,
|
|
97
|
+
assertNotProtected,
|
|
98
|
+
assertRoleAssignable,
|
|
99
|
+
assignableRoles,
|
|
100
|
+
applyPermissionCeiling,
|
|
101
|
+
assertBoundaryIntact,
|
|
102
|
+
assertCanUpdateUser,
|
|
103
|
+
assertCanCreateUser,
|
|
104
|
+
canDelegateUsers,
|
|
105
|
+
type DelegationCode,
|
|
106
|
+
type DelegationActor,
|
|
107
|
+
type DelegationTarget,
|
|
108
|
+
type DelegationRole,
|
|
109
|
+
type DelegationChanges,
|
|
110
|
+
} from "./delegation";
|
|
111
|
+
|
|
112
|
+
export {
|
|
113
|
+
createWorkspaceCollectionHandlers,
|
|
114
|
+
createWorkspaceItemHandlers,
|
|
115
|
+
createWorkspaceMoveHandler,
|
|
116
|
+
type WorkspaceHandlerDeps,
|
|
117
|
+
} from "./route-handlers";
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
WorkspaceTreeView,
|
|
121
|
+
countTreeNodes,
|
|
122
|
+
type WorkspaceTreeNode,
|
|
123
|
+
type WorkspaceTreeViewProps,
|
|
124
|
+
} from "./components/workspace-tree-view";
|
|
125
|
+
|
|
126
|
+
export {
|
|
127
|
+
WorkspaceSwitcher,
|
|
128
|
+
type WorkspaceOption,
|
|
129
|
+
type WorkspaceSwitcherProps,
|
|
130
|
+
} from "./components/workspace-switcher";
|
|
131
|
+
|
|
132
|
+
export {
|
|
133
|
+
ScopeLevelSelect,
|
|
134
|
+
scopeLevelLabel,
|
|
135
|
+
type ScopeLevelSelectProps,
|
|
136
|
+
} from "./components/scope-level-select";
|
|
137
|
+
|
|
138
|
+
export {
|
|
139
|
+
WorkspaceListPage,
|
|
140
|
+
type WorkspaceListPageProps,
|
|
141
|
+
} from "./pages/workspace-list-page";
|
|
142
|
+
|
|
143
|
+
export {
|
|
144
|
+
NO_WORKSPACE_ACCESS,
|
|
145
|
+
DEFAULT_MAX_DEPTH,
|
|
146
|
+
DEFAULT_MAX_CHILDREN,
|
|
147
|
+
SCOPE_LEVELS,
|
|
148
|
+
SCOPE_LEVEL_LABELS,
|
|
149
|
+
type WorkspaceKind,
|
|
150
|
+
type WorkspaceKindConfig,
|
|
151
|
+
type WorkspaceNode,
|
|
152
|
+
type WorkspaceMembership,
|
|
153
|
+
type WorkspaceScope,
|
|
154
|
+
type WorkspaceScopeConfig,
|
|
155
|
+
type WorkspaceScopeDb,
|
|
156
|
+
type ScopeLevel,
|
|
157
|
+
} from "./types";
|