# Plan — Implement Data Scope (row-level permission) trên OpenGate

> Cập nhật: 06/7/2026 · Thuộc [Kế hoạch giai đoạn 1](README.md) · Liên quan: [WS0](WS0-co-cau-to-chuc-va-phan-quyen-2026-07-06.md) §3, [Schema](schema-du-lieu-2026-07-06.md)
>
> **Bối cảnh:** data scope (GV chỉ thấy học viên phụ trách, học viên chỉ thấy mình) **đã có trong thiết kế** permission engine (RT-13/RT-15) nhưng **chưa implement** vào route, SDK và manifest. Plan này nối thiết kế → code chạy thật, lấy module `student` làm pilot.

---

## 1. Hiện trạng (đã đọc code `ws_suns`)

### Đã có (thiết kế + engine thuần)
| Thành phần | File | Trạng thái |
|---|---|---|
| Grant `kind:'rule'` (predicate theo row) + `kind:'field'` trong `rt_role.grants`; resolve giữ `rowFilter`/`fields`/`deny` | `apps/api/src/core/permissionEngine/resolve.js` | ✅ Hàm thuần, có test được |
| Predicate → Sequelize `where` an toàn (parametrized, field allowlist); ctx vars `current_user_id`, `current_dept_id`, `current_dept_subtree` | `permissionEngine/predicate.js` (`buildWhere`) | ✅ Toolkit, **chưa gắn route nào** |
| L2 route-guard `requirePermission(code)` đọc từ engine + L3 accessor `rowPredicate`/`predicateCtx` | `permissionEngine/guard.js` | ✅ **OPT-IN — chưa gắn**; `predicateCtx` còn TODO `deptSubtree` |
| 3 bảng engine: `rt_role` (grants JSONB), `rt_role_assignment`, `rt_permission_epoch` | `permissionEngine/models.js` | ✅ |
| Authorize hybrid (union CP-cache + engine) cho mã 4 cấp | `core/authorize.js` | ✅ đang chạy — nhưng **chỉ mức route (L2), không row** |

### Chưa có (gap)
- **G1 · Manifest:** schema manifest chưa có chỗ khai **field allowlist** của resource và **scope template** — module không tự mô tả được "resource này scope được theo biến nào".
- **G2 · SDK:** `defineApp`/middleware của module chỉ nhận `authenticate` + `authorize(code)` (xem `modules/opengate-app-student/src/backend/middleware.js`) — **không có accessor scope** để controller lọc row.
- **G3 · Ctx vars nghiệp vụ:** predicate mới có `current_user_id/dept` — chưa có biến domain (`current_giao_vien_id`, `current_nguoi_lxid`); và **chưa có mapping user CP ↔ giáo viên/học viên** (`teacher_giao_vien`, `student_nguoi_lx` không có cột `user_id`).
- **G4 · Seed role:** chưa có `rt_role` mẫu chứa grant `rule` cho `instructor`/`student`.
- **G5 · FE:** `hasPermission` chỉ binary — UI không biết đang bị scope để ẩn bộ lọc "tất cả".
- **G6 · Test/rollout:** guard ghi rõ chỉ bật sau khi di cư role (RT-16), user chưa có `rt_role` sẽ 403.

---

## 2. Quyết định thiết kế (đề xuất)

1. **Nguồn sự thật predicate = `rt_role.grants`** (như thiết kế B10.1 "một nguồn duy nhất"). Manifest **không chứa predicate**, chỉ khai:
   - `fields`: allowlist cột được phép xuất hiện trong predicate/filter (chống leak — khớp yêu cầu `buildWhere`).
   - `scopeVars`: biến ngữ cảnh module cung cấp (vd `current_giao_vien_id`) — để UI admin gợi ý khi soạn rule.
2. **Module tự resolve biến domain** qua hook mới trong `defineApp` (vd `ctxVars(req)`) — engine không cần biết bảng nghiệp vụ.
3. **Enforce tại service/controller của module** bằng accessor SDK (`ctx.authz.scopeWhere(...)`) — không magic ở ORM layer; route nào chưa gắn vẫn chạy như cũ (opt-in đúng tinh thần RT-15).
4. Mapping identity: thêm cột `user_id` (id user CP) vào `teacher_giao_vien` và `student_nguoi_lx` — migration mới trong module tương ứng, backfill theo `so_cmt`/SĐT/email.

---

## 3. Các bước thực hiện

### P1 — Contract: manifest + SDK (≈ 0.5 MM)
- [ ] Mở rộng schema manifest v1: mỗi `permissions[].feature` thêm (tùy chọn) `fields: string[]`, `scopeVars: string[]`. Cập nhật validator trong `module-sdk` (`test/manifest-schema.test.js`).
- [ ] `module-sdk/node`: `defineApp` nhận thêm `ctxVars(req) → Promise<Record<string,any>>`; middleware module nhận thêm `setScope(fn)` bên cạnh `setAuthorize`.
- [ ] Bump minor `moduleSdk` (không breaking — field mới optional).

### P2 — Wiring host runtime (≈ 1 MM)
- [ ] Host (apps/api) khi mount module: truyền `ctx.authz.requirePermission` (từ `guard.js`) và accessor mới `ctx.authz.scopeWhere(req, {module, feature, action, allowedFields})`:
  1. `engine.resolveContext(userId, tenant)` (cache theo `rt_permission_epoch`),
  2. `rowPredicate(resolved, module, feature, action)` — `undefined` = không giới hạn,
  3. gọi `ctxVars(req)` của module + `predicateCtx(req)` → merge ctx,
  4. `buildWhere(pred, ctx, allowedFields)` → trả `where` cho Sequelize.
- [ ] Hoàn thiện `predicateCtx`: nạp `deptId/deptSubtree` khi có org (chưa chặn pilot — biến domain đi trước).
- [ ] Cache resolve theo request + invalidate theo epoch (bump khi sửa role — cơ chế `rt_permission_epoch` đã có).

### P3 — Identity mapping (≈ 0.5 MM)
- [ ] Migration `teacher/002-add-user-id.sql`: `ALTER TABLE teacher_giao_vien ADD COLUMN IF NOT EXISTS user_id VARCHAR(100)` + index.
- [ ] Migration `student/00X-add-user-id.sql`: tương tự cho `student_nguoi_lx` (phục vụ app học viên self-scope).
- [ ] Script backfill đối chiếu `so_cmt`/SĐT/email ↔ user CP; báo cáo bản ghi không khớp.
- [ ] Module `teacher`/`student` implement `ctxVars(req)`: tra `user_id` → `{ current_giao_vien_id, current_nguoi_lxid }` (cache theo request).

### P4 — Seed role + bật pilot ở module `student` (≈ 0.5 MM) · *ví dụ chi tiết: §3b*
- [ ] Seed `rt_role`:
  - `instructor`: grant `{kind:'feature', module:'student', feature:'student', actions:['view']}` + `{kind:'rule', module:'student', feature:'student', actions:['view'], predicate:{op:'cmp', field:'giao_vien_id', cmp:'=', value:{ctx:'current_giao_vien_id'}}}`.
  - `student_self` (app học viên): rule `nguoi_lxid = ctx.current_nguoi_lxid`.
  - `registrar`/`training_mgr`/`director`: feature grants không rule (thấy tất).
- [ ] Module `student`: route list/get chuyển `authorize(...)` → `requirePermission(...)`; controller `getAll/getOne` merge `scopeWhere` vào query.
- [ ] Khai `fields`/`scopeVars` trong `manifest.json` của `student` (allowlist: `giao_vien_id`, `nguoi_lxid`, `ma_csdt`, `khoa_hoc_id`).

### P5 — FE + kiểm thử + rollout (≈ 0.5 MM)
- [ ] FE (tùy chọn MVP): expose cờ "scoped" qua session/me để UI ẩn filter toàn cục khi bị scope.
- [ ] Test: unit `buildWhere` (thuần — nhanh); integration: instructor chỉ thấy học viên phụ trách, 403 khi thiếu quyền, super admin không bị scope; regression các route chưa gắn scope.
- [ ] Rollout: bật theo thứ tự `student` → `teacher`/`course` → còn lại. **Điều kiện tiên quyết:** user thật đã có `rt_role` (di cư RT-16) — nếu chưa, user không role sẽ 403 (ghi chú ngay trong `guard.js`).

**Tổng ước tính: ~3 MM** (chưa gồm màn hình admin soạn rule — dùng seed/script giai đoạn đầu).

---

## 3b. Ví dụ end-to-end — data scope theo trạng thái hồ sơ

Minh họa nguyên tắc chia đôi: **manifest khai "cái gì scope được"**, **`rt_role.grants` khai "ai thấy hồ sơ nào"**.

### (a) Manifest module `student` — feature `hoso`

```json
{
  "key": "student",
  "version": "1.2.0",
  "permissions": [
    {
      "group": "main",
      "feature": "hoso",
      "label": "Hồ sơ học viên",
      "actions": ["view", "edit", "approve"],

      "fields": [
        "trang_thai_ho_so", "loai_ho_so", "phong_phu_trach_id",
        "giao_vien_id", "nguoi_lxid", "khoa_hoc_id", "ma_csdt"
      ],

      "scopeVars": [
        "current_user_id", "current_giao_vien_id",
        "current_nguoi_lxid", "current_dept_id"
      ]
    }
  ]
}
```

- `fields` = **allowlist** cột được phép xuất hiện trong predicate (yêu cầu của `buildWhere` — field ngoài danh sách bị chặn, chống leak).
- `scopeVars` = biến ngữ cảnh module cung cấp qua hook `ctxVars(req)` — UI admin dựa vào đây khi soạn rule.
- Manifest **không chứa rule cụ thể** — "ai thấy trạng thái nào" là chính sách từng trung tâm, không phải thuộc tính module.

### (b) `rt_role.grants` — rule theo trạng thái

**`admission`** (tuyển sinh) — chỉ thấy hồ sơ khâu tiếp nhận:
```json
{
  "kind": "rule",
  "module": "student", "feature": "hoso", "actions": ["view", "edit"],
  "predicate": {
    "op": "in", "field": "trang_thai_ho_so",
    "values": [
      { "lit": "tu_van" }, { "lit": "tiep_nhan" },
      { "lit": "cho_bo_sung" }, { "lit": "hop_le" }
    ]
  }
}
```

**`accountant`** (kế toán) — thấy từ hợp lệ trở đi (để thu đợt 1):
```json
{
  "kind": "rule",
  "module": "student", "feature": "hoso", "actions": ["view"],
  "predicate": {
    "op": "in", "field": "trang_thai_ho_so",
    "values": [
      { "lit": "hop_le" }, { "lit": "da_thu_dot1" },
      { "lit": "xep_khoa" }, { "lit": "da_bao_so" }, { "lit": "dang_hoc" }
    ]
  }
}
```

**`instructor`** (giáo viên) — kết hợp trạng thái **và** phụ trách (biến `ctx`):
```json
{
  "kind": "rule",
  "module": "student", "feature": "hoso", "actions": ["view"],
  "predicate": {
    "op": "and",
    "of": [
      { "op": "cmp", "field": "trang_thai_ho_so", "cmp": "=", "value": { "lit": "dang_hoc" } },
      { "op": "cmp", "field": "giao_vien_id", "cmp": "=", "value": { "ctx": "current_giao_vien_id" } }
    ]
  }
}
```

**`training_mgr` / `director`** — grant feature không kèm rule → thấy tất:
```json
{ "kind": "feature", "module": "student", "feature": "hoso", "actions": ["view", "edit", "approve"] }
```

### (c) Luồng runtime

```
GET /api/student/hoso  (user = kế toán)
  → requirePermission('student.main.hoso.view')          # L2: được vào route
  → scopeWhere(req, {module:'student', feature:'hoso', action:'view', allowedFields})
      ├─ resolveContext(userId)  → rowFilter['student hoso view'] = predicate IN(...)
      ├─ ctxVars(req)            → { current_giao_vien_id, ... }  (module tự tra)
      └─ buildWhere(predicate, ctx, allowlist)
  → WHERE trang_thai_ho_so IN ('hop_le','da_thu_dot1','xep_khoa','da_bao_so','dang_hoc')
```

Ghi chú:
- Nhiều rule cùng resource+action = **OR** (cộng quyền — sẵn trong `resolve.js`): user vừa `admission` vừa `accountant` thấy hợp cả hai dải trạng thái.
- Đổi chính sách ("kế toán thấy từ trạng thái nào") = sửa grant trong `rt_role` + bump epoch — **không đụng manifest, không release module** `.ogapp`.

---

## 4. Rủi ro & lưu ý
| Rủi ro | Giảm thiểu |
|---|---|
| User chưa di cư `rt_role` → 403 hàng loạt | Chỉ bật `requirePermission` ở module pilot sau khi seed + gán role đủ; giữ hybrid `authorize` cho phần còn lại |
| PAT/token path không có permission trong cache (ghi chú trong `authorize.js`) | Đường engine (`rt_role_assignment`) không phụ thuộc cache CP — kiểm tra PAT đi qua engine |
| Hiệu năng: resolveContext mỗi request | Cache theo user+epoch (đã có `rt_permission_epoch`); benchmark route list |
| Allowlist field lệch với model khi schema đổi | Validator build: đối chiếu `fields` trong manifest với cột migration; fail build nếu lệch |
| Backfill user_id không khớp hết (trùng SĐT, thiếu email) | Báo cáo ngoại lệ, gán tay qua màn hình admin; không chặn go-live phần không scope |

## 5. Ngoài phạm vi plan này
- Màn hình admin soạn predicate trực quan (sau MVP — dùng seed/JSON trước).
- `deny` field/row enforcement đầy đủ (engine đã giữ cấu trúc — bật sau).
- Audit log chung ở platform: **chưa có** (xác nhận 06/7) — MVP dùng `student_ho_so_lichsu` như [Schema §3](schema-du-lieu-2026-07-06.md); audit toàn cục là hạng mục riêng.
