@goplusvn/core 0.1.66 → 0.1.67

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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.67 — Quyền trong session: dạng map thay cho mảng cặp
4
+
5
+ `session.user.permissions` là `Permission[]` — mảng các cặp
6
+ `{resourceCode, actionCode}`, lặp lại tên resource ở MỌI cặp. Đo trên một ERP
7
+ thật (user 9 vai trò): **2.428 quyền trải trên 73 resource = 139,7 KB JSON**,
8
+ đi kèm mọi lượt đọc session, kể cả chuyến ra client. Và mỗi lần
9
+ `checkPermission` là một lần quét tuyến tính cả 2.428 phần tử.
10
+
11
+ **Thêm `ExtendedUser.permissionMap?: PermissionMap`** (`Record<resource,
12
+ action[]>`) — cùng bộ quyền đó còn **~31 KB**, và tra cứu thành "lấy đúng
13
+ resource rồi quét vài chục action" thay vì quét cả mảng.
14
+
15
+ **KHÔNG breaking.** `permissions` vẫn chạy nguyên vẹn: mọi hàm kiểm quyền đọc
16
+ `permissionMap` trước rồi mới lùi về `permissions`. App chưa chuyển không phải
17
+ sửa gì. (Bản chuyển đổi cho đường cũ được nhớ trong `WeakMap` theo tham chiếu
18
+ mảng, nếu không thì một trang gọi `checkPermission` 20 lần sẽ dựng map 20 lần —
19
+ tệ hơn hẳn phép quét có thoát sớm mà nó thay thế.) Có cả hai thì
20
+ `permissionMap` thắng.
21
+
22
+ **Thêm `getAccessibleResources(session, actionCode?)`.** Trả `Set` các resource
23
+ user chạm được — bỏ trống `actionCode` là "có bất kỳ action nào" (App Launcher),
24
+ truyền `"view"` là lọc menu. Admin trả `Set(["*"])` khớp quy ước wildcard của
25
+ cây điều hướng. Có ở core vì mọi app đều tự viết lại đúng vòng lặp này, mỗi nơi
26
+ một kiểu.
27
+
28
+ Kèm `toPermissionMap` / `fromPermissionMap` cho ranh giới với code chưa chuyển.
29
+
30
+ **Dọn hai bản sao của `Permission`.** `auth/index.ts` khai báo lại y hệt bản
31
+ trong `types/index.ts`, và `rbac/index.ts` còn một `ExtendedUser` thứ ba không
32
+ ai dùng. Nay `Permission`/`PermissionMap` có ĐÚNG một nguồn ở `types`, các nơi
33
+ khác chỉ re-export.
34
+
3
35
  ## 0.1.66 — Cache: hết TTL vô hạn, hết nuốt lỗi RBAC
4
36
 
5
37
  Hai chế độ hỏng âm thầm, cùng một họ: hệ thống *tin* là nó có dữ liệu trong khi
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.66",
4
+ "version": "0.1.67",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ checkPermission,
5
+ fromPermissionMap,
6
+ getAccessibleResources,
7
+ getCrudPermissionsFromSession,
8
+ getUserPermissions,
9
+ toPermissionMap,
10
+ } from "../index";
11
+
12
+ /** Session dạng CŨ — app chưa chuyển vẫn gán `permissions`. */
13
+ const legacy = {
14
+ user: {
15
+ id: "u1",
16
+ roles: ["ACCOUNTANT"],
17
+ permissions: [
18
+ { resourceCode: "receipt", actionCode: "view" },
19
+ { resourceCode: "receipt", actionCode: "create" },
20
+ { resourceCode: "expense", actionCode: "view" },
21
+ ],
22
+ },
23
+ };
24
+
25
+ /** Session dạng GỌN — cùng đúng bộ quyền trên. */
26
+ const compact = {
27
+ user: {
28
+ id: "u1",
29
+ roles: ["ACCOUNTANT"],
30
+ permissionMap: { receipt: ["view", "create"], expense: ["view"] },
31
+ },
32
+ };
33
+
34
+ const both = [
35
+ ["dạng cũ (permissions)", legacy],
36
+ ["dạng gọn (permissionMap)", compact],
37
+ ] as const;
38
+
39
+ describe("checkPermission — hai dạng phải cho CÙNG kết quả", () => {
40
+ for (const [label, session] of both) {
41
+ it(`${label}: đúng resource + đúng action → true`, () => {
42
+ expect(checkPermission(session, "receipt", "create")).toBe(true);
43
+ });
44
+
45
+ it(`${label}: đúng resource nhưng SAI action → false`, () => {
46
+ expect(checkPermission(session, "receipt", "delete")).toBe(false);
47
+ });
48
+
49
+ it(`${label}: action có ở resource KHÁC không được lây sang`, () => {
50
+ // Bẫy kinh điển của việc gộp khoá: "expense" có "view", "receipt" có
51
+ // "create" — không được suy ra "expense:create".
52
+ expect(checkPermission(session, "expense", "create")).toBe(false);
53
+ });
54
+
55
+ it(`${label}: resource lạ → false`, () => {
56
+ expect(checkPermission(session, "ghost", "view")).toBe(false);
57
+ });
58
+ }
59
+
60
+ it("SUPER_ADMIN qua cổng kể cả khi không có dòng quyền nào", () => {
61
+ const admin = { user: { id: "u2", roles: ["SUPER_ADMIN"] } };
62
+ expect(checkPermission(admin, "bất-kỳ", "bất-kỳ")).toBe(true);
63
+ });
64
+
65
+ it("không có session → false", () => {
66
+ expect(checkPermission(null, "receipt", "view")).toBe(false);
67
+ });
68
+
69
+ it("permissionMap ĐÈ permissions khi có cả hai", () => {
70
+ const mixed = {
71
+ user: {
72
+ id: "u1",
73
+ roles: [],
74
+ permissions: [{ resourceCode: "receipt", actionCode: "delete" }],
75
+ permissionMap: { receipt: ["view"] },
76
+ },
77
+ };
78
+ expect(checkPermission(mixed, "receipt", "view")).toBe(true);
79
+ expect(checkPermission(mixed, "receipt", "delete")).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("getCrudPermissionsFromSession", () => {
84
+ for (const [label, session] of both) {
85
+ it(`${label}: chỉ bật đúng action đang có`, () => {
86
+ const p = getCrudPermissionsFromSession(session, "receipt");
87
+ expect(p).toEqual({
88
+ view: true,
89
+ create: true,
90
+ update: false,
91
+ delete: false,
92
+ export: false,
93
+ import: false,
94
+ approve: false,
95
+ reject: false,
96
+ });
97
+ });
98
+ }
99
+
100
+ it("entity không có quyền nào → tất cả false", () => {
101
+ const p = getCrudPermissionsFromSession(compact, "ghost");
102
+ expect(Object.values(p).every((v) => v === false)).toBe(true);
103
+ });
104
+ });
105
+
106
+ describe("getAccessibleResources", () => {
107
+ for (const [label, session] of both) {
108
+ it(`${label}: bỏ trống action = có BẤT KỲ action nào`, () => {
109
+ expect(getAccessibleResources(session)).toEqual(
110
+ new Set(["receipt", "expense"]),
111
+ );
112
+ });
113
+
114
+ it(`${label}: lọc theo action cụ thể`, () => {
115
+ expect(getAccessibleResources(session, "create")).toEqual(
116
+ new Set(["receipt"]),
117
+ );
118
+ });
119
+ }
120
+
121
+ it("admin → wildcard, khớp quy ước cây điều hướng", () => {
122
+ const admin = { user: { id: "u2", roles: ["admin"] } };
123
+ expect(getAccessibleResources(admin)).toEqual(new Set(["*"]));
124
+ });
125
+
126
+ it("không có session → rỗng, KHÔNG phải wildcard", () => {
127
+ expect(getAccessibleResources(null)).toEqual(new Set());
128
+ });
129
+ });
130
+
131
+ describe("chuyển đổi hai chiều", () => {
132
+ it("toPermissionMap gộp trùng", () => {
133
+ expect(
134
+ toPermissionMap([
135
+ { resourceCode: "receipt", actionCode: "view" },
136
+ { resourceCode: "receipt", actionCode: "view" },
137
+ { resourceCode: "receipt", actionCode: "create" },
138
+ ]),
139
+ ).toEqual({ receipt: ["view", "create"] });
140
+ });
141
+
142
+ it("đi rồi về giữ nguyên tập quyền", () => {
143
+ const back = fromPermissionMap(toPermissionMap(legacy.user.permissions));
144
+ expect(new Set(back.map((p) => `${p.resourceCode}:${p.actionCode}`))).toEqual(
145
+ new Set(
146
+ legacy.user.permissions.map((p) => `${p.resourceCode}:${p.actionCode}`),
147
+ ),
148
+ );
149
+ });
150
+
151
+ it("getUserPermissions bung map ra dạng cũ", () => {
152
+ expect(new Set(getUserPermissions(compact).map((p) => p.resourceCode))).toEqual(
153
+ new Set(["receipt", "expense"]),
154
+ );
155
+ expect(getUserPermissions(compact)).toHaveLength(3);
156
+ });
157
+
158
+ it("map rỗng ↔ mảng rỗng", () => {
159
+ expect(toPermissionMap([])).toEqual({});
160
+ expect(fromPermissionMap({})).toEqual([]);
161
+ });
162
+ });
package/src/auth/index.ts CHANGED
@@ -1,25 +1,30 @@
1
1
  // @goerp/core/auth
2
2
  // Authentication and RBAC utilities for GoERP
3
3
 
4
- import type { Session } from "../types";
4
+ import type { Permission, PermissionMap, Session } from "../types";
5
5
 
6
6
  export * from "./auth-service";
7
7
 
8
+ // `Permission` từng được KHAI BÁO LẠI ở đây, y hệt bản trong ../types — hai
9
+ // nguồn sự thật cho cùng một shape. Giờ chỉ còn re-export.
10
+ export type { Permission, PermissionMap };
11
+
8
12
  // ============================================================================
9
13
  // Types
10
14
  // ============================================================================
11
15
 
12
- export interface Permission {
13
- resourceCode: string;
14
- actionCode: string;
15
- }
16
-
17
16
  export interface ExtendedUser {
18
17
  id: string;
19
18
  name?: string | null;
20
19
  email?: string | null;
21
20
  image?: string | null;
22
21
  roles?: string[];
22
+ /**
23
+ * Dạng GỌN, nên dùng: resource → các action. Xem `PermissionMap`.
24
+ * Có `permissionMap` thì `permissions` bị BỎ QUA hoàn toàn.
25
+ */
26
+ permissionMap?: PermissionMap;
27
+ /** Dạng cũ. Vẫn chạy nguyên vẹn, chỉ tốn ~4,5 lần payload. */
23
28
  permissions?: Permission[];
24
29
  }
25
30
 
@@ -72,17 +77,105 @@ export function getActionCode(action: string): string {
72
77
  // Permission Functions
73
78
  // ============================================================================
74
79
 
80
+ // ============================================================================
81
+ // Permission Map — chuyển đổi + tra cứu
82
+ // ============================================================================
83
+
84
+ /** `Permission[]` → dạng gọn. Tự gộp trùng. */
85
+ export function toPermissionMap(permissions: Permission[]): PermissionMap {
86
+ const map: PermissionMap = {};
87
+ for (const p of permissions) {
88
+ const actions = (map[p.resourceCode] ??= []);
89
+ if (!actions.includes(p.actionCode)) actions.push(p.actionCode);
90
+ }
91
+ return map;
92
+ }
93
+
94
+ /** Dạng gọn → `Permission[]`. Chỉ dùng ở ranh giới với code chưa chuyển. */
95
+ export function fromPermissionMap(map: PermissionMap): Permission[] {
96
+ const out: Permission[] = [];
97
+ for (const resourceCode of Object.keys(map)) {
98
+ for (const actionCode of map[resourceCode]) {
99
+ out.push({ resourceCode, actionCode });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Nhớ bản đã chuyển đổi theo CHÍNH mảng `permissions` của session cũ.
107
+ *
108
+ * Không có nó, mỗi lần `checkPermission` của app chưa chuyển sẽ dựng lại map
109
+ * từ đầu — một trang gọi 20 lần là 20 lần dựng, tệ hơn hẳn phép quét tuyến
110
+ * tính có thoát sớm mà nó thay thế. WeakMap khoá theo tham chiếu mảng nên
111
+ * session hết hạn là bản nhớ tự rụng, không cần dọn.
112
+ */
113
+ const legacyMapCache = new WeakMap<Permission[], PermissionMap>();
114
+
115
+ /**
116
+ * Đọc quyền của session về dạng gọn, bất kể app gán kiểu nào.
117
+ * Trả `undefined` khi session không có user (khác hẳn "user không có quyền
118
+ * nào", vốn là `{}`) để chỗ gọi tự quyết cách xử lý.
119
+ */
120
+ function readPermissionMap(session: Session | null): PermissionMap | undefined {
121
+ if (!session?.user) return undefined;
122
+ const user = session.user as ExtendedUser;
123
+ if (user.permissionMap) return user.permissionMap;
124
+ if (!user.permissions) return {};
125
+
126
+ let cached = legacyMapCache.get(user.permissions);
127
+ if (!cached) {
128
+ cached = toPermissionMap(user.permissions);
129
+ legacyMapCache.set(user.permissions, cached);
130
+ }
131
+ return cached;
132
+ }
133
+
134
+ function isAdminUser(user: ExtendedUser): boolean {
135
+ return Boolean(
136
+ user.roles?.includes(ADMIN_ROLE_CODE) || user.roles?.includes("SUPER_ADMIN"),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Các resource user chạm được — dùng để lọc menu và App Launcher.
142
+ *
143
+ * `actionCode` bỏ trống = "có BẤT KỲ action nào" (thấy tile là vào được trang);
144
+ * truyền `"view"` = đúng nghĩa xem được. Admin trả `Set(["*"])`, khớp quy ước
145
+ * wildcard mà cây điều hướng đang dùng.
146
+ *
147
+ * Có sẵn ở core vì mọi app đều tự viết lại đúng vòng lặp này, mỗi nơi một kiểu.
148
+ */
149
+ export function getAccessibleResources(
150
+ session: Session | null,
151
+ actionCode?: string,
152
+ ): Set<string> {
153
+ if (!session?.user) return new Set();
154
+ if (isAdminUser(session.user as ExtendedUser)) return new Set(["*"]);
155
+
156
+ const map = readPermissionMap(session) ?? {};
157
+ const out = new Set<string>();
158
+ for (const resourceCode of Object.keys(map)) {
159
+ if (!actionCode || map[resourceCode].includes(actionCode)) {
160
+ out.add(resourceCode);
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
75
166
  /**
76
167
  * Get all permissions from session
168
+ *
169
+ * Trả về dạng CŨ (`Permission[]`) nên phải bung map ra — mỗi lần gọi là một
170
+ * lần dựng lại vài nghìn object. Cần lọc theo resource thì dùng
171
+ * `getAccessibleResources`; cần kiểm một quyền thì dùng `checkPermission`.
77
172
  */
78
173
  export function getUserPermissions(session: Session | null): Permission[] {
79
174
  if (!session?.user) return [];
80
175
  const user = session.user as ExtendedUser;
81
176
 
82
- if (!user.permissions) {
83
- return [];
84
- }
85
- return user.permissions;
177
+ if (user.permissionMap) return fromPermissionMap(user.permissionMap);
178
+ return user.permissions ?? [];
86
179
  }
87
180
 
88
181
  /**
@@ -135,10 +228,7 @@ export function getCrudPermissionsFromSession(
135
228
  }
136
229
 
137
230
  // Check admin role first (fastest check)
138
- const isAdmin =
139
- user.roles?.includes(ADMIN_ROLE_CODE) ||
140
- user.roles?.includes("SUPER_ADMIN");
141
- if (isAdmin) {
231
+ if (isAdminUser(user)) {
142
232
  return {
143
233
  create: true,
144
234
  view: true,
@@ -151,16 +241,12 @@ export function getCrudPermissionsFromSession(
151
241
  };
152
242
  }
153
243
 
154
- // Pre-compute permission keys for fast lookup
155
- const permissions = user.permissions || [];
156
- const permissionKeys = new Set(
157
- permissions.map((p) => `${p.resourceCode}:${p.actionCode}`),
158
- );
244
+ // Chỉ lấy danh sách action CỦA RIÊNG entity này. Bản cũ dựng một Set
245
+ // "resource:action" cho TOÀN BỘ quyền của user (vài nghìn chuỗi) rồi hỏi
246
+ // đúng 8 lần — dựng lại ở mỗi lần gọi hàm.
247
+ const actions = readPermissionMap(session)?.[entity] ?? [];
159
248
 
160
- const hasPermission = (action: string) => {
161
- const key = `${entity}:${action}`;
162
- return permissionKeys.has(key);
163
- };
249
+ const hasPermission = (action: string) => actions.includes(action);
164
250
 
165
251
  return {
166
252
  create: hasPermission(getActionCode("create")),
@@ -191,20 +277,13 @@ export function checkPermission(
191
277
 
192
278
  // Admin role bypass — checked BEFORE the empty-permissions guard so an admin
193
279
  // who relies on their role (no explicit permission rows) is not locked out.
194
- if (
195
- user.roles?.includes(ADMIN_ROLE_CODE) ||
196
- user.roles?.includes("SUPER_ADMIN")
197
- ) {
280
+ if (isAdminUser(user)) {
198
281
  return true;
199
282
  }
200
283
 
201
- if (!user.permissions || user.permissions.length === 0) {
202
- return false;
203
- }
204
-
205
- return user.permissions.some(
206
- (p) => p.resourceCode === resourceCode && p.actionCode === actionCode,
207
- );
284
+ // Tra thẳng theo resource rồi quét danh sách action của riêng nó (vài chục
285
+ // phần tử), thay vì quét tuyến tính CẢ mảng quyền như bản `Permission[]`.
286
+ return readPermissionMap(session)?.[resourceCode]?.includes(actionCode) ?? false;
208
287
  }
209
288
 
210
289
  /**
package/src/rbac/index.ts CHANGED
@@ -106,22 +106,19 @@ export { getFromCache, setToCache, pendingQueries };
106
106
  // Permissions
107
107
  // ============================================================================
108
108
 
109
- interface ExtendedUser {
110
- id: string;
111
- name?: string | null;
112
- email?: string | null;
113
- image?: string | null;
114
- roles?: string[];
115
- permissions?: Permission[];
116
- }
117
-
118
109
  // Permission checks now live in a single hardened source: @goerp/core/auth
119
110
  // (bypass is dev-only; the admin role is honored BEFORE the empty-permissions
120
111
  // guard). Re-exported here so existing @goerp/core/rbac consumers are unchanged
121
112
  // and can never diverge from auth again.
113
+ //
114
+ // (Ở đây từng có thêm một `interface ExtendedUser` thứ ba, không ai dùng — bản
115
+ // thật nằm ở ../auth.)
122
116
  export {
123
117
  getUserPermissions,
124
118
  getCrudPermissionsFromSession,
119
+ getAccessibleResources,
120
+ toPermissionMap,
121
+ fromPermissionMap,
125
122
  checkPermission,
126
123
  hasPermission,
127
124
  requirePermission,
@@ -164,6 +164,23 @@ export interface Permission {
164
164
 
165
165
  export type PermissionType = Permission;
166
166
 
167
+ /**
168
+ * Bản GỌN của `Permission[]` để nhét vào session: resource → các action.
169
+ *
170
+ * Vì sao cần: một user nhiều vai trò có thể có hàng nghìn cặp
171
+ * resource/action, mà `Permission[]` lặp lại tên resource ở MỌI cặp. Đo trên
172
+ * ERP thật (2.428 quyền / 73 resource): `Permission[]` là 139,7 KB JSON, bản
173
+ * map là ~31 KB — mà payload đó đi kèm mọi lượt đọc session, kể cả về client.
174
+ *
175
+ * Tra cứu cũng đổi chất: `Permission[]` bắt quét tuyến tính cả mảng cho MỖI
176
+ * lần `checkPermission`, map thì tra thẳng theo resource rồi chỉ quét danh
177
+ * sách action của riêng nó (vài chục phần tử).
178
+ *
179
+ * `ExtendedUser` nhận CẢ HAI: app chưa chuyển vẫn gán `permissions`, mọi hàm
180
+ * kiểm quyền của core đọc `permissionMap` trước rồi mới lùi về `permissions`.
181
+ */
182
+ export type PermissionMap = Record<string, string[]>;
183
+
167
184
  export type PermissionMatrix = Record<string, Record<string, boolean>>;
168
185
 
169
186
  // ============================================================================