"""HTTP surface for the GRC spine (redesigned 安全合规 menu home). Mounts under ``/api/v1/compliance``. RBAC mirrors the auditor surface: * ``can_view_security`` — read (posture / frameworks / controls) * ``can_manage_security`` — recompute control status The detective-control CRUD (MFA list, exposure resolve, …) stays on the existing ``/api/v1/auditor`` router; this router is the governance layer that sits ABOVE those controls. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_security, can_view_security from app.core.response import success_response from app.domains.auditor import access_certification_service as acs from app.domains.auditor import compliance_service as svc from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/compliance", tags=["compliance"], ) def _require_view(user: IdentityPerson) -> None: if not can_view_security(user.role_names): raise ForbiddenError("compliance read access required") def _require_manage(user: IdentityPerson) -> None: if not can_manage_security(user.role_names): raise ForbiddenError("compliance manage access required") async def _ensure_seeded(db: AsyncSession) -> None: """Bootstrap the catalog on first read so the UI is never empty even before the first recompute cron fires. Idempotent.""" from sqlalchemy import func, select from app.domains.auditor.models import ComplianceFramework n = int((await db.execute( select(func.count()).select_from(ComplianceFramework) )).scalar_one()) if n == 0: await svc.sync_compliance_catalog(db) await db.commit() @router.get("/posture") async def get_posture( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Headline posture: per-framework readiness % + control status rollup.""" _require_view(user) await _ensure_seeded(db) data = await svc.get_posture(db) return success_response(data=data, request=request) @router.get("/frameworks") async def list_frameworks( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) await _ensure_seeded(db) data = await svc.list_frameworks(db) return success_response(data={"frameworks": data}, request=request) @router.get("/frameworks/{framework_key}") async def get_framework( framework_key: str, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Framework detail: every requirement, its coverage state, and the controls satisfying it.""" _require_view(user) await _ensure_seeded(db) data = await svc.get_framework(db, framework_key) if data is None: raise NotFoundError("ComplianceFramework", framework_key) return success_response(data=data, request=request) @router.get("/controls") async def list_controls( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Control catalog with live status + framework crosswalk refs.""" _require_view(user) await _ensure_seeded(db) data = await svc.list_controls(db) return success_response(data={"controls": data}, request=request) @router.get("/controls/{control_key}") async def get_control( control_key: str, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) await _ensure_seeded(db) data = await svc.get_control(db, control_key) if data is None: raise NotFoundError("ComplianceControl", control_key) return success_response(data=data, request=request) @router.post("/controls/recompute") async def recompute( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Synchronously re-evaluate every control (catalog reconcile + continuous control monitoring). Same logic the cron runs.""" _require_manage(user) result = await svc.recompute_control_status(db) return success_response(data=result, request=request) # ───────────────────────────────────────────────────────────────────── # Access certification — periodic access-review campaigns # ───────────────────────────────────────────────────────────────────── class CreateCampaignBody(BaseModel): label: str = Field(min_length=1, max_length=128) campaign_type: str = Field(default="software_seat") scope_config: dict = Field(default_factory=dict) due_in_days: int = Field(default=14, ge=1, le=180) class DecideItemBody(BaseModel): decision: str # 'keep' | 'revoke' note: str | None = None class OpenSelfAttestationBody(BaseModel): due_in_days: int = Field(default=21, ge=1, le=120) @router.get("/certifications") async def list_certifications( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) data = await acs.list_campaigns(db) return success_response( data={"campaigns": data, "campaign_types": list(acs.CAMPAIGN_TYPES)}, request=request, ) @router.post("/certifications") async def create_certification( body: CreateCampaignBody, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Open a new access-review campaign (snapshots entitlements now).""" _require_manage(user) due_at = datetime.now(UTC) + timedelta(days=body.due_in_days) cert = await acs.create_campaign( db, label=body.label, campaign_type=body.campaign_type, scope_config=body.scope_config, due_at=due_at, actor_person_id=user.id, ) await db.commit() data = await acs.get_campaign(db, cert.id) return success_response(data=data, request=request) @router.post("/certifications/self-attestation/open-now") async def open_self_attestation_now( body: OpenSelfAttestationBody, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Deliberately open the half-yearly all-employee self-attestation campaign for the current period right now (the manual trigger the cron deliberately does NOT fire retroactively mid-cycle). Snapshots every active employee's footprint and sends one batched confirmation card per holder. Idempotent: a campaign for this period already open → no-op.""" _require_manage(user) now = datetime.now(UTC) y = now.year # Current half: Jun–Nov ⇒ H1, else H2. half = "H1" if 6 <= now.month <= 11 else "H2" period_year = y if now.month >= 6 else y - 1 result = await acs.open_self_attestation_campaign( db, period_year=period_year, half=half, due_after_days=body.due_in_days, actor_person_id=user.id, ) await db.commit() return success_response(data=result, request=request) @router.get("/certifications/{certification_id}") async def get_certification( certification_id: UUID, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) data = await acs.get_campaign(db, certification_id) if data is None: raise NotFoundError("AccessCertification", str(certification_id)) return success_response(data=data, request=request) @router.get("/certifications/{certification_id}/self-attestation-summary") async def self_attestation_summary( certification_id: UUID, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Auditor roll-up of a self-attestation campaign: coverage, per-decision / per-type counts, flagged-removal disposition queue, non-responders. 404 when the id isn't a self_attestation campaign.""" _require_view(user) data = await acs.build_self_attestation_summary( db, certification_id=certification_id, ) if data is None: raise NotFoundError("self_attestation campaign", str(certification_id)) return success_response(data=data, request=request) @router.get("/certifications/{certification_id}/export") async def export_self_attestation( certification_id: UUID, fmt: str = Query("docx", pattern="^(docx|pdf)$"), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Download the self-attestation campaign report (Word / PDF) — the SOC2 CC6.3 / ISO A.5.15 access-review evidence artifact.""" from starlette.responses import StreamingResponse from app.domains.auditor import access_review_export as exporter _require_view(user) result = ( await exporter.build_pdf(db, certification_id) if fmt == "pdf" else await exporter.build_docx(db, certification_id) ) if result is None: raise NotFoundError("self_attestation campaign", str(certification_id)) buf, stem = result media = ("application/pdf" if fmt == "pdf" else "application/vnd.openxmlformats-officedocument." "wordprocessingml.document") # RFC 5987 via the shared helper (#2127). The campaign label is # generated as f"{year}-{half} 全员访问自查自证" — always Chinese — # and the old sanitiser used ``[^\w.-]``, whose ``\w`` MATCHES CJK # in Python str mode, so the characters survived into the header. # starlette encodes header values as latin-1, so every download of # this SOC2 CC6.3 / ISO A.5.15 evidence artefact raised # UnicodeEncodeError → 500, for every real campaign. CI stayed green # because the export tests call ``build_docx`` / ``build_pdf`` # directly and never touch the HTTP layer. from app.core.exporting import content_disposition return StreamingResponse( buf, media_type=media, headers={"Content-Disposition": content_disposition(f"{stem}.{fmt}")}, ) @router.get("/certifications/{certification_id}/items") async def list_certification_items( certification_id: UUID, request: Request, decision: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) items, total = await acs.list_items( db, certification_id=certification_id, decision=decision, page=page, page_size=page_size, ) return success_response( data={"items": items, "meta": { "page": page, "page_size": page_size, "total": total}}, request=request, ) @router.post("/certifications/items/{item_id}/decide") async def decide_certification_item( item_id: UUID, body: DecideItemBody, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) item = await acs.decide_item( db, item_id=item_id, decision=body.decision, note=body.note, actor_person_id=user.id, trace_id=UUID(request.state.trace_id), ) await db.commit() return success_response( data={"id": str(item.id), "decision": item.decision}, request=request, ) @router.post("/certifications/{certification_id}/complete") async def complete_certification( certification_id: UUID, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) cert = await acs.complete_campaign( db, certification_id=certification_id, actor_person_id=user.id, trace_id=UUID(request.state.trace_id), ) await db.commit() return success_response( data={"id": str(cert.id), "status": cert.status}, request=request, )