@goplusvn/core 0.1.77 → 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.
@@ -590,3 +590,101 @@ describe("memberScopeWhere", () => {
590
590
  ).toEqual({});
591
591
  });
592
592
  });
593
+
594
+ /**
595
+ * CÔ LẬP DỮ LIỆU THEO VAI TRÒ (`Role.dataScope` → `getScopeLevel`).
596
+ *
597
+ * Điểm mấu chốt của thiết kế: bốn nấc `none/workspace/subtree/all` được GẤP vào
598
+ * chính đối tượng phạm vi lúc dựng, nên mọi `scopedWhere` / `memberScopeWhere`
599
+ * / guard lớp 2 đang có tự tuân theo — không trang nào phải sửa. Test dưới đây
600
+ * canh đúng tính chất đó; hỏng nó là hỏng toàn bộ cách tiếp cận.
601
+ */
602
+ describe("nấc cô lập dữ liệu gấp vào phạm vi", () => {
603
+ const TREE = [
604
+ { id: "spa", path: "/spa/" },
605
+ { id: "spa-qc", path: "/spa/spa-qc/" },
606
+ { id: "spa-kho", path: "/spa/spa-kho/" },
607
+ ];
608
+
609
+ function configure(level: string | null) {
610
+ resetWorkspaceConfig();
611
+ configureWorkspaces<{ id: string }>({
612
+ scopeField: "workspaceId",
613
+ getUserId: (s) => s.id,
614
+ canViewAll: () => false,
615
+ getScopeLevel: () => level as never,
616
+ getMemberships: () => [{ workspaceId: "spa", isAdmin: true }],
617
+ db: {
618
+ userWorkspace: { findMany: async () => [] },
619
+ workspace: {
620
+ findMany: async ({ where }: { where: any }) =>
621
+ where?.id?.in
622
+ ? TREE.filter((n) => where.id.in.includes(n.id))
623
+ : TREE.filter((n) =>
624
+ (where?.OR ?? []).some((c: any) =>
625
+ n.path.startsWith(c.path.startsWith),
626
+ ),
627
+ ),
628
+ },
629
+ },
630
+ });
631
+ }
632
+
633
+ beforeEach(() => resetWorkspaceConfig());
634
+
635
+ it("mặc định (không khai nấc) = subtree — bung con cháu, y như trước", async () => {
636
+ configure(null);
637
+ const scope = await getWorkspaceScope({ id: "u1" });
638
+ expect(scope.allowedIds).toEqual(["spa", "spa-qc", "spa-kho"]);
639
+ expect(scopedWhere(scope)).toEqual({
640
+ OR: [
641
+ { workspaceId: { in: ["spa", "spa-qc", "spa-kho"] } },
642
+ { workspaceId: null },
643
+ ],
644
+ });
645
+ });
646
+
647
+ it("nấc `workspace` KHÔNG bung con cháu", async () => {
648
+ configure("workspace");
649
+ const scope = await getWorkspaceScope({ id: "u1" });
650
+ expect(scope.allowedIds).toEqual(["spa"]);
651
+ expect(scopedWhere(scope)).toEqual({
652
+ OR: [{ workspaceId: { in: ["spa"] } }, { workspaceId: null }],
653
+ });
654
+ });
655
+
656
+ it("nấc `all` bỏ cô lập — kể cả khi không có quyền view-all", async () => {
657
+ configure("all");
658
+ const scope = await getWorkspaceScope({ id: "u1" });
659
+ expect(scope.canViewAll).toBe(true);
660
+ expect(scopedWhere(scope)).toEqual({});
661
+ expect(memberScopeWhere(scope)).toEqual({});
662
+ });
663
+
664
+ it("nấc `none` đóng sạch — sentinel chứ không phải 'không lọc'", async () => {
665
+ configure("none");
666
+ const scope = await getWorkspaceScope({ id: "u1" });
667
+ expect(scope.canViewAll).toBe(false);
668
+ expect(scope.allowedIds).toEqual([NO_WORKSPACE_ACCESS]);
669
+ });
670
+
671
+ it("nấc `own` lọc theo cột chủ sở hữu, và THIẾU cột thì đóng chứ không nới", async () => {
672
+ configure("own");
673
+ const scope = await getWorkspaceScope({ id: "u1" });
674
+ expect(scopedWhere(scope, { ownerField: "createdBy" })).toEqual({
675
+ createdBy: "u1",
676
+ });
677
+ // Không khai ownerField ⇒ fail-closed.
678
+ expect(scopedWhere(scope)).toEqual({ id: NO_WORKSPACE_ACCESS });
679
+ // Trên chính bảng người dùng: chỉ thấy hồ sơ của mình.
680
+ expect(memberScopeWhere(scope)).toEqual({ id: "u1" });
681
+ });
682
+
683
+ it("nấc `own` qua quan hệ", async () => {
684
+ configure("own");
685
+ const scope = await getWorkspaceScope({ id: "u1" });
686
+ expect(
687
+ scopedWhere(scope, { relation: "employee", ownerField: "user_id" }),
688
+ ).toEqual({ employee: { user_id: "u1" } });
689
+ });
690
+ });
@@ -278,6 +278,154 @@ export function assignableRoles<T extends DelegationRole>(
278
278
  });
279
279
  }
280
280
 
281
+ /**
282
+ * Vai trò đang được TẠO hoặc SỬA — đầu vào của đường quản trị vai trò.
283
+ *
284
+ * Khác `DelegationRole` (thứ đã có trong DB, dùng để hỏi "gán đi được không"):
285
+ * đây là bản nháp người dùng vừa bấm lưu, chưa qua cổng nào.
286
+ */
287
+ export interface RoleDraft {
288
+ code: string;
289
+ /** Bỏ trống = để engine đóng dấu theo nhánh của người tạo. */
290
+ workspaceId?: string | null;
291
+ rank?: number | null;
292
+ isSystem?: boolean;
293
+ permissions: readonly string[];
294
+ }
295
+
296
+ /**
297
+ * Vai trò DÙNG CHUNG (`workspaceId === null`) là của cấp vận hành: admin nhánh
298
+ * NHÌN thấy để biết mình đang gán gì, nhưng không sửa được — một nhánh sửa thì
299
+ * mọi đơn vị khác lãnh đủ.
300
+ */
301
+ export function canManageRole(
302
+ actor: DelegationActor,
303
+ role: Pick<DelegationRole, "workspaceId" | "isSystem">,
304
+ ): boolean {
305
+ if (actor.canManageAll) return true;
306
+ if (role.isSystem) return false;
307
+ if (!role.workspaceId) return false;
308
+ return isWorkspaceAdmin(actor.scope, role.workspaceId);
309
+ }
310
+
311
+ /**
312
+ * Nhánh mà vai trò mới sẽ mang dấu. Người quản trị nhiều nhánh thì lấy nhánh
313
+ * mặc định nếu nó nằm trong quyền quản trị, không thì nhánh đầu tiên.
314
+ */
315
+ export function defaultRoleWorkspaceIdFor(
316
+ actor: DelegationActor,
317
+ ): string | null {
318
+ if (actor.canManageAll) return null;
319
+ const { defaultId, adminIds } = actor.scope;
320
+ if (defaultId && adminIds.includes(defaultId)) return defaultId;
321
+ return adminIds[0] ?? null;
322
+ }
323
+
324
+ /**
325
+ * Cổng đầy đủ cho đường TẠO / SỬA một vai trò (D1 + D2 + D4).
326
+ *
327
+ * Đây là lỗ hổng đối xứng với `assertCanCreateUser`: chặn *gán* vai trò mạnh mà
328
+ * để ngỏ đường *tạo* vai trò mạnh thì admin nhánh chỉ cần đi vòng một bước —
329
+ * tạo vai trò "Trợ lý" có `role:update` rồi tự gán cho tài khoản mình vừa lập.
330
+ *
331
+ * `existing` là vai trò trong DB (null khi tạo mới). Trả về `workspaceId` +
332
+ * `rank` đã chuẩn hoá để route ghi thẳng, khỏi tự suy lại rồi suy sai.
333
+ */
334
+ export function assertCanManageRole(
335
+ actor: DelegationActor,
336
+ existing: DelegationRole | null,
337
+ draft: RoleDraft,
338
+ ): { workspaceId: string | null; rank: number } {
339
+ const requestedRank = draft.rank ?? existing?.rank ?? 100;
340
+
341
+ if (actor.canManageAll) {
342
+ return {
343
+ workspaceId:
344
+ draft.workspaceId !== undefined
345
+ ? draft.workspaceId
346
+ : (existing?.workspaceId ?? null),
347
+ rank: requestedRank,
348
+ };
349
+ }
350
+
351
+ if (actor.scope.adminIds.length === 0) {
352
+ throw new DelegationError(
353
+ "NO_DELEGATION",
354
+ "Bạn không được uỷ quyền quản trị workspace nào.",
355
+ );
356
+ }
357
+
358
+ if (existing && !canManageRole(actor, existing)) {
359
+ throw new DelegationError(
360
+ existing.isSystem ? "D4_DANGEROUS_ROLE" : "D1_OUT_OF_SCOPE",
361
+ existing.isSystem
362
+ ? `Vai trò "${existing.code}" là vai trò hệ thống — chỉ quản trị toàn hệ thống sửa được.`
363
+ : `Vai trò "${existing.code}" ${existing.workspaceId ? "thuộc đơn vị ngoài phạm vi của bạn" : "là vai trò dùng chung của cấp trên"} — bạn xem được nhưng không sửa được.`,
364
+ );
365
+ }
366
+
367
+ if (draft.isSystem) {
368
+ throw new DelegationError(
369
+ "D4_DANGEROUS_ROLE",
370
+ "Không thể đánh dấu vai trò là vai trò hệ thống.",
371
+ );
372
+ }
373
+
374
+ // Đóng dấu nhánh: vai trò không mang dấu là vai trò DÙNG CHUNG, tức là admin
375
+ // nhánh vừa tạo ra thứ mà mọi đơn vị khác cũng gán được.
376
+ const workspaceId =
377
+ draft.workspaceId ??
378
+ existing?.workspaceId ??
379
+ defaultRoleWorkspaceIdFor(actor);
380
+ if (!workspaceId || !isWorkspaceAdmin(actor.scope, workspaceId)) {
381
+ throw new DelegationError(
382
+ "D1_OUT_OF_SCOPE",
383
+ "Vai trò phải thuộc một đơn vị trong phạm vi quản trị của bạn.",
384
+ );
385
+ }
386
+
387
+ // D2 lúc TẠO: vai trò yếu hơn chính người tạo. Không có luật này thì họ tạo
388
+ // vai trò rank 0 rồi nhờ người khác gán ngược lại cho mình.
389
+ if (requestedRank <= actor.rank) {
390
+ throw new DelegationError(
391
+ "D2_EXCEEDS_CEILING",
392
+ `Vai trò phải có cấp thấp hơn bạn (rank > ${actor.rank}).`,
393
+ );
394
+ }
395
+
396
+ const dangerous = draft.permissions.filter(isDangerousPermission);
397
+ if (dangerous.length > 0) {
398
+ throw new DelegationError(
399
+ "D4_DANGEROUS_ROLE",
400
+ `Không thể đưa quyền quản trị hệ thống vào vai trò: ${dangerous.join(", ")}.`,
401
+ );
402
+ }
403
+
404
+ const exceeded = draft.permissions.filter((p) => !actor.permissions.has(p));
405
+ if (exceeded.length > 0) {
406
+ throw new DelegationError(
407
+ "D2_EXCEEDS_CEILING",
408
+ `Vai trò chứa quyền bạn không có: ${exceeded.slice(0, 5).join(", ")}${exceeded.length > 5 ? "…" : ""}.`,
409
+ );
410
+ }
411
+
412
+ return { workspaceId, rank: requestedRank };
413
+ }
414
+
415
+ /** Cổng cho đường XOÁ vai trò — cùng luật với sửa, không có phần bộ quyền. */
416
+ export function assertCanDeleteRole(
417
+ actor: DelegationActor,
418
+ role: DelegationRole,
419
+ ): void {
420
+ if (actor.canManageAll) return;
421
+ if (!canManageRole(actor, role)) {
422
+ throw new DelegationError(
423
+ role.isSystem ? "D4_DANGEROUS_ROLE" : "D1_OUT_OF_SCOPE",
424
+ `Vai trò "${role.code}" nằm ngoài phạm vi quản trị của bạn — không xoá được.`,
425
+ );
426
+ }
427
+ }
428
+
281
429
  /**
282
430
  * D2 bản CHẠY — quyền hiệu lực = GIAO của quyền vai trò và trần quyền.
283
431
  *
@@ -97,6 +97,10 @@ export {
97
97
  assertNotProtected,
98
98
  assertRoleAssignable,
99
99
  assignableRoles,
100
+ canManageRole,
101
+ defaultRoleWorkspaceIdFor,
102
+ assertCanManageRole,
103
+ assertCanDeleteRole,
100
104
  applyPermissionCeiling,
101
105
  assertBoundaryIntact,
102
106
  assertCanUpdateUser,
@@ -107,6 +111,7 @@ export {
107
111
  type DelegationTarget,
108
112
  type DelegationRole,
109
113
  type DelegationChanges,
114
+ type RoleDraft,
110
115
  } from "./delegation";
111
116
 
112
117
  export {
@@ -66,6 +66,8 @@ export function createWorkspaceScope(input: {
66
66
  allowedIds?: string[];
67
67
  adminIds?: string[];
68
68
  defaultId?: string;
69
+ level?: ScopeLevel;
70
+ userId?: string;
69
71
  }): WorkspaceScope {
70
72
  if (input.canViewAll) {
71
73
  return {
@@ -73,6 +75,8 @@ export function createWorkspaceScope(input: {
73
75
  rootIds: input.rootIds ?? [],
74
76
  adminIds: input.adminIds ?? [],
75
77
  defaultId: input.defaultId,
78
+ level: input.level,
79
+ userId: input.userId,
76
80
  };
77
81
  }
78
82
  const allowed =
@@ -86,6 +90,8 @@ export function createWorkspaceScope(input: {
86
90
  allowedBranchIds: allowed,
87
91
  adminIds: input.adminIds ?? [],
88
92
  defaultId: input.defaultId,
93
+ level: input.level,
94
+ userId: input.userId,
89
95
  };
90
96
  }
91
97
 
@@ -97,8 +103,18 @@ export async function getWorkspaceScope<TSession>(
97
103
  session: TSession,
98
104
  ): Promise<WorkspaceScope> {
99
105
  const config = requireConfigured();
106
+ const userId = config.getUserId(session) ?? undefined;
107
+ // Nấc cô lập của vai trò. `all` mở toàn bộ, `none` đóng sạch — hai nấc này
108
+ // quyết định xong là không cần đọc membership nữa.
109
+ const level = (await config.getScopeLevel?.(session)) ?? undefined;
110
+ if (level === "all") {
111
+ return createWorkspaceScope({ canViewAll: true, level, userId });
112
+ }
113
+ if (level === "none") {
114
+ return createWorkspaceScope({ canViewAll: false, allowedIds: [], level, userId });
115
+ }
100
116
  if (config.canViewAll(session)) {
101
- return createWorkspaceScope({ canViewAll: true });
117
+ return createWorkspaceScope({ canViewAll: true, level, userId });
102
118
  }
103
119
 
104
120
  const memberships = await readMemberships(config, session);
@@ -109,7 +125,10 @@ export async function getWorkspaceScope<TSession>(
109
125
  const defaultId =
110
126
  memberships.find((m) => m.isDefault)?.workspaceId ?? rootIds[0];
111
127
 
112
- const allowedIds = await expand(config, rootIds);
128
+ // `workspace` = đúng nút được gán, KHÔNG bung con cháu. Gấp ngay vào
129
+ // `allowedIds` ở đây thay vì bắt từng trang nhớ gọi `scopeLevelWhere`.
130
+ const allowedIds =
131
+ level === "workspace" ? rootIds : await expand(config, rootIds);
113
132
  // Quản trị viên của nhánh nào thì quản trị được cả con cháu nhánh đó — cùng
114
133
  // luật cộng dồn với phạm vi đọc, nếu không thì admin nút cha lại không đụng
115
134
  // được phòng ban con của chính mình.
@@ -122,6 +141,8 @@ export async function getWorkspaceScope<TSession>(
122
141
  allowedIds,
123
142
  adminIds,
124
143
  defaultId,
144
+ level,
145
+ userId,
125
146
  });
126
147
  }
127
148
 
@@ -252,6 +273,8 @@ export function memberScopeWhere(
252
273
  scope: WorkspaceScope,
253
274
  options?: { relation?: string; field?: string; adminOnly?: boolean },
254
275
  ): Record<string, unknown> {
276
+ // Nấc `own` trên chính bảng NGƯỜI DÙNG = chỉ thấy hồ sơ của mình.
277
+ if (scope.level === "own") return { id: scope.userId ?? NO_WORKSPACE_ACCESS };
255
278
  if (scope.canViewAll) return {};
256
279
  const config = configured;
257
280
  const relation =
@@ -274,8 +297,23 @@ export function memberScopeWhere(
274
297
  */
275
298
  export function scopedWhere(
276
299
  scope: WorkspaceScope,
277
- options?: { field?: string; relation?: string },
300
+ options?: { field?: string; relation?: string; ownerField?: string },
278
301
  ): Record<string, unknown> {
302
+ // Nấc `own` là nấc DUY NHẤT không gấp được vào `allowedIds` — nó lọc theo
303
+ // người chứ không theo không gian. Không khai `ownerField` (ở đây hoặc trong
304
+ // `configureWorkspaces`) thì đóng lại: fail-closed, đừng lặng lẽ nới thành
305
+ // `subtree`.
306
+ if (scope.level === "own") {
307
+ const ownerField = options?.ownerField ?? configured?.ownerField;
308
+ if (options?.relation) {
309
+ return ownerField
310
+ ? { [options.relation]: { [ownerField]: scope.userId ?? NO_WORKSPACE_ACCESS } }
311
+ : { [options.relation]: { id: NO_WORKSPACE_ACCESS } };
312
+ }
313
+ return ownerField
314
+ ? { [ownerField]: scope.userId ?? NO_WORKSPACE_ACCESS }
315
+ : { id: NO_WORKSPACE_ACCESS };
316
+ }
279
317
  if (scope.canViewAll) return {};
280
318
  const field = options?.field ?? workspaceScopeField();
281
319
  const ids = scope.allowedIds ?? [NO_WORKSPACE_ACCESS];
@@ -84,6 +84,18 @@ export interface WorkspaceScope extends BranchScope {
84
84
  adminIds: string[];
85
85
  /** Không gian mở sẵn khi đăng nhập / gán cho bản ghi mới tạo. */
86
86
  defaultId?: string;
87
+ /**
88
+ * Nấc cô lập dữ liệu ĐÃ ÁP dụng khi dựng phạm vi này (Δ1). Bỏ trống = `subtree`,
89
+ * đúng hành vi cũ.
90
+ *
91
+ * Bốn nấc `none/workspace/subtree/all` được **gấp thẳng vào** `canViewAll` +
92
+ * `allowedIds` lúc dựng, nên mọi `scopedWhere` / `memberScopeWhere` / guard
93
+ * lớp 2 đang có tự tuân theo mà không phải sửa một call-site nào. Riêng `own`
94
+ * cần biết cột chủ sở hữu nên phải khai ở call-site (`ownerField`).
95
+ */
96
+ level?: ScopeLevel;
97
+ /** Chủ phiên — chỉ dùng cho nấc `own`. */
98
+ userId?: string;
87
99
  }
88
100
 
89
101
  /**
@@ -175,6 +187,19 @@ export interface WorkspaceScopeConfig<TSession = unknown> {
175
187
  getUserId: (session: TSession) => string | null | undefined;
176
188
  /** "Xem mọi không gian" — app tự quyết (vai trò quản trị / quyền `view-all-workspaces`). */
177
189
  canViewAll: (session: TSession) => boolean;
190
+ /**
191
+ * NẤC CÔ LẬP DỮ LIỆU của phiên (Δ1) — thường là nấc RỘNG NHẤT trong các vai
192
+ * trò của người này (`Role.dataScope`). Bỏ trống ⇒ `subtree`, y hệt hôm nay.
193
+ *
194
+ * Trả `all` là bỏ cô lập (thấy hết), `workspace` là **không** bung con cháu,
195
+ * `none` là không thấy gì. Nấc này được gấp vào phạm vi lúc dựng nên không
196
+ * trang nào phải sửa — đó là lý do nó nằm ở đây chứ không rải ra call-site.
197
+ */
198
+ getScopeLevel?: (
199
+ session: TSession,
200
+ ) => ScopeLevel | null | undefined | Promise<ScopeLevel | null | undefined>;
201
+ /** Cột chủ sở hữu mặc định cho nấc `own` (VD `createdBy`). */
202
+ ownerField?: string;
178
203
  /** Trần độ sâu cây. Mặc định 4 (Δ9). */
179
204
  maxDepth?: number;
180
205
  /** Ngưỡng cảnh báo số con trực tiếp. Mặc định 200. */
@@ -1,13 +1,25 @@
1
+ import { buildNavigationForSession } from "@goerp/core/navigation"
2
+
1
3
  import type { ReactNode } from "react"
2
4
 
3
5
  import { dictionary } from "@/data/dictionary"
4
6
  import { navigations } from "@/data/navigations"
7
+ import { getSession } from "@/lib/auth"
5
8
 
6
9
  import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
7
10
 
8
- export default function MainAreaLayout({ children }: { children: ReactNode }) {
11
+ export default async function MainAreaLayout({
12
+ children,
13
+ }: {
14
+ children: ReactNode
15
+ }) {
16
+ // Cây menu thô là bản đồ TOÀN BỘ hệ thống — truyền thẳng xuống layout thì ai
17
+ // đăng nhập cũng thấy đủ mục rồi bấm vào bị cổng gác đá về. Lọc theo quyền
18
+ // ngay ở server: mục không có quyền không nằm trong payload gửi xuống.
19
+ const navigation = buildNavigationForSession(navigations, await getSession())
20
+
9
21
  return (
10
- <MainLayoutWrapper dictionary={dictionary} navigation={navigations}>
22
+ <MainLayoutWrapper dictionary={dictionary} navigation={navigation}>
11
23
  {children}
12
24
  </MainLayoutWrapper>
13
25
  )