Appearance
Đặc tả phân quyền (Permission Spec) — Hồ sơ bệnh án v1.0.0
Phiên bản: 1.0.0 Ngày: 30/04/2026 Tác giả: PO/BA + Tech Lead + Security Lead Phạm vi: Phân quyền runtime cho toàn bộ FR-001..019 trong package ho-so-benh-anLiên quan: Permission v2 reference của Diva (.agents/skills/po-ba-workflow/references/diva-permission-model.md)
File này dùng để làm gì: đây là canonical owner của mọi quy ước phân quyền cho feature Hồ sơ bệnh án. PRD/UI/Dev/QA chỉ trỏ về file này; nếu các file dẫn xuất xung đột, file này thắng (sau
SOURCE_OF_TRUTH.md).Đọc trước:
SOURCE_OF_TRUTH.mdmục 5.2 →prd.mdFR-011 →dev-spec.mdC8 → file này theo thứ tự P1 → P12.Văn phong: Mọi action, module, role, portal đều dùng đúng
codeliteral trong backtick. Display VI cho người đọc; mã code không dịch trong diagram/migration.
Lịch sử thay đổi
| Phiên bản | Ngày | Tác giả | Thay đổi |
|---|---|---|---|
| 1.0.0 | 30/04/2026 | PO/BA + TL + Security | Permission Spec canonical đầu tiên cho package: 22 action, default seed matrix 22×9, branch_mode 4 giá trị, portal 5 loại, field masking 5 view_mode, emergency override governance, cache TTL, migration backward-compat preset, FE constants TS, resolver Go signature, Compliance UI contract (SCR-18), test matrix mapping QA. |
Tài liệu đầu vào chuẩn
| File | Vai trò | Nếu xung đột |
|---|---|---|
SOURCE_OF_TRUTH.md mục 0 + 5.2 | Glossary + Permission v2 canonical Diva | Ưu tiên cao nhất |
prd.md FR-011 | Mô tả nghiệp vụ phân quyền 3 tầng | File này chi tiết hoá; PRD trỏ ngược về |
dev-spec.md C8 | Implementation contract permission | File này là spec; dev-spec là implementation |
ui-spec.md B6 + B2.8/B2.8A | UI gating + variant per role | UI trỏ về file này |
qa-test-plan.md D2.11 + D3.4 | Test matrix permission | Trỏ về P12 file này |
references/diva-permission-model.md | Mô hình Diva chung | Tuân thủ + chi tiết hoá |
Quy tắc: thay đổi nào ở file này → bắt buộc ripple sang PRD/UI/Dev/QA và
_consistency-matrix.md. Migration seed bắt buộc khớp 100% với P2 default seed matrix.
P1) Catalog 22 action × module × portal × sensitivity
Đây là canonical action list. Migration
module_permission_actionPHẢI seed đúng tên + portal + sensitivity. Code FE/BE/Hasura PHẢI dùng đúng action key này (snake_case).
P1.1) Bảng catalog đầy đủ
| # | Module | Action | Portal áp dụng | Sensitivity | Mô tả nghiệp vụ | Ref FR / DEC |
|---|---|---|---|---|---|---|
| 1 | clinic_module | access | Admin | low | Vào tab Phòng khám trong Branch Detail (chỉ đọc) | FR-001, DEC-001 |
| 2 | clinic_module | configure | Admin | medium | Sửa cấu hình 6 bước trợ lý | FR-002, DEC-031 |
| 3 | clinic_module | publish | Admin | high | Phát hành / tạm dừng module per CN | FR-015, DEC-030, DEC-041 |
| 4 | clinical_record | view_summary | Admin, Staff | low | Xem tóm tắt BA tầng 1+2 (count, status, safe alert) | FR-011, DEC-008 |
| 5 | clinical_record | view_medical_detail | Admin, Staff | high (tier 3) | Xem chi tiết y tế tầng 3 (ICD, ghi chú BS, form raw) | FR-011, DEC-008, DEC-009 |
| 6 | clinical_record | edit_medical_form | Admin, Staff | high (tier 3) | Sửa chẩn đoán / ICD-10 / y lệnh / form BA | FR-005, FR-006, DEC-008 |
| 7 | clinical_record | edit_admin_shared | Admin, Staff | medium | Sửa phần hành chính + 5 phiếu shared (không tầng 3) | FR-007, DEC-008 |
| 8 | clinical_record | print | Admin, Staff | medium | In bộ hồ sơ pháp lý | FR-009, DEC-007 |
| 9 | clinical_record | upload_scan | Admin, Staff | medium | Tải bản scan đã ký + ảnh trước-sau | FR-009, DEC-022 |
| 10 | clinical_record | export_visit_log | Admin | medium | Xuất sổ khám / sổ thủ thuật Excel | FR-012 |
| 11 | clinical_record | emergency_override | Admin, Staff | critical | Mở quyền tầng 3 khác CN trong tình huống cấp cứu | FR-011, DEC-010 |
| 12 | clinical_record | refuse_procedure | Admin, Staff | high | Xác nhận khách từ chối thủ thuật + huỷ order_item | FR-007, DEC-024 |
| 13 | clinical_sales | view_safe_summary | CRM, Admin | low | Trang xem an toàn (Sale) — tầng 1+2 đã diễn giải | FR-016, DEC-032 |
| 14 | clinical_sales_handoff | create | CRM, Admin, Staff | low | Tạo phiếu chuyển bác sĩ tư vấn | FR-016 |
| 15 | clinical_sales_handoff | acknowledge_close | Staff, Admin | low | BS nhận / đóng phiếu chuyển | FR-016 |
| 16 | doctor_workbench | access | Staff, Admin | low | Vào Bàn việc bác sĩ | FR-017, DEC-033 |
| 17 | doctor_workbench | view_branch_queue | Staff, Admin | medium | Xem queue toàn CN (BS trưởng, QL CN y tế) | FR-017 |
| 18 | customer_clinical_intake | open_session | POS, Staff, Admin | medium | Mở token Phiếu khách tự khai 15 phút | FR-018, DEC-034 |
| 19 | customer_clinical_intake | review_accept | Staff, Admin | medium | BS / Y tá rà soát + nhận intake vào hồ sơ | FR-018, DEC-034 |
| 20 | clinic_ops | view_branch_dashboard | Admin | medium | Trang điều phối CN mình (QL CN) | FR-019, DEC-035 |
| 21 | clinic_ops | view_all_dashboard | Admin | medium | Trang điều phối toàn chuỗi (Admin/Ops) | FR-019 |
| 22 | clinic_daily_close | close_branch_day | Admin, Staff | high | Chốt ngày phòng khám per CN | FR-019, DEC-036 |
Tổng: 22 action × 8 module. Hard-deny pairs (xem P6.4) override mọi grant.
P1.2) Quy ước sensitivity
| Sensitivity | Ý nghĩa | Audit log bắt buộc | Notification escalation |
|---|---|---|---|
low | Tóm tắt an toàn, không gây leak nếu sai | Không | Không |
medium | Vận hành; có thể chứa thông tin thương mại / vận hành CN | Audit per request (sample 10%) | Không |
high | Tầng 3 y tế hoặc thao tác đổi state nghiệp vụ quan trọng | Audit per request 100% | Notification cho QL CN nếu lệch CN scope |
critical | Truy cập tầng 3 ngoài quy ước thông thường (cross-branch) | Audit per request 100% + retention 10 năm | Notification ngay tới Medical Lead + Ops + Compliance |
P1.3) Module-level deny (no override)
Bất kể grant từ Permission UI, các pair sau LUÔN deny:
| Role business | Action bị deny cứng | Lý do |
|---|---|---|
| Sale (CRM portal) | clinical_record.view_medical_detail | Luật KCB 2023 điều 34, DEC-009 |
| Sale (CRM portal) | clinical_record.edit_medical_form | Cùng lý do |
| Sale (CRM portal) | clinical_record.print | Tránh leak qua bản in |
| Sale (CRM portal) | clinical_record.upload_scan | Tránh leak qua scan |
| Sale (CRM portal) | clinical_record.emergency_override | Sale không có vai trò y tế cấp cứu |
| Sale (CRM portal) | clinical_record.refuse_procedure | Sale không có quyền can thiệp đơn thuốc/thủ thuật |
| Lễ tân (POS portal) | clinical_record.view_medical_detail / edit_medical_form / emergency_override | Lễ tân không phải nhân sự y tế |
User is_pos_only=true | Mọi action ở Admin/CRM portal | Theo quy ước Diva — POS only không vào portal khác |
Backend test: Ngay cả khi admin gán nhầm action lên role / portal trong Permission UI, resolver MUST trả
denied=truecho các pair P1.3.
P2) Default seed matrix (role × action × default granted)
Mục đích: migration đầu tiên seed
role_module.actionscho 9 business role × 22 action. PO/Tech Lead/Security sign-off bảng này TRƯỚC khi migration deploy.
P2.1) Default seed table (canonical, 22 hàng × 9 cột)
Ký hiệu:
✓= mặc định cấp;—= mặc định không cấp;✗= hard-deny (P1.3) không override;(b)= giới hạnbranch_mode=branch;(m)=multi_branch;(s)=self;(a)=all;pos!=is_pos_only=true.
| Action | BS DL | BS TM | Y tá | Y tá trưởng | Lễ tân | Sale | QL CN | Admin/Ops | Medical Lead | Compliance officer |
|---|---|---|---|---|---|---|---|---|---|---|
clinic_module.access | — | — | — | — | — | — | ✓ (b) | ✓ (a) | ✓ (m) | ✓ (a) read |
clinic_module.configure | — | — | — | — | — | — | — | ✓ (a) | — | — |
clinic_module.publish | — | — | — | — | — | — | — | ✓ (a) | — | — |
clinical_record.view_summary | ✓ (b) | ✓ (b) | ✓ (b) | ✓ (b) | ✓ (b) pos! | ✓ (a) safe | ✓ (b) | ✓ (a) | ✓ (m) | ✓ (a) audit |
clinical_record.view_medical_detail | ✓ (b) | ✓ (b) | — | — | ✗ | ✗ | — | ✓ (a) audit only | ✓ (m) | ✓ (a) audit only |
clinical_record.edit_medical_form | ✓ (b) | ✓ (b) | — | — | ✗ | ✗ | — | — | ✓ (m) | — |
clinical_record.edit_admin_shared | ✓ (b) | ✓ (b) | ✓ (b) | ✓ (b) | — | — | — | — | ✓ (m) | — |
clinical_record.print | ✓ (b) | ✓ (b) | ✓ (b) | ✓ (b) | — | ✗ | — | ✓ (a) | ✓ (m) | — |
clinical_record.upload_scan | ✓ (b) | ✓ (b) | ✓ (b) | ✓ (b) | — | ✗ | — | ✓ (a) | ✓ (m) | — |
clinical_record.export_visit_log | — | — | — | — | — | — | ✓ (b) | ✓ (a) | ✓ (m) | ✓ (a) audit |
clinical_record.emergency_override | ✓ (b) ratelimit | ✓ (b) ratelimit | — | — | ✗ | ✗ | — | — | ✓ (m) | — |
clinical_record.refuse_procedure | ✓ (b) | ✓ (b) | — | ✓ (b) | — | ✗ | — | — | ✓ (m) | — |
clinical_sales.view_safe_summary | ✓ (b) | ✓ (b) | — | — | — | ✓ (a) | ✓ (b) | ✓ (a) | ✓ (m) | — |
clinical_sales_handoff.create | — | — | — | — | — | ✓ (a) | ✓ (b) | ✓ (a) | — | — |
clinical_sales_handoff.acknowledge_close | ✓ (b) | ✓ (b) | — | — | — | — | — | ✓ (a) | ✓ (m) | — |
doctor_workbench.access | ✓ (b) | ✓ (b) | ✓ (b) | ✓ (b) | — | — | — | — | ✓ (m) | — |
doctor_workbench.view_branch_queue | — | — | — | ✓ (b) | — | — | ✓ (b) | ✓ (a) | ✓ (m) | — |
customer_clinical_intake.open_session | — | — | ✓ (b) | ✓ (b) | ✓ (b) pos! | — | ✓ (b) | ✓ (a) | — | — |
customer_clinical_intake.review_accept | ✓ (b) | ✓ (b) | ✓ (b) admin_only | ✓ (b) | — | — | — | — | ✓ (m) | — |
clinic_ops.view_branch_dashboard | — | — | — | — | — | — | ✓ (b) | ✓ (a) | ✓ (m) | ✓ (a) read |
clinic_ops.view_all_dashboard | — | — | — | — | — | — | — | ✓ (a) | ✓ (m) | ✓ (a) read |
clinic_daily_close.close_branch_day | — | — | — | — | — | — | ✓ (b) | ✓ (a) | — | — |
P2.2) Diễn giải bảng P2.1
- 9 business role: BS DL, BS TM, Y tá, Y tá trưởng, Lễ tân, Sale, QL CN, Admin/Ops, Medical Lead, Compliance officer (dùng để seed mẫu, không hard-code).
branch_modemặc định: BS/Y tá/Lễ tân =branch; Sale =all(toàn chuỗi nhưng chỉ tầng 1+2); QL CN =branch; Admin/Ops =all; Medical Lead =multi_branch(chuỗi CN có module clinical); Compliance officer =allaudit.is_pos_only: Lễ tân portal POS có flagpos!cho actionview_summary+intake.open_session— chỉ vào POS, không Admin/CRM. Lễ tân Admin (làm cả admin) là role khác, không có flag này.✓ ratelimit: BS cóemergency_overridemặc định nhưng chịu rate limit ở P7.✓ audit only: Compliance officer + Admin/Ops "audit only" có quyền xem qua audit page (SCR-18) chứ không vào form BA edit.admin_only: Y tá hỗ trợreview_acceptchỉ được prefill phần hành chính + dị ứng, không sửa diagnosis (BE phân biệt quafield allowlist).
P2.3) Tổng số grant mặc định
22 action × 9 role với pattern trên cho 115 grant rows trong migration role_module_action_seed.
P2.4) Quy ước override qua Dynamic Permission UI
- Admin có thể grant ngoài default cho user cụ thể (ví dụ y tá đặc biệt được
view_medical_detailchỉ ở 1 CN). - Hard-deny ở P1.3 KHÔNG override được — backend resolver luôn deny.
- Mọi override phải log vào
permission_change_logvớichanged_by,changed_at,reason.
P3) Resolver contract (Backend)
P3.1) Go function signature
go
// File: services/ecommerce-api/permission/clinical_resolver.go (mới)
package permission
import (
"context"
"github.com/google/uuid"
)
type ClinicalPermissionInput struct {
UserID string // account.id
SessionID string // for cache key + audit
Portal Portal // 'admin' | 'pos' | 'crm' | 'staff' | 'public'
ModuleID string // 'clinic_module' | 'clinical_record' | ...
Action string // 'view_summary' | ...
BranchID *uuid.UUID // CN context của request, nullable cho global query
RecordID *uuid.UUID // clinical_record.id nếu request về record cụ thể
CustomerID *string // KH liên quan, để check cross-branch
}
type ClinicalPermissionOutput struct {
Allowed bool
Reason string // human-readable nếu denied
DenyCode string // 'no_action' | 'wrong_portal' | 'branch_scope' | 'hard_deny' | 'pos_only_violation' | 'rate_limit' | 'session_expired'
BranchScope BranchScopeMode // 'self' | 'branch' | 'multi_branch' | 'all'
AllowedBranches []uuid.UUID // nếu multi_branch
ViewMode ViewMode // 'hidden' | 'summary' | 'admin_shared' | 'full_tier3'
FieldAllowlist []string // field names được trả về cho client
AuditRequired bool // ghi vào medical_record_access_log
EmergencyContext *EmergencySession // nếu đang trong session emergency_override
}
type Portal string
const (
PortalAdmin Portal = "admin"
PortalPOS Portal = "pos"
PortalCRM Portal = "crm"
PortalStaff Portal = "staff"
PortalPublic Portal = "public" // token-based, không phải user logged in
)
type BranchScopeMode string
const (
ScopeSelf BranchScopeMode = "self"
ScopeBranch BranchScopeMode = "branch"
ScopeMultiBranch BranchScopeMode = "multi_branch"
ScopeAll BranchScopeMode = "all"
)
type ViewMode string
const (
ViewModeHidden ViewMode = "hidden" // không trả field
ViewModeSummary ViewMode = "summary" // tầng 1+2 only
ViewModeAdminShared ViewMode = "admin_shared" // tầng 1+2 + phần hành chính
ViewModeFullTier3 ViewMode = "full_tier3" // tầng 3 đầy đủ
)
type EmergencySession struct {
StartedAt time.Time
ExpiresAt time.Time
Reason string
LogID uuid.UUID
}
func ResolveClinicalPermission(
ctx context.Context,
in ClinicalPermissionInput,
) (ClinicalPermissionOutput, error)P3.2) Algorithm chuẩn
ResolveClinicalPermission(in):
1. Load session từ cache (Redis: 'sess:{sessionID}', TTL 60s sliding)
- Nếu cache miss → query account + role_module + branch_assignment từ DB
- Cache lại với TTL 60s
2. Hard-deny check (P1.3):
- if user.role IN [sale_crm, receptionist_pos] AND in.Action IN [tier3_actions]:
return Allowed=false, DenyCode='hard_deny'
3. Portal check:
- action_portals := lookup(P1.1)[in.ModuleID][in.Action]
- if in.Portal NOT IN action_portals:
return Allowed=false, DenyCode='wrong_portal'
4. is_pos_only check:
- if user.is_pos_only=true AND in.Portal != 'pos':
return Allowed=false, DenyCode='pos_only_violation'
5. Action grant check:
- granted := role_module.actions[in.ModuleID].includes(in.Action)
- + override grants từ permission_change_log
- if NOT granted:
return Allowed=false, DenyCode='no_action'
6. Branch scope check:
- branch_mode := role_module.branch_mode
- allowed_branches := lookup branch_assignment(user, branch_mode)
- if in.BranchID != nil AND in.BranchID NOT IN allowed_branches:
// Trừ trường hợp emergency_override active
if has_active_emergency_session(user, in.RecordID):
emergency := load_emergency_session(user, in.RecordID)
return Allowed=true, ViewMode='full_tier3', EmergencyContext=emergency, AuditRequired=true
return Allowed=false, DenyCode='branch_scope'
7. Compute ViewMode:
- if in.Action IN [view_medical_detail, edit_medical_form]:
view_mode = 'full_tier3'
- elif in.Action == 'edit_admin_shared':
view_mode = 'admin_shared'
- elif in.Action == 'view_summary':
view_mode = 'summary'
- else:
view_mode = action-specific (xem P10)
8. Compute FieldAllowlist:
- lookup P10 matrix[ModuleID][Action][ViewMode] → allowlist
9. Rate limit check (cho 'emergency_override'):
- if in.Action == 'emergency_override':
count := count_overrides_today(user)
if count >= 5:
return Allowed=false, DenyCode='rate_limit', Reason='Đã vượt giới hạn 5 lần/ngày'
10. Audit required:
- audit_required := action.sensitivity IN ['high', 'critical']
11. Return Allowed=true, BranchScope, AllowedBranches, ViewMode, FieldAllowlist, AuditRequiredP3.3) Quy tắc cứng phía backend
- KHÔNG tin
view_mode,branch_id,field_allowlisttừ FE. Resolver luôn tự tính. - KHÔNG hard-code role name trong handler nghiệp vụ. Chỉ check action.
- Mọi action handler GỌI resolver TRƯỚC khi query DB / mutation. Không có ngoại lệ.
- Hard-deny luôn precede grant. Check hard-deny ở bước 2 trước action grant.
- Multi-role user: nếu user có 2+ business role → effective = UNION action; branch_mode = max scope (
all > multi_branch > branch > self). - Lỗi resolver = deny, không default allow. Nếu DB connection fail / cache fail → return Allowed=false, DenyCode='resolver_error', + alert ops.
P3.4) Multi-role union rule
user_roles := [BS_DL, Manager_branch_X]
effective_actions(module_id) := UNION(role.actions for role in user_roles)
effective_branch_mode := max([role.branch_mode for role in user_roles])
effective_is_pos_only := AND([role.is_pos_only for role in user_roles]) -- chỉ true nếu MỌI role POS-onlyTest: TC-PERM-MULTI-ROLE-01..03 (bổ sung vào QA).
P4) FE permission constants + label VI
P4.1) TypeScript constants
File:
diva-admin/src/modules/clinical/constants/permissions.ts(mới)
typescript
export const CLINICAL_MODULES = {
CLINIC_MODULE: 'clinic_module',
CLINICAL_RECORD: 'clinical_record',
CLINICAL_SALES: 'clinical_sales',
CLINICAL_SALES_HANDOFF: 'clinical_sales_handoff',
DOCTOR_WORKBENCH: 'doctor_workbench',
CUSTOMER_CLINICAL_INTAKE: 'customer_clinical_intake',
CLINIC_OPS: 'clinic_ops',
CLINIC_DAILY_CLOSE: 'clinic_daily_close',
} as const
export const CLINICAL_ACTIONS = {
// clinic_module
CLINIC_MODULE_ACCESS: 'access',
CLINIC_MODULE_CONFIGURE: 'configure',
CLINIC_MODULE_PUBLISH: 'publish',
// clinical_record
CR_VIEW_SUMMARY: 'view_summary',
CR_VIEW_MEDICAL_DETAIL: 'view_medical_detail',
CR_EDIT_MEDICAL_FORM: 'edit_medical_form',
CR_EDIT_ADMIN_SHARED: 'edit_admin_shared',
CR_PRINT: 'print',
CR_UPLOAD_SCAN: 'upload_scan',
CR_EXPORT_VISIT_LOG: 'export_visit_log',
CR_EMERGENCY_OVERRIDE: 'emergency_override',
CR_REFUSE_PROCEDURE: 'refuse_procedure',
// clinical_sales
SALES_VIEW_SAFE_SUMMARY: 'view_safe_summary',
// handoff
HANDOFF_CREATE: 'create',
HANDOFF_ACK_CLOSE: 'acknowledge_close',
// workbench
WB_ACCESS: 'access',
WB_VIEW_BRANCH_QUEUE: 'view_branch_queue',
// intake
INTAKE_OPEN_SESSION: 'open_session',
INTAKE_REVIEW_ACCEPT: 'review_accept',
// ops
OPS_VIEW_BRANCH_DASHBOARD: 'view_branch_dashboard',
OPS_VIEW_ALL_DASHBOARD: 'view_all_dashboard',
// daily close
CLOSE_BRANCH_DAY: 'close_branch_day',
} as const
export const CLINICAL_ACTION_LABELS_VI: Record<string, string> = {
'clinic_module.access': 'Vào tab Phòng khám',
'clinic_module.configure': 'Cấu hình module Phòng khám',
'clinic_module.publish': 'Phát hành module Phòng khám',
'clinical_record.view_summary': 'Xem tóm tắt bệnh án',
'clinical_record.view_medical_detail': 'Xem chi tiết y tế (tầng 3)',
'clinical_record.edit_medical_form': 'Sửa chẩn đoán + form BA',
'clinical_record.edit_admin_shared': 'Sửa phần hành chính + phiếu shared',
'clinical_record.print': 'In bộ hồ sơ',
'clinical_record.upload_scan': 'Tải bản scan đã ký',
'clinical_record.export_visit_log': 'Xuất sổ khám / sổ thủ thuật',
'clinical_record.emergency_override': 'Mở quyền khẩn cấp tầng 3 khác CN',
'clinical_record.refuse_procedure': 'Xác nhận khách từ chối thủ thuật',
'clinical_sales.view_safe_summary': 'Trang xem an toàn (Sale)',
'clinical_sales_handoff.create': 'Tạo phiếu chuyển bác sĩ tư vấn',
'clinical_sales_handoff.acknowledge_close': 'Nhận / đóng phiếu chuyển',
'doctor_workbench.access': 'Vào Bàn việc bác sĩ',
'doctor_workbench.view_branch_queue': 'Xem queue toàn CN',
'customer_clinical_intake.open_session': 'Mở phiên Phiếu khách tự khai',
'customer_clinical_intake.review_accept': 'Rà soát + nhận phiếu khách tự khai',
'clinic_ops.view_branch_dashboard': 'Trang điều phối CN mình',
'clinic_ops.view_all_dashboard': 'Trang điều phối toàn chuỗi',
'clinic_daily_close.close_branch_day': 'Chốt ngày phòng khám',
}P4.2) Helper function
typescript
export function canDo(
moduleId: string,
action: string,
context?: { branchId?: string; recordId?: string }
): boolean {
const store = useGlobalStore()
return store.hasPermission(moduleId, action, context)
}
export function getViewMode(
moduleId: string,
recordId?: string
): 'hidden' | 'summary' | 'admin_shared' | 'full_tier3' {
const store = useGlobalStore()
return store.getEffectiveViewMode(moduleId, recordId)
}P4.3) Quy tắc dùng FE
- KHÔNG hard-code role name trong component. Dùng
canDo(). - Menu/route guard: dùng
canDo()trong navigation. - Field-level rendering: kiểm
getViewMode()trước khi render block tầng 3. - Cache: FE cache permission state 60 giây; hết hạn → refetch.
- Reactivity: store emit event khi permission đổi (qua mutation Dynamic Permission UI hoặc WebSocket push) → component re-render.
- Fallback: nếu store rỗng (chưa load) → render skeleton, KHÔNG render full UI rồi ẩn (tránh flash content).
P5) Branch_mode behavior matrix
4 giá trị
branch_mode: cách user "thấy" record clinical theo CN.
P5.1) Matrix branch_mode × action × kết quả query
| branch_mode | Diễn giải | Query filter | Test case |
|---|---|---|---|
self | Chỉ thấy record do user tạo / phụ trách | WHERE primary_doctor_id = $userID OR created_by = $userID | TC-PERM-BRANCH-SELF-01 |
branch | Chỉ thấy record của CN được gán (1 CN) | WHERE branch_id = $userBranchID | TC-052E (existing) |
multi_branch | Thấy record của các CN trong branch_assignment | WHERE branch_id IN ($assignedBranches) | TC-PERM-BRANCH-MULTI-01 (mới) |
all | Thấy toàn bộ record toàn chuỗi | Không filter branch | TC-PERM-BRANCH-ALL-01 (mới) |
P5.2) Cross-branch behavior
| Tình huống | Behavior |
|---|---|
User branch_mode=branch xem record CN khác (không emergency) | Tầng 1+2 thấy theo view_summary global; tầng 3 = NULL |
User branch_mode=branch mở emergency_override → record CN khác | Trong session 1 giờ + audit |
User branch_mode=multi_branch xem record CN trong assignment | Đầy đủ theo action grant |
User branch_mode=multi_branch xem record CN ngoài assignment | Như "branch_mode=branch xem CN khác" |
User branch_mode=all | Không cần emergency, xem được toàn chuỗi |
P5.3) Branch assignment data model
sql
-- Tham chiếu (đã có trong Diva model)
CREATE TABLE branch_assignment (
account_id TEXT REFERENCES account(id),
branch_id UUID REFERENCES branch(id),
role_id TEXT,
primary_branch BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (account_id, branch_id, role_id)
);Resolver query
branch_assignmentđể buildAllowedBranchesở P3.1 step 6.
P5.4) Edge cases
| Case | Behavior |
|---|---|
| User chuyển CN giữa session | Cache invalidate; request kế load lại branch_assignment |
| User bị remove khỏi tất cả CN | branch_mode = empty → mọi action có branch context = denied |
| User có 2 role khác branch_mode | Effective = max scope (P3.4) |
P6) Portal isolation + is_pos_only enforcement
P6.1) Portal định danh
| Portal | Domain / route prefix | Mục đích |
|---|---|---|
admin | app.diva.com/admin/*, /s/*, /u/* | Settings, Branch Detail, User mgmt |
crm | app.diva.com/crm/* | Customer Detail (CRM context), Sale tools |
pos | app.diva.com/pos/*, app.diva.com/p/order/* | POS, Order, lễ tân |
staff | app.diva.com/e/* | Bàn việc bác sĩ, BA form, sổ khám |
public | app.diva.com/p/customer-clinical-intake/{token} | Token-based, không cần login |
P6.2) Action × Portal matrix (lookup table cho resolver step 3)
| Action | Admin | CRM | POS | Staff | Public |
|---|---|---|---|---|---|
clinic_module.* | ✓ | — | — | — | — |
clinical_record.view_summary | ✓ | — | ✓ POS lễ tân | ✓ | — |
clinical_record.view_medical_detail | ✓ | — | — | ✓ | — |
clinical_record.edit_medical_form | ✓ | — | — | ✓ | — |
clinical_record.edit_admin_shared | ✓ | — | — | ✓ | — |
clinical_record.print | ✓ | — | — | ✓ | — |
clinical_record.upload_scan | ✓ | — | — | ✓ | — |
clinical_record.export_visit_log | ✓ | — | — | — | — |
clinical_record.emergency_override | ✓ | — | — | ✓ | — |
clinical_record.refuse_procedure | ✓ | — | — | ✓ | — |
clinical_sales.view_safe_summary | ✓ | ✓ | — | — | — |
clinical_sales_handoff.create | ✓ | ✓ | — | ✓ | — |
clinical_sales_handoff.acknowledge_close | ✓ | — | — | ✓ | — |
doctor_workbench.* | ✓ | — | — | ✓ | — |
customer_clinical_intake.open_session | ✓ | — | ✓ | ✓ | — |
customer_clinical_intake.review_accept | ✓ | — | — | ✓ | — |
clinic_ops.* | ✓ | — | — | — | — |
clinic_daily_close.close_branch_day | ✓ | — | — | ✓ | — |
Resolver logic: in.Portal phải nằm trong cột
✓của hàng action; sai portal → 403 vớiDenyCode='wrong_portal'.
P6.3) is_pos_only=true enforcement
| Tình huống | Behavior |
|---|---|
User is_pos_only=true mở app.diva.com/pos/* | OK theo action grant |
User is_pos_only=true mở app.diva.com/admin/* URL | Server redirect về /pos + log warning |
User is_pos_only=true mở app.diva.com/crm/* URL | Tương tự — redirect |
User is_pos_only=true cố call API ngoài POS | API trả 403 + DenyCode='pos_only_violation' |
P6.4) Hard-deny pairs (revisit P1.3, programmatic)
go
// File: services/ecommerce-api/permission/hard_deny.go
var hardDenyPairs = map[string]map[Portal][]string{
"sale_crm": {
PortalCRM: {
"clinical_record.view_medical_detail",
"clinical_record.edit_medical_form",
"clinical_record.print",
"clinical_record.upload_scan",
"clinical_record.emergency_override",
"clinical_record.refuse_procedure",
},
},
"receptionist_pos": {
PortalPOS: {
"clinical_record.view_medical_detail",
"clinical_record.edit_medical_form",
"clinical_record.emergency_override",
},
},
}
func isHardDenied(role string, portal Portal, action string) bool {
if portals, ok := hardDenyPairs[role]; ok {
if actions, ok := portals[portal]; ok {
for _, a := range actions {
if a == action {
return true
}
}
}
}
return false
}Test: TC-PERM-HARD-DENY-01..06 (xem P12).
P7) Emergency override governance
P7.1) Lifecycle session
issued (user click "Mở quyền khẩn cấp" + nhập lý do ≥30 ký tự)
↓
active (TTL 1 giờ, có thể renew không?)
↓
expired (auto sau 1 giờ) | revoked (admin force) | closed (user click đóng)P7.2) Rate limit per user
| Khung thời gian | Giới hạn | Behavior khi vượt |
|---|---|---|
| Mỗi giờ | ≤2 lần | Banner cảnh báo + log |
| Mỗi ngày | ≤5 lần | Block + đề nghị liên hệ Medical Lead duyệt thủ công |
| Mỗi tuần | ≤15 lần | Force compliance review post-pilot |
Implementation: count từ
medical_record_access_log WHERE action='emergency_override' AND actor_user_id=$user.
P7.3) Session state
sql
-- Bảng mới (extension cho medical_record_access_log)
CREATE TABLE emergency_override_session (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id TEXT NOT NULL,
clinical_record_id UUID NOT NULL REFERENCES clinical_record(id),
reason TEXT NOT NULL CHECK (length(trim(reason)) >= 30),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, -- started_at + 1 hour
closed_at TIMESTAMPTZ NULL, -- null = active
closed_by TEXT NULL, -- 'self' | 'admin_revoke' | 'cron_expired'
log_id UUID NOT NULL REFERENCES medical_record_access_log(id),
CONSTRAINT one_active_per_record CHECK (
closed_at IS NOT NULL OR (
SELECT COUNT(*) FROM emergency_override_session s
WHERE s.account_id = account_id AND s.clinical_record_id = clinical_record_id
AND s.closed_at IS NULL
) <= 1
)
);
CREATE INDEX idx_eos_active ON emergency_override_session(account_id, expires_at)
WHERE closed_at IS NULL;P7.4) UI countdown + notification
| Trạng thái session | UI |
|---|---|
| Còn > 30 phút | Badge xanh "Phiên khẩn cấp còn {MM}:{SS}" header BA |
| Còn ≤ 30 phút | Badge vàng |
| Còn ≤ 5 phút | Badge đỏ + nháy nhẹ |
| Hết hạn | Modal "Phiên khẩn cấp đã hết. Vui lòng đóng và mở lại nếu cần." + revoke ngay |
P7.5) Notification escalation
Mỗi emergency_override issued → notification ngay tới:
- Medical Lead (toàn chuỗi): in-app + email
- QL CN của record (CN tạo BA): in-app
- Compliance officer: in-app + audit log
- Admin/Ops: in-app
Payload AN TOÀN: chỉ record_id, customer_id_masked, branch_id, actor_role, reason_excerpt (≤80 ký tự). KHÔNG gồm diagnosis, ICD, form_data.
P7.6) Cron expire
go
// scheduler/emergency_override_expire.go (mới, cron mỗi 5 phút)
UPDATE emergency_override_session
SET closed_at = NOW(), closed_by = 'cron_expired'
WHERE closed_at IS NULL AND expires_at <= NOW();P7.7) Manual revoke
Admin / Medical Lead có thể force revoke session đang active:
yaml
type Mutation {
revoke_emergency_override(
session_id: uuid!
reason: String!
): EmergencyOverrideSession
}→ Notification ngay tới user bị revoke + log audit.
P8) Cache + invalidation strategy
P8.1) Cache topology
| Layer | TTL | Invalidation trigger |
|---|---|---|
| FE in-memory store (Pinia) | 60s | (1) timer; (2) WebSocket push event permission.changed; (3) user-triggered refetch |
| BE Redis session cache | 60s sliding | (1) TTL; (2) pubsub channel perm:invalidate:{userID} từ Permission UI |
| BE in-process LRU cho action catalog (P1.1) | 1 giờ | Migration deploy |
P8.2) Invalidation events
| Event | FE action | BE action |
|---|---|---|
| Admin grant/revoke action qua Permission UI | Receive WS push → reload store cho ảnh hưởng user | Pub perm:invalidate:{userID} → Redis cache miss → reload từ DB |
| User chuyển CN | WS push self → reload | Pub self |
| User bị suspend | WS push → force logout | Pub + invalidate session |
| Migration deploy permission catalog mới | Force reload all FE (banner "Hệ thống cập nhật, vui lòng tải lại") | LRU cache clear |
P8.3) Refetch contract
typescript
// FE store
async function refetchPermissions(userID: string) {
const fresh = await graphql.query({
query: GetAccountRoleModules,
variables: { accountID: userID },
fetchPolicy: 'network-only',
})
store.setPermissions(fresh)
store.emit('permission_refreshed')
}P8.4) Eventual consistency window
- Tối đa 60s từ admin grant → user thấy thay đổi (TTL)
- Tốt nhất < 1s nếu WebSocket push hoạt động
- Trong 60s cửa sổ này: backend đã enforce theo DB truth (không trust FE cache); user có thể thấy menu/CTA cũ nhưng action sẽ trả 403
P8.5) Stale cache recovery
| Tình huống | Behavior |
|---|---|
| FE cache nói có quyền, BE trả 403 | FE force refetch + toast "Quyền của bạn đã thay đổi. Vui lòng tải lại trang." |
| BE Redis cache stale, DB truth khác | TTL 60s tự sync; trong window: BE lấy DB truth nếu action sensitivity ≥ high |
| Migration chạy giữa session đang dùng | Banner "Hệ thống đang cập nhật" + force logout sau 5 phút |
P9) Migration backward-compat plan
P9.1) Migration order (canonical)
Phase 0:
M001 CREATE TABLE module_permission_action (nếu chưa có)
M002 CREATE TABLE role_module (nếu chưa có)
M003 CREATE TABLE branch_assignment (nếu chưa có)
Phase 1 (clinical):
M101 INSERT 8 module mới vào module_permission_action.module_id
M102 INSERT 22 action mới vào module_permission_action (P1.1)
M103 CREATE TABLE emergency_override_session (P7.3)
M104 CREATE TABLE permission_change_log (P9.4)
M105 Apply Default Seed Preset (P9.2) cho 9 business role × 22 action
M106 Backfill role_module.actions cho user existing với role business tương ứng
M107 Audit: liệt kê user chưa có action → output report cho admin reviewP9.2) Default Seed Preset SQL
sql
-- File: db/migrations/M105_seed_clinical_permissions.sql
-- Step 1: Insert 22 actions
INSERT INTO module_permission_action (module_id, action, label_vi, portal, sensitivity, default_enabled)
VALUES
('clinic_module', 'access', 'Vào tab Phòng khám', 'admin', 'low', true),
('clinic_module', 'configure', 'Cấu hình module Phòng khám', 'admin', 'medium', false),
-- ... (22 rows từ P1.1)
;
-- Step 2: Apply default seed cho business role (chỉ chạy nếu role tồn tại)
WITH role_grants AS (
SELECT
r.id AS role_id,
r.code AS role_code,
a.module_id, a.action, a.portal,
CASE
WHEN r.code = 'doctor_dl' AND a.module_id = 'clinical_record' AND a.action IN (
'view_summary', 'view_medical_detail', 'edit_medical_form',
'edit_admin_shared', 'print', 'upload_scan',
'emergency_override', 'refuse_procedure'
) THEN true
WHEN r.code = 'doctor_tm' AND a.module_id = 'clinical_record' AND a.action IN (
-- same as doctor_dl
'view_summary', 'view_medical_detail', 'edit_medical_form',
'edit_admin_shared', 'print', 'upload_scan',
'emergency_override', 'refuse_procedure'
) THEN true
WHEN r.code = 'sale' AND a.module_id = 'clinical_sales' AND a.action = 'view_safe_summary' THEN true
WHEN r.code = 'sale' AND a.module_id = 'clinical_sales_handoff' AND a.action = 'create' THEN true
-- ... (theo bảng P2.1)
ELSE false
END AS granted
FROM role r
CROSS JOIN module_permission_action a
WHERE r.code IN ('doctor_dl', 'doctor_tm', 'nurse', 'head_nurse',
'receptionist', 'sale', 'branch_manager',
'admin_ops', 'medical_lead', 'compliance_officer')
)
INSERT INTO role_module (role_id, module_id, actions, branch_mode, is_pos_only)
SELECT
rg.role_id,
rg.module_id,
jsonb_agg(rg.action) FILTER (WHERE rg.granted),
CASE
WHEN rg.role_code IN ('admin_ops', 'sale', 'compliance_officer') THEN 'all'
WHEN rg.role_code = 'medical_lead' THEN 'multi_branch'
ELSE 'branch'
END,
CASE WHEN rg.role_code = 'receptionist_pos_only' THEN true ELSE false END
FROM role_grants rg
GROUP BY rg.role_id, rg.module_id, rg.role_code
ON CONFLICT (role_id, module_id) DO UPDATE
SET actions = EXCLUDED.actions,
branch_mode = EXCLUDED.branch_mode,
is_pos_only = EXCLUDED.is_pos_only;P9.3) Backward-compat strategy
| Tình huống legacy | Plan |
|---|---|
Role business đã tồn tại (vd branch_manager) | M105 update role_module.actions thêm action clinical theo P2.1 |
Role business chưa tồn tại (vd medical_lead mới) | M104 tạo role + M105 seed actions |
| User existing với role legacy đã được gán action ngoài P2.1 | Giữ nguyên grant cũ; chỉ append clinical actions mới |
| User existing không có business role nào trong P2.1 | Output report → admin review thủ công; KHÔNG tự seed (an toàn) |
P9.4) Permission change log
sql
CREATE TABLE permission_change_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
changed_by TEXT NOT NULL, -- account.id
target_type TEXT NOT NULL, -- 'role' | 'role_module' | 'branch_assignment'
target_id TEXT NOT NULL,
change_type TEXT NOT NULL, -- 'grant' | 'revoke' | 'modify'
before_value JSONB,
after_value JSONB,
reason TEXT,
source TEXT NOT NULL -- 'permission_ui' | 'migration' | 'api'
);
CREATE INDEX idx_pcl_target ON permission_change_log(target_type, target_id, changed_at DESC);
CREATE INDEX idx_pcl_actor ON permission_change_log(changed_by, changed_at DESC);P9.5) Rollback plan
| Migration | Rollback |
|---|---|
| M101-M102 (catalog) | Down migration: DELETE FROM module_permission_action WHERE module_id IN (8 modules); FK constraint với role_module = SAFE nếu chưa có grant |
| M105 (default seed) | Down migration: UPDATE role_module SET actions = actions - (clinical actions) WHERE module_id IN (8 modules) |
| M103 (emergency_session) | Down: DROP TABLE (chỉ pre-pilot; D0+ phải pause feature, không drop) |
| M104 (permission_change_log) | KHÔNG được rollback sau D0 (audit retention) |
P10) Field-level masking matrix
Mục đích: chốt đúng cách backend masking field theo
view_mode. FE đọc theo response, không tự suy luận.
P10.1) Matrix field × view_mode
| Field | hidden | summary | admin_shared | full_tier3 | emergency_active |
|---|---|---|---|---|---|
clinical_record.id | omit | return | return | return | return |
clinical_record.profile_code | omit | return | return | return | return |
clinical_record.visit_log_number | omit | return | return | return | return |
clinical_record.status | omit | return | return | return | return |
clinical_record.form_type | omit | return | return | return | return |
clinical_record.primary_doctor_id | omit | return | return | return | return |
clinical_record.created_at | omit | return | return | return | return |
clinical_record.icd10_primary_code | omit | omit | omit | return | return + audit |
clinical_record.icd10_secondary_codes | omit | omit | omit | return | return + audit |
clinical_record.diagnosis_description | omit | omit | omit | return | return + audit |
clinical_record.customer_refused_procedure | omit | return | return | return | return |
clinical_record.refusal_reason | omit | omit (label only) | omit (label only) | return | return + audit |
clinical_record.witness_nurse_id | omit | omit | return | return | return |
clinical_form_instance.form_data (BA DL/TM raw) | omit | omit | omit | return | return + audit |
clinical_form_instance.form_data (admin_shared forms) | omit | omit | return (admin section only) | return | return |
clinical_form_instance.signature_typed_text | omit | omit | return | return | return |
prescription.items[] | omit | omit (count only) | omit (count only) | return | return + audit |
reference_file (type='clinical_form_instance.scan') | omit | omit (count only) | return (URL signed) | return | return + audit |
reference_file (type='clinical_record.before_after') | omit | omit | omit | return (URL signed) | return + audit |
treatment_progress_entry.diagnosis_notes | omit | omit | omit | return | return + audit |
treatment_progress_entry.medical_order | omit | omit | omit | return | return + audit |
clinical_record.allergy_summary (label) | omit | return (level only) | return | return | return |
| Safe alert dictionary (level/code/text) | omit | return | return | return | return |
customer.full_name | mask | return | return | return | return |
customer.phone | mask | mask (last 3) | mask | return | return |
P10.2) Quy ước masking
| Pattern | Khi nào |
|---|---|
omit | Field bị remove khỏi GraphQL response (không trả null) |
mask | Field trả về dạng Đặng *** Sương hoặc 0909***456 |
return | Field trả nguyên bản |
return + audit | Field trả + insert vào medical_record_access_log |
count only | Trả count thay vì list (vd prescription_count: 3 thay vì prescription.items[]) |
level only | Trả enum level thay vì raw text (vd allergy_summary.level='high' không có raw allergens) |
P10.3) Implementation
Backend resolver sau khi query DB phải đi qua field allowlist filter:
go
func ApplyFieldAllowlist(record interface{}, allowlist []string, viewMode ViewMode) interface{} {
// 1. Reflect / map record fields
// 2. Bỏ field không có trong allowlist
// 3. Apply masking pattern theo P10.1
// 4. Audit insert nếu return + audit
return filtered
}Test: TC-PERM-MASK-01..15 (xem P12).
P10.4) Hasura permission filter expression
yaml
# clinical_record_secure_summary_view permission cho role 'user':
- table: clinical_record_secure_summary_view
select_permissions:
- role: user
permission:
columns:
# Chỉ tầng 1+2, không có icd10, diagnosis_description, form_data
- id
- profile_code
- visit_log_number
- status
- form_type
- primary_doctor_id
- created_at
- completed_at
- branch_id
- customer_id
- allergy_level
- safe_alert_level
- safe_alert_code
filter:
# Branch scope filter inline
_or:
- branch_id: { _eq: 'X-Hasura-Branch-Id' }
- branch_mode: { _eq: 'all' }
limit: 100Tầng 3 không qua Hasura raw query — chỉ qua action
get_clinical_record_detailcó resolver.
P11) Audit retention + Compliance UI contract
P11.1) Audit retention
| Bảng audit | Retention hot | Cold archive | Purge |
|---|---|---|---|
medical_record_access_log | 1 năm online | 9 năm S3 Glacier | KHÔNG xoá theo TT46/2018 |
emergency_override_session | 1 năm | 9 năm | KHÔNG xoá |
permission_change_log | 2 năm | 8 năm | KHÔNG xoá (Sec audit) |
clinical_form_instance (paper_fallback audit) | 1 năm | 9 năm | KHÔNG xoá |
P11.2) Compliance officer UI (SCR-18)
Vị trí: Admin portal, route
/s/compliance/clinical-audit(mới, build Phase 2 nếu Day-1 không kịp).
Quyền cần: clinical_record.export_visit_log + custom flag compliance_role=true ở account.
Wireframe: xem ui-spec.md SCR-18 (sẽ thêm).
Chức năng tối thiểu:
| Feature | Mô tả | Day-1? |
|---|---|---|
List medical_record_access_log | Bộ lọc user / CN / action / khoảng ngày | ✓ Day-1 |
| Detail event | Xem full payload (không bao gồm tầng 3 raw) + correlation_id | ✓ Day-1 |
| List emergency override sessions | Đang active + lịch sử | ✓ Day-1 |
| Force revoke override | CTA + lý do bắt buộc | Phase 2 |
| Export audit Excel | Theo P11.3 | ✓ Day-1 |
| Anomaly detection dashboard | ML detect lệch pattern | Phase 3 |
| Forward to authority | Gửi báo cáo Sở Y tế | Phase 3 |
P11.3) Audit export rule
- Format: Excel + CSV với UTF-8 BOM
- Sheet 1: log entries
- Sheet 2: filter applied + actor info
- Sheet 3: anomaly highlights (Day-1: rate limit hit, hard-deny attempts)
- Watermark: "Bản audit nội bộ — không phổ biến"
- Mỗi export ghi
audit_export_log(exported_by, exported_at, filter, row_count, purpose) - Tên file:
clinical-audit-{from}-{to}_{compliance_user_slug}_{timestamp}.xlsx
P11.4) Anomaly auto-alert (Day-1)
Cron mỗi 15 phút check:
- User có ≥3
emergency_overridetrong 1 giờ → Slack alert + email Compliance + Medical Lead - User có ≥5
wrong_portaldeny trong 1 giờ → Slack alert (gợi ý phishing / cấu hình sai) - User có ≥1
hard_denyattempt → Slack alert ngay (gợi ý cố ý vi phạm)
P12) Test matrix (mapping QA TC)
Bảng này là canonical test list cho Permission v2. QA Test Plan D2.11 + D3.4 phải implement đủ mọi TC ở đây trước Phase 4 exit.
P12.1) Hard deny tests
| TC ID | Tên | Setup | Expected |
|---|---|---|---|
| TC-PERM-HD-01 | Sale mở view_medical_detail qua API | Admin nhầm grant action cho role Sale | API trả 403 + log warning "hard_deny" |
| TC-PERM-HD-02 | Sale mở edit_medical_form qua API | ↑ | 403 |
| TC-PERM-HD-03 | Sale mở print qua API | ↑ | 403 |
| TC-PERM-HD-04 | Sale mở upload_scan qua API | ↑ | 403 |
| TC-PERM-HD-05 | Sale mở emergency_override qua API | ↑ | 403 |
| TC-PERM-HD-06 | Sale mở refuse_procedure qua API | ↑ | 403 |
| TC-PERM-HD-07 | Lễ tân POS mở view_medical_detail | Admin grant action lên role Lễ tân | 403 + log |
P12.2) Branch mode tests
| TC ID | Tên | Setup | Expected |
|---|---|---|---|
| TC-PERM-BM-01 | branch_mode='self' chỉ thấy record mình | BS có 5 BA mình + 10 BA CN khác | Query trả 5 record |
| TC-PERM-BM-02 | branch_mode='branch' thấy CN gán | BS gán CN Cao Lãnh, có 50 BA | Query trả 50 |
| TC-PERM-BM-03 | branch_mode='multi_branch' thấy 2 CN | BS Medical Lead gán CN Cao Lãnh + Tân Bình II | Query trả union |
| TC-PERM-BM-04 | branch_mode='all' thấy toàn chuỗi | Admin/Ops | Không filter branch |
| TC-PERM-BM-05 | Branch_mode user 1 CN xem CN khác → tầng 3 = NULL | BS Cao Lãnh xem KH ở Tân Bình II | tầng 1+2 OK; tầng 3 omit |
P12.3) Portal isolation tests
| TC ID | Tên | Setup | Expected |
|---|---|---|---|
| TC-PERM-PO-01 | User Admin grant action POS-only mở Admin route | Admin nhầm grant intake.open_session cho user only POS | Admin URL → 403 wrong_portal |
| TC-PERM-PO-02 | Sale CRM mở Admin route → 403 | Sale có quyền view_safe_summary CRM | Admin URL trả 403 |
| TC-PERM-PO-03 | is_pos_only=true cố mở Admin URL | Lễ tân POS-only | Server redirect /pos + log |
| TC-PERM-PO-04 | is_pos_only=true API call ngoài POS | ↑ | API 403 pos_only_violation |
P12.4) Multi-role tests
| TC ID | Tên | Setup | Expected |
|---|---|---|---|
| TC-PERM-MR-01 | User vừa BS vừa Manager → effective union | BS có view_medical_detail, Manager có export_visit_log | User có cả 2 |
| TC-PERM-MR-02 | Multi-role với branch_mode khác → max scope | BS branch + Manager multi_branch | effective = multi_branch |
| TC-PERM-MR-03 | is_pos_only chỉ true nếu MỌI role POS-only | 1 role POS-only + 1 role Admin | effective = false (vào được Admin) |
P12.5) Cache invalidation tests
| TC ID | Tên | Setup | Expected |
|---|---|---|---|
| TC-PERM-CI-01 | Grant action xong, user thấy menu mới sau ≤60s | Admin grant qua Permission UI | User refresh trong 60s thấy menu |
| TC-PERM-CI-02 | Revoke giữa session → request kế trả 403 | User đang ở SCR-06 | Save → 403 + toast "Quyền đã thay đổi" |
| TC-PERM-CI-03 | WS push → FE update không cần refresh | Admin grant + WS healthy | FE store update tự động |
| TC-PERM-CI-04 | WS down → fallback TTL 60s | WS disconnect | User vẫn nhận update sau 60s |
P12.6) Emergency override tests
| TC ID | Tên | Setup | Expected |
|---|---|---|---|
| TC-PERM-EO-01 | BS mở emergency lý do <30 ký tự | Lý do "ngắn" | Validation error |
| TC-PERM-EO-02 | BS mở emergency lý do hợp lệ | "Khách cấp cứu chuyển từ TBII, cần xem chẩn đoán cũ" | Session active 1 giờ |
| TC-PERM-EO-03 | Session active → xem được tầng 3 | ↑ | View full + audit log |
| TC-PERM-EO-04 | Session expire 1h → mất quyền | Đợi 1h | API trả 403 + UI ẩn tầng 3 |
| TC-PERM-EO-05 | Rate limit 5/ngày → block | BS đã dùng 5 lần | Lần thứ 6 → block + đề nghị Medical Lead |
| TC-PERM-EO-06 | Notification escalation | Mở emergency | Medical Lead + QL CN + Compliance + Admin nhận |
| TC-PERM-EO-07 | Manual revoke bởi Admin | Session đang active | User mất quyền ngay + log |
| TC-PERM-EO-08 | Cron auto-expire | Session quá 1h | closed_at set + closed_by='cron_expired' |
P12.7) Field masking tests
| TC ID | Tên | Setup | Expected |
|---|---|---|---|
| TC-PERM-MASK-01 | view_mode=summary trả tầng 1+2 only | BS khác CN | icd10/diagnosis omit |
| TC-PERM-MASK-02 | view_mode=admin_shared cho Y tá | Y tá cùng CN | Hành chính + dị ứng OK; diagnosis omit |
| TC-PERM-MASK-03 | view_mode=full_tier3 cho BS | BS cùng CN | Đầy đủ + audit |
| TC-PERM-MASK-04 | view_mode=hidden = field omit khỏi response | Sale | response không có field tầng 3 (không phải null) |
| TC-PERM-MASK-05 | Customer phone masked theo view_mode | Sale | phone = "0909***456" |
| TC-PERM-MASK-06 | prescription count only cho summary | view_summary | prescription_count: 3, không có items[] |
P12.8) Migration tests
| TC ID | Tên | Setup | Expected |
|---|---|---|---|
| TC-PERM-MIG-01 | Migration M105 seed default | Fresh staging | 9 role × 22 action với grant đúng P2.1 |
| TC-PERM-MIG-02 | Migration không ghi đè grant cũ | User existing đã có grant ngoài default | Grant cũ giữ; chỉ append clinical |
| TC-PERM-MIG-03 | Rollback M105 | Trước D0 | Down migration thành công |
| TC-PERM-MIG-04 | Audit report user thiếu role | User không có role business chuẩn | Output report với danh sách user → admin review |
P12.9) Compliance UI tests (SCR-18, có thể Phase 2)
| TC ID | Tên | Expected |
|---|---|---|
| TC-PERM-CO-01 | Compliance officer xem audit list | Hiển thị log filter được |
| TC-PERM-CO-02 | Export audit Excel | File với 4 sheet, watermark |
| TC-PERM-CO-03 | Anomaly auto-alert | Slack alert trong 15 phút |
P12.10) Tổng số TC permission
| Nhóm | Số TC | Phase |
|---|---|---|
| Hard deny (HD) | 7 | Phase 4 |
| Branch mode (BM) | 5 | Phase 4 |
| Portal isolation (PO) | 4 | Phase 4 |
| Multi-role (MR) | 3 | Phase 4 |
| Cache invalidation (CI) | 4 | Phase 4 |
| Emergency override (EO) | 8 | Phase 4 |
| Field masking (MASK) | 6 | Phase 4 |
| Migration (MIG) | 4 | Phase 1 |
| Compliance UI (CO) | 3 | Phase 2 |
| Tổng | 44 TC permission | — |
Phase 4 exit yêu cầu 100% Phase 4 TC = 37 TC đạt. Migration TC = 4 TC đạt ở Phase 1.
Phụ lục A — Quan hệ với các spec khác
| File | Section liên quan | Quy ước |
|---|---|---|
SOURCE_OF_TRUTH.md | mục 5.2 Permission v2 catalog | Đồng bộ với P1.1 — nếu lệch, SoT thắng |
prd.md | FR-011 + FR-016/017/018/019 | PRD trỏ về P1-P12; AC-011.* trỏ về TC-PERM-* |
ui-spec.md | B6 + B2.8/B2.8A + SCR-18 (mới) | UI gating đọc P4 constants; SCR-18 Compliance UI theo P11.2 |
dev-spec.md | C8.0 + C8.1 + C5.9 emergency_override | Implementation theo P3 resolver + P9 migration; C5.9 mở rộng theo P7 |
qa-test-plan.md | D2.11 + D3.4 + D6 | Bổ sung 44 TC-PERM-* theo P12 |
_consistency-matrix.md | Section 5 Permission × Action | Trỏ về P2 default seed cho default seed |
handoff.md | B-12A + B-16 + RACI | Permission seed + B-16 (refuse_procedure) phải khớp P9 migration |
go-live-checklist.md | E1 P0 permission gates | Toàn bộ TC-PERM-* P0 đạt trước go-live |
Phụ lục B — Câu hỏi còn mở
| ID | Câu hỏi | Phụ trách | Hạn |
|---|---|---|---|
| PD-PERM-001 | Compliance officer là role mới hay extend role admin? | PO + Security Lead | Trước Phase 1 |
| PD-PERM-002 | WebSocket push permission change — infra sẵn sàng Day-1 hay fallback TTL 60s? | Infra Lead | Trước Phase 4 |
| PD-PERM-003 | Rate limit emergency_override 5/ngày có nghiêm ngặt cho Y tá trưởng không? | Medical Lead | Trước pilot |
| PD-PERM-004 | SCR-18 Compliance UI Day-1 hay Phase 2? | PO | Trước Phase 1 sprint planning |
| PD-PERM-005 | Anomaly auto-alert đẩy về Slack channel nào? | Ops + Security | Trước Phase 4 |
End of Permission Spec v1.0.0. File này là canonical owner cho phân quyền feature Hồ sơ bệnh án. Mọi thay đổi action / role / portal / branch_mode / view_mode / masking → cập nhật file này TRƯỚC khi sửa downstream specs.