Sheet 2 · Vai trò & Hệ thống ngoài
Vai trò & Phân quyền
Phân quyền hai chiều, không phải một trục. Chức danh KHÔNG quyết định phạm vi dữ liệu: role (admin / member) quyết định thao tác được phép, data_scope (self / team / all) quyết định thấy bao nhiêu dữ liệu, modules (mảng JSON) quyết định vào được phân hệ nào, và owner (env ERP_OWNERS, mặc định admin,minh) là tầng trên cùng cho các việc chỉ chủ hệ thống được làm. Muốn một tài khoản quản trị nhưng nhìn hẹp thì hạ data_scope của nó, không cần hạ chức danh.
Vai trò
| NO | Vai trò | Mô tả |
|---|---|---|
| 1 | Admin | role='admin' — toàn quyền mọi module bất kể cột modules lưu gì (usePerm().allowed() luôn trả true nếu isAdmin); có thêm quyền riêng: CRUD tài khoản (/api/admin/users), duyệt Yêu cầu phát triển (/api/requests/review), xem toàn bộ comment review (kể cả thread không tham gia), xem khối "Toàn đội" ở Phân tích công việc, đồng bộ HubSpot. |
| 2 | Member (nhân viên) | role='member' — chỉ thấy route/menu tương ứng module được cấp trong modules (kiểm bởi RouteGuard ở (erp)/layout, redirect về firstRoute() nếu vào route ngoài quyền). Dữ liệu bị SCOPE ở tầng client (erp/lib/perm.ts): task theo assigneeId, contact theo assigneeId, project theo memberIds hoặc có task của mình, event theo assigneeIds (rỗng = sự kiện chung ai cũng thấy). |
| 3 | Owner (chủ hệ thống) | Tên đăng nhập nằm trong env ERP_OWNERS (mặc định admin,minh) — kiểm bởi isOwnerRow ở tenant.js. Chỉ owner: quản lý người dùng & phân quyền, cấp khoá API phạm vi người khác, thu hồi toàn bộ phiên đăng nhập của tenant (POST /api/me/sessions/revoke-all), đổi cài đặt cấp hệ thống. Owner là tầng riêng, không phải một giá trị của role. |
| 4 | Phạm vi dữ liệu (data_scope) | Trục độc lập với chức danh, áp ở tầng server: self chỉ phần được giao cho mình · team thêm các dự án mình tham gia · all toàn công ty. Hàm dataScope() và seesAllData() ở tenant.js là nơi duy nhất đọc trục này. |
scopeStateForUser(filterStateForUser(inner, actor), actor)
Tầng 1 (filterStateForUser) cắt theo phân hệ được cấp — key nào không thuộc phân hệ của người đó thì thay bằng giá trị RỖNG cùng kiểu, không xoá hẳn (frontend đọc state.transactions mà thành undefined sẽ vỡ). Tầng 2 (scopeStateForUser) cắt theo phạm vi cá nhân của TỪNG bản ghi. Thiếu tầng 2 là rò dữ liệu cả công ty — đã xảy ra ba lần ở GET /api/<entity>, readState của bot và /api/solution-tags/list, nay có test khoá chặt.Chốt bảo mật đã vá (02–03/09/2026)
| NO | Chốt | Vì sao cần |
|---|---|---|
| 1 | Hai tầng lọc ở mọi đường đọc | Bắt buộc, không có ngoại lệ. Bất kỳ endpoint mới trả dữ liệu nghiệp vụ đều phải gọi đủ cả hai. |
| 2 | settings chuyển sang admin-ghi | Slice settings giữ tên công ty, mã số thuế, số tài khoản ngân hàng nhận tiền, tiền tố hoá đơn. Trước 02/09 nó map always, nên một member không có phân hệ nào vẫn đổi được số tài khoản nhận tiền qua PUT /api/state. Nay ai cũng đọc được (để in hoá đơn), chỉ role='admin' ghi được; member gửi lên gì cũng giữ bản của server, không trả 403 để họ vẫn lưu được việc của mình. |
| 3 | sanitizeRefs trong mergeScopedState | Ép projectId/contactId ngoài phạm vi về rỗng. Không có nó, member gán bản ghi của mình vào dự án của người khác là leo được quyền đọc dự án đó. |
| 4 | auth_users.session_ver | Chữ ký phiên phủ cả số phiên bản này, nên tăng nó là mọi cookie cũ mất hiệu lực ngay — dùng cho POST /api/me/sessions/revoke-all (chỉ owner). |
| 5 | Khoá ký dẫn xuất theo tenant | tenantSignKey: cookie phát ở tenant A không dùng được ở tenant B, dù cùng một máy chủ và cùng SESSION_SECRET. |
| 6 | revisions.actor_id + revisions.source | Mỗi bản ghi state biết ai ghi và qua đường nào (web / api-key / agent / system) — truy vết được thay đổi đến từ trợ lý hay từ người. |
| 7 | Rate limit | /api/agent/*: 120 lượt/phút/khoá và 300 lượt/phút/IP (chặn dò khoá trước cả khi tra khoá). Mã liên kết Telegram: 5 lần thử / 10 phút. Dựng bản nháp qua trợ lý: 40 bản/người/24 giờ. |
| 8 | Khử trùng lặp ưu tiên bản server | Số hoá đơn (dedupeInvoiceNumbers), mã việc và mã khách (dedupeTaskKeys/dedupeCodes) — hai tab cùng tạo thì bản của server thắng, không sinh hai bản ghi cùng số. |
Phân hệ (ALL_MODULE_IDS, 24 phân hệ)
Nguồn: khai báo ALL_MODULE_IDS trong sync-server/tenant.js. Bản tài liệu trước ghi 12 phân hệ — con số đó đã cũ.
| Nhóm | Phân hệ |
|---|---|
| Tổng quan | dashboard · analytics · reports |
| Vận hành | tasks · crm · projects · calendar · mail · workflows · skills · library |
| Tài chính | finance · invoices · vendors · pricing · contracts · income |
| Hệ thống | requests · bots · system-log · settings · help |
| Còn lại vì tương thích | budget · news |
hasModule ở tenant.js. Tài khoản chỉ được cấp tasks vẫn dùng được các phân hệ tách ra sau này: analytics, mail, workflows, skills, library, pricing, income, requests, bots — cho tới khi quản trị chỉnh lại. Riêng contracts là phân hệ mới và KHÔNG kế thừa.
Một nguồn duy nhất, học được bằng lỗi thật. Trước 03/09 server.js và agent-api.js mỗi bên có bản kiểm quyền riêng: bản của server áp kế thừa, bản của agent thì không. Kết quả là cùng một tài khoản vào được Kỹ năng trên web nhưng trợ lý lại báo "chưa được cấp phân hệ". Nay hasModule ở tenant.js là nơi duy nhất trả lời câu hỏi này, và có test khoá chặt việc cả hai lớp cùng gọi nó.
assertModuleAccess tra theo key của blob state — chỉ đúng cho dữ liệu nằm trong blob (tasks, contacts, projects…). Kỹ năng, quy trình, thư viện, báo giá nằm ở bảng riêng, nên tên skills đưa vào assertModuleAccess chỉ là "key lạ": nó ghi một dòng cảnh báo rồi cho qua. Với bảng riêng phải dùng hasModule.Tài khoản thật — tenant ctc
Đọc read-only từ sync-server/data/tenants/ctc/crm.db trên VPS production, ngày 03/09/2026 — 9 tài khoản, tất cả đang hoạt động. Chỉ liệt kê vai trò/chức danh/phạm vi, KHÔNG có mật khẩu.
| Username | Tên hiển thị | role | data_scope | Số phân hệ | Chức danh |
|---|---|---|---|---|---|
admin | CTC Admin | admin | all | 12 | Quản trị hệ thống |
minh | Minh | admin | all | 11 | CSMO — Điều hành |
cuong | Justin Cường | admin | all | 12 | CEO |
tu | Tuấn Tú | admin | all | 12 | CTO |
nam | Nam | admin | all | 11 | SysAdmin / Tech Lead |
linh | Linh | member | team | 15 | Giám đốc Kinh doanh |
tung | Tùng | member | self | 14 | Consultant |
huong | Hương | member | self | 10 | Multimedia & Content Creator |
khoi | Vũ Hữu Khôi | member | self | 12 | Trợ lý kinh doanh |
Đáng chú ý: linh là member nhưng data_scope='team' — nhìn được các dự án mình tham gia, rộng hơn self mà vẫn không phải quản trị. Đó chính là điểm của việc tách hai trục: chức danh và phạm vi dữ liệu điều chỉnh độc lập. Bản tài liệu trước ghi nam là member — nay là admin.
Khoá API cho trợ lý ngoài
Bảng bot_api_keys. Đây là cách duy nhất một hệ thống bên ngoài đọc/ghi dữ liệu ERP.
| NO | Nguyên tắc | Chi tiết |
|---|---|---|
| 1 | Khoá là danh tính | Mỗi khoá gắn chết scope_user_id. ERP tra khoá ra người rồi áp nguyên quyền người đó — trợ lý không tự khai mình là ai. Nếu để bên ngoài truyền erpUserId thì ai chiếm được khoá đều đọc được dữ liệu của mọi người. |
| 2 | Không mở được cửa người đó không có | Thiếu phân hệ thì trả 403 kèm missingModule nói rõ thiếu gì, để trợ lý báo lại đúng việc cần xin — thay vì im lặng trả danh sách rỗng khiến người dùng tưởng mình không có việc nào. |
| 3 | Chỉ lưu băm | Cột key_hash giữ SHA-256; bản rõ hiện một lần duy nhất lúc cấp. Thu hồi (revoked_at) có hiệu lực tức thì. |
| 4 | Cấp cho người khác: chỉ owner | Tự cấp cho chính mình thì ai cũng được. Cấp khoá phạm vi người khác chỉ owner. Cấp khoá cho tài khoản quản trị thì được — muốn khoá hẹp thì hạ data_scope của tài khoản đó, đừng dựa vào chức danh. |
| 5 | Kỹ năng phát hành theo khoá | skill_ids giữ danh sách kỹ năng mà agent cầm khoá đó biết. Khác hẳn chia sẻ kỹ năng cho người đọc — xem trang Chức năng. |
| 6 | Có nhật ký | Từ 03/09 mọi lời gọi /api/agent/* ghi dòng [agent]: tên hành động, người thực hiện, kết quả. Không ghi tham số (chứa nội dung người dùng) và không ghi khoá. Trước đó đường này im lặng hoàn toàn, nên khi bot báo "đã tạo" mà ERP trống thì không có cách nào biết lời gọi có tới hay không. |
Hệ thống ngoài tích hợp
| NO | Hệ thống | Mô tả |
|---|---|---|
| 1 | HubSpot CRM | sync-server/hubspot.js + endpoint /api/hubspot/config (GET/POST, admin) và /api/hubspot/sync (POST, admin, mặc định dry-run). Map lifecycle stage HubSpot → dealStage; khớp theo hubspotId/email; deal HubSpot chưa có project tương ứng → tạo project mới id tiền tố hsdeal-. Portal thật đã kiểm chứng: 9.949 contact (379 customer, 168 lead). |
| 2 | Zalo OA | Tab "Kết nối" trong /settings (admin) lưu cấu hình vào integrations.zalo; relay POST /api/me/relay/zalo kéo followers + profile, import contact chống trùng theo phone/tên. |
| 3 | Gmail (qua Google Apps Script) | Mỗi user tự cấp mail-token cá nhân (POST /api/me/mail-token); Apps Script Web App gọi POST /api/mail/ingest để đẩy mail mới về (cần chạy setupTrigger phía Apps Script); gửi mail đi qua relay GWS chung. Trạng thái kết nối tự chẩn đoán (chưa có token / có token nhưng script chưa gọi lần nào / lần cuối script gọi). |
| 4 | Telegram (qua AI Gateway) | ERP không giữ kênh Telegram. AI Gateway (/home/web/app/ai-gateway, workspace riêng cho từng người) nhận tin nhắn rồi gọi vào ERP bằng khoá API của chính người đang nhắn. Đảo chiều 29/08 vì trước đó ERP tự long-poll và tranh bot với Gateway (lỗi 409 triền miên); bot-worker.js gỡ 30/08. Vẫn còn relay cá nhân POST /api/me/relay/telegram (mỗi user lưu botToken+chatId trong user_data) dùng cho action send_telegram của Quy trình tự động — đó là đường ERP gửi ra, không phải đường nhận vào. |
| 5 | Webhook Google Workspace | Relay POST /api/me/relay/gws — chỉ nhận URL thuộc domain script.google.com/googleusercontent.com/chat.googleapis.com/zapier.com (whitelist trong server); dùng cho action send_gws trong Workflow và làm cầu nối gửi mail/ping thử kết nối. |
server.js). Không tìm thấy bằng chứng tích hợp SiYuan trong source erp-platform — không đưa vào danh sách.