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ò

NOVai tròMô tả
1Adminrole='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.
2Member (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).
3Owner (chủ hệ thống)Tên đăng nhập nằm trong env ERP_OWNERS (mặc định admin,minh) — kiểm bởi isOwnerRowtenant.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.
4Phạ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()seesAllData()tenant.js là nơi duy nhất đọc trục này.
Cô lập phía server — đã làm, vá 02/09/2026. Bản tài liệu trước ghi rằng scope chỉ chặn ở tầng hiển thị và cô lập cứng phía server "chưa làm". Điều đó không còn đúng. Nay mọi đường đọc dữ liệu nghiệp vụ đều đi qua hai tầng lọc, theo đúng thứ tự 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)

NOChốtVì sao cần
1Hai tầng lọc ở mọi đường đọcBắ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.
2settings chuyển sang admin-ghiSlice 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.
3sanitizeRefs 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 đó.
4auth_users.session_verChữ 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).
5Khoá ký dẫn xuất theo tenanttenantSignKey: 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.
6revisions.actor_id + revisions.sourceMỗ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.
7Rate 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ờ.
8Khử trùng lặp ưu tiên bản serverSố 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ómPhân hệ
Tổng quandashboard · analytics · reports
Vận hànhtasks · crm · projects · calendar · mail · workflows · skills · library
Tài chínhfinance · invoices · vendors · pricing · contracts · income
Hệ thốngrequests · bots · system-log · settings · help
Còn lại vì tương thíchbudget · news
Kế thừa lịch sử — hasModuletenant.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.jsagent-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 hasModuletenant.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ó.

Hai hàm kiểm quyền, dùng sai là hở. 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.

UsernameTên hiển thịroledata_scopeSố phân hệChức danh
adminCTC Adminadminall12Quản trị hệ thống
minhMinhadminall11CSMO — Điều hành
cuongJustin Cườngadminall12CEO
tuTuấn Túadminall12CTO
namNamadminall11SysAdmin / Tech Lead
linhLinhmemberteam15Giám đốc Kinh doanh
tungTùngmemberself14Consultant
huongHươngmemberself10Multimedia & Content Creator
khoiVũ Hữu Khôimemberself12Trợ lý kinh doanh

Đáng chú ý: linhmember 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.

NONguyên tắcChi tiết
1Khoá là danh tínhMỗ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.
2Khô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.
3Chỉ lưu bămCộ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ì.
4Cấp cho người khác: chỉ ownerTự 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.
5Kỹ 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.
6Có 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

NOHệ thốngMô tả
1HubSpot CRMsync-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).
2Zalo OATab "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.
3Gmail (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).
4Telegram (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.
5Webhook Google WorkspaceRelay 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.
Rate-limit chung cho các endpoint relay cá nhân: 30 request / 10 phút / user (kiểm trong 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.