Permissions
Toàn bộ quyền được thi hành ở backend (workers/api/src/shared/authz.ts — một nguồn duy nhất cho mọi module; bản cũ modules/knowledge/authz.ts đã gỡ). Frontend ẩn nút chỉ là trải nghiệm — không bao giờ là bảo mật (QG-008).
Trang này viết tay và phải khớp code thật (AS-07.2.5). Cách kiểm lại khi nghi ngờ:
grep -rn "requireLearnerAccess\|resolveLearnerAccess\|canAccessLearner" workers/api/src/modules
grep -rn "requireRole\|requireStaff\|requireAdmin\|hasRole" workers/api/src/modulesCột Auth trong API Catalog được sinh tự động từ chính các guard đó. Nếu bảng ở đây nói khác cột kia, thì cột kia đúng và trang này sai.
1. Cách xác thực
| Web | Mobile | |
|---|---|---|
| Mang token | Cookie nemo12_session, domain .nemo12.com, HttpOnly | Authorization: Bearer <token> |
| CSRF | Bắt buộc Origin thuộc *.nemo12.com với mọi request thay đổi dữ liệu | Miễn (không có Origin, không có cookie) |
| Lưu trữ | DB chỉ lưu SHA-256 hash của token | như trên |
| Xoay vòng | Quá 7 ngày → cấp token mới, rotated_from trỏ về bản cũ | như trên |
2. Vai trò
| Vai | Nguồn sự thật | Ý nghĩa |
|---|---|---|
| (ẩn danh) | không có session | Chỉ chạm được §5 |
| Chính learner | learners.user_id = session.user_id | Em ấy |
owner / guardian | family_members | Phụ huynh có toàn quyền trong gia đình |
supporter | family_members | Người thân hỗ trợ — hôm nay quyền y hệt guardian; cột đã có, luật thu hẹp thì chưa |
mentor | role_assignments | Dolphin — role-gated, xem được mọi learner |
staff | role_assignments | Nhân sự vận hành nội dung (Coral) |
admin | role_assignments | Quản trị hệ thống |
Vai lấy từ role_assignments, không từ email (RISK-013 — email đổi được, chuyển chủ được, không phải khóa danh tính). role_assignments có sẵn scope_type/scope_id nhưng hôm nay mọi dòng đều global/*: thu hẹp phạm vi theo trường/gia đình là chỗ trống đã chuẩn bị, chưa dùng.
Mentor xem được mọi learner
learnerAccess() cho phép bất kỳ ai có role mentor truy cập bất kỳ learner nào (SRC-037, REQ-MEN-01). mentor_assignments chỉ còn là phân công theo dõi chính, không còn là điều kiện truy cập — nó chỉ đặt cờ assigned trong kết quả để ghi vào audit.
Đây là quyết định có chủ ý (mentor cần hỗ trợ chéo), và nó khiến audit log trở thành bắt buộc cho mọi truy cập của mentor — đó là lớp kiểm soát duy nhất còn lại.
3. Thuật toán learnerAccess() — nguồn của mọi quyết định
shared/authz.ts. Trả về { allowed, via, family_role, assigned }; via là đường nào được vào, và chính nó quyết định có ghi nhật ký hay không.
learner không tồn tại → denied
learner.user_id == session.user_id → allowed, via = self
session ∈ family_members(owner|guardian|supporter) → allowed, via = family (kèm family_role)
session có role 'admin' → allowed, via = admin
session có role 'staff' → allowed, via = staff
session có role 'mentor' → allowed, via = mentor (kèm assigned)
còn lại → deniedThứ tự vai nội bộ là admin > staff > mentor để nhật ký ghi đúng vai cao nhất.
Ghi nhật ký theo via
via | audit_log.actor_role | Có ghi không |
|---|---|---|
self | learner | ❌ |
family | parent | ❌ |
mentor | mentor | ✅ bắt buộc |
staff | staff | ✅ bắt buộc |
admin | admin | ✅ bắt buộc |
Truy cập bằng quyền mượn để lại dấu vết; chính em ấy và người nhà đọc dữ liệu của mình thì không — nhật ký để soi người ngoài, không phải để đếm bố mẹ. Ghi log không bao giờ chặn nghiệp vụ: logAccess tự nuốt lỗi (REQ-NFR-01).
4. Ba tầng chặn
| Tầng | Cách | Dùng khi |
|---|---|---|
| Middleware trên path | router.use(path, requireSession) | Cả nhóm route đều cần đăng nhập |
| Middleware theo route | router.use(route.getRoutingPath(), requireSession) | Route lẻ |
| Guard trong handler | requireLearnerAccess() / resolveLearnerAccess() / requireRole() / requireStaff() / requireAdmin() / guardGuardian() | Cần biết learner nào hoặc vai nào mới quyết được |
Bài học đã trả giá (SRC-036):
router.use("*", requireSession)trên router mount ở/sẽ chặn toàn bộ API, kể cả endpoint public của router khác. Luôn giới hạn middleware theo path. Middleware phải đăng ký trước handler, và dùng cú pháp:paramcủa Hono (không phải{param}của OpenAPI).
Chọn hàm nào:
| Hàm | Trả về | Dùng khi |
|---|---|---|
requireLearnerAccess(c, id) | Response | null | Chỉ cần biết được/không được |
resolveLearnerAccess(c, id) | LearnerAccess | Response | Cần biết vai (vd chỉ phụ huynh được ghi). Một lượt quyết định, một dòng audit — đừng gọi chồng lên requireLearnerAccess |
requireRole(c, [...]) | Response | null | Route không gắn với learner cụ thể |
5. Route public — 12 endpoint, từng cái một lý do
Public là quyết định, không phải sơ suất. Endpoint mới hiện 🌐 public trong api.md mà không có dòng ở đây là bug bảo mật.
| Endpoint | Vì sao public có chủ đích | Có learner data không |
|---|---|---|
POST /v1/auth/google | Cửa đăng nhập — bắt session ở đây thì không ai vào được. Bù lại: rate limit login (20/5 phút theo IP) chạy trước khi verify JWT, và audience GOOGLE_CLIENT_ID được verify chặt | ❌ |
GET /v1/health | Cloudflare/health check gọi khi chưa có ai đăng nhập; phản ánh phụ thuộc thật, không trả 200 cứng | ❌ |
GET /v1/subjects | Bản đồ môn học là thông tin công khai của chương trình GDPT 2018 — trang web giới thiệu cần đọc được | ❌ |
GET /v1/subjects/{subjectId}/graph | Như trên: cấu trúc kỹ năng của môn, không gắn với ai | ❌ |
GET /v1/subjects/{subjectId}/exams | Danh mục đề (tên, lớp, thời lượng) để trang giới thiệu liệt kê. Nội dung câu hỏi và bài làm thì không public — POST /v1/exams/{id}/start cần session | ❌ |
GET /v1/whale/countries | Dữ liệu tham chiếu du học — quốc gia | ❌ |
GET /v1/whale/countries/{code} | như trên | ❌ |
GET /v1/whale/universities/{id} | Thông tin trường, dữ liệu công khai | ❌ |
GET /v1/whale/scholarships | Danh sách học bổng — chính lý do tồn tại của Whale là để gia đình tra cứu được trước khi có tài khoản | ❌ |
GET /v1/whale/scholarships/{id} | như trên | ❌ |
GET /v1/whale/opportunities | Cơ hội (trại hè, chương trình) | ❌ |
GET /v1/whale/stories | Câu chuyện thành công đã được duyệt để công bố | ❌ |
Không public dù dễ tưởng là public: GET /v1/forum/topics và GET /v1/forum/topics/{id} đã chuyển sang yêu cầu session — danh sách topic trả author_name + author_role của trẻ em, không được để người chưa đăng nhập đọc. GET /v1/whale/preferences và /v1/whale/recommendations cũng cần session vì gắn với learner.
6. Ma trận quyền — vai × tài nguyên × hành động
✅ được · ❌ không · 📝 được, nhưng ghi audit_log · ⚠️ có luật riêng, xem chú thích.
6.1 Danh tính & gia đình
| Tài nguyên | Hành động | Ẩn danh | Learner | Phụ huynh | Mentor | Staff | Admin |
|---|---|---|---|---|---|---|---|
| Đăng nhập Google | write | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
/v1/me, đăng xuất | read/write | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
Lời mời (/v1/invitations) | read/write | ❌ | ⚠️¹ | ✅ | ❌ | ❌ | ❌ |
Cấp role (POST /v1/admin/roles) | write | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Phân công mentor | write | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
¹ Learner nhận lời mời được (POST /v1/invitations/accept); tạo lời mời là việc của người lớn trong gia đình.
6.2 Dữ liệu học của một learner
Mọi dòng dưới đây đi qua learnerAccess() — cột Mentor/Staff/Admin luôn là 📝.
| Tài nguyên | Hành động | Learner (chính mình) | Phụ huynh cùng family | Mentor | Staff | Admin |
|---|---|---|---|---|---|---|
| Hồ sơ & onboarding | read | ✅ | ✅ | 📝 | 📝 | 📝 |
| Hồ sơ & onboarding | write | ✅ | ✅ | 📝 | 📝 | 📝 |
| Tiến độ, cockpit, overview | read | ✅ | ✅ | 📝 | 📝 | 📝 |
| Phiên luyện tập, trả lời câu hỏi | write | ✅ | ✅ | 📝 | 📝 | 📝 |
| Lab, experience | read/write | ✅ | ✅ | 📝 | 📝 | 📝 |
| Bài thi: bắt đầu, nộp | write | ✅ | ✅ | 📝 | 📝 | 📝 |
| Mục tiêu, lịch thi, trường đích | read/write | ✅ | ✅ | 📝 | 📝 | 📝 |
| Learning plan, replan | read/write | ✅ | ✅ | 📝 | 📝 | 📝 |
| Lời khai bối cảnh (context events) | read/write | ✅ | ✅ | 📝 | 📝 | 📝 |
| Lịch sử phiên bản model | read | ✅ | ✅ | 📝 | 📝 | 📝 |
| Số retention/priority/probe thô | read | ❌⚠️² | ❌⚠️² | 📝 | 📝 | 📝 |
| Whale preferences & recommendations | read/write | ✅ | ✅ | 📝 | 📝 | 📝 |
| Orca actions | write | ✅ | ✅ | 📝 | 📝 | 📝 |
² Không phải chặn ở tầng route mà chặn ở tầng nội dung phản hồi: learner và phụ huynh nhận nhãn và lời, không nhận số thô của engine. Chi tiết: retention-model §6.3.
6.3 Student Portrait — nơi luật nghiêm nhất
| Tài nguyên | Hành động | Learner | Phụ huynh | Mentor | Staff | Admin |
|---|---|---|---|---|---|---|
| Portrait (phần bố mẹ viết) | read | ✅ luôn³ | ✅ | 📝 | 📝 | 📝 |
| Portrait (phần bố mẹ viết) | write | ❌ | ✅ | ❌ | ❌ | ❌ |
| Portrait learner sections (phần con viết) | write | ✅ chỉ mình em⁴ | ❌ | ❌ | ❌ | ❌ |
Phản ứng với portrait (agree/unsure/disagree) | write | ✅ | ❌ | ❌ | ❌ | ❌ |
| Tracks, milestones, activities | write | ❌ | ✅ | 📝 | 📝 | 📝 |
| Parent beliefs, observations | read/write | ❌⁵ | ✅ | 📝 | 📝 | 📝 |
| Parent recommendation | read | ❌⁵ | ✅ | 📝 | 📝 | 📝 |
³ REQ-POR-08: không có bức tranh tương lai nào được vẽ sau lưng đứa trẻ. ⁴ PUT /v1/portraits/{id}/learner-sections kiểm familyRole(...) === "learner" → "Chỉ chính Nemo được viết phần của mình" (AS-07.3.3). ⁵ guardGuardian() từ chối khi via === "self": "Chức năng dành cho phụ huynh". Đây là chỗ duy nhất trong hệ mà chính learner bị chặn khỏi dữ liệu về mình — có chủ đích, vì đó là nhận định riêng của bố mẹ, không phải hồ sơ của con.
6.4 Quyền riêng tư
| Tài nguyên | Hành động | Learner | Phụ huynh | Mentor | Staff | Admin |
|---|---|---|---|---|---|---|
GET /v1/consents | read | ✅ | ✅ | ❌ | ❌ | ❌ |
POST /v1/consents, revoke | write | ❌⁶ | ✅ | ❌ | ❌ | ❌ |
GET /v1/privacy/data-usage | read | ✅ | ✅ | ❌ | ❌ | ❌ |
| Yêu cầu xoá dữ liệu | write | ❌ | ✅ | ❌ | ❌ | ❌ |
GET /v1/privacy/retention-policies | read | ✅ | ✅ | ✅ | ✅ | ✅ |
| Đồng bộ/sửa retention policy | write | ❌ | ❌ | ❌ | ❌ | ✅ |
⁶ Đồng thuận là parent-first (AS-07.3.2 🔴): người lớn trong gia đình bấm, không phải đứa trẻ. Ba route ghi ở đây có rate limit privacy-write (30/10 phút).
6.5 Nội dung (Coral & content plane)
Toàn bộ /v1/coral/** và /v1/content/** chặn bằng requireStaff() = hasRole('staff') || hasRole('admin').
| Tài nguyên | Hành động | Learner | Phụ huynh | Mentor | Staff | Admin |
|---|---|---|---|---|---|---|
| Duyệt/sửa item, blueprint, experience | read/write | ❌ | ❌ | ❌ | ✅ | ✅ |
| Publish / reject / rollback item | write | ❌ | ❌ | ❌ | ✅ | ✅ |
| Sinh nội dung bằng AI | write | ❌ | ❌ | ❌ | ✅⚠️⁷ | ✅⚠️⁷ |
| Hàng chờ soát nội dung | read/write | ❌ | ❌ | ❌ | ✅ | ✅ |
Báo sai nội dung (POST /v1/content/items/{id}/report) | write | ✅ | ✅ | ✅ | ✅ | ✅ |
Prompt registry (GET /v1/coral/prompts) | read | ❌ | ❌ | ❌ | ✅ | ✅ |
⁷ Kèm rate limit ai-generate (30 lượt/giờ/tài khoản) vì mỗi lượt là tiền thật.
6.6 Mentor & vận hành
| Tài nguyên | Hành động | Learner | Phụ huynh | Mentor | Staff | Admin |
|---|---|---|---|---|---|---|
| Danh sách learner của mentor | read | ❌ | ❌ | ✅ | ❌ | ❌⁸ |
| Ghi chú mentor | read/write | ❌ | ❌ | 📝 | ❌ | ❌⁸ |
| Diễn đàn (đọc, đăng, vote, report) | read/write | ✅ | ✅ | ✅ | ✅ | ✅ |
GET /v1/admin/audit-log | read | ❌ | ❌ | ❌ | ❌ | ✅ |
engine_runs, workflow_runs, run metrics | read | ❌ | ❌ | ❌ | ❌ | ✅ |
| DLQ: xem, replay | read/write | ❌ | ❌ | ❌ | ❌ | ✅ |
| Recompute models, retention refresh | write | ❌ | ❌ | ❌ | ❌ | ✅ |
⁸ /v1/mentor/** và /v1/learners/{id}/mentor-notes chặn bằng requireRole(["mentor"]) — admin không tự động có role mentor. Muốn vào thì phải được cấp thêm dòng role_assignments. Đây là chủ đích: "quản trị hệ thống" và "người hướng dẫn một đứa trẻ" là hai việc khác nhau.
7. Bất biến — không được phá
- Không phân quyền bằng email (RISK-013).
grep -n "email" workers/api/src/shared/authz.ts→ chỉ có dòng comment cấm. - Không suy quyền từ tham số client gửi lên.
learner_idtrong body là cái client muốn đọc, không phải cái client được đọc. - Một lượt quyết định, một dòng audit. Đừng gọi
requireLearnerAccessrồi lạiresolveLearnerAccesscho cùng một request. audit_logchỉ nối thêm.grep -rn "audit_log" workers/api/src | grep -Ei "update|delete"→ phải rỗng.- Mentor truy cập = luôn có dòng nhật ký. Bỏ audit đi thì quyền của mentor thành quyền không ai giám sát.
8. Bắt buộc khi thêm endpoint chạm learner data
- Chặn session ở một trong ba tầng §4.
- Gọi
requireLearnerAccess()/resolveLearnerAccess()— không tự viết lại logic quyền. - Cần luật hẹp hơn (chỉ phụ huynh, chỉ chính learner) → viết thêm trên nền kết quả
via, đừng truy vấn lạilearners. - Route theo vai, không theo learner →
requireRole(). - Chạy
npm run gen:referencerồi đọc lại cột Auth trong api.md: endpoint mới hiện🌐 publicmà không có dòng trong §5 là bug bảo mật. - Thêm dòng vào ma trận §6 của trang này.
Trace
- REQ-SEC-02 (backend authz), REQ-SEC-06 (CORS/CSRF), REQ-MEN-01, REQ-POR-08, REQ-ACC-06 (mobile bearer).
- Rủi ro: RISK-013 (cấm dùng email làm khóa quyền).
- Thiết kế: SDD-001 §6, SDD-015, SDD-017 §10.
- Trang anh em: API Catalog (sinh tự động), Data Semantics §2, §5.
- Kiểm chứng: QG-008.