---
url: https://docs.nemo12.com/reference/permissions.md
description: >-
  Permissions: vai trò và quyền truy cập learner data, thi hành ở backend qua
  authz.ts, frontend ẩn nút không phải bảo mật.
---

# 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](api.md) đượ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.

::: warning 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`

| `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 `:param` củ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](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](retention-model.md#63-shaping-theo-vai--ai-được-thấy-con-số).

### 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á

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](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](../architecture/sdd-001-platform.md) §6, [SDD-015](../architecture/sdd-015-student-portrait.md), [SDD-017](../architecture/sdd-017-retention.md) §10.
* Trang anh em: [API Catalog](api.md) (sinh tự động), [Data Semantics](data-semantics.md) §2, §5.
* Kiểm chứng: QG-008.
