Audit #006 — Hệ admin
Audit chuyên đề cho admin.nemo12.com và các endpoint /v1/admin đứng sau nó. Bổ trợ Audit #004, không đặt thang điểm mới (lý do).
Câu hỏi dẫn đường: người vận hành mở admin ra thì làm được gì, và khi hệ hỏng thì họ có nhìn ra không. Hai câu đó quan trọng hơn danh sách tính năng, vì admin là chỗ duy nhất một người thật nhìn vào hệ thống mà không phải đọc log.
1. Hệ admin đang là gì
| Mã nguồn | 1.854 dòng, 10 tệp |
| Màn hình | 5 tab: Overview · Learners · Models · Runs · Access & Mentors |
Endpoint /v1/admin trên production | 22 |
| Bảo vệ | Cloudflare Access (chỉ dac2205@gmail.com) + requireSession + role admin ở API |
| Ngôn ngữ | nhãn giao diện tiếng Anh toàn bộ (SRC-098); phần giải thích trong popover tiếng Việt có chủ đích (SRC-143) |
Nền móng thì chắc: 0 lời gọi fetch ngoài api.ts, 36/36 chỗ bấm được đều là <button> thật (không có <div onClick> nào), phân quyền có test chạy trên D1 thật, và Access chắn ở tầng hạ tầng trước khi tới app.
2. Phát hiện
A1 · Năm endpoint chạy trên production mà không màn hình nào gọi tới
Đối chiếu 22 route /v1/admin trong spec với mọi đường dẫn api.ts gọi:
| Endpoint | Nó phục vụ việc gì | Chỉ báo AS liên quan |
|---|---|---|
GET /v1/admin/audit-log | Điều tra sự cố: ai đã đụng vào learner nào, lúc nào | AS-07.4.5 |
GET /v1/admin/dead-letters · POST .../replay | Message hỏng nằm ở đâu, và chạy lại nó | AS-08.2.3 |
GET /v1/admin/retention-policies · POST .../sync | Chính sách giữ dữ liệu trẻ em | AS-04.5.1 |
GET /v1/admin/run-metrics | Số run, số lỗi theo thời gian | AS-08.3.4 |
Bốn chỉ báo trên đang PASS trong Audit #004 với bằng chứng "endpoint tồn tại". Đúng chữ nhưng hụt nghĩa: người vận hành không tới được chúng. Muốn đọc nhật ký điều tra sự cố hôm nay thì phải tự dựng lời gọi HTTP kèm cookie phiên — tức là đúng cái việc mà admin sinh ra để khỏi phải làm.
Đây là phát hiện nặng nhất của bản audit này, vì nó đúng vào lúc cần nhất: khi có sự cố.
A2 · Overview quay vòng mãi mãi khi API lỗi
const [ov, setOv] = useState<AdminOverview | null>(null);
useEffect(() => { admin.overview().then(setOv).catch(() => setOv(null)); }, []);
if (!ov) return <Loading />; // ← lỗi cũng rơi vào đâyAPI chết, mạng rớt, phiên hết hạn: cả ba đều cho ra spinner "Loading…" quay không dừng, không một chữ nào nói có chuyện gì. Đây là màn hình ĐẦU TIÊN người vận hành mở.
A3 · Lỗi hạ tầng trông y hệt "không có dữ liệu" (5 chỗ)
| Màn hình | Khi API lỗi | Người vận hành đọc ra |
|---|---|---|
| Learners | catch(() => setLearners([])) | "No learners." |
| Models · chọn learner | catch(() => setLearners([])) | "No learners." |
| Runs · tab Workflows | catch(() => setWorkflows([])) | không có run nào |
| Runs · tab Engines | catch(() => setEngines([])) | không có run nào |
| Overview | xem A2 | quay mãi |
Component ErrorBox đã có sẵn và có nút thử lại, nhưng chỉ được dùng ở các trang sâu của Models. Năm màn hình trên nuốt lỗi im lặng.
Hại thật sự nằm ở chỗ hiểu ngược: "0 workflow run" là tín hiệu báo động (cron chết, engine không chạy), còn "API lỗi" là chuyện khác hẳn. Trộn hai cái vào cùng một màn hình trắng là làm hỏng đúng công dụng của trang Runs.
A4 · Cắt danh sách âm thầm — 586/686 dòng đang bị giấu
Trang Runs xin limit: 100. Production hiện có 686 dòng engine_runs. Không có phân trang, không có dòng "còn n dòng nữa", không có bộ lọc theo thời gian. Người vận hành nhìn 100 dòng mới nhất và tin rằng đó là tất cả.
Cùng lỗi ở GET /v1/admin/learners: LIMIT 100 cứng trong SQL, không báo. Hiện mới 12 learner nên chưa đau, nhưng nó sẽ đau đúng lúc hệ đông người.
Chuẩn audit đã có nguyên tắc cho ca này ở phần workflow ("no silent caps: nếu bound coverage thì phải log() cái bị bỏ") nhưng chưa có chỉ báo nào áp cho giao diện.
A5 · Cấp quyền được, thu quyền thì không
api.ts khai đủ hai chiều: grantRole(email, role, "grant" | "revoke") và assignMentor(..., "assign" | "unassign"). Nhưng màn hình Access chỉ gọi nhánh mặc định:
admin.grantRole(roleEmail, roleKind) // luôn là "grant"
admin.assignMentor(assignEmail, assignLearner) // luôn là "assign"Nghĩa là qua giao diện, người vận hành cấp được quyền admin cho một email nhưng không rút lại được. Khi một tài khoản bị lộ, đường xử lý nhanh nhất lại không nằm trong công cụ.
A6 · Không có xác nhận cho bất kỳ hành động ghi nào
grep confirm( trong apps/admin/src → không có dòng nào. Trong khi đó admin có năm hành động ghi, gồm hai cái đáng phải hỏi lại:
- cấp quyền
admincho một email: một cú bấm, không hỏi lại, không hoàn tác được (xem A5); refreshRetention: chạm model của mọi learner trong hệ.
Đối chiếu: cổng learn đã có dialog xác nhận chỉ để rời khỏi Dashboard (SRC-397), vì "trên màn hình có mấy chục thẻ nằm sát nhau nên một cú bấm nhầm ném learner sang trang khác". Cấp quyền admin thì rủi ro lớn hơn nhiều mà lại không có lớp chặn nào.
A7 · Phiên hết hạn không được nhận ra
request() trong api.ts gom mọi lỗi thành một Error chung; không có nhánh nào xét 401. Phiên hết hạn vì vậy đi thẳng vào A2 và A3: spinner quay mãi hoặc danh sách rỗng, thay vì một câu "phiên đã hết hạn, đăng nhập lại".
A8 · Không có đường tự phục vụ cho hai việc vận hành đã có API
POST /v1/admin/retention-policies/sync và POST /v1/admin/dead-letters/{id}/replay là hai thao tác sửa chữa đã được xây và có test, nhưng nằm ngoài tầm với của giao diện (hệ quả của A1). Chúng đáng được lên màn hình hơn phần lớn thứ đang có, vì đó là lúc hệ đang hỏng.
3. Việc sinh ra từ audit này
| # | Việc | Đóng | Cỡ |
|---|---|---|---|
| M1 | ErrorBox cho cả 5 màn hình danh sách; Overview thôi quay vòng khi lỗi | A2, A3 | nhỏ |
| M2 | request() nhận diện 401 và đưa về màn đăng nhập kèm lời giải thích | A7 | nhỏ |
| M3 | Xác nhận trước khi cấp quyền admin và trước khi chạy refresh toàn hệ | A6 | nhỏ |
| M4 | Nút thu quyền và gỡ phân công mentor trên màn Access | A5 | nhỏ |
| M5 | Nói rõ danh sách đang bị cắt: "hiện 100 / 686", kèm nút xem thêm | A4 | vừa |
| M6 | Tab Ops: audit log tra cứu được, dead letters kèm nút replay, retention policies kèm nút sync, run metrics | A1, A8 · AS-07.4.5, AS-08.2.3, AS-08.3.4, AS-04.5.1 | LỚN |
Thứ tự đề xuất: M1 → M2 → M3 → M4 → M5 → M6. Năm việc đầu đều nhỏ và đóng đúng lớp "hệ hỏng mà người vận hành không nhìn ra"; M6 là màn hình mới nên để sau cùng.
4. Đề xuất cho bộ chuẩn
Ba phát hiện A1, A4, A6 đều không vi phạm chỉ báo AS nào đang có, vì các chỉ báo hiện hỏi "có endpoint không", "có audit log không" chứ không hỏi "người vận hành có tới được không". Đề xuất bổ sung cho lần cập nhật chuẩn kế tiếp, dưới dạng ba chỉ báo của AS-09 (Trải nghiệm) hoặc AS-08 (Vận hành):
- Mọi endpoint vận hành (
/v1/admin/*) hoặc có đường vào từ giao diện, hoặc được liệt kê tường minh là "chỉ dùng bằng script" kèm lý do. - Không màn hình nào cắt danh sách mà không nói ra tổng số thật.
- Hành động không hoàn tác được (cấp quyền, chạy tác vụ toàn hệ, xoá) phải có bước xác nhận.
5. Trace
| Nguồn | Chuẩn | Ghi vào |
|---|---|---|
| SRC-416 (chỉ đạo: "audit toàn bộ hệ thống tài liệu, hệ thống admin này, tạo ra các audit report, sau đó fix dần") | audit-standard/index.md v0.2 — bổ trợ, không thay thang điểm | báo cáo này + index + việc M1..M6 |
6. Đã sửa — 2026-08-20, ngay sau khi chấm
| Việc | Kết quả kiểm được |
|---|---|
| M1 | Thêm Loadable vào ui.tsx: một chỗ duy nhất quyết định ba nhánh đang tải · rỗng thật · hỏng, để không màn hình nào tự chế lại rồi chế lệch. Nối vào cả 5 màn hình. Overview thôi rơi vào nhánh Loading khi lỗi. → A2, A3 đóng |
| M2 | request() nay ném ApiError mang status và code, có .expired cho 401; mất mạng (chưa có Response) cũng thành lỗi nói được thành lời thay vì mảng rỗng. → A7 đóng |
| M3 | Cấp quyền admin phải xác nhận, kèm câu nói rõ hậu quả. Cấp mentor/staff không hỏi, vì rủi ro thấp và đó là thao tác làm hàng loạt. → A6 đóng phần nặng nhất |
| M4 | Màn Access có grant/revoke và assign/unassign; nhãn nút đổi theo lựa chọn. → A5 đóng |
| M5 | Trang Runs nói ra khi danh sách bị cắt: "Showing the 100 most recent runs. There are probably more." kèm nút xin thêm 100. Đổi tab hoặc đổi bộ lọc thì quay về trang đầu. → A4 đóng |
Cách kiểm: dựng admin ở máy, giả lập tầng fetch để ép đúng từng ca, rồi đọc lại màn hình thật:
| Ca ép ra | Trước | Sau (đo được trên màn hình) |
|---|---|---|
GET /overview trả 500 | spinner quay mãi | "Something went wrong · D1 is unreachable · Try again" |
| Bấm "Try again" sau khi API sống lại | không có nút | bảng số liệu hiện ra đầy đủ |
GET /learners trả [] | "No learners." | "No learners." (giữ nguyên, đúng) |
GET /learners trả 500 | "No learners." | "Something went wrong · D1 is unreachable · Try again" |
Chọn revoke | không có lựa chọn | nút đổi thành "Revoke" |
Cấp admin, bấm Huỷ ở hộp xác nhận | không có hộp nào | API không được gọi (grantCalled: false) |
Cấp mentor | không có hộp nào | không hỏi lại, API được gọi ngay |
| M6 | Tab Ops với bốn khu, xếp theo mức cấp bách khi có sự cố chứ không theo thứ tự API: Metrics (engine nào đang fail, cửa sổ 1h/24h/7d/30d) · Queue (message kẹt + nút Replay) · Audit log (lọc theo target và action) · Retention (chính sách đã khai vs bảng thật, kèm nút Sync). → A1 và A8 đóng; bốn chỉ báo AS-07.4.5, AS-08.2.3, AS-08.3.4, AS-04.5.1 nay người vận hành với tới được, không còn PASS bằng lý do "endpoint tồn tại" |
Kiểm tab Ops
Giả lập bốn endpoint rồi đọc lại màn hình thật:
| Khu | Đo được trên màn hình |
|---|---|
| Metrics | 3 engine fail và 1 workflow fail hiện đỏ ở ô thống kê, kèm câu "4 failed run(s) in this window"; window đổi được 1h → 30 ngày |
| Queue | Dòng dead letter hiện đủ queue, message id, nguyên văn lỗi; bấm Replay gọi đúng POST /v1/admin/dead-letters/dl1/replay |
| Audit log | mentor@example.com · mentor · learner_data.read · learner 8933bc48 · 203.0.113.9 — đúng bộ câu hỏi ai/làm gì/với ai/từ đâu |
| Retention | Cảnh báo đỏ "Declared in code but missing from the database: learner_context_events" kèm nút Sync |
Cả hai hành động ghi của tab này (Replay, Sync) đều hỏi lại trước khi chạy, đúng luật vừa đặt ở M3.
Vậy là M1..M6 đóng hết. Ba đề xuất bổ sung chỉ báo ở §4 vẫn để nguyên cho lần cập nhật chuẩn kế tiếp.