Skip to content

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ờ:

bash
grep -rn "requireLearnerAccess\|resolveLearnerAccess\|canAccessLearner" workers/api/src/modules
grep -rn "requireRole\|requireStaff\|requireAdmin\|hasRole" workers/api/src/modules

Cộ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 ​

WebMobile
Mang tokenCookie nemo12_session, domain .nemo12.com, HttpOnlyAuthorization: Bearer <token>
CSRFBắt buộc Origin thuộc *.nemo12.com với mọi request thay đổi dữ liệuMiễn (không có Origin, không có cookie)
Lưu trữDB chỉ lưu SHA-256 hash của tokennhư trên
Xoay vòngQuá 7 ngày → cấp token mới, rotated_from trỏ về bản cũnhư trên

2. Vai trò ​

VaiNguồn sự thậtÝ nghĩa
(ẩn danh)không có sessionChỉ chạm được §5
Chính learnerlearners.user_id = session.user_idEm ấy
owner / guardianfamily_membersPhụ huynh có toàn quyền trong gia đình
supporterfamily_membersNgườ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
mentorrole_assignmentsDolphin — role-gated, xem được mọi learner
staffrole_assignmentsNhân sự vận hành nội dung (Coral)
adminrole_assignmentsQuả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                                                  → denied

Thứ tự vai nội bộ là admin > staff > mentor để nhật ký ghi đúng vai cao nhất.

Ghi nhật ký theo via ​

viaaudit_log.actor_roleCó ghi không
selflearner❌
familyparent❌
mentormentor✅ bắt buộc
staffstaff✅ bắt buộc
adminadmin✅ 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ầngCáchDùng khi
Middleware trên pathrouter.use(path, requireSession)Cả nhóm route đều cần đăng nhập
Middleware theo routerouter.use(route.getRoutingPath(), requireSession)Route lẻ
Guard trong handlerrequireLearnerAccess() / 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 :param của Hono (không phải {param} của OpenAPI).

Chọn hàm nào:

HàmTrả vềDùng khi
requireLearnerAccess(c, id)Response | nullChỉ cần biết được/không được
resolveLearnerAccess(c, id)LearnerAccess | ResponseCầ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 | nullRoute 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.

EndpointVì sao public có chủ đíchCó learner data không
POST /v1/auth/googleCử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/healthCloudflare/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/subjectsBả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}/graphNhư trên: cấu trúc kỹ năng của môn, không gắn với ai❌
GET /v1/subjects/{subjectId}/examsDanh 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/countriesDữ 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/scholarshipsDanh 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/opportunitiesCơ hội (trại hè, chương trình)❌
GET /v1/whale/storiesCâ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ênHành độngẨn danhLearnerPhụ huynhMentorStaffAdmin
Đăng nhập Googlewrite✅✅✅✅✅✅
/v1/me, đăng xuấtread/write❌✅✅✅✅✅
Lời mời (/v1/invitations)read/write❌⚠️¹✅❌❌❌
Cấp role (POST /v1/admin/roles)write❌❌❌❌❌✅
Phân công mentorwrite❌❌❌❌❌✅

¹ 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ênHành độngLearner (chính mình)Phụ huynh cùng familyMentorStaffAdmin
Hồ sơ & onboardingread✅✅📝📝📝
Hồ sơ & onboardingwrite✅✅📝📝📝
Tiến độ, cockpit, overviewread✅✅📝📝📝
Phiên luyện tập, trả lời câu hỏiwrite✅✅📝📝📝
Lab, experienceread/write✅✅📝📝📝
Bài thi: bắt đầu, nộpwrite✅✅📝📝📝
Mục tiêu, lịch thi, trường đíchread/write✅✅📝📝📝
Learning plan, replanread/write✅✅📝📝📝
Lời khai bối cảnh (context events)read/write✅✅📝📝📝
Lịch sử phiên bản modelread✅✅📝📝📝
Số retention/priority/probe thôread❌⚠️²❌⚠️²📝📝📝
Whale preferences & recommendationsread/write✅✅📝📝📝
Orca actionswrite✅✅📝📝📝

² 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ênHành độngLearnerPhụ huynhMentorStaffAdmin
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, activitieswrite❌✅📝📝📝
Parent beliefs, observationsread/write❌⁵✅📝📝📝
Parent recommendationread❌⁵✅📝📝📝

³ 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ênHành độngLearnerPhụ huynhMentorStaffAdmin
GET /v1/consentsread✅✅❌❌❌
POST /v1/consents, revokewrite❌⁶✅❌❌❌
GET /v1/privacy/data-usageread✅✅❌❌❌
Yêu cầu xoá dữ liệuwrite❌✅❌❌❌
GET /v1/privacy/retention-policiesread✅✅✅✅✅
Đồng bộ/sửa retention policywrite❌❌❌❌✅

⁶ Đồ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ênHành độngLearnerPhụ huynhMentorStaffAdmin
Duyệt/sửa item, blueprint, experienceread/write❌❌❌✅✅
Publish / reject / rollback itemwrite❌❌❌✅✅
Sinh nội dung bằng AIwrite❌❌❌✅⚠️⁷✅⚠️⁷
Hàng chờ soát nội dungread/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ênHành độngLearnerPhụ huynhMentorStaffAdmin
Danh sách learner của mentorread❌❌✅❌❌⁸
Ghi chú mentorread/write❌❌📝❌❌⁸
Diễn đàn (đọc, đăng, vote, report)read/write✅✅✅✅✅
GET /v1/admin/audit-logread❌❌❌❌✅
engine_runs, workflow_runs, run metricsread❌❌❌❌✅
DLQ: xem, replayread/write❌❌❌❌✅
Recompute models, retention refreshwrite❌❌❌❌✅

⁸ /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á ​

  1. 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.
  2. Không suy quyền từ tham số client gửi lên. learner_id trong body là cái client muốn đọc, không phải cái client được đọc.
  3. Một lượt quyết định, một dòng audit. Đừng gọi requireLearnerAccess rồi lại resolveLearnerAccess cho cùng một request.
  4. audit_log chỉ nối thêm. grep -rn "audit_log" workers/api/src | grep -Ei "update|delete" → phải rỗng.
  5. 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 ​

  1. Chặn session ở một trong ba tầng §4.
  2. Gọi requireLearnerAccess() / resolveLearnerAccess() — không tự viết lại logic quyền.
  3. 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ại learners.
  4. Route theo vai, không theo learner → requireRole().
  5. Chạy npm run gen:reference rồi đọc lại cột Auth trong api.md: endpoint mới hiện 🌐 public mà không có dòng trong §5 là bug bảo mật.
  6. 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.