@goplusvn/core 0.1.78 → 0.1.79
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 +56 -0
- package/package.json +1 -1
- package/src/rbac/__tests__/route-handlers.test.ts +183 -0
- package/src/rbac/components/roles/role-card.tsx +36 -17
- package/src/rbac/pages/role-form-page.tsx +135 -4
- package/src/rbac/pages/role-list-page.tsx +5 -2
- package/src/rbac/role-service.ts +82 -1
- package/src/rbac/route-handlers.ts +180 -10
- package/src/rbac/types.ts +9 -0
- package/src/user/user-service.ts +57 -0
- package/src/workspace/__tests__/workspace-delegation.test.ts +102 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +98 -0
- package/src/workspace/delegation.ts +148 -0
- package/src/workspace/index.ts +5 -0
- package/src/workspace/scope.ts +41 -3
- package/src/workspace/types.ts +25 -0
package/src/rbac/role-service.ts
CHANGED
|
@@ -35,6 +35,32 @@ export type RoleServiceSchema = {
|
|
|
35
35
|
roleTimestamps?: boolean;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* PHẠM VI vai trò — chỉ dành cho app đã bật multi-tenant (cột `Role.workspaceId`).
|
|
40
|
+
*
|
|
41
|
+
* Không truyền thì hàm chạy y như trước: mọi vai trò, mọi người dùng nhúng bên
|
|
42
|
+
* trong. App một-đơn-vị (vinhhoa) cứ để trống.
|
|
43
|
+
*
|
|
44
|
+
* Vai trò `workspaceId = null` là vai trò DÙNG CHUNG do cấp vận hành dựng
|
|
45
|
+
* ("Nhân viên", "Bếp"): quản trị viên đơn vị vẫn THẤY để biết mình đang gán gì,
|
|
46
|
+
* nhưng `canManage` trả `false` nên nút sửa/xoá tắt.
|
|
47
|
+
*/
|
|
48
|
+
export type RoleQueryScope = {
|
|
49
|
+
/** Nhóm vận hành — thấy tất cả, sửa tất cả. */
|
|
50
|
+
canManageAll?: boolean;
|
|
51
|
+
/** Các workspace người này quản trị (`scope.adminIds`). */
|
|
52
|
+
adminWorkspaceIds?: readonly string[];
|
|
53
|
+
/** Có kèm vai trò dùng chung (`workspaceId = null`) không. Mặc định có. */
|
|
54
|
+
includeShared?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Điều kiện kẹp danh sách NGƯỜI DÙNG nhúng trong mỗi vai trò — dạng where của
|
|
57
|
+
* bảng User (thường là `memberScopeWhere(scope)`). Thiếu nó thì trang vai trò
|
|
58
|
+
* là cửa hậu xem toàn bộ danh bạ: mỗi thẻ vai trò in kèm avatar + email của
|
|
59
|
+
* mọi người mang vai trò đó, kể cả đơn vị khác.
|
|
60
|
+
*/
|
|
61
|
+
userWhere?: any;
|
|
62
|
+
};
|
|
63
|
+
|
|
38
64
|
export type RoleData = {
|
|
39
65
|
id: string;
|
|
40
66
|
name: string;
|
|
@@ -54,6 +80,12 @@ export type RoleData = {
|
|
|
54
80
|
updatedAt: string;
|
|
55
81
|
createdBy?: string;
|
|
56
82
|
updatedBy?: string;
|
|
83
|
+
/** Chỉ có ở app multi-tenant. `null` = vai trò dùng chung. */
|
|
84
|
+
workspaceId?: string | null;
|
|
85
|
+
isSystem?: boolean;
|
|
86
|
+
rank?: number;
|
|
87
|
+
/** Người đang xem có sửa/xoá được vai trò này không. `undefined` = không xét. */
|
|
88
|
+
canManage?: boolean;
|
|
57
89
|
};
|
|
58
90
|
|
|
59
91
|
// Interface for the DB client injected into the service
|
|
@@ -68,6 +100,12 @@ export interface RolePrismaClient {
|
|
|
68
100
|
take: number;
|
|
69
101
|
include: any;
|
|
70
102
|
}) => Promise<any[]>;
|
|
103
|
+
/**
|
|
104
|
+
* Prisma sinh sẵn — dùng để dò cột tuỳ chọn (workspaceId/rank/isSystem).
|
|
105
|
+
* Kiểu `object` chứ không `Record<string, unknown>`: `RoleFieldRefs` của
|
|
106
|
+
* Prisma không có index signature nên app truyền thẳng `db` sẽ đỏ.
|
|
107
|
+
*/
|
|
108
|
+
fields?: object;
|
|
71
109
|
};
|
|
72
110
|
}
|
|
73
111
|
|
|
@@ -75,6 +113,11 @@ export interface RolePrismaClient {
|
|
|
75
113
|
// Service Logic
|
|
76
114
|
// ============================================================================
|
|
77
115
|
|
|
116
|
+
/** App chưa có cột thì bỏ qua điều kiện tương ứng — hỏi cột không tồn tại là Prisma ném. */
|
|
117
|
+
const hasRoleField = (db: RolePrismaClient, field: string) =>
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
+
Boolean((db as any)?.role?.fields?.[field]);
|
|
120
|
+
|
|
78
121
|
/**
|
|
79
122
|
* Get roles data with pagination and filtering
|
|
80
123
|
* Generic core implementation using injected DB client
|
|
@@ -86,6 +129,7 @@ export async function getRolesData(
|
|
|
86
129
|
db: RolePrismaClient,
|
|
87
130
|
params: RoleFilters = {},
|
|
88
131
|
schema: RoleServiceSchema = {},
|
|
132
|
+
scope?: RoleQueryScope,
|
|
89
133
|
): Promise<{
|
|
90
134
|
total: number;
|
|
91
135
|
page: number;
|
|
@@ -117,6 +161,22 @@ export async function getRolesData(
|
|
|
117
161
|
whereConditions.push({ status });
|
|
118
162
|
}
|
|
119
163
|
|
|
164
|
+
// Kẹp phạm vi — chỉ khi app THẬT SỰ có cột `workspaceId` trên Role. Dò bằng
|
|
165
|
+
// `role.fields` (Prisma sinh sẵn) thay vì đoán theo tên app.
|
|
166
|
+
const hasWorkspaceField = hasRoleField(db, "workspaceId");
|
|
167
|
+
const adminIds = scope?.adminWorkspaceIds ?? [];
|
|
168
|
+
const scoped = Boolean(scope) && !scope?.canManageAll && hasWorkspaceField;
|
|
169
|
+
|
|
170
|
+
if (scoped) {
|
|
171
|
+
const branches: any[] = [];
|
|
172
|
+
if (adminIds.length > 0) branches.push({ workspaceId: { in: [...adminIds] } });
|
|
173
|
+
if (scope?.includeShared !== false) branches.push({ workspaceId: null });
|
|
174
|
+
// Không quản trị đơn vị nào và không lấy vai trò dùng chung ⇒ không thấy gì.
|
|
175
|
+
// Viết tường minh chứ không để `OR: []` — dựa vào cách Prisma diễn giải mảng
|
|
176
|
+
// rỗng là dựa vào chi tiết cài đặt.
|
|
177
|
+
whereConditions.push(branches.length > 0 ? { OR: branches } : { id: { in: [] } });
|
|
178
|
+
}
|
|
179
|
+
|
|
120
180
|
const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
|
|
121
181
|
|
|
122
182
|
// Build the user select from the (possibly overridden) field names.
|
|
@@ -132,7 +192,10 @@ export async function getRolesData(
|
|
|
132
192
|
skip: (page - 1) * pageSize,
|
|
133
193
|
take: pageSize,
|
|
134
194
|
include: {
|
|
135
|
-
userRoles: {
|
|
195
|
+
userRoles: {
|
|
196
|
+
...(scope?.userWhere ? { where: { user: scope.userWhere } } : {}),
|
|
197
|
+
include: { user: { select: userSelect } },
|
|
198
|
+
},
|
|
136
199
|
rolePermissions: {
|
|
137
200
|
include: {
|
|
138
201
|
resource: { select: { code: true, name: true, icon: true } },
|
|
@@ -154,6 +217,8 @@ export async function getRolesData(
|
|
|
154
217
|
permissions: role.rolePermissions.map(
|
|
155
218
|
(rp: any) => `${rp.actionCode}:${rp.resourceCode}`,
|
|
156
219
|
),
|
|
220
|
+
// Đếm theo danh sách ĐÃ kẹp: người quản trị đơn vị thấy "3 người dùng" là 3
|
|
221
|
+
// người trong đơn vị mình, không phải tổng toàn hệ thống.
|
|
157
222
|
usersCount: role.userRoles.length,
|
|
158
223
|
users: role.userRoles.map((ur: any) => ({
|
|
159
224
|
id: ur.user.id,
|
|
@@ -166,6 +231,22 @@ export async function getRolesData(
|
|
|
166
231
|
updatedAt: hasTimestamps && role.updatedAt ? role.updatedAt.toISOString() : "",
|
|
167
232
|
createdBy: role.createdBy || undefined,
|
|
168
233
|
updatedBy: role.updatedBy || undefined,
|
|
234
|
+
...(hasWorkspaceField
|
|
235
|
+
? {
|
|
236
|
+
workspaceId: role.workspaceId ?? null,
|
|
237
|
+
isSystem: Boolean(role.isSystem),
|
|
238
|
+
rank: typeof role.rank === "number" ? role.rank : undefined,
|
|
239
|
+
// Vai trò dùng chung / vai trò hệ thống / vai trò của đơn vị khác:
|
|
240
|
+
// xem được, không sửa được. Cùng luật với `canManageRole` bên
|
|
241
|
+
// `@goerp/core/workspace` — đây chỉ là bản chiếu xuống UI.
|
|
242
|
+
canManage: scope
|
|
243
|
+
? scope.canManageAll ||
|
|
244
|
+
(!role.isSystem &&
|
|
245
|
+
Boolean(role.workspaceId) &&
|
|
246
|
+
adminIds.includes(role.workspaceId))
|
|
247
|
+
: undefined,
|
|
248
|
+
}
|
|
249
|
+
: {}),
|
|
169
250
|
}));
|
|
170
251
|
|
|
171
252
|
return { total, page, pageSize, items: transformedItems };
|
|
@@ -13,12 +13,22 @@
|
|
|
13
13
|
// prisma, getSession, getCrudPermissions, schema: { userNameField: "fullName", ... },
|
|
14
14
|
// });
|
|
15
15
|
|
|
16
|
-
import { getRolesData
|
|
16
|
+
import { getRolesData } from "./role-service";
|
|
17
17
|
import { normalizeLandingPath } from "./landing-path";
|
|
18
18
|
import {
|
|
19
19
|
bumpPermissionsVersion,
|
|
20
20
|
getPermissionsVersion,
|
|
21
21
|
} from "./permissions-version";
|
|
22
|
+
import {
|
|
23
|
+
DelegationError,
|
|
24
|
+
assertCanDeleteRole,
|
|
25
|
+
assertCanManageRole,
|
|
26
|
+
canManageRole,
|
|
27
|
+
} from "../workspace/delegation";
|
|
28
|
+
import { memberScopeWhere } from "../workspace/scope";
|
|
29
|
+
|
|
30
|
+
import type { RoleQueryScope, RoleServiceSchema } from "./role-service";
|
|
31
|
+
import type { DelegationActor } from "../workspace/delegation";
|
|
22
32
|
|
|
23
33
|
type MaybePromise<T> = T | Promise<T>;
|
|
24
34
|
|
|
@@ -29,12 +39,81 @@ export interface RbacHandlerDeps {
|
|
|
29
39
|
getCrudPermissions: (session: any, resource: string) => Promise<{ read?: boolean; create?: boolean; update?: boolean; delete?: boolean }>;
|
|
30
40
|
/** Schema field-map when User/Role diverge from defaults. */
|
|
31
41
|
schema?: RoleServiceSchema;
|
|
42
|
+
/**
|
|
43
|
+
* UỶ QUYỀN (app multi-tenant). Bỏ trống ⇒ handler chạy y như trước: quyền
|
|
44
|
+
* `role:create/update/delete` là toàn quyền trên MỌI vai trò.
|
|
45
|
+
*
|
|
46
|
+
* Truyền vào thì cùng bộ luật D1/D2/D4 của người dùng được áp cho vai trò:
|
|
47
|
+
* quản trị viên đơn vị chỉ thấy vai trò của đơn vị mình (+ vai trò dùng chung,
|
|
48
|
+
* chỉ đọc), tạo vai trò thì bị đóng dấu đơn vị và không nhét được quyền mà
|
|
49
|
+
* chính họ không có. Thiếu cửa này thì chặn *gán* vai trò mạnh là vô nghĩa —
|
|
50
|
+
* họ tự tạo một vai trò mạnh rồi gán.
|
|
51
|
+
*/
|
|
52
|
+
getDelegationActor?: (session: any) => MaybePromise<DelegationActor | null>;
|
|
32
53
|
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
const json = (data: unknown, status = 200) =>
|
|
36
57
|
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
|
|
37
58
|
|
|
59
|
+
/** DelegationError → 403 kèm mã bất biến; lỗi khác trả về cho `fail` xử lý. */
|
|
60
|
+
const delegationDenied = (e: unknown) =>
|
|
61
|
+
e instanceof DelegationError ? json({ error: e.message, code: e.code }, 403) : null;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Phạm vi đọc vai trò của một người thao tác. Export vì TRANG danh sách vai trò
|
|
65
|
+
* (server component) phải nạp đúng bộ dữ liệu mà API sẽ trả — lệch nhau thì
|
|
66
|
+
* lần render đầu hiện vai trò của đơn vị khác rồi mới biến mất sau khi SWR chạy.
|
|
67
|
+
*/
|
|
68
|
+
export function roleScopeOf(actor: DelegationActor | null): RoleQueryScope | undefined {
|
|
69
|
+
if (!actor) return undefined;
|
|
70
|
+
return {
|
|
71
|
+
canManageAll: actor.canManageAll,
|
|
72
|
+
adminWorkspaceIds: actor.scope.adminIds,
|
|
73
|
+
userWhere: actor.canManageAll ? undefined : memberScopeWhere(actor.scope),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Quyền trong vai trò đi trên dây dưới dạng `"action:resource"` (khuôn của
|
|
79
|
+
* `RoleListPage`), còn tầng uỷ quyền nói `"resource:action"`. Đổi chiều ở đúng
|
|
80
|
+
* một chỗ — trộn hai khuôn thì mọi phép so quyền lặng lẽ trả về "không khớp",
|
|
81
|
+
* tức hàng rào D2 vẫn xanh nhưng không chặn gì.
|
|
82
|
+
*/
|
|
83
|
+
function toResourceAction(permissions: readonly string[]): string[] {
|
|
84
|
+
return permissions
|
|
85
|
+
.map((p) => {
|
|
86
|
+
const [actionCode, resourceCode] = p.split(":");
|
|
87
|
+
return actionCode && resourceCode ? `${resourceCode}:${actionCode}` : null;
|
|
88
|
+
})
|
|
89
|
+
.filter((p): p is string => Boolean(p));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Cột uỷ quyền chỉ tồn tại ở app đã migrate — ghi mù là ném `Unknown argument`. */
|
|
93
|
+
const hasRoleField = (prisma: any, field: string) => Boolean(prisma?.role?.fields?.[field]);
|
|
94
|
+
|
|
95
|
+
/** Đọc vai trò trong DB về khuôn `DelegationRole` cho cổng D1/D2/D4. */
|
|
96
|
+
async function loadRoleForDelegation(prisma: any, id: string) {
|
|
97
|
+
const role = await prisma.role.findUnique({
|
|
98
|
+
where: { id },
|
|
99
|
+
include: { rolePermissions: { select: { resourceCode: true, actionCode: true } } },
|
|
100
|
+
});
|
|
101
|
+
if (!role) return null;
|
|
102
|
+
return {
|
|
103
|
+
role,
|
|
104
|
+
delegation: {
|
|
105
|
+
id: role.id,
|
|
106
|
+
code: role.code,
|
|
107
|
+
rank: typeof role.rank === "number" ? role.rank : 100,
|
|
108
|
+
isSystem: Boolean(role.isSystem),
|
|
109
|
+
workspaceId: role.workspaceId ?? null,
|
|
110
|
+
permissions: (role.rolePermissions ?? []).map(
|
|
111
|
+
(p: any) => `${p.resourceCode}:${p.actionCode}`,
|
|
112
|
+
),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
38
117
|
// permissions "action:resource" → RolePermission rows {roleCode,resourceCode,actionCode}.
|
|
39
118
|
async function writePermissions(tx: any, roleCode: string, permissions: string[]) {
|
|
40
119
|
await tx.rolePermission.deleteMany({ where: { roleCode } });
|
|
@@ -59,9 +138,26 @@ function landingPathData(prisma: any, body: any) {
|
|
|
59
138
|
return { landingPath: normalizeLandingPath(body.landingPath) };
|
|
60
139
|
}
|
|
61
140
|
|
|
141
|
+
// ── Nấc cô lập dữ liệu theo vai trò (`Role.dataScope`) ─────────────────────
|
|
142
|
+
const SCOPE_LEVELS = ["none", "own", "workspace", "subtree", "all"] as const;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* CHỈ VAI VẬN HÀNH đổi được nấc này. Nếu để quản trị viên đơn vị tự đặt, họ chỉ
|
|
146
|
+
* cần tạo một vai trò `all` rồi tự gán — cô lập dữ liệu biến thành tuỳ chọn.
|
|
147
|
+
* `actor === null` (app chưa bật uỷ quyền) thì giữ hành vi cũ: ai sửa vai trò
|
|
148
|
+
* được thì sửa được cột này.
|
|
149
|
+
*/
|
|
150
|
+
function dataScopeData(prisma: any, body: any, actor: DelegationActor | null) {
|
|
151
|
+
if (!hasRoleField(prisma, "dataScope") || !("dataScope" in (body ?? {}))) return {};
|
|
152
|
+
if (actor && !actor.canManageAll) return {};
|
|
153
|
+
const raw = body.dataScope;
|
|
154
|
+
if (raw === null || raw === "") return { dataScope: null };
|
|
155
|
+
return SCOPE_LEVELS.includes(raw) ? { dataScope: raw } : {};
|
|
156
|
+
}
|
|
157
|
+
|
|
62
158
|
// GET (list, schema-tolerant) + POST (create + permissions) for /api/roles.
|
|
63
159
|
export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
64
|
-
const { prisma, getSession, getCrudPermissions, schema, onError } = deps;
|
|
160
|
+
const { prisma, getSession, getCrudPermissions, schema, getDelegationActor, onError } = deps;
|
|
65
161
|
const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
|
|
66
162
|
|
|
67
163
|
async function GET(req: Request) {
|
|
@@ -71,6 +167,7 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
|
71
167
|
const perms = await getCrudPermissions(session, "role");
|
|
72
168
|
if (!perms.read) return json({ error: "Forbidden" }, 403);
|
|
73
169
|
const sp = new URL(req.url).searchParams;
|
|
170
|
+
const actor = getDelegationActor ? await getDelegationActor(session) : null;
|
|
74
171
|
const result = await getRolesData(
|
|
75
172
|
prisma,
|
|
76
173
|
{
|
|
@@ -80,6 +177,7 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
|
80
177
|
status: sp.get("status")?.trim() || undefined,
|
|
81
178
|
},
|
|
82
179
|
schema,
|
|
180
|
+
roleScopeOf(actor),
|
|
83
181
|
);
|
|
84
182
|
return json(result);
|
|
85
183
|
} catch (e) {
|
|
@@ -98,6 +196,29 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
|
98
196
|
const name = (body.name ?? "").trim();
|
|
99
197
|
if (!code || !name) return json({ error: "Thiếu mã hoặc tên vai trò" }, 400);
|
|
100
198
|
const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
|
|
199
|
+
|
|
200
|
+
// Cổng uỷ quyền: đóng dấu đơn vị + ép cấp thấp hơn người tạo + chặn quyền
|
|
201
|
+
// vượt trần. Chỉ chạy khi app khai `getDelegationActor`.
|
|
202
|
+
const stamp: Record<string, unknown> = {};
|
|
203
|
+
const actor = getDelegationActor ? await getDelegationActor(session) : null;
|
|
204
|
+
if (actor) {
|
|
205
|
+
try {
|
|
206
|
+
const gate = assertCanManageRole(actor, null, {
|
|
207
|
+
code,
|
|
208
|
+
workspaceId: body.workspaceId ?? undefined,
|
|
209
|
+
rank: typeof body.rank === "number" ? body.rank : undefined,
|
|
210
|
+
isSystem: Boolean(body.isSystem),
|
|
211
|
+
permissions: toResourceAction(permissions),
|
|
212
|
+
});
|
|
213
|
+
if (hasRoleField(prisma, "workspaceId")) stamp.workspaceId = gate.workspaceId;
|
|
214
|
+
if (hasRoleField(prisma, "rank")) stamp.rank = gate.rank;
|
|
215
|
+
} catch (e) {
|
|
216
|
+
const denied = delegationDenied(e);
|
|
217
|
+
if (denied) return denied;
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
101
222
|
const role = await prisma.$transaction(async (tx: any) => {
|
|
102
223
|
const created = await tx.role.create({
|
|
103
224
|
data: {
|
|
@@ -105,7 +226,9 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
|
105
226
|
name,
|
|
106
227
|
description: body.description ?? null,
|
|
107
228
|
status: body.status ?? "active",
|
|
229
|
+
...stamp,
|
|
108
230
|
...landingPathData(prisma, body),
|
|
231
|
+
...dataScopeData(prisma, body, actor),
|
|
109
232
|
},
|
|
110
233
|
});
|
|
111
234
|
await writePermissions(tx, created.code, permissions);
|
|
@@ -123,7 +246,7 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
|
123
246
|
|
|
124
247
|
// GET + PUT (update + replace permissions) + DELETE (guarded by user count) for /api/roles/[id].
|
|
125
248
|
export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
126
|
-
const { prisma, getSession, getCrudPermissions, onError } = deps;
|
|
249
|
+
const { prisma, getSession, getCrudPermissions, getDelegationActor, onError } = deps;
|
|
127
250
|
const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
|
|
128
251
|
type Ctx = { params: Promise<{ id: string }> };
|
|
129
252
|
|
|
@@ -134,11 +257,17 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
134
257
|
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
135
258
|
const perms = await getCrudPermissions(session, "role");
|
|
136
259
|
if (!perms.read) return json({ error: "Forbidden" }, 403);
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
260
|
+
const loaded = await loadRoleForDelegation(prisma, id);
|
|
261
|
+
if (!loaded) return json({ error: "Not found" }, 404);
|
|
262
|
+
const { role } = loaded;
|
|
263
|
+
// Vai trò ngoài phạm vi thì coi như KHÔNG TỒN TẠI: trả 403 ở đây là xác
|
|
264
|
+
// nhận "có vai trò id này", đủ để dò danh sách vai trò của đơn vị khác.
|
|
265
|
+
const viewer = getDelegationActor ? await getDelegationActor(session) : null;
|
|
266
|
+
if (viewer && !viewer.canManageAll) {
|
|
267
|
+
const ws = loaded.delegation.workspaceId;
|
|
268
|
+
const visible = !ws || viewer.scope.adminIds.includes(ws);
|
|
269
|
+
if (!visible) return json({ error: "Not found" }, 404);
|
|
270
|
+
}
|
|
142
271
|
return json({
|
|
143
272
|
id: role.id,
|
|
144
273
|
code: role.code,
|
|
@@ -146,6 +275,10 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
146
275
|
description: role.description ?? "",
|
|
147
276
|
status: role.status,
|
|
148
277
|
landingPath: role.landingPath ?? null,
|
|
278
|
+
dataScope: role.dataScope ?? null,
|
|
279
|
+
workspaceId: role.workspaceId ?? null,
|
|
280
|
+
isSystem: Boolean(role.isSystem),
|
|
281
|
+
canManage: viewer ? canManageRole(viewer, loaded.delegation) : undefined,
|
|
149
282
|
permissions: role.rolePermissions.map((p: any) => `${p.actionCode}:${p.resourceCode}`),
|
|
150
283
|
});
|
|
151
284
|
} catch (e) {
|
|
@@ -161,9 +294,32 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
161
294
|
const perms = await getCrudPermissions(session, "role");
|
|
162
295
|
if (!perms.update) return json({ error: "Forbidden" }, 403);
|
|
163
296
|
const body = await req.json();
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
297
|
+
const loaded = await loadRoleForDelegation(prisma, id);
|
|
298
|
+
if (!loaded) return json({ error: "Not found" }, 404);
|
|
299
|
+
const existing = loaded.role;
|
|
166
300
|
const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
|
|
301
|
+
|
|
302
|
+
// Vai trò hệ thống / vai trò dùng chung / vai trò đơn vị khác: chặn ở đây.
|
|
303
|
+
// Không có cổng này thì quản trị viên đơn vị chỉ cần MỞ vai trò "Quản trị
|
|
304
|
+
// hệ thống" rồi bấm lưu là tự nâng cấp mình.
|
|
305
|
+
const stamp: Record<string, unknown> = {};
|
|
306
|
+
const actor = getDelegationActor ? await getDelegationActor(session) : null;
|
|
307
|
+
if (actor) {
|
|
308
|
+
try {
|
|
309
|
+
const gate = assertCanManageRole(actor, loaded.delegation, {
|
|
310
|
+
code: existing.code,
|
|
311
|
+
workspaceId: loaded.delegation.workspaceId,
|
|
312
|
+
rank: typeof body.rank === "number" ? body.rank : loaded.delegation.rank,
|
|
313
|
+
permissions: toResourceAction(permissions),
|
|
314
|
+
});
|
|
315
|
+
if (hasRoleField(prisma, "rank")) stamp.rank = gate.rank;
|
|
316
|
+
} catch (e) {
|
|
317
|
+
const denied = delegationDenied(e);
|
|
318
|
+
if (denied) return denied;
|
|
319
|
+
throw e;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
167
323
|
const role = await prisma.$transaction(async (tx: any) => {
|
|
168
324
|
const updated = await tx.role.update({
|
|
169
325
|
where: { id },
|
|
@@ -171,7 +327,9 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
171
327
|
name: (body.name ?? existing.name).trim(),
|
|
172
328
|
description: body.description ?? existing.description,
|
|
173
329
|
status: body.status ?? existing.status,
|
|
330
|
+
...stamp,
|
|
174
331
|
...landingPathData(prisma, body),
|
|
332
|
+
...dataScopeData(prisma, body, actor),
|
|
175
333
|
},
|
|
176
334
|
});
|
|
177
335
|
await writePermissions(tx, updated.code, permissions);
|
|
@@ -192,6 +350,18 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
192
350
|
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
193
351
|
const perms = await getCrudPermissions(session, "role");
|
|
194
352
|
if (!perms.delete) return json({ error: "Forbidden" }, 403);
|
|
353
|
+
const loaded = await loadRoleForDelegation(prisma, id);
|
|
354
|
+
if (!loaded) return json({ error: "Not found" }, 404);
|
|
355
|
+
const actor = getDelegationActor ? await getDelegationActor(session) : null;
|
|
356
|
+
if (actor) {
|
|
357
|
+
try {
|
|
358
|
+
assertCanDeleteRole(actor, loaded.delegation);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
const denied = delegationDenied(e);
|
|
361
|
+
if (denied) return denied;
|
|
362
|
+
throw e;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
195
365
|
const role = await prisma.role.findUnique({ where: { id }, include: { _count: { select: { userRoles: true } } } });
|
|
196
366
|
if (!role) return json({ error: "Not found" }, 404);
|
|
197
367
|
if (role._count.userRoles > 0)
|
package/src/rbac/types.ts
CHANGED
|
@@ -20,6 +20,15 @@ export type Role = {
|
|
|
20
20
|
updatedAt: string;
|
|
21
21
|
createdBy?: string | null | undefined;
|
|
22
22
|
updatedBy?: string | null | undefined;
|
|
23
|
+
/** App multi-tenant: đơn vị sở hữu vai trò; `null` = vai trò dùng chung. */
|
|
24
|
+
workspaceId?: string | null;
|
|
25
|
+
isSystem?: boolean;
|
|
26
|
+
rank?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Người đang xem có SỬA/XOÁ được vai trò này không — server tính, client chỉ
|
|
29
|
+
* vẽ theo. `undefined` = app không xét uỷ quyền (hành vi cũ, coi như được).
|
|
30
|
+
*/
|
|
31
|
+
canManage?: boolean;
|
|
23
32
|
};
|
|
24
33
|
|
|
25
34
|
export type Permission = {
|
package/src/user/user-service.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { assignableRoles } from "../workspace/delegation";
|
|
1
2
|
import { memberScopeWhere } from "../workspace/scope";
|
|
2
3
|
|
|
4
|
+
import type { DelegationActor, DelegationRole } from "../workspace/delegation";
|
|
3
5
|
import type { WorkspaceScope } from "../workspace/types";
|
|
4
6
|
import type { CrudResponse } from "../types";
|
|
5
7
|
|
|
@@ -441,6 +443,61 @@ export async function getActiveRoles(db: UserPrismaClient) {
|
|
|
441
443
|
return roles;
|
|
442
444
|
}
|
|
443
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Vai trò mà NGƯỜI ĐANG THAO TÁC được phép gán — dùng cho ô chọn vai trò trong
|
|
448
|
+
* hộp thoại người dùng.
|
|
449
|
+
*
|
|
450
|
+
* `getActiveRoles` trả về mọi vai trò đang hoạt động, kể cả "Quản trị hệ thống".
|
|
451
|
+
* Bản thân việc gán đã bị `assertCanCreateUser` chặn ở tầng API, nên đây không
|
|
452
|
+
* phải lỗ hổng — nhưng nó là cái bẫy giao diện: quản trị viên đơn vị chọn vai
|
|
453
|
+
* trò, bấm lưu, rồi mới ăn 403. Danh sách này lọc trước bằng CHÍNH luật D4
|
|
454
|
+
* (`assignableRoles`), nên thứ hiện ra là thứ lưu được.
|
|
455
|
+
*
|
|
456
|
+
* App chưa có cột `rank` (một tổ chức, không uỷ quyền) rơi về `getActiveRoles`.
|
|
457
|
+
*/
|
|
458
|
+
export async function getAssignableRoles(
|
|
459
|
+
db: UserPrismaClient,
|
|
460
|
+
actor: DelegationActor | null,
|
|
461
|
+
) {
|
|
462
|
+
if (!actor || actor.canManageAll || !db.role.fields?.rank) {
|
|
463
|
+
return getActiveRoles(db);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const rows = await db.role.findMany({
|
|
467
|
+
where: { status: "active" },
|
|
468
|
+
select: {
|
|
469
|
+
id: true,
|
|
470
|
+
code: true,
|
|
471
|
+
name: true,
|
|
472
|
+
status: true,
|
|
473
|
+
rank: true,
|
|
474
|
+
isSystem: db.role.fields?.isSystem ? true : undefined,
|
|
475
|
+
workspaceId: db.role.fields?.workspaceId ? true : undefined,
|
|
476
|
+
rolePermissions: { select: { resourceCode: true, actionCode: true } },
|
|
477
|
+
},
|
|
478
|
+
orderBy: { name: "asc" },
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const candidates: (DelegationRole & { name: string; status: string })[] = rows.map((r: any) => ({
|
|
482
|
+
id: r.id,
|
|
483
|
+
code: r.code,
|
|
484
|
+
name: r.name,
|
|
485
|
+
status: r.status,
|
|
486
|
+
rank: r.rank,
|
|
487
|
+
isSystem: Boolean(r.isSystem),
|
|
488
|
+
workspaceId: r.workspaceId ?? null,
|
|
489
|
+
permissions: (r.rolePermissions ?? []).map(
|
|
490
|
+
(p: any) => `${p.resourceCode}:${p.actionCode}`,
|
|
491
|
+
),
|
|
492
|
+
}));
|
|
493
|
+
|
|
494
|
+
return assignableRoles(actor, candidates).map((r) => ({
|
|
495
|
+
code: r.code,
|
|
496
|
+
name: r.name,
|
|
497
|
+
status: r.status,
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
|
|
444
501
|
/**
|
|
445
502
|
* Get all active departments for filter dropdown
|
|
446
503
|
*/
|
|
@@ -10,8 +10,12 @@ import {
|
|
|
10
10
|
assertNotSelf,
|
|
11
11
|
assertRoleAssignable,
|
|
12
12
|
assertWorkspacesInScope,
|
|
13
|
+
assertCanDeleteRole,
|
|
14
|
+
assertCanManageRole,
|
|
13
15
|
assignableRoles,
|
|
14
16
|
canDelegateUsers,
|
|
17
|
+
canManageRole,
|
|
18
|
+
defaultRoleWorkspaceIdFor,
|
|
15
19
|
DelegationError,
|
|
16
20
|
isDangerousPermission,
|
|
17
21
|
} from "../delegation";
|
|
@@ -24,6 +28,7 @@ import type {
|
|
|
24
28
|
DelegationActor,
|
|
25
29
|
DelegationRole,
|
|
26
30
|
DelegationTarget,
|
|
31
|
+
RoleDraft,
|
|
27
32
|
} from "../delegation";
|
|
28
33
|
|
|
29
34
|
/**
|
|
@@ -361,3 +366,100 @@ describe("cổng tổng hợp", () => {
|
|
|
361
366
|
expect(() => assertCanCreateUser(opsAdmin(), [])).not.toThrow();
|
|
362
367
|
});
|
|
363
368
|
});
|
|
369
|
+
|
|
370
|
+
describe("Quản trị VAI TRÒ — cổng tạo/sửa/xoá", () => {
|
|
371
|
+
const draft = (over: Partial<RoleDraft> = {}): RoleDraft => ({
|
|
372
|
+
code: "TO_TRUONG",
|
|
373
|
+
permissions: ["meal-order:view"],
|
|
374
|
+
...over,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("tạo vai trò trong nhánh mình: được đóng dấu workspace + giữ rank", () => {
|
|
378
|
+
const gate = assertCanManageRole(customerAdmin(), null, draft({ rank: 80 }));
|
|
379
|
+
expect(gate.workspaceId).toBe("spa");
|
|
380
|
+
expect(gate.rank).toBe(80);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("không tự tạo được vai trò DÙNG CHUNG (workspaceId null)", () => {
|
|
384
|
+
// Cửa hậu kinh điển: vai trò không mang dấu đơn vị thì đơn vị khác cũng gán
|
|
385
|
+
// được — admin nhánh vừa ghi vào không gian của cả hệ thống.
|
|
386
|
+
const actor = customerAdmin({
|
|
387
|
+
scope: createWorkspaceScope({
|
|
388
|
+
canViewAll: false,
|
|
389
|
+
rootIds: ["spa"],
|
|
390
|
+
allowedIds: ["spa"],
|
|
391
|
+
adminIds: [],
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
expect(() => assertCanManageRole(actor, null, draft())).toThrow(
|
|
395
|
+
DelegationError,
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("không tạo được vai trò ngang hoặc mạnh hơn chính mình", () => {
|
|
400
|
+
try {
|
|
401
|
+
assertCanManageRole(customerAdmin(), null, draft({ rank: 50 }));
|
|
402
|
+
throw new Error("đáng lẽ phải ném");
|
|
403
|
+
} catch (error) {
|
|
404
|
+
expect((error as DelegationError).code).toBe("D2_EXCEEDS_CEILING");
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("không nhét được quyền quản trị hệ thống vào vai trò mới (D4)", () => {
|
|
409
|
+
try {
|
|
410
|
+
assertCanManageRole(
|
|
411
|
+
customerAdmin(),
|
|
412
|
+
null,
|
|
413
|
+
draft({ rank: 80, permissions: ["role:create"] }),
|
|
414
|
+
);
|
|
415
|
+
throw new Error("đáng lẽ phải ném");
|
|
416
|
+
} catch (error) {
|
|
417
|
+
expect((error as DelegationError).code).toBe("D4_DANGEROUS_ROLE");
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("không cấp được quyền mà chính mình không có (D2)", () => {
|
|
422
|
+
try {
|
|
423
|
+
assertCanManageRole(
|
|
424
|
+
customerAdmin(),
|
|
425
|
+
null,
|
|
426
|
+
draft({ rank: 80, permissions: ["payroll:view"] }),
|
|
427
|
+
);
|
|
428
|
+
throw new Error("đáng lẽ phải ném");
|
|
429
|
+
} catch (error) {
|
|
430
|
+
expect((error as DelegationError).code).toBe("D2_EXCEEDS_CEILING");
|
|
431
|
+
expect((error as DelegationError).message).toContain("payroll:view");
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("vai trò dùng chung: THẤY nhưng không sửa, không xoá", () => {
|
|
436
|
+
const shared = role({ rank: 100, workspaceId: null });
|
|
437
|
+
expect(canManageRole(customerAdmin(), shared)).toBe(false);
|
|
438
|
+
expect(() =>
|
|
439
|
+
assertCanManageRole(customerAdmin(), shared, draft({ rank: 100 })),
|
|
440
|
+
).toThrow(DelegationError);
|
|
441
|
+
expect(() => assertCanDeleteRole(customerAdmin(), shared)).toThrow(
|
|
442
|
+
DelegationError,
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("vai trò của đơn vị khác / vai trò hệ thống đều không sửa được", () => {
|
|
447
|
+
expect(
|
|
448
|
+
canManageRole(customerAdmin(), role({ workspaceId: "tanloc" })),
|
|
449
|
+
).toBe(false);
|
|
450
|
+
expect(
|
|
451
|
+
canManageRole(customerAdmin(), role({ workspaceId: "spa", isSystem: true })),
|
|
452
|
+
).toBe(false);
|
|
453
|
+
expect(canManageRole(customerAdmin(), role({ workspaceId: "spa-qc" }))).toBe(
|
|
454
|
+
true,
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("nhóm vận hành miễn mọi luật", () => {
|
|
459
|
+
expect(canManageRole(opsAdmin(), role({ isSystem: true }))).toBe(true);
|
|
460
|
+
expect(() =>
|
|
461
|
+
assertCanManageRole(opsAdmin(), null, draft({ permissions: ["role:create"] })),
|
|
462
|
+
).not.toThrow();
|
|
463
|
+
expect(defaultRoleWorkspaceIdFor(opsAdmin())).toBeNull();
|
|
464
|
+
});
|
|
465
|
+
});
|