Stage 4:多輪稽核設計規格書

日期:2026-03-24 狀態:Draft


§1

一、目標

實作多輪稽核歷史保留:每次 close-round 建立新一輪 ar_data,保留前一輪完整快照,支援 round 切換瀏覽與歷史 findings 參考。


§2

二、設計決策摘要

決策 結論 理由
多輪資料結構 每輪建新 ar_data(run_no 遞增) 符合 OSCAL 規範,不可變審計軌跡
已通過控制項 繼承上一輪 verdict 覆核只看矯正部分,非完整重新稽核
Findings 處理 不複製到新一輪 避免資料膨脹、POA&M ar_finding_id 斷裂
歷史 findings 查閱 AR control detail 回傳 previous_findings 供不同稽核員瞭解前次缺失
歷史輪次保護 後端驗證,非最新輪禁止修改 審計合規要求,不能只靠前端控制
跨輪比較 不做(未來需要時再加) YAGNI,資料基礎已具備

§3

三、完整流程

Round 1(首次稽核 — launch_audit)
  建立 ar_data (run_no=1) + 15 個 ar_controls (verdict=null)
  稽核員填寫 verdict + findings
  confirm_audit → 5 fail → AP=remediation,產生 5 筆 POA&M

  PM 處理 POA&M → 全部 closed

Round 2(覆核 — close_round)
  建立 ar_data (run_no=2) + 15 個 ar_controls
    - 10 個 pass/na 控制項:verdict 繼承 Round 1
    - 5 個有 POA&M 的控制項:verdict=null(待覆核)
  稽核員覆核(可查看 Round 1 的 previous_findings)
  confirm_audit → 2 fail → AP=remediation,產生 2 筆新 POA&M

  PM 處理 POA&M → 全部 closed

Round 3(第二次覆核 — close_round)
  建立 ar_data (run_no=3) + 15 個 ar_controls
    - 13 個繼承
    - 2 個 verdict=null
  稽核員覆核
  confirm_audit → 全 pass → AP=closed(結案)

§4

四、close_round 邏輯變更

Stage 3(被取代)

重置同一份 ar_data (run_no=1) 上的 verdict = null

Stage 4(新邏輯)

def close_round(ap_uid, curr_user):
    # 1. 驗證 AP status = remediation
    # 2. 驗證所有 POA&M = closed
    # 3. 查當前 ar_data(最大 run_no = N)
    # 4. 收集有 POA&M 的 control_identifiers
    # 5. 建立新 ar_data (run_no = N+1)
    # 6. 複製 ar_controls 到新一輪:
    #    - control_id 在 POA&M 集合中 → verdict=null, 清除 confidence/rationale/remarks
    #    - control_id 不在 POA&M 集合中 → 完整繼承 verdict/confidence/rationale/remarks
    # 7. AP status → auditing
    # 回傳 {ap_uid, status, run_no: N+1, reset_count, inherited_count}

差異對照

項目 Stage 3 Stage 4
ar_data 複用 run_no=1 新建 run_no=N+1
未通過的 ar_controls 重置 verdict 複製到新輪,verdict=null
已通過的 ar_controls 不動 複製到新輪,verdict 繼承
歷史保留 無(覆蓋) 完整保留每一輪

§5

五、confirm_audit 修改

僅一處改動:查 ar_data 時從硬編碼 run_no == 1 改為查最新 run_no。

# Before (Stage 2/3)
ar_data = session.query(OscalAssessmentResultData).filter(
    OscalAssessmentResultData.assessment_result_id == ar.id,
    OscalAssessmentResultData.run_no == 1,
).first()

# After (Stage 4)
ar_data = session.query(OscalAssessmentResultData).filter(
    OscalAssessmentResultData.assessment_result_id == ar.id,
).order_by(OscalAssessmentResultData.run_no.desc()).first()

launch_audit 不改(首次啟動建 run_no=1,邏輯不受影響)。


§6

六、API 設計

所有 API 在 AP-scoped 路徑下:/api/1.0/grc/project/<project_uid>/ap/<ap_uid>/...

6.1 新增:Round 列表

GET /api/1.0/grc/project/<project_uid>/ap/<ap_uid>/ar/rounds

Response:

{
  "code": 1,
  "data": [
    {
      "run_no": 1,
      "title": "稽核執行",
      "started_at": "2026-03-10T09:00:00",
      "completed_at": "2026-03-15T17:00:00",
      "total_controls": 15,
      "pass_count": 10,
      "fail_count": 4,
      "partial_count": 1,
      "na_count": 0,
      "pending_count": 0,
      "is_current": false
    },
    {
      "run_no": 2,
      "title": "覆核",
      "started_at": "2026-03-20T09:00:00",
      "completed_at": null,
      "total_controls": 15,
      "pass_count": 10,
      "fail_count": 0,
      "partial_count": 0,
      "na_count": 0,
      "pending_count": 5,
      "is_current": true
    }
  ]
}

is_current:標記最新一輪(唯一可編輯的輪次)。

verdict 統計從 ar_controls 即時聚合:

  • pass_count:verdict = "pass"
  • fail_count:verdict = "fail"
  • partial_count:verdict = "partial"
  • na_count:verdict = "na"
  • pending_count:verdict IS NULL

6.2 修改:AR controls list

POST /api/1.0/grc/project/<project_uid>/ap/<ap_uid>/ar/controls/list

Request 新增欄位:

{
  "run_no": 2,
  "pager": { "page": 1, "page_size": 25 },
  "sort": [{ "field": "control_id", "order": "asc" }],
  "filters": { "verdict": "fail" }
}
  • run_no:optional,預設最新一輪
  • 其餘欄位不變

Response 新增欄位:

每筆 ar_control 多回傳 is_inherited(Boolean),標記該筆 verdict 是否繼承自上一輪。

{
  "ar_control_uid": "uuid",
  "control_id": "AC.L2-3.1.1",
  "control_title": "...",
  "verdict": "pass",
  "is_inherited": true
}

is_inherited 計算邏輯:run_no=1 時全部為 false。run_no>1 時,verdict 非 null 且該 control_id 未出現在當輪的 POA&M 集合中 → true。

6.3 修改:AR control detail

GET /api/1.0/grc/project/<project_uid>/ap/<ap_uid>/ar/control/<ar_control_uid>

Response 新增欄位:

{
  "data": {
    "ar_control_uid": "uuid",
    "control_id": "AC.L2-3.1.1",
    "verdict": null,
    "findings": [],
    "previous_findings": [
      {
        "uid": "finding-uuid",
        "title": "存取控制政策未完整",
        "category": "deficiency",
        "severity": "high",
        "description": "未能提供完整的存取控制政策文件...",
        "recommendation": "建議補充政策文件...",
        "run_no": 1
      }
    ]
  }
}
  • previous_findings:上一輪同 control_id 的 findings,唯讀
  • run_no=1 時為空陣列
  • 查詢邏輯:從當前 ar_control 的 ar_data 找 run_no,查 run_no-1 的 ar_data,找同 control_id 的 ar_control,撈其 findings

6.4 修改:close-round Response

{
  "code": 1,
  "data": {
    "ap_uid": "ap-uuid",
    "status": "auditing",
    "run_no": 2,
    "reset_count": 5,
    "inherited_count": 10
  }
}

新增 run_no(新建的輪次)和 inherited_count(繼承的控制項數量)。


§7

七、歷史輪次保護

Helper function

def _check_latest_round(ar_control_uid: str):
    """驗證 ar_control 屬於最新一輪 ar_data,否則 412"""

查詢邏輯:

  1. 從 ar_control_uid 查到所屬 ar_data
  2. 從 ar_data 查到所屬 assessment_result
  3. 查該 assessment_result 下最大 run_no 的 ar_data
  4. 比對是否為同一筆 ar_data,不是則 raise 412

套用位置

API 呼叫 _check_latest_round
PUT .../ar/control//verdict
POST .../ar/control//findings
PUT .../ar/finding/ 是(透過 finding → ar_control 反查)
DELETE .../ar/finding/
GET .../ar/controls/list 否(唯讀)
GET .../ar/control/ 否(唯讀)

§8

八、Error Codes

新增至 common/code/grc_error_code.py

Code 中文 Error Code
GRC_AR_NOT_LATEST_ROUND 只能操作最新一輪的稽核結果 GRC_412008

§9

九、DDD 分層架構

新增檔案

Layer 檔案 說明
Route api/grc/routes/audit_route.py 內新增 ArRoundListResource Round 列表
Serializer api/grc/serializers/audit.py 內新增 schemas Round response + request 修改

修改檔案

檔案 變更
app/project/service/oscal_audit_service.py close_round 重寫 + confirm_audit 改 run_no 查詢
app/grc/service/audit_service.py list_ar_controls 支援 run_no 參數 + get_ar_control_detail 加 previous_findings + _check_latest_round
api/grc/serializers/audit.py AR control request/response 加欄位 + round schemas
api/grc/routes/audit_route.py verdict/finding routes 加 _check_latest_round + 新增 ArRoundListResource
api/grc/__init__.py 註冊 round 列表 URL
api/grc/serializers/poam.py CloseRoundResponseSchema 加 run_no / inherited_count
common/code/grc_error_code.py 新增 1 個 error code
infra/grc/repository/grc_audit_repo_impl.py list_ar_controls 支援 run_no 參數

§10

十、與 Stage 2/3 的關係

取代 Stage 3 close_round

Stage 3 的 close_round(重置 verdict)被 Stage 4 完全取代。因為 Stage 3 尚未 deploy,無向下相容問題。

confirm_audit 改動影響

confirm_audit 的 run_no==1 改為查最新 run_no。這影響 Stage 2 的程式碼,但語意完全相容 — 首次稽核時最新 run_no 就是 1,行為不變。

launch_audit 不動

首次啟動建 run_no=1 的邏輯不受影響。

POA&M 不受影響

POA&M 的 ar_finding_id 始終指向原始 round 的 finding。Findings 不複製到新輪,所以 FK 關係不會斷裂。