日期:2026-03-24 狀態:Draft
實作多輪稽核歷史保留:每次 close-round 建立新一輪 ar_data,保留前一輪完整快照,支援 round 切換瀏覽與歷史 findings 參考。
| 決策 | 結論 | 理由 |
|---|---|---|
| 多輪資料結構 | 每輪建新 ar_data(run_no 遞增) | 符合 OSCAL 規範,不可變審計軌跡 |
| 已通過控制項 | 繼承上一輪 verdict | 覆核只看矯正部分,非完整重新稽核 |
| Findings 處理 | 不複製到新一輪 | 避免資料膨脹、POA&M ar_finding_id 斷裂 |
| 歷史 findings 查閱 | AR control detail 回傳 previous_findings | 供不同稽核員瞭解前次缺失 |
| 歷史輪次保護 | 後端驗證,非最新輪禁止修改 | 審計合規要求,不能只靠前端控制 |
| 跨輪比較 | 不做(未來需要時再加) | YAGNI,資料基礎已具備 |
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(結案)
重置同一份 ar_data (run_no=1) 上的 verdict = null
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 繼承 |
| 歷史保留 | 無(覆蓋) | 完整保留每一輪 |
僅一處改動:查 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,邏輯不受影響)。
所有 API 在 AP-scoped 路徑下:/api/1.0/grc/project/<project_uid>/ap/<ap_uid>/...
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 NULLPOST /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。
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,唯讀{
"code": 1,
"data": {
"ap_uid": "ap-uuid",
"status": "auditing",
"run_no": 2,
"reset_count": 5,
"inherited_count": 10
}
}新增 run_no(新建的輪次)和 inherited_count(繼承的控制項數量)。
def _check_latest_round(ar_control_uid: str):
"""驗證 ar_control 屬於最新一輪 ar_data,否則 412"""查詢邏輯:
| API | 呼叫 _check_latest_round |
|---|---|
| PUT .../ar/control/ |
是 |
| POST .../ar/control/ |
是 |
| PUT .../ar/finding/ |
是(透過 finding → ar_control 反查) |
| DELETE .../ar/finding/ |
是 |
| GET .../ar/controls/list | 否(唯讀) |
| GET .../ar/control/ |
否(唯讀) |
新增至 common/code/grc_error_code.py:
| Code | 中文 | Error Code |
|---|---|---|
| GRC_AR_NOT_LATEST_ROUND | 只能操作最新一輪的稽核結果 | GRC_412008 |
| 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 參數 |
Stage 3 的 close_round(重置 verdict)被 Stage 4 完全取代。因為 Stage 3 尚未 deploy,無向下相容問題。
confirm_audit 的 run_no==1 改為查最新 run_no。這影響 Stage 2 的程式碼,但語意完全相容 — 首次稽核時最新 run_no 就是 1,行為不變。
首次啟動建 run_no=1 的邏輯不受影響。
POA&M 的 ar_finding_id 始終指向原始 round 的 finding。Findings 不複製到新輪,所以 FK 關係不會斷裂。