Skip to content

Đặ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.md mục 5.2 → prd.md FR-011 → dev-spec.md C8 → file này theo thứ tự P1 → P12.

Văn phong: Mọi action, module, role, portal đều dùng đúng code literal 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ảnNgàyTác giảThay đổi
1.0.030/04/2026PO/BA + TL + SecurityPermission 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

FileVai tròNếu xung đột
SOURCE_OF_TRUTH.md mục 0 + 5.2Glossary + Permission v2 canonical DivaƯu tiên cao nhất
prd.md FR-011Mô tả nghiệp vụ phân quyền 3 tầngFile này chi tiết hoá; PRD trỏ ngược về
dev-spec.md C8Implementation contract permissionFile này là spec; dev-spec là implementation
ui-spec.md B6 + B2.8/B2.8AUI gating + variant per roleUI trỏ về file này
qa-test-plan.md D2.11 + D3.4Test matrix permissionTrỏ về P12 file này
references/diva-permission-model.mdMô hình Diva chungTuâ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_action PHẢ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 đủ

#ModuleActionPortal áp dụngSensitivityMô tả nghiệp vụRef FR / DEC
1clinic_moduleaccessAdminlowVào tab Phòng khám trong Branch Detail (chỉ đọc)FR-001, DEC-001
2clinic_moduleconfigureAdminmediumSửa cấu hình 6 bước trợ lýFR-002, DEC-031
3clinic_modulepublishAdminhighPhát hành / tạm dừng module per CNFR-015, DEC-030, DEC-041
4clinical_recordview_summaryAdmin, StafflowXem tóm tắt BA tầng 1+2 (count, status, safe alert)FR-011, DEC-008
5clinical_recordview_medical_detailAdmin, Staffhigh (tier 3)Xem chi tiết y tế tầng 3 (ICD, ghi chú BS, form raw)FR-011, DEC-008, DEC-009
6clinical_recordedit_medical_formAdmin, Staffhigh (tier 3)Sửa chẩn đoán / ICD-10 / y lệnh / form BAFR-005, FR-006, DEC-008
7clinical_recordedit_admin_sharedAdmin, StaffmediumSửa phần hành chính + 5 phiếu shared (không tầng 3)FR-007, DEC-008
8clinical_recordprintAdmin, StaffmediumIn bộ hồ sơ pháp lýFR-009, DEC-007
9clinical_recordupload_scanAdmin, StaffmediumTải bản scan đã ký + ảnh trước-sauFR-009, DEC-022
10clinical_recordexport_visit_logAdminmediumXuất sổ khám / sổ thủ thuật ExcelFR-012
11clinical_recordemergency_overrideAdmin, StaffcriticalMở quyền tầng 3 khác CN trong tình huống cấp cứuFR-011, DEC-010
12clinical_recordrefuse_procedureAdmin, StaffhighXác nhận khách từ chối thủ thuật + huỷ order_itemFR-007, DEC-024
13clinical_salesview_safe_summaryCRM, AdminlowTrang xem an toàn (Sale) — tầng 1+2 đã diễn giảiFR-016, DEC-032
14clinical_sales_handoffcreateCRM, Admin, StafflowTạo phiếu chuyển bác sĩ tư vấnFR-016
15clinical_sales_handoffacknowledge_closeStaff, AdminlowBS nhận / đóng phiếu chuyểnFR-016
16doctor_workbenchaccessStaff, AdminlowVào Bàn việc bác sĩFR-017, DEC-033
17doctor_workbenchview_branch_queueStaff, AdminmediumXem queue toàn CN (BS trưởng, QL CN y tế)FR-017
18customer_clinical_intakeopen_sessionPOS, Staff, AdminmediumMở token Phiếu khách tự khai 15 phútFR-018, DEC-034
19customer_clinical_intakereview_acceptStaff, AdminmediumBS / Y tá rà soát + nhận intake vào hồ sơFR-018, DEC-034
20clinic_opsview_branch_dashboardAdminmediumTrang điều phối CN mình (QL CN)FR-019, DEC-035
21clinic_opsview_all_dashboardAdminmediumTrang điều phối toàn chuỗi (Admin/Ops)FR-019
22clinic_daily_closeclose_branch_dayAdmin, StaffhighChốt ngày phòng khám per CNFR-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ĩaAudit log bắt buộcNotification escalation
lowTóm tắt an toàn, không gây leak nếu saiKhôngKhông
mediumVận hành; có thể chứa thông tin thương mại / vận hành CNAudit per request (sample 10%)Không
highTầng 3 y tế hoặc thao tác đổi state nghiệp vụ quan trọngAudit per request 100%Notification cho QL CN nếu lệch CN scope
criticalTruy cập tầng 3 ngoài quy ước thông thường (cross-branch)Audit per request 100% + retention 10 nămNotification 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 businessAction bị deny cứngLý do
Sale (CRM portal)clinical_record.view_medical_detailLuật KCB 2023 điều 34, DEC-009
Sale (CRM portal)clinical_record.edit_medical_formCùng lý do
Sale (CRM portal)clinical_record.printTránh leak qua bản in
Sale (CRM portal)clinical_record.upload_scanTránh leak qua scan
Sale (CRM portal)clinical_record.emergency_overrideSale không có vai trò y tế cấp cứu
Sale (CRM portal)clinical_record.refuse_procedureSale 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_overrideLễ tân không phải nhân sự y tế
User is_pos_only=trueMọi action ở Admin/CRM portalTheo 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=true cho các pair P1.3.


P2) Default seed matrix (role × action × default granted)

Mục đích: migration đầu tiên seed role_module.actions cho 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ạn branch_mode=branch; (m) = multi_branch; (s) = self; (a) = all; pos! = is_pos_only=true.

ActionBS DLBS TMY táY tá trưởngLễ tânSaleQL CNAdmin/OpsMedical LeadCompliance 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_mode mặ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 = all audit.
  • is_pos_only: Lễ tân portal POS có flag pos! cho action view_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_override mặ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_accept chỉ được prefill phần hành chính + dị ứng, không sửa diagnosis (BE phân biệt qua field 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_detail chỉ ở 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_log với changed_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, AuditRequired

P3.3) Quy tắc cứng phía backend

  1. KHÔNG tin view_mode, branch_id, field_allowlist từ FE. Resolver luôn tự tính.
  2. KHÔNG hard-code role name trong handler nghiệp vụ. Chỉ check action.
  3. Mọi action handler GỌI resolver TRƯỚC khi query DB / mutation. Không có ngoại lệ.
  4. Hard-deny luôn precede grant. Check hard-deny ở bước 2 trước action grant.
  5. Multi-role user: nếu user có 2+ business role → effective = UNION action; branch_mode = max scope (all > multi_branch > branch > self).
  6. 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-only

Test: 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

  1. KHÔNG hard-code role name trong component. Dùng canDo().
  2. Menu/route guard: dùng canDo() trong navigation.
  3. Field-level rendering: kiểm getViewMode() trước khi render block tầng 3.
  4. Cache: FE cache permission state 60 giây; hết hạn → refetch.
  5. Reactivity: store emit event khi permission đổi (qua mutation Dynamic Permission UI hoặc WebSocket push) → component re-render.
  6. 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_modeDiễn giảiQuery filterTest case
selfChỉ thấy record do user tạo / phụ tráchWHERE primary_doctor_id = $userID OR created_by = $userIDTC-PERM-BRANCH-SELF-01
branchChỉ thấy record của CN được gán (1 CN)WHERE branch_id = $userBranchIDTC-052E (existing)
multi_branchThấy record của các CN trong branch_assignmentWHERE branch_id IN ($assignedBranches)TC-PERM-BRANCH-MULTI-01 (mới)
allThấy toàn bộ record toàn chuỗiKhông filter branchTC-PERM-BRANCH-ALL-01 (mới)

P5.2) Cross-branch behavior

Tình huốngBehavior
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ácTrong 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 assignmentNhư "branch_mode=branch xem CN khác"
User branch_mode=allKhô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 để build AllowedBranches ở P3.1 step 6.

P5.4) Edge cases

CaseBehavior
User chuyển CN giữa sessionCache invalidate; request kế load lại branch_assignment
User bị remove khỏi tất cả CNbranch_mode = empty → mọi action có branch context = denied
User có 2 role khác branch_modeEffective = max scope (P3.4)

P6) Portal isolation + is_pos_only enforcement

P6.1) Portal định danh

PortalDomain / route prefixMục đích
adminapp.diva.com/admin/*, /s/*, /u/*Settings, Branch Detail, User mgmt
crmapp.diva.com/crm/*Customer Detail (CRM context), Sale tools
posapp.diva.com/pos/*, app.diva.com/p/order/*POS, Order, lễ tân
staffapp.diva.com/e/*Bàn việc bác sĩ, BA form, sổ khám
publicapp.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)

ActionAdminCRMPOSStaffPublic
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ới DenyCode='wrong_portal'.

P6.3) is_pos_only=true enforcement

Tình huốngBehavior
User is_pos_only=true mở app.diva.com/pos/*OK theo action grant
User is_pos_only=true mở app.diva.com/admin/* URLServer redirect về /pos + log warning
User is_pos_only=true mở app.diva.com/crm/* URLTương tự — redirect
User is_pos_only=true cố call API ngoài POSAPI 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 gianGiới hạnBehavior khi vượt
Mỗi giờ≤2 lầnBanner cảnh báo + log
Mỗi ngày≤5 lầnBlock + đề nghị liên hệ Medical Lead duyệt thủ công
Mỗi tuần≤15 lầnForce 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 sessionUI
Còn > 30 phútBadge xanh "Phiên khẩn cấp còn {MM}:{SS}" header BA
Còn ≤ 30 phútBadge vàng
Còn ≤ 5 phútBadge đỏ + nháy nhẹ
Hết hạnModal "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

LayerTTLInvalidation trigger
FE in-memory store (Pinia)60s(1) timer; (2) WebSocket push event permission.changed; (3) user-triggered refetch
BE Redis session cache60s 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

EventFE actionBE action
Admin grant/revoke action qua Permission UIReceive WS push → reload store cho ảnh hưởng userPub perm:invalidate:{userID} → Redis cache miss → reload từ DB
User chuyển CNWS push self → reloadPub self
User bị suspendWS push → force logoutPub + invalidate session
Migration deploy permission catalog mớiForce 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ốngBehavior
FE cache nói có quyền, BE trả 403FE 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ácTTL 60s tự sync; trong window: BE lấy DB truth nếu action sensitivity ≥ high
Migration chạy giữa session đang dùngBanner "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 review

P9.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 legacyPlan
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.1Giữ nguyên grant cũ; chỉ append clinical actions mới
User existing không có business role nào trong P2.1Output 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

MigrationRollback
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

Fieldhiddensummaryadmin_sharedfull_tier3emergency_active
clinical_record.idomitreturnreturnreturnreturn
clinical_record.profile_codeomitreturnreturnreturnreturn
clinical_record.visit_log_numberomitreturnreturnreturnreturn
clinical_record.statusomitreturnreturnreturnreturn
clinical_record.form_typeomitreturnreturnreturnreturn
clinical_record.primary_doctor_idomitreturnreturnreturnreturn
clinical_record.created_atomitreturnreturnreturnreturn
clinical_record.icd10_primary_codeomitomitomitreturnreturn + audit
clinical_record.icd10_secondary_codesomitomitomitreturnreturn + audit
clinical_record.diagnosis_descriptionomitomitomitreturnreturn + audit
clinical_record.customer_refused_procedureomitreturnreturnreturnreturn
clinical_record.refusal_reasonomitomit (label only)omit (label only)returnreturn + audit
clinical_record.witness_nurse_idomitomitreturnreturnreturn
clinical_form_instance.form_data (BA DL/TM raw)omitomitomitreturnreturn + audit
clinical_form_instance.form_data (admin_shared forms)omitomitreturn (admin section only)returnreturn
clinical_form_instance.signature_typed_textomitomitreturnreturnreturn
prescription.items[]omitomit (count only)omit (count only)returnreturn + audit
reference_file (type='clinical_form_instance.scan')omitomit (count only)return (URL signed)returnreturn + audit
reference_file (type='clinical_record.before_after')omitomitomitreturn (URL signed)return + audit
treatment_progress_entry.diagnosis_notesomitomitomitreturnreturn + audit
treatment_progress_entry.medical_orderomitomitomitreturnreturn + audit
clinical_record.allergy_summary (label)omitreturn (level only)returnreturnreturn
Safe alert dictionary (level/code/text)omitreturnreturnreturnreturn
customer.full_namemaskreturnreturnreturnreturn
customer.phonemaskmask (last 3)maskreturnreturn

P10.2) Quy ước masking

PatternKhi nào
omitField bị remove khỏi GraphQL response (không trả null)
maskField trả về dạng Đặng *** Sương hoặc 0909***456
returnField trả nguyên bản
return + auditField trả + insert vào medical_record_access_log
count onlyTrả count thay vì list (vd prescription_count: 3 thay vì prescription.items[])
level onlyTrả 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: 100

Tầng 3 không qua Hasura raw query — chỉ qua action get_clinical_record_detail có resolver.


P11) Audit retention + Compliance UI contract

P11.1) Audit retention

Bảng auditRetention hotCold archivePurge
medical_record_access_log1 năm online9 năm S3 GlacierKHÔNG xoá theo TT46/2018
emergency_override_session1 năm9 nămKHÔNG xoá
permission_change_log2 năm8 nămKHÔNG xoá (Sec audit)
clinical_form_instance (paper_fallback audit)1 năm9 nămKHÔ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:

FeatureMô tảDay-1?
List medical_record_access_logBộ lọc user / CN / action / khoảng ngày✓ Day-1
Detail eventXem 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 overrideCTA + lý do bắt buộcPhase 2
Export audit ExcelTheo P11.3✓ Day-1
Anomaly detection dashboardML detect lệch patternPhase 3
Forward to authorityGử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_override trong 1 giờ → Slack alert + email Compliance + Medical Lead
  • User có ≥5 wrong_portal deny trong 1 giờ → Slack alert (gợi ý phishing / cấu hình sai)
  • User có ≥1 hard_deny attempt → 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 IDTênSetupExpected
TC-PERM-HD-01Sale mở view_medical_detail qua APIAdmin nhầm grant action cho role SaleAPI trả 403 + log warning "hard_deny"
TC-PERM-HD-02Sale mở edit_medical_form qua API403
TC-PERM-HD-03Sale mở print qua API403
TC-PERM-HD-04Sale mở upload_scan qua API403
TC-PERM-HD-05Sale mở emergency_override qua API403
TC-PERM-HD-06Sale mở refuse_procedure qua API403
TC-PERM-HD-07Lễ tân POS mở view_medical_detailAdmin grant action lên role Lễ tân403 + log

P12.2) Branch mode tests

TC IDTênSetupExpected
TC-PERM-BM-01branch_mode='self' chỉ thấy record mìnhBS có 5 BA mình + 10 BA CN khácQuery trả 5 record
TC-PERM-BM-02branch_mode='branch' thấy CN gánBS gán CN Cao Lãnh, có 50 BAQuery trả 50
TC-PERM-BM-03branch_mode='multi_branch' thấy 2 CNBS Medical Lead gán CN Cao Lãnh + Tân Bình IIQuery trả union
TC-PERM-BM-04branch_mode='all' thấy toàn chuỗiAdmin/OpsKhông filter branch
TC-PERM-BM-05Branch_mode user 1 CN xem CN khác → tầng 3 = NULLBS Cao Lãnh xem KH ở Tân Bình IItầng 1+2 OK; tầng 3 omit

P12.3) Portal isolation tests

TC IDTênSetupExpected
TC-PERM-PO-01User Admin grant action POS-only mở Admin routeAdmin nhầm grant intake.open_session cho user only POSAdmin URL → 403 wrong_portal
TC-PERM-PO-02Sale CRM mở Admin route → 403Sale có quyền view_safe_summary CRMAdmin URL trả 403
TC-PERM-PO-03is_pos_only=true cố mở Admin URLLễ tân POS-onlyServer redirect /pos + log
TC-PERM-PO-04is_pos_only=true API call ngoài POSAPI 403 pos_only_violation

P12.4) Multi-role tests

TC IDTênSetupExpected
TC-PERM-MR-01User vừa BS vừa Manager → effective unionBS có view_medical_detail, Manager có export_visit_logUser có cả 2
TC-PERM-MR-02Multi-role với branch_mode khác → max scopeBS branch + Manager multi_brancheffective = multi_branch
TC-PERM-MR-03is_pos_only chỉ true nếu MỌI role POS-only1 role POS-only + 1 role Admineffective = false (vào được Admin)

P12.5) Cache invalidation tests

TC IDTênSetupExpected
TC-PERM-CI-01Grant action xong, user thấy menu mới sau ≤60sAdmin grant qua Permission UIUser refresh trong 60s thấy menu
TC-PERM-CI-02Revoke giữa session → request kế trả 403User đang ở SCR-06Save → 403 + toast "Quyền đã thay đổi"
TC-PERM-CI-03WS push → FE update không cần refreshAdmin grant + WS healthyFE store update tự động
TC-PERM-CI-04WS down → fallback TTL 60sWS disconnectUser vẫn nhận update sau 60s

P12.6) Emergency override tests

TC IDTênSetupExpected
TC-PERM-EO-01BS mở emergency lý do <30 ký tựLý do "ngắn"Validation error
TC-PERM-EO-02BS 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-03Session active → xem được tầng 3View full + audit log
TC-PERM-EO-04Session expire 1h → mất quyềnĐợi 1hAPI trả 403 + UI ẩn tầng 3
TC-PERM-EO-05Rate limit 5/ngày → blockBS đã dùng 5 lầnLần thứ 6 → block + đề nghị Medical Lead
TC-PERM-EO-06Notification escalationMở emergencyMedical Lead + QL CN + Compliance + Admin nhận
TC-PERM-EO-07Manual revoke bởi AdminSession đang activeUser mất quyền ngay + log
TC-PERM-EO-08Cron auto-expireSession quá 1hclosed_at set + closed_by='cron_expired'

P12.7) Field masking tests

TC IDTênSetupExpected
TC-PERM-MASK-01view_mode=summary trả tầng 1+2 onlyBS khác CNicd10/diagnosis omit
TC-PERM-MASK-02view_mode=admin_shared cho Y táY tá cùng CNHành chính + dị ứng OK; diagnosis omit
TC-PERM-MASK-03view_mode=full_tier3 cho BSBS cùng CNĐầy đủ + audit
TC-PERM-MASK-04view_mode=hidden = field omit khỏi responseSaleresponse không có field tầng 3 (không phải null)
TC-PERM-MASK-05Customer phone masked theo view_modeSalephone = "0909***456"
TC-PERM-MASK-06prescription count only cho summaryview_summaryprescription_count: 3, không có items[]

P12.8) Migration tests

TC IDTênSetupExpected
TC-PERM-MIG-01Migration M105 seed defaultFresh staging9 role × 22 action với grant đúng P2.1
TC-PERM-MIG-02Migration không ghi đè grant cũUser existing đã có grant ngoài defaultGrant cũ giữ; chỉ append clinical
TC-PERM-MIG-03Rollback M105Trước D0Down migration thành công
TC-PERM-MIG-04Audit report user thiếu roleUser không có role business chuẩnOutput report với danh sách user → admin review

P12.9) Compliance UI tests (SCR-18, có thể Phase 2)

TC IDTênExpected
TC-PERM-CO-01Compliance officer xem audit listHiển thị log filter được
TC-PERM-CO-02Export audit ExcelFile với 4 sheet, watermark
TC-PERM-CO-03Anomaly auto-alertSlack alert trong 15 phút

P12.10) Tổng số TC permission

NhómSố TCPhase
Hard deny (HD)7Phase 4
Branch mode (BM)5Phase 4
Portal isolation (PO)4Phase 4
Multi-role (MR)3Phase 4
Cache invalidation (CI)4Phase 4
Emergency override (EO)8Phase 4
Field masking (MASK)6Phase 4
Migration (MIG)4Phase 1
Compliance UI (CO)3Phase 2
Tổng44 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

FileSection liên quanQuy ước
SOURCE_OF_TRUTH.mdmục 5.2 Permission v2 catalogĐồng bộ với P1.1 — nếu lệch, SoT thắng
prd.mdFR-011 + FR-016/017/018/019PRD trỏ về P1-P12; AC-011.* trỏ về TC-PERM-*
ui-spec.mdB6 + B2.8/B2.8A + SCR-18 (mới)UI gating đọc P4 constants; SCR-18 Compliance UI theo P11.2
dev-spec.mdC8.0 + C8.1 + C5.9 emergency_overrideImplementation theo P3 resolver + P9 migration; C5.9 mở rộng theo P7
qa-test-plan.mdD2.11 + D3.4 + D6Bổ sung 44 TC-PERM-* theo P12
_consistency-matrix.mdSection 5 Permission × ActionTrỏ về P2 default seed cho default seed
handoff.mdB-12A + B-16 + RACIPermission seed + B-16 (refuse_procedure) phải khớp P9 migration
go-live-checklist.mdE1 P0 permission gatesToàn bộ TC-PERM-* P0 đạt trước go-live

Phụ lục B — Câu hỏi còn mở

IDCâu hỏiPhụ tráchHạn
PD-PERM-001Compliance officer là role mới hay extend role admin?PO + Security LeadTrước Phase 1
PD-PERM-002WebSocket push permission change — infra sẵn sàng Day-1 hay fallback TTL 60s?Infra LeadTrước Phase 4
PD-PERM-003Rate limit emergency_override 5/ngày có nghiêm ngặt cho Y tá trưởng không?Medical LeadTrước pilot
PD-PERM-004SCR-18 Compliance UI Day-1 hay Phase 2?POTrước Phase 1 sprint planning
PD-PERM-005Anomaly auto-alert đẩy về Slack channel nào?Ops + SecurityTrướ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.