Skip to content

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ồn1.854 dòng, 10 tệp
Màn hình5 tab: Overview · Learners · Models · Runs · Access & Mentors
Endpoint /v1/admin trên production22
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:

EndpointNó 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àoAS-07.4.5
GET /v1/admin/dead-letters · POST .../replayMessage hỏng nằm ở đâu, và chạy lại nóAS-08.2.3
GET /v1/admin/retention-policies · POST .../syncChính sách giữ dữ liệu trẻ emAS-04.5.1
GET /v1/admin/run-metricsSố run, số lỗi theo thời gianAS-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 ​

ts
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 đây

API 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ìnhKhi API lỗiNgười vận hành đọc ra
Learnerscatch(() => setLearners([]))"No learners."
Models · chọn learnercatch(() => setLearners([]))"No learners."
Runs · tab Workflowscatch(() => setWorkflows([]))không có run nào
Runs · tab Enginescatch(() => setEngines([]))không có run nào
Overviewxem A2quay 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:

ts
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 admin cho 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ĐóngCỡ
M1ErrorBox cho cả 5 màn hình danh sách; Overview thôi quay vòng khi lỗiA2, A3nhỏ
M2request() nhận diện 401 và đưa về màn đăng nhập kèm lời giải thíchA7nhỏ
M3Xác nhận trước khi cấp quyền admin và trước khi chạy refresh toàn hệA6nhỏ
M4Nút thu quyền và gỡ phân công mentor trên màn AccessA5nhỏ
M5Nói rõ danh sách đang bị cắt: "hiện 100 / 686", kèm nút xem thêmA4vừa
M6Tab Ops: audit log tra cứu được, dead letters kèm nút replay, retention policies kèm nút sync, run metricsA1, A8 · AS-07.4.5, AS-08.2.3, AS-08.3.4, AS-04.5.1LỚ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):

  1. 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.
  2. Không màn hình nào cắt danh sách mà không nói ra tổng số thật.
  3. 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ồnChuẩnGhi 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ểmbáo cáo này + index + việc M1..M6

6. Đã sửa — 2026-08-20, ngay sau khi chấm ​

ViệcKết quả kiểm được
M1Thê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
M2request() 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
M3Cấ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
M4Màn Access có grant/revoke và assign/unassign; nhãn nút đổi theo lựa chọn. → A5 đóng
M5Trang 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 raTrướcSau (đo được trên màn hình)
GET /overview trả 500spinner quay mãi"Something went wrong · D1 is unreachable · Try again"
Bấm "Try again" sau khi API sống lạikhông có nútbả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 revokekhông có lựa chọnnút đổi thành "Revoke"
Cấp admin, bấm Huỷ ở hộp xác nhậnkhông có hộp nàoAPI không được gọi (grantCalled: false)
Cấp mentorkhông có hộp nàokhô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
Metrics3 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
QueueDò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 logmentor@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
RetentionCả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.