@goplusvn/core 0.1.65 → 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 +63 -0
- package/package.json +1 -1
- package/src/auth/__tests__/permissions.test.ts +162 -0
- package/src/auth/index.ts +113 -34
- package/src/rbac/__tests__/permission-service.test.ts +113 -0
- package/src/rbac/index.ts +6 -9
- package/src/rbac/permission-service.ts +15 -1
- package/src/system/services/__tests__/settings-service.test.ts +85 -0
- package/src/system/services/settings-service.ts +45 -3
- package/src/types/index.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
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
|
+
|
|
35
|
+
## 0.1.66 — Cache: hết TTL vô hạn, hết nuốt lỗi RBAC
|
|
36
|
+
|
|
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
|
|
38
|
+
thực ra không.
|
|
39
|
+
|
|
40
|
+
**`getUserPermissionsFromDB` NÉM thay vì trả `[]`.** Trước đây `catch → return []`.
|
|
41
|
+
Trong hệ RBAC, "không có quyền nào" và "không hỏi được quyền" hiện ra y hệt nhau
|
|
42
|
+
— menu rỗng, nút biến mất, API 403 — nên người dùng đọc thành "tôi bị thu hồi
|
|
43
|
+
quyền". Tệ hơn: `getCachedUserPermissions` ghi kết quả vào cache 5 phút, nên một
|
|
44
|
+
cú nghẽn DB chớp nhoáng bị ĐÓNG BĂNG thành mảng rỗng suốt 5 phút kể cả khi DB đã
|
|
45
|
+
hồi ngay. Nay lỗi ném ra, `.catch` không ghi cache, lượt sau tự hỏi lại.
|
|
46
|
+
|
|
47
|
+
⚠️ **BREAKING (hành vi).** App nào đang dựa vào `[]`-khi-lỗi phải tự bắt. Khuyến
|
|
48
|
+
nghị: để lỗi nổi lên `error.tsx` — nhớ đặt boundary ở segment CHA của layout gọi
|
|
49
|
+
nó, vì `error.tsx` không bọc `layout.tsx` cùng segment.
|
|
50
|
+
|
|
51
|
+
**`SettingsService.getSettings` thêm `revalidate: 60`.** `unstable_cache` không
|
|
52
|
+
khai `revalidate` là cache tới khi process chết. Mọi đường ghi thẳng
|
|
53
|
+
`system_config` không qua `saveSettings` (route CRUD generic, script, migration)
|
|
54
|
+
— hoặc ghi từ tiến trình khác nên không xoá được cache tiến trình này — đều làm
|
|
55
|
+
setting cũ đóng băng VĨNH VIỄN. TTL là lưới an toàn; `saveSettings` vẫn xoá tag
|
|
56
|
+
để đường ghi chuẩn hiện tức thì.
|
|
57
|
+
|
|
58
|
+
**`revalidateTag` được bọc `try/catch`.** Next 16 throw "Invariant: static
|
|
59
|
+
generation store missing" khi gọi ngoài request scope (cron, task nền). Lời gọi
|
|
60
|
+
luôn nằm SAU lệnh ghi DB, nên không bọc là "lưu xong rồi vẫn báo lỗi". Chỉ an
|
|
61
|
+
toàn nuốt vì cache đã có TTL hữu hạn. Tag xuất khẩu thành
|
|
62
|
+
`SYSTEM_SETTINGS_CACHE_TAG`.
|
|
63
|
+
|
|
64
|
+
Kèm 8 test ratchet (đã kiểm: cả 8 đỏ khi hoàn nguyên bản vá).
|
|
65
|
+
|
|
3
66
|
## 0.1.44 — UnifiedProfileDialog: chi nhánh mặc định + ngày sinh DatePicker + fix Select loại người dùng
|
|
4
67
|
|
|
5
68
|
**Ngày sinh dùng `DatePicker` (thay `<input type="date">`):** ô ngày sinh cũ khó
|
package/package.json
CHANGED
|
@@ -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 (
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
/**
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { clearAllPermissionsCache } from "../index";
|
|
4
|
+
import {
|
|
5
|
+
getCachedUserPermissions,
|
|
6
|
+
getUserPermissionsFromDB,
|
|
7
|
+
} from "../permission-service";
|
|
8
|
+
|
|
9
|
+
import type { RBACPrismaClient } from "../permission-service";
|
|
10
|
+
|
|
11
|
+
/** DB giả: mỗi lượt gọi lấy kịch bản kế tiếp trong hàng đợi. */
|
|
12
|
+
function makeDb(outcomes: Array<{ rows?: unknown[]; fail?: Error }>): {
|
|
13
|
+
db: RBACPrismaClient;
|
|
14
|
+
calls: () => number;
|
|
15
|
+
} {
|
|
16
|
+
let calls = 0;
|
|
17
|
+
const db = {
|
|
18
|
+
userRole: {
|
|
19
|
+
findMany: async () => {
|
|
20
|
+
const outcome = outcomes[Math.min(calls, outcomes.length - 1)];
|
|
21
|
+
calls += 1;
|
|
22
|
+
if (outcome.fail) throw outcome.fail;
|
|
23
|
+
return outcome.rows as never;
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
} as unknown as RBACPrismaClient;
|
|
27
|
+
return { db, calls: () => calls };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ROW_WITH_VIEW = {
|
|
31
|
+
roleCode: "accountant",
|
|
32
|
+
role: { rolePermissions: [{ resourceCode: "receipt", actionCode: "view" }] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("getUserPermissionsFromDB", () => {
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await clearAllPermissionsCache();
|
|
38
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("dedupe quyền trùng giữa các vai trò", async () => {
|
|
42
|
+
const { db } = makeDb([
|
|
43
|
+
{
|
|
44
|
+
rows: [
|
|
45
|
+
ROW_WITH_VIEW,
|
|
46
|
+
{
|
|
47
|
+
roleCode: "cashier",
|
|
48
|
+
role: {
|
|
49
|
+
rolePermissions: [
|
|
50
|
+
{ resourceCode: "receipt", actionCode: "view" },
|
|
51
|
+
{ resourceCode: "receipt", actionCode: "create" },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
expect(await getUserPermissionsFromDB(db, "u1")).toEqual([
|
|
60
|
+
{ resourceCode: "receipt", actionCode: "view" },
|
|
61
|
+
{ resourceCode: "receipt", actionCode: "create" },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("user KHÔNG có vai trò nào → mảng rỗng (đây là câu trả lời hợp lệ)", async () => {
|
|
66
|
+
const { db } = makeDb([{ rows: [] }]);
|
|
67
|
+
expect(await getUserPermissionsFromDB(db, "u1")).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("query hỏng → NÉM, không được nuốt thành mảng rỗng", async () => {
|
|
71
|
+
const { db } = makeDb([{ fail: new Error("P1017 ConnectionClosed") }]);
|
|
72
|
+
|
|
73
|
+
// Trả `[]` ở đây là biến sự cố hạ tầng thành "user không có quyền nào" —
|
|
74
|
+
// menu rỗng, nút biến mất, mà không ai biết là DB hỏng.
|
|
75
|
+
await expect(getUserPermissionsFromDB(db, "u1")).rejects.toThrow(
|
|
76
|
+
"P1017 ConnectionClosed",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("getCachedUserPermissions", () => {
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
await clearAllPermissionsCache();
|
|
84
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("lỗi DB KHÔNG bị đóng băng vào cache 5 phút", async () => {
|
|
88
|
+
// Lượt 1 hỏng, lượt 2 trở đi thành công.
|
|
89
|
+
const { db } = makeDb([
|
|
90
|
+
{ fail: new Error("nghẽn DB") },
|
|
91
|
+
{ rows: [ROW_WITH_VIEW] },
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
await expect(getCachedUserPermissions(db, "u1")).rejects.toThrow(
|
|
95
|
+
"nghẽn DB",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Nếu lỗi bị nuốt thành `[]`, `[]` đã được setToCache và user mất sạch
|
|
99
|
+
// quyền suốt 5 phút kể cả khi DB hồi ngay lập tức.
|
|
100
|
+
expect(await getCachedUserPermissions(db, "u1")).toEqual([
|
|
101
|
+
{ resourceCode: "receipt", actionCode: "view" },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("lượt thành công thì có cache — không hỏi DB lần hai", async () => {
|
|
106
|
+
const { db, calls } = makeDb([{ rows: [ROW_WITH_VIEW] }]);
|
|
107
|
+
|
|
108
|
+
await getCachedUserPermissions(db, "u1");
|
|
109
|
+
await getCachedUserPermissions(db, "u1");
|
|
110
|
+
|
|
111
|
+
expect(calls()).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
});
|
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,
|
|
@@ -41,6 +41,20 @@ export interface RBACPrismaClient {
|
|
|
41
41
|
* Fetch user permissions from database based on roles.
|
|
42
42
|
* Deduplicates permissions by resourceCode:actionCode.
|
|
43
43
|
*
|
|
44
|
+
* NÉM LỖI khi query hỏng — KHÔNG trả `[]`.
|
|
45
|
+
*
|
|
46
|
+
* Trước 2026-08 hàm này `catch → logger.error → return []`. Trong một hệ RBAC,
|
|
47
|
+
* "không có quyền nào" và "không hỏi được quyền" là hai chuyện khác hẳn nhau,
|
|
48
|
+
* nhưng cả hai đều hiện ra y như nhau: menu rỗng, nút biến mất, API trả 403.
|
|
49
|
+
* Người dùng đọc thành "tôi bị thu hồi quyền" và đi báo sai chỗ.
|
|
50
|
+
*
|
|
51
|
+
* Tệ hơn: `getCachedUserPermissions` bên dưới ghi kết quả vào cache 5 phút.
|
|
52
|
+
* Một cú nghẽn DB chớp nhoáng vì thế bị ĐÓNG BĂNG thành mảng rỗng suốt 5 phút
|
|
53
|
+
* kể cả khi DB đã hồi ngay sau đó. Ném lỗi thì `.catch` ở đó không ghi cache,
|
|
54
|
+
* lượt sau tự hỏi lại DB.
|
|
55
|
+
*
|
|
56
|
+
* Nơi gọi phải tự quyết: hiện error boundary, hay hạ cấp có chủ đích.
|
|
57
|
+
*
|
|
44
58
|
* @param db - The tenant-specific Prisma Client instance
|
|
45
59
|
* @param userId - The user ID to fetch permissions for
|
|
46
60
|
*/
|
|
@@ -94,7 +108,7 @@ export async function getUserPermissionsFromDB(
|
|
|
94
108
|
return uniquePermissions;
|
|
95
109
|
} catch (error) {
|
|
96
110
|
logger.error("Error fetching user permissions from DB", error);
|
|
97
|
-
|
|
111
|
+
throw error;
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const unstableCacheMock = vi.fn(
|
|
4
|
+
(
|
|
5
|
+
fn: (...args: unknown[]) => unknown,
|
|
6
|
+
_keyParts?: string[],
|
|
7
|
+
_options?: unknown,
|
|
8
|
+
) =>
|
|
9
|
+
(...args: unknown[]) =>
|
|
10
|
+
fn(...args),
|
|
11
|
+
);
|
|
12
|
+
const revalidateTagMock = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock("next/cache", () => ({
|
|
15
|
+
unstable_cache: (
|
|
16
|
+
fn: (...args: unknown[]) => unknown,
|
|
17
|
+
keyParts?: string[],
|
|
18
|
+
options?: unknown,
|
|
19
|
+
) => unstableCacheMock(fn, keyParts, options),
|
|
20
|
+
revalidateTag: (...args: unknown[]) => revalidateTagMock(...args),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { SettingsService, configureSettingsService, SYSTEM_SETTINGS_CACHE_TAG } =
|
|
24
|
+
await import("../settings-service");
|
|
25
|
+
|
|
26
|
+
function fakeDb(value: string | null) {
|
|
27
|
+
return {
|
|
28
|
+
systemConfig: {
|
|
29
|
+
findUnique: vi.fn(async () => (value === null ? null : { value })),
|
|
30
|
+
upsert: vi.fn(async () => ({})),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("SettingsService cache options", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
unstableCacheMock.mockClear();
|
|
38
|
+
revalidateTagMock.mockClear();
|
|
39
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("LUÔN khai `revalidate` hữu hạn — thiếu nó là cache vĩnh viễn", async () => {
|
|
43
|
+
configureSettingsService(fakeDb(JSON.stringify({ a: 1 })));
|
|
44
|
+
|
|
45
|
+
await SettingsService.getSettings("demo", { a: 0 });
|
|
46
|
+
|
|
47
|
+
const options = unstableCacheMock.mock.calls[0][2] as {
|
|
48
|
+
tags: string[];
|
|
49
|
+
revalidate?: number;
|
|
50
|
+
};
|
|
51
|
+
// Bỏ `revalidate` là entry sống tới khi process chết: mọi đường ghi thẳng
|
|
52
|
+
// `system_config` mà quên xoá tag sẽ đóng băng setting cũ VĨNH VIỄN.
|
|
53
|
+
expect(options.revalidate).toBeTypeOf("number");
|
|
54
|
+
expect(options.revalidate).toBeGreaterThan(0);
|
|
55
|
+
expect(options.tags).toContain(SYSTEM_SETTINGS_CACHE_TAG);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("saveSettings xoá tag sau khi ghi", async () => {
|
|
59
|
+
const db = fakeDb(null);
|
|
60
|
+
configureSettingsService(db);
|
|
61
|
+
|
|
62
|
+
await SettingsService.saveSettings("demo", { a: 1 });
|
|
63
|
+
|
|
64
|
+
expect(db.systemConfig.upsert).toHaveBeenCalledOnce();
|
|
65
|
+
expect(revalidateTagMock).toHaveBeenCalledWith(
|
|
66
|
+
SYSTEM_SETTINGS_CACHE_TAG,
|
|
67
|
+
"max",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("revalidateTag ném ngoài request scope KHÔNG được kéo đổ lệnh ghi", async () => {
|
|
72
|
+
const db = fakeDb(null);
|
|
73
|
+
configureSettingsService(db);
|
|
74
|
+
revalidateTagMock.mockImplementation(() => {
|
|
75
|
+
throw new Error("Invariant: static generation store missing");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Cron/task nền chạy ngoài request scope. Lời gọi nằm SAU lệnh ghi DB nên
|
|
79
|
+
// để lỗi thoát ra là "lưu xong rồi vẫn báo lỗi" cho người dùng.
|
|
80
|
+
await expect(
|
|
81
|
+
SettingsService.saveSettings("demo", { a: 1 }),
|
|
82
|
+
).resolves.toBeUndefined();
|
|
83
|
+
expect(db.systemConfig.upsert).toHaveBeenCalledOnce();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,6 +1,45 @@
|
|
|
1
|
-
import { cache } from "react";
|
|
2
1
|
import { revalidateTag, unstable_cache } from "next/cache";
|
|
3
2
|
|
|
3
|
+
import { logger } from "../../utils";
|
|
4
|
+
|
|
5
|
+
/** Tag dùng chung cho mọi entry cache của SettingsService. */
|
|
6
|
+
export const SYSTEM_SETTINGS_CACHE_TAG = "system-settings";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TTL của cache setting — LƯỚI AN TOÀN, không phải cơ chế chính.
|
|
10
|
+
*
|
|
11
|
+
* `saveSettings` xoá tag ngay nên đường ghi chuẩn vẫn hiện tức thì. TTL này
|
|
12
|
+
* chặn kịch bản kẹt vĩnh viễn: `unstable_cache` KHÔNG khai `revalidate` là
|
|
13
|
+
* cache tới khi process chết. Mọi đường ghi thẳng bảng `system_config` không
|
|
14
|
+
* qua `saveSettings` (route CRUD generic của app, script, migration) — hoặc ghi
|
|
15
|
+
* từ tiến trình khác nên không xoá được cache của tiến trình này — đều làm
|
|
16
|
+
* setting cũ đóng băng luôn.
|
|
17
|
+
*
|
|
18
|
+
* 60s: đủ ngắn để sai sót tự lành, đủ dài để không thành một vòng DB mỗi lượt
|
|
19
|
+
* đọc setting.
|
|
20
|
+
*/
|
|
21
|
+
const SETTINGS_TTL_SECONDS = 60;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `revalidateTag` NÉM khi không có request scope — Next 16 throw
|
|
25
|
+
* "Invariant: static generation store missing" nếu `workAsyncStorage` rỗng.
|
|
26
|
+
* Cron và task nền là chạy ngoài scope, mà lời gọi luôn nằm SAU lệnh ghi DB:
|
|
27
|
+
* không bọc thì dữ liệu đã lưu xong vẫn báo lỗi ngược cho người gọi.
|
|
28
|
+
*
|
|
29
|
+
* Nuốt lỗi ở đây CÓ CHỦ ĐÍCH và chỉ an toàn vì cache đã có TTL hữu hạn — tệ
|
|
30
|
+
* nhất là chậm đúng một nhịp TTL. Vẫn log để lộ chỗ gọi sai ngữ cảnh.
|
|
31
|
+
*/
|
|
32
|
+
function safeRevalidateSettingsTag(): void {
|
|
33
|
+
try {
|
|
34
|
+
revalidateTag(SYSTEM_SETTINGS_CACHE_TAG, "max");
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logger.warn(
|
|
37
|
+
`revalidateTag("${SYSTEM_SETTINGS_CACHE_TAG}") bỏ qua — gọi ngoài request scope`,
|
|
38
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
4
43
|
/**
|
|
5
44
|
* DB được INJECT từ app (core không import prisma client cụ thể). Trước
|
|
6
45
|
* 2026-07 chỗ này là `const db: any = {...} // Mocked for build` — mọi
|
|
@@ -115,7 +154,10 @@ export class SettingsService {
|
|
|
115
154
|
}
|
|
116
155
|
},
|
|
117
156
|
[`system-settings-${key}`],
|
|
118
|
-
{
|
|
157
|
+
{
|
|
158
|
+
tags: [SYSTEM_SETTINGS_CACHE_TAG],
|
|
159
|
+
revalidate: SETTINGS_TTL_SECONDS,
|
|
160
|
+
},
|
|
119
161
|
)();
|
|
120
162
|
}
|
|
121
163
|
|
|
@@ -155,7 +197,7 @@ export class SettingsService {
|
|
|
155
197
|
},
|
|
156
198
|
});
|
|
157
199
|
|
|
158
|
-
|
|
200
|
+
safeRevalidateSettingsTag();
|
|
159
201
|
}
|
|
160
202
|
|
|
161
203
|
static async saveAuditSettings(settings: AuditSettings): Promise<void> {
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
// ============================================================================
|