For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 實作 POA&M CRUD(列表/詳情/更新)+ close-round(觸發覆核),銜接 Stage 2 的稽核迴圈。
Architecture: 擴充現有 POA&M 基礎建設(Stage 2 已建 Model/Entity/Mapper/Repo),新增 Domain Service + App Service + Serializer + Route。close-round 放在 OscalAuditService,與 launch_audit / confirm_audit 並列。
Tech Stack: Python 3.11, Flask-RESTful, SQLAlchemy 2.0, dependency-injector, marshmallow, jedi-oscal, jedi-flow-engine
| 檔案 | 職責 |
|---|---|
scripts/sql/stage3_migration.sql |
DDL:3 個 ALTER TABLE ADD COLUMN |
domain/grc/service/grc_poam_domain_service.py |
POA&M domain service,委派 repo |
app/grc/dto/poam_dto.py |
POA&M DTO(列表 + 詳情) |
app/grc/service/poam_service.py |
POA&M App Service(CRUD + 狀態驗證) |
api/grc/serializers/poam.py |
Marshmallow Request/Response schemas |
api/grc/routes/poam_route.py |
3 個 Flask Resource(List/Detail/Update) |
| 檔案 | 變更 |
|---|---|
infra/grc/model/poam_model.py |
加 3 個 column |
domain/grc/entities/poam_entity.py |
加 3 個欄位 |
infra/grc/mapper/poam_mapper.py |
映射新欄位 |
domain/grc/repository/i_poam_repo.py |
新增 3 個方法簽名 |
infra/grc/repository/poam_repo_impl.py |
實作 list/get/update + JOIN |
common/code/grc_error_code.py |
新增 5 個 error codes |
app/project/service/oscal_audit_service.py |
新增 close_round 方法 |
di_containers/grc/grc_containers.py |
加 poam_domain_service / poam_service |
api/grc/routes/audit_route.py |
新增 CloseRoundResource |
api/grc/__init__.py |
註冊 4 個 URL |
Files:
scripts/sql/stage3_migration.sqlcommon/code/grc_error_code.py-- Stage 3: POA&M Remediation
ALTER TABLE compliance.poams ADD COLUMN IF NOT EXISTS remediation_plan TEXT;
ALTER TABLE compliance.poams ADD COLUMN IF NOT EXISTS due_date DATE;
ALTER TABLE compliance.poams ADD COLUMN IF NOT EXISTS assignee_uid VARCHAR(36);儲存至 scripts/sql/stage3_migration.sql。
在 common/code/grc_error_code.py 的 class 尾部新增:
# ── POA&M ────────────────────────────────────────────────────────────
GRC_POAM_NOT_FOUND = ("POA&M 不存在", "GRC_404019")
GRC_POAM_INVALID_STATUS_TRANSITION = ("POA&M 狀態轉換不合法", "GRC_412006")
GRC_POAM_REMEDIATION_PLAN_REQUIRED = ("需填寫矯正方案", "GRC_412007")
# ── Close Round ──────────────────────────────────────────────────────
GRC_AP_NOT_REMEDIATION = ("稽核計畫不在矯正中狀態", "GRC_412004")
GRC_POAM_NOT_ALL_CLOSED = ("尚有 POA&M 未結案", "GRC_412005")Files:
infra/grc/model/poam_model.pydomain/grc/entities/poam_entity.pyinfra/grc/mapper/poam_mapper.py在 infra/grc/model/poam_model.py 的 org_unit_id 後面新增:
remediation_plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
due_date: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True)
assignee_uid: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)頂部 import 加上 Text, Date:
from sqlalchemy import String, Integer, DateTime, Text, Datedatetime import 補上 date(如果 due_date 用 Date type,Mapped 型別用 date):
from datetime import datetime, date
# due_date 改為:
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)在 domain/grc/entities/poam_entity.py 的 __init__ 中,closed_at=None 之後加入:
remediation_plan=None,
due_date=None,
assignee_uid=None,在 self.closed_at = closed_at 之後加入:
self.remediation_plan = remediation_plan
self.due_date = due_date
self.assignee_uid = assignee_uid在 infra/grc/mapper/poam_mapper.py 的 to_entity 中,closed_at 之後加入:
remediation_plan=model.remediation_plan,
due_date=model.due_date,
assignee_uid=model.assignee_uid,在 to_model 中,status=entity.status 之後加入:
remediation_plan=entity.remediation_plan,
due_date=entity.due_date,
assignee_uid=entity.assignee_uid,Files:
domain/grc/repository/i_poam_repo.pyinfra/grc/repository/poam_repo_impl.pyfrom abc import abstractmethod
from typing import List, Optional
from jedi_common.interfaces.entities import PageDataEntity
from jedi_common.interfaces.spec.base_query_spec import PageSpec, SortSpec
from domain.grc.entities.poam_entity import PoamEntity
class IPoamRepo:
@abstractmethod
def batch_create(self, entities: List[PoamEntity]) -> List[PoamEntity]:
pass
@abstractmethod
def list_poams(
self, ap_uid: str, page_spec: Optional[PageSpec],
sorts: Optional[List[SortSpec]], **filters
) -> PageDataEntity:
pass
@abstractmethod
def get_poam(self, ap_uid: str, poam_uid: str) -> Optional[PoamEntity]:
pass
@abstractmethod
def update_poam(self, poam_uid: str, updates: dict, curr_user: str) -> Optional[PoamEntity]:
pass覆寫整個 infra/grc/repository/poam_repo_impl.py:
import math
import uuid
from datetime import datetime
from typing import List, Optional
from sqlalchemy import func as sa_func
from jedi_common.interfaces.entities import PageDataEntity, PageMetaEntity
from jedi_common.interfaces.spec.base_query_spec import PageSpec, SortSpec
from jedi_common.session.database.session_context import get_session
from jedi_oscal.infra.model.ap.assessment_plan import OscalAssessmentPlan
from jedi_oscal.infra.model.ar.assessment_result_finding import OscalAssessmentResultFinding
from domain.grc.entities.poam_entity import PoamEntity
from domain.grc.repository.i_poam_repo import IPoamRepo
from infra.grc.model.poam_model import PoamModel
from infra.grc.mapper.poam_mapper import PoamMapper
class PoamRepoImpl(IPoamRepo):
@property
def session(self):
return get_session()
# ── batch_create(Stage 2 原有) ──────────────────────────────────────
def batch_create(self, entities: List[PoamEntity]) -> List[PoamEntity]:
models = []
for entity in entities:
entity.uid = str(uuid.uuid4())
model = PoamMapper.to_model(entity)
self.session.add(model)
models.append(model)
self.session.flush()
return [PoamMapper.to_entity(m) for m in models]
# ── list_poams ────────────────────────────────────────────────────────
def list_poams(
self, ap_uid: str, page_spec: Optional[PageSpec],
sorts: Optional[List[SortSpec]], **filters
) -> PageDataEntity:
page = page_spec.page if page_spec else 1
page_size = page_spec.page_size if page_spec else 25
# 取得 AP id
ap = self.session.query(OscalAssessmentPlan.id).filter(
OscalAssessmentPlan.uid == ap_uid
).first()
if ap is None:
return PageDataEntity(
meta=PageMetaEntity(paging=True, page=page, page_size=page_size,
total=0, total_pages=0, has_next=False, has_prev=False),
data=[],
)
# 主查詢:POA&M LEFT JOIN finding(取 title/severity/category)
from jedi_auth.infra.models.user import User
query = (
self.session.query(
PoamModel,
OscalAssessmentResultFinding.title.label("finding_title"),
OscalAssessmentResultFinding.severity.label("finding_severity"),
OscalAssessmentResultFinding.category.label("finding_category"),
User.nickname.label("assignee_name"),
)
.outerjoin(
OscalAssessmentResultFinding,
OscalAssessmentResultFinding.id == PoamModel.ar_finding_id,
)
.outerjoin(
User,
User.uid == PoamModel.assignee_uid,
)
.filter(PoamModel.assessment_plan_id == ap.id)
)
# filters
status = filters.get("status")
if status:
query = query.filter(PoamModel.status == status)
search = filters.get("search")
if search:
like = f"%{search}%"
query = query.filter(
sa_func.coalesce(PoamModel.control_identifier, "").ilike(like)
| sa_func.coalesce(OscalAssessmentResultFinding.title, "").ilike(like)
)
# sort
if sorts:
for s in sorts:
col = getattr(PoamModel, s.field, None)
if col is not None:
query = query.order_by(col.desc() if s.order == "desc" else col.asc())
else:
query = query.order_by(PoamModel.created_at.desc())
total = query.count()
rows = query.offset((page - 1) * page_size).limit(page_size).all()
entities = []
for row in rows:
poam_model = row[0]
entity = PoamMapper.to_entity(poam_model)
entity.finding_title = row.finding_title
entity.finding_severity = row.finding_severity
entity.finding_category = row.finding_category
entity.assignee_name = row.assignee_name
entities.append(entity)
return PageDataEntity(
meta=PageMetaEntity(
paging=True, page=page, page_size=page_size,
total=total,
total_pages=math.ceil(total / page_size) if page_size else 0,
has_next=page * page_size < total,
has_prev=page > 1,
),
data=entities,
)
# ── get_poam ──────────────────────────────────────────────────────────
def get_poam(self, ap_uid: str, poam_uid: str) -> Optional[PoamEntity]:
from jedi_auth.infra.models.user import User
ap = self.session.query(OscalAssessmentPlan.id).filter(
OscalAssessmentPlan.uid == ap_uid
).first()
if ap is None:
return None
row = (
self.session.query(
PoamModel,
User.nickname.label("assignee_name"),
)
.outerjoin(User, User.uid == PoamModel.assignee_uid)
.filter(
PoamModel.uid == poam_uid,
PoamModel.assessment_plan_id == ap.id,
)
.first()
)
if row is None:
return None
entity = PoamMapper.to_entity(row[0])
entity.assignee_name = row.assignee_name
return entity
# ── update_poam ───────────────────────────────────────────────────────
def update_poam(self, poam_uid: str, updates: dict, curr_user: str) -> Optional[PoamEntity]:
model = self.session.query(PoamModel).filter(PoamModel.uid == poam_uid).first()
if model is None:
return None
for key, value in updates.items():
if hasattr(model, key):
setattr(model, key, value)
model.updated_user = curr_user
self.session.flush()
return PoamMapper.to_entity(model)Files:
domain/grc/service/grc_poam_domain_service.pyfrom typing import Optional, List
from jedi_common.interfaces.entities import PageDataEntity
from jedi_common.interfaces.spec.base_query_spec import PageSpec, SortSpec
from domain.grc.entities.poam_entity import PoamEntity
from domain.grc.repository.i_poam_repo import IPoamRepo
class GrcPoamDomainService:
def __init__(self, poam_repo: IPoamRepo):
self._repo = poam_repo
def list_poams(
self, ap_uid: str, page_spec: Optional[PageSpec],
sorts: Optional[List[SortSpec]], **filters
) -> PageDataEntity:
return self._repo.list_poams(ap_uid, page_spec, sorts, **filters)
def get_poam(self, ap_uid: str, poam_uid: str) -> Optional[PoamEntity]:
return self._repo.get_poam(ap_uid, poam_uid)
def update_poam(self, poam_uid: str, updates: dict, curr_user: str) -> Optional[PoamEntity]:
return self._repo.update_poam(poam_uid, updates, curr_user)
def batch_create(self, entities: List[PoamEntity]) -> List[PoamEntity]:
return self._repo.batch_create(entities)Files:
app/grc/dto/poam_dto.pyapp/grc/service/poam_service.pyfrom dataclasses import dataclass, field
from typing import Optional
from datetime import datetime, date
@dataclass
class PoamFindingDto:
uid: str
title: Optional[str] = None
category: Optional[str] = None
severity: Optional[str] = None
description: Optional[str] = None
recommendation: Optional[str] = None
@staticmethod
def from_finding_model(model) -> "PoamFindingDto":
return PoamFindingDto(
uid=str(model.uid),
title=model.title,
category=model.category,
severity=model.severity,
description=model.description,
recommendation=model.recommendation,
)
@dataclass
class PoamJobInfoDto:
workflow_execution_id: Optional[int] = None
job_uid: Optional[str] = None
job_status: Optional[str] = None
@dataclass
class PoamDto:
uid: str
control_identifier: Optional[str] = None
ao_uid: Optional[str] = None
status: Optional[str] = None
remediation_plan: Optional[str] = None
due_date: Optional[date] = None
assignee_uid: Optional[str] = None
assignee_name: Optional[str] = None
closed_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# 列表用(JOIN 來的)
finding_title: Optional[str] = None
finding_severity: Optional[str] = None
finding_category: Optional[str] = None
@staticmethod
def from_entity(entity) -> "PoamDto":
return PoamDto(
uid=entity.uid,
control_identifier=entity.control_identifier,
ao_uid=entity.ao_uid,
status=entity.status,
remediation_plan=entity.remediation_plan,
due_date=entity.due_date,
assignee_uid=entity.assignee_uid,
assignee_name=getattr(entity, "assignee_name", None),
closed_at=entity.closed_at,
created_at=entity.created_at,
updated_at=entity.updated_at,
finding_title=getattr(entity, "finding_title", None),
finding_severity=getattr(entity, "finding_severity", None),
finding_category=getattr(entity, "finding_category", None),
)
@staticmethod
def from_entity_list(entities) -> list:
return [PoamDto.from_entity(e) for e in entities]
@dataclass
class PoamDetailDto(PoamDto):
finding: Optional[PoamFindingDto] = None
job_info: Optional[PoamJobInfoDto] = Nonefrom datetime import datetime
from typing import Optional
from jedi_common.handler.exception import NotFound, PreconditionFailedError
from jedi_common.interfaces.dto import PageDataDto, PageDto
from jedi_common.interfaces.spec.base_query_spec import PageSpec, SortSpec
from jedi_common.session.database.db import transaction
from jedi_common.session.database.session_context import get_session
from app.grc.dto.poam_dto import PoamDto, PoamDetailDto, PoamFindingDto, PoamJobInfoDto
from common.code.grc_error_code import GrcErrorCode
from domain.grc.service.grc_poam_domain_service import GrcPoamDomainService
# 合法狀態轉換
_VALID_TRANSITIONS = {
("open", "in_progress"),
("open", "closed"),
("in_progress", "closed"),
("closed", "in_progress"),
}
class PoamService:
def __init__(self, grc_poam_domain_service: GrcPoamDomainService):
self._domain_service = grc_poam_domain_service
@transaction
def list_poams(self, ap_uid: str, pager: dict, sorts: list, **filters) -> PageDataDto:
page_spec = PageSpec(**pager) if pager else None
sorts_spec = [SortSpec(**s) for s in sorts] if sorts else None
result = self._domain_service.list_poams(ap_uid, page_spec, sorts_spec, **filters)
return PageDataDto(
meta=PageDto.from_entity(result.meta),
data=PoamDto.from_entity_list(result.data),
)
@transaction
def get_poam_detail(self, ap_uid: str, poam_uid: str) -> Optional[PoamDetailDto]:
entity = self._domain_service.get_poam(ap_uid, poam_uid)
if entity is None:
return None
base = PoamDto.from_entity(entity)
# 查 finding 詳情
finding_dto = None
if entity.ar_finding_id:
finding_dto = self._load_finding(entity.ar_finding_id)
# 查 job info
job_info_dto = None
if entity.ao_uid:
job_info_dto = self._load_job_info(entity.ao_uid)
return PoamDetailDto(
**{k: v for k, v in base.__dict__.items()},
finding=finding_dto,
job_info=job_info_dto,
)
@transaction
def update_poam(self, ap_uid: str, poam_uid: str, curr_user: str, **data) -> PoamDto:
entity = self._domain_service.get_poam(ap_uid, poam_uid)
if entity is None:
raise NotFound(GrcErrorCode.GRC_POAM_NOT_FOUND)
new_status = data.get("status")
# 狀態轉換驗證
if new_status and new_status != entity.status:
if (entity.status, new_status) not in _VALID_TRANSITIONS:
raise PreconditionFailedError(GrcErrorCode.GRC_POAM_INVALID_STATUS_TRANSITION)
# open → in_progress 需要 remediation_plan
if entity.status == "open" and new_status == "in_progress":
plan = data.get("remediation_plan") or entity.remediation_plan
if not plan:
raise PreconditionFailedError(GrcErrorCode.GRC_POAM_REMEDIATION_PLAN_REQUIRED)
# 組 updates dict
updates = {}
for field in ("status", "remediation_plan", "due_date", "assignee_uid"):
if field in data and data[field] is not None:
updates[field] = data[field]
# 自動寫入 / 清除 closed_at
if new_status == "closed":
updates["closed_at"] = datetime.now()
elif new_status and new_status != "closed" and entity.closed_at:
updates["closed_at"] = None
updated = self._domain_service.update_poam(poam_uid, updates, curr_user)
return PoamDto.from_entity(updated)
def _load_finding(self, ar_finding_id: int) -> Optional[PoamFindingDto]:
from jedi_oscal.infra.model.ar.assessment_result_finding import OscalAssessmentResultFinding
session = get_session()
finding = session.query(OscalAssessmentResultFinding).filter(
OscalAssessmentResultFinding.id == ar_finding_id
).first()
if finding is None:
return None
return PoamFindingDto.from_finding_model(finding)
def _load_job_info(self, ao_uid: str) -> Optional[PoamJobInfoDto]:
from jedi_oscal.infra.model.ap.assessment_plan_task import OscalAssessmentPlanTask
from infra.project.models.assessment_plan_task_workflow_execution_mapping import (
AssessmentPlanTaskWorkflowExecutionMapping,
)
from jedi_flow_engine.infra.models.job_execution import JobExecution
session = get_session()
task = session.query(OscalAssessmentPlanTask).filter(
OscalAssessmentPlanTask.uid == ao_uid
).first()
if task is None:
return None
mapping = session.query(AssessmentPlanTaskWorkflowExecutionMapping).filter(
AssessmentPlanTaskWorkflowExecutionMapping.assessment_plan_task_id == task.id
).first()
if mapping is None:
return None
job = (
session.query(JobExecution)
.filter(
JobExecution.workflow_execution_id == mapping.workflow_execution_id,
JobExecution.type == "USER",
)
.order_by(JobExecution.id.desc())
.first()
)
if job is None:
return None
return PoamJobInfoDto(
workflow_execution_id=mapping.workflow_execution_id,
job_uid=job.uid,
job_status=job.status,
)Files:
api/grc/serializers/poam.pyapi/grc/routes/poam_route.pyfrom marshmallow import Schema, fields
from jedi_common.interfaces.schema.common import RequestMetaSchema, EnvelopeSchema
# ── Filters ──
class PoamFiltersSchema(Schema):
status = fields.String(load_default=None, allow_none=True)
search = fields.String(load_default=None, allow_none=True)
# ── Request ──
class PoamListRequestSchema(RequestMetaSchema):
filters = fields.Nested(PoamFiltersSchema, missing={}, allow_none=True)
class PoamUpdateRequestSchema(Schema):
status = fields.String(load_default=None, allow_none=True)
remediation_plan = fields.String(load_default=None, allow_none=True)
due_date = fields.Date(load_default=None, allow_none=True)
assignee_uid = fields.String(load_default=None, allow_none=True)
# ── Response items ──
class PoamResponseSchema(Schema):
uid = fields.String()
control_identifier = fields.String(allow_none=True)
ao_uid = fields.String(allow_none=True)
status = fields.String(allow_none=True)
remediation_plan = fields.String(allow_none=True)
due_date = fields.Date(allow_none=True)
assignee_uid = fields.String(allow_none=True)
assignee_name = fields.String(allow_none=True)
closed_at = fields.DateTime(allow_none=True)
created_at = fields.DateTime(allow_none=True)
updated_at = fields.DateTime(allow_none=True)
# 列表用
finding_title = fields.String(allow_none=True)
finding_severity = fields.String(allow_none=True)
finding_category = fields.String(allow_none=True)
class PoamFindingResponseSchema(Schema):
uid = fields.String()
title = fields.String(allow_none=True)
category = fields.String(allow_none=True)
severity = fields.String(allow_none=True)
description = fields.String(allow_none=True)
recommendation = fields.String(allow_none=True)
class PoamJobInfoResponseSchema(Schema):
workflow_execution_id = fields.Integer(allow_none=True)
job_uid = fields.String(allow_none=True)
job_status = fields.String(allow_none=True)
class PoamDetailInnerSchema(PoamResponseSchema):
finding = fields.Nested(PoamFindingResponseSchema, allow_none=True)
job_info = fields.Nested(PoamJobInfoResponseSchema, allow_none=True)
# ── Response envelopes ──
class PoamListResponseSchema(EnvelopeSchema):
data = fields.Nested(PoamResponseSchema, many=True, dump_default=[])
class PoamDetailResponseSchema(Schema):
data = fields.Nested(PoamDetailInnerSchema, allow_none=True)
# ── Close Round ──
class CloseRoundResponseSchema(Schema):
ap_uid = fields.String()
status = fields.String()
reset_count = fields.Integer()import logging
from dependency_injector.wiring import inject, Provide
from flask import request
from flask_apispec import MethodResource, doc, use_kwargs, marshal_with
from flask_jwt_extended import jwt_required
from api.grc.serializers.poam import (
PoamListRequestSchema,
PoamListResponseSchema,
PoamUpdateRequestSchema,
PoamResponseSchema,
PoamDetailResponseSchema,
)
from app.grc.service.poam_service import PoamService
from common.enum.schema_code import AUTH_PARAMS
from common.util.response_util import return_response
from di_containers.containers import Containers
logger = logging.getLogger(__name__)
class PoamListResource(MethodResource):
"""POST /grc/project/<pid>/ap/<ap_uid>/poams/list"""
@doc(description="POA&M 分頁列表", tags=["GRC POA&M"], params=AUTH_PARAMS)
@use_kwargs(PoamListRequestSchema, location="json", apply=False)
@marshal_with(PoamListResponseSchema, apply=False)
@jwt_required()
@inject
def post(
self,
project_uid: str,
ap_uid: str,
poam_service: PoamService = Provide[Containers.grc_container.poam_service],
):
payload = request.get_json(silent=True) or {}
data = PoamListRequestSchema().load(payload)
pager = data.get("pager")
sorts = data.get("sort")
filters = data.get("filters") or {}
result = poam_service.list_poams(ap_uid, pager, sorts, **filters)
return return_response(True, PoamListResponseSchema().dump(result))
class PoamDetailResource(MethodResource):
"""GET /grc/project/<pid>/ap/<ap_uid>/poam/<poam_uid>"""
@doc(description="POA&M 詳情(含 finding + job info)", tags=["GRC POA&M"], params=AUTH_PARAMS)
@marshal_with(PoamDetailResponseSchema, apply=False)
@jwt_required()
@inject
def get(
self,
project_uid: str,
ap_uid: str,
poam_uid: str,
poam_service: PoamService = Provide[Containers.grc_container.poam_service],
):
result = poam_service.get_poam_detail(ap_uid, poam_uid)
if result is None:
return return_response(False, None)
return return_response(True, PoamDetailResponseSchema().dump({"data": result}))
class PoamUpdateResource(MethodResource):
"""PUT /grc/project/<pid>/ap/<ap_uid>/poam/<poam_uid>"""
@doc(description="更新 POA&M(狀態 / 改善計畫 / 負責人)", tags=["GRC POA&M"], params=AUTH_PARAMS)
@use_kwargs(PoamUpdateRequestSchema, location="json", apply=False)
@marshal_with(PoamResponseSchema, apply=False)
@jwt_required()
@inject
def put(
self,
project_uid: str,
ap_uid: str,
poam_uid: str,
poam_service: PoamService = Provide[Containers.grc_container.poam_service],
):
payload = request.get_json(silent=True) or {}
data = PoamUpdateRequestSchema().load(payload)
from jedi_common.session.auth.auth_context import get_user_context
user = get_user_context()
result = poam_service.update_poam(ap_uid, poam_uid, user.login_name, **data)
return return_response(True, PoamResponseSchema().dump(result))Files:
app/project/service/oscal_audit_service.py在 _revert_ao_workflow 方法之前(confirm_audit 之後),新增:
@transaction
def close_round(self, ap_uid: str, curr_user) -> dict:
session = get_session()
# 1. 查 AP
ap = self._ap_service.get_assessment_plan(uid=ap_uid)
if ap is None:
raise NotFound(GrcErrorCode.GRC_AP_NOT_FOUND)
if ap.status != "remediation":
raise PreconditionFailedError(GrcErrorCode.GRC_AP_NOT_REMEDIATION)
# 2. 查該 AP 所有 POA&M
from infra.grc.model.poam_model import PoamModel
poams = (
session.query(PoamModel)
.filter(PoamModel.assessment_plan_id == ap.id)
.all()
)
# 3. 驗證全部 closed
not_closed = [p for p in poams if p.status != "closed"]
if not_closed:
from jedi_common.handler.exception import BadRequestError
raise BadRequestError(GrcErrorCode.GRC_POAM_NOT_ALL_CLOSED)
# 4. 收集需要重置的 control_identifiers
control_ids = list({p.control_identifier for p in poams})
# 5. 找 ar_data (run_no=1) 下對應的 ar_controls,重置 verdict
ar = self._ar_service.get_assessment_result(assessment_plan_id=ap.id)
from jedi_oscal.infra.model.ar.assessment_result_data import OscalAssessmentResultData
from jedi_oscal.infra.model.ar.assessment_result_control import OscalAssessmentResultControl
ar_data = (
session.query(OscalAssessmentResultData)
.filter(
OscalAssessmentResultData.assessment_result_id == ar.id,
OscalAssessmentResultData.run_no == 1,
)
.first()
)
reset_count = 0
if ar_data and control_ids:
ar_controls = (
session.query(OscalAssessmentResultControl)
.filter(
OscalAssessmentResultControl.assessment_result_data_id == ar_data.id,
OscalAssessmentResultControl.control_id.in_(control_ids),
)
.all()
)
for arc in ar_controls:
arc.verdict = None
arc.confidence = None
arc.rationale = None
arc.remarks = None
arc.updated_user = str(curr_user)
reset_count = len(ar_controls)
session.flush()
# 6. AP status → auditing
self._ap_service.update_assessment_plan(ap.id, {"status": "auditing"}, curr_user)
return {"ap_uid": ap_uid, "status": "auditing", "reset_count": reset_count}需在檔案頂部確認 import:GrcErrorCode 中新增的 GRC_AP_NOT_REMEDIATION 和 GRC_POAM_NOT_ALL_CLOSED 已在 Task 1 加入。
Files:
di_containers/grc/grc_containers.pyapi/grc/routes/audit_route.pyapi/grc/__init__.py在 di_containers/grc/grc_containers.py 頂部加 import:
from app.grc.service.poam_service import PoamService
from domain.grc.service.grc_poam_domain_service import GrcPoamDomainService在 audit_service provider 之後加入:
# ── POA&M ─────────────────────────────────────────────────────────────
grc_poam_domain_service = providers.Factory(
GrcPoamDomainService,
poam_repo=poam_repo,
)
poam_service = providers.Factory(
PoamService,
grc_poam_domain_service=grc_poam_domain_service,
)注意:
poam_repo是 Stage 2 已加入的providers.Singleton(PoamRepoImpl),直接複用。
在 api/grc/routes/audit_route.py 的 import 區加入:
from api.grc.serializers.poam import CloseRoundResponseSchema在 ConfirmAuditResource 之後加入:
class CloseRoundResource(MethodResource):
"""POST /grc/project/<pid>/ap/<ap_uid>/close-round"""
@doc(description="觸發覆核(close-round)", tags=["GRC Audit"], params=AUTH_PARAMS)
@marshal_with(CloseRoundResponseSchema, apply=False)
@jwt_required()
@inject
def post(
self,
project_uid: str,
ap_uid: str,
oscal_audit_service: OscalAuditService = Provide[
Containers.project_container.oscal_audit_service
],
):
user = get_user_context()
_check_auditor_role(project_uid, user.id)
result = oscal_audit_service.close_round(
ap_uid=ap_uid,
curr_user=user.login_name,
)
return return_response(True, CloseRoundResponseSchema().dump(result))在 api/grc/__init__.py 的 import 區加入:
from api.grc.routes.poam_route import (
PoamListResource,
PoamDetailResource,
PoamUpdateResource,
)
from api.grc.routes.audit_route import CloseRoundResource在 # --- Audit (AP-scoped) --- 區塊尾部(ArFindingDetailResource 之後)加入:
# --- POA&M (AP-scoped) ---
api.add_resource(
PoamListResource,
"/project/<project_uid>/ap/<ap_uid>/poams/list",
)
api.add_resource(
PoamDetailResource,
"/project/<project_uid>/ap/<ap_uid>/poam/<poam_uid>",
)
api.add_resource(
PoamUpdateResource,
"/project/<project_uid>/ap/<ap_uid>/poam/<poam_uid>",
endpoint="poam_update",
)
api.add_resource(
CloseRoundResource,
"/project/<project_uid>/ap/<ap_uid>/close-round",
)注意:
PoamDetailResource(GET)和PoamUpdateResource(PUT)共用同一 URL path。Flask-RESTful 需要透過endpoint參數區分。或者合併成一個 Resource class 同時處理 GET 和 PUT。
更好的做法:合併 Detail + Update 為一個 Resource。回到 api/grc/routes/poam_route.py,把 PoamDetailResource 和 PoamUpdateResource 合併:
class PoamDetailResource(MethodResource):
"""GET/PUT /grc/project/<pid>/ap/<ap_uid>/poam/<poam_uid>"""
@doc(description="POA&M 詳情(含 finding + job info)", tags=["GRC POA&M"], params=AUTH_PARAMS)
@marshal_with(PoamDetailResponseSchema, apply=False)
@jwt_required()
@inject
def get(
self,
project_uid: str,
ap_uid: str,
poam_uid: str,
poam_service: PoamService = Provide[Containers.grc_container.poam_service],
):
result = poam_service.get_poam_detail(ap_uid, poam_uid)
if result is None:
return return_response(False, None)
return return_response(True, PoamDetailResponseSchema().dump({"data": result}))
@doc(description="更新 POA&M(狀態 / 改善計畫 / 負責人)", tags=["GRC POA&M"], params=AUTH_PARAMS)
@use_kwargs(PoamUpdateRequestSchema, location="json", apply=False)
@marshal_with(PoamResponseSchema, apply=False)
@jwt_required()
@inject
def put(
self,
project_uid: str,
ap_uid: str,
poam_uid: str,
poam_service: PoamService = Provide[Containers.grc_container.poam_service],
):
payload = request.get_json(silent=True) or {}
data = PoamUpdateRequestSchema().load(payload)
from jedi_common.session.auth.auth_context import get_user_context
user = get_user_context()
result = poam_service.update_poam(ap_uid, poam_uid, user.login_name, **data)
return return_response(True, PoamResponseSchema().dump(result))這樣 Blueprint 註冊就只需要:
api.add_resource(
PoamDetailResource,
"/project/<project_uid>/ap/<ap_uid>/poam/<poam_uid>",
)python -c "
import ast, sys
files = [
'scripts/sql/stage3_migration.sql', # skip (SQL)
'common/code/grc_error_code.py',
'infra/grc/model/poam_model.py',
'domain/grc/entities/poam_entity.py',
'infra/grc/mapper/poam_mapper.py',
'domain/grc/repository/i_poam_repo.py',
'infra/grc/repository/poam_repo_impl.py',
'domain/grc/service/grc_poam_domain_service.py',
'app/grc/dto/poam_dto.py',
'app/grc/service/poam_service.py',
'api/grc/serializers/poam.py',
'api/grc/routes/poam_route.py',
'api/grc/routes/audit_route.py',
'app/project/service/oscal_audit_service.py',
'di_containers/grc/grc_containers.py',
'api/grc/__init__.py',
]
for f in files:
try:
with open(f) as fh: ast.parse(fh.read())
print(f'OK: {f}')
except SyntaxError as e:
print(f'FAIL: {f} -> {e}')
sys.exit(1)
print('All files passed syntax check.')
"python -c "
from domain.grc.entities.poam_entity import PoamEntity
from domain.grc.service.grc_poam_domain_service import GrcPoamDomainService
from app.grc.dto.poam_dto import PoamDto, PoamDetailDto, PoamFindingDto, PoamJobInfoDto
from api.grc.serializers.poam import (
PoamListRequestSchema, PoamListResponseSchema,
PoamUpdateRequestSchema, PoamResponseSchema,
PoamDetailResponseSchema, CloseRoundResponseSchema,
)
print('All imports OK')
"