"""HTTP surface for 安全问卷 · 供应商安全评估 (vendor security assessment). Mounts under ``/api/v1/auditor/vendor-assessments``. RBAC mirrors the rest of the auditor domain: * ``can_view_security`` — read endpoints (auditor + global_admin + management) * ``can_manage_security`` — template/assessment mutations, review, import Evidence + the returned filled workbook go to S3 via a conditional presigned PUT scoped to this domain (image/pdf/xlsx only). The vendor's filled questionnaire is imported via a small multipart upload the backend parses in-memory (openpyxl). Helix never emails an external vendor — the blank questionnaire is EXPORTED and dispatched out of band. """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user, get_real_ip from app.core.exceptions import ForbiddenError, ValidationError from app.core.exporting import xlsx_export_response, xlsx_response from app.core.rbac import can_manage_security, can_view_security from app.core.response import success_response from app.core.upload import read_upload_bounded from app.domains.auditor import vendor_assessment_service as svc from app.domains.auditor.vendor_assessment_schemas import ( AnswerReviewRequest, AnswerSetRequest, AssessmentCompleteRequest, AssessmentCreateRequest, AssessmentMetaUpdateRequest, AttachmentPresignRequest, AttachmentRegisterRequest, RatingOverrideRequest, StatusChangeRequest, TemplateCloneRequest, TemplateCreateRequest, TemplateQuestionCreateRequest, TemplateQuestionUpdateRequest, TemplateUpdateRequest, ) router = APIRouter( prefix="/api/v1/auditor/vendor-assessments", tags=["auditor"], ) _MAX_IMPORT_BYTES = 20 * 1024 * 1024 def _require_view(user: AuthUser) -> None: if not can_view_security(user.role_names): raise ForbiddenError( "auditor or global_admin role required to view security data", ) def _require_manage(user: AuthUser) -> None: if not can_manage_security(user.role_names): raise ForbiddenError( "auditor or global_admin role required to mutate security data", ) async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str | None, detail: dict | None = None, ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type="auditor_vendor_assessment", resource_id=resource_id, detail=detail or {}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) # ════════════════════════════════════════════════════════════════════ # Overview # ════════════════════════════════════════════════════════════════════ @router.get("/overview") async def get_overview( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response(await svc.get_overview(db), request=request) # ════════════════════════════════════════════════════════════════════ # Templates # ════════════════════════════════════════════════════════════════════ @router.get("/templates") async def list_templates( request: Request, include_disabled: bool = Query(True), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) items = await svc.list_templates(db, include_disabled=include_disabled) return success_response({"items": items}, request=request) @router.get("/templates/{template_id}") async def get_template( request: Request, template_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response( await svc.get_template_detail(db, template_id), request=request, ) @router.post("/templates") async def create_template( request: Request, payload: TemplateCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) t = await svc.create_template(db, payload=payload.model_dump()) await _audit( db, request, user, action="auditor.vendor_assessment.template.create", resource_id=str(t.id), detail={"key": t.key}, ) await db.commit() return success_response(svc.template_view(t), request=request) @router.patch("/templates/{template_id}") async def update_template( request: Request, template_id: uuid.UUID, payload: TemplateUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) t = await svc.update_template( db, template_id, payload=payload.model_dump(exclude_unset=True), ) await _audit( db, request, user, action="auditor.vendor_assessment.template.update", resource_id=str(t.id), ) await db.commit() return success_response(svc.template_view(t), request=request) @router.post("/templates/{template_id}/clone") async def clone_template( request: Request, template_id: uuid.UUID, payload: TemplateCloneRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) t = await svc.clone_template( db, template_id, new_key=payload.new_key, name_zh=payload.name_zh, name_en=payload.name_en, ) await _audit( db, request, user, action="auditor.vendor_assessment.template.clone", resource_id=str(t.id), detail={"source": str(template_id)}, ) await db.commit() return success_response(svc.template_view(t), request=request) @router.delete("/templates/{template_id}") async def delete_template( request: Request, template_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await svc.delete_template(db, template_id) await _audit( db, request, user, action="auditor.vendor_assessment.template.delete", resource_id=str(template_id), ) await db.commit() return success_response({"deleted": True}, request=request) @router.post("/templates/{template_id}/questions") async def add_template_question( request: Request, template_id: uuid.UUID, payload: TemplateQuestionCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) q = await svc.add_template_question(db, template_id, payload=payload.model_dump()) await _audit( db, request, user, action="auditor.vendor_assessment.template.question.add", resource_id=str(template_id), detail={"code": q.code}, ) await db.commit() return success_response(svc.question_view(q), request=request) @router.patch("/questions/{question_id}") async def update_template_question( request: Request, question_id: uuid.UUID, payload: TemplateQuestionUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) q = await svc.update_template_question( db, question_id, payload=payload.model_dump(exclude_unset=True), ) await _audit( db, request, user, action="auditor.vendor_assessment.template.question.update", resource_id=str(q.template_id), detail={"code": q.code}, ) await db.commit() return success_response(svc.question_view(q), request=request) @router.delete("/questions/{question_id}") async def delete_template_question( request: Request, question_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) template_id = await svc.delete_template_question(db, question_id) await _audit( db, request, user, action="auditor.vendor_assessment.template.question.delete", resource_id=str(template_id), detail={"question_id": str(question_id)}, ) await db.commit() return success_response({"deleted": True}, request=request) # ════════════════════════════════════════════════════════════════════ # Assessments # ════════════════════════════════════════════════════════════════════ @router.get("") async def list_assessments( request: Request, status: str | None = Query(None), tier: str | None = Query(None), risk_rating: str | None = Query(None), owner_person_id: uuid.UUID | None = Query(None), search: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) result = await svc.list_assessments( db, status=status, tier=tier, risk_rating=risk_rating, owner_person_id=owner_person_id, search=search, page=page, page_size=page_size, ) return success_response(result, request=request) @router.post("") async def create_assessment( request: Request, payload: AssessmentCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) a = await svc.create_assessment( db, payload=payload.model_dump(), actor_id=user.id, ) await _audit( db, request, user, action="auditor.vendor_assessment.create", resource_id=str(a.id), detail={"vendor": a.vendor_name, "tier": a.tier}, ) await db.commit() return success_response({"id": str(a.id)}, request=request) @router.get("/{assessment_id}") async def get_assessment( request: Request, assessment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response( await svc.get_assessment_detail(db, assessment_id), request=request, ) @router.patch("/{assessment_id}") async def update_assessment( request: Request, assessment_id: uuid.UUID, payload: AssessmentMetaUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) a = await svc.update_assessment_meta( db, assessment_id, payload=payload.model_dump(exclude_unset=True), ) await _audit( db, request, user, action="auditor.vendor_assessment.update", resource_id=str(a.id), ) await db.commit() return success_response({"id": str(a.id)}, request=request) @router.post("/{assessment_id}/send") async def mark_sent( request: Request, assessment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) a = await svc.mark_sent(db, assessment_id) await _audit( db, request, user, action="auditor.vendor_assessment.send", resource_id=str(a.id), ) await db.commit() return success_response({"id": str(a.id), "status": a.status}, request=request) @router.post("/{assessment_id}/complete") async def complete_assessment( request: Request, assessment_id: uuid.UUID, payload: AssessmentCompleteRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) a = await svc.complete_assessment( db, assessment_id, summary=payload.summary, reviewer_person_id=payload.reviewer_person_id or user.id, ) await _audit( db, request, user, action="auditor.vendor_assessment.complete", resource_id=str(a.id), detail={"score_pct": a.score_pct, "risk_rating": a.risk_rating}, ) await db.commit() return success_response( {"id": str(a.id), "status": a.status, "score_pct": a.score_pct, "risk_rating": a.risk_rating}, request=request, ) @router.post("/{assessment_id}/reopen") async def reopen_assessment( request: Request, assessment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) a = await svc.reopen_assessment(db, assessment_id) await _audit( db, request, user, action="auditor.vendor_assessment.reopen", resource_id=str(a.id), ) await db.commit() return success_response({"id": str(a.id), "status": a.status}, request=request) @router.post("/{assessment_id}/rating") async def override_rating( request: Request, assessment_id: uuid.UUID, payload: RatingOverrideRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) a = await svc.override_rating(db, assessment_id, rating=payload.rating) await _audit( db, request, user, action="auditor.vendor_assessment.rating_override", resource_id=str(a.id), detail={"rating": a.risk_rating}, ) await db.commit() return success_response( {"id": str(a.id), "risk_rating": a.risk_rating, "rating_overridden": a.rating_overridden}, request=request, ) @router.post("/{assessment_id}/status") async def change_status( request: Request, assessment_id: uuid.UUID, payload: StatusChangeRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) a = await svc.set_status(db, assessment_id, status=payload.status) await _audit( db, request, user, action="auditor.vendor_assessment.status", resource_id=str(a.id), detail={"status": a.status}, ) await db.commit() return success_response({"id": str(a.id), "status": a.status}, request=request) # ── Blank questionnaire export (to send to the vendor) ──────────────── @router.get("/{assessment_id}/questionnaire") async def download_blank_questionnaire( request: Request, assessment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) from sqlalchemy import select from app.domains.auditor.models import AuditorVendorAssessmentAnswer a = await svc.get_assessment(db, assessment_id) answers = list((await db.execute( select(AuditorVendorAssessmentAnswer).where( AuditorVendorAssessmentAnswer.assessment_id == a.id, ).order_by( AuditorVendorAssessmentAnswer.sort_order, AuditorVendorAssessmentAnswer.question_code, ) )).scalars().all()) await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action="auditor.vendor_assessment.questionnaire_export", status="success", resource_type="auditor_vendor_assessment", resource_id=str(a.id), detail={"question_count": len(answers)}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() buf = svc.build_blank_questionnaire(a, answers) return xlsx_response( buf, filename_stem=f"security_questionnaire_{a.vendor_name}", returned=len(answers), total_matched=len(answers), ) # ── Results export (internal, scored) ───────────────────────────────── @router.get("/{assessment_id}/export") async def export_answers( request: Request, assessment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows, total = await svc.export_answer_rows(db, assessment_id) return await xlsx_export_response( db, request, user, columns=svc.EXPORT_COLUMNS, rows=rows, total_matched=total, filename_stem="vendor_assessment_answers", sheet_title="供应商安全评估", audit_action="auditor.vendor_assessment.export", audit_resource_type="auditor_vendor_assessment", filters={"assessment_id": str(assessment_id)}, ) # ── Import the vendor's filled workbook ─────────────────────────────── @router.post("/{assessment_id}/import") async def import_answers( request: Request, assessment_id: uuid.UUID, file: UploadFile = File(...), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) filename = (file.filename or "").lower() if not filename.endswith(".xlsx"): await file.close() raise ValidationError("only .xlsx files are supported for import") payload = await read_upload_bounded( file, _MAX_IMPORT_BYTES, message="uploaded file exceeds 20 MB limit", close=True, ) result = await svc.import_answers(db, assessment_id, payload=payload) await _audit( db, request, user, action="auditor.vendor_assessment.import", resource_id=str(assessment_id), detail={"updated": result["updated"], "unmatched": len(result["unmatched_codes"])}, ) await db.commit() return success_response(result, request=request) # ════════════════════════════════════════════════════════════════════ # Answers # ════════════════════════════════════════════════════════════════════ @router.patch("/answers/{answer_id}") async def set_answer( request: Request, answer_id: uuid.UUID, payload: AnswerSetRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) ans = await svc.set_answer( db, answer_id, payload=payload.model_dump(exclude_unset=True), ) await _audit( db, request, user, action="auditor.vendor_assessment.answer.set", resource_id=str(ans.assessment_id), detail={"code": ans.question_code}, ) await db.commit() return success_response(svc.answer_view(ans), request=request) @router.post("/answers/{answer_id}/review") async def review_answer( request: Request, answer_id: uuid.UUID, payload: AnswerReviewRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) ans = await svc.review_answer( db, answer_id, review_flag=payload.review_flag, reviewer_note=payload.reviewer_note, ) await _audit( db, request, user, action="auditor.vendor_assessment.answer.review", resource_id=str(ans.assessment_id), detail={"code": ans.question_code, "flag": ans.review_flag}, ) await db.commit() return success_response(svc.answer_view(ans), request=request) # ════════════════════════════════════════════════════════════════════ # Evidence attachments # ════════════════════════════════════════════════════════════════════ @router.post("/{assessment_id}/attachments/presign") async def presign_attachment( request: Request, assessment_id: uuid.UUID, payload: AttachmentPresignRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) result = await svc.presign_evidence_upload( db, assessment_id=assessment_id, person_id=user.id, filename=payload.filename, content_type=payload.content_type, size_bytes=payload.size_bytes, checksum_sha256=payload.checksum_sha256, ) return success_response(result, request=request) @router.post("/{assessment_id}/attachments") async def register_attachment( request: Request, assessment_id: uuid.UUID, payload: AttachmentRegisterRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) att = await svc.register_attachment( db, assessment_id=assessment_id, object_key=payload.object_key, filename=payload.filename, content_type=payload.content_type, uploaded_by_person_id=user.id, answer_id=payload.answer_id, kind=payload.kind, ) await _audit( db, request, user, action="auditor.vendor_assessment.attachment.add", resource_id=str(assessment_id), detail={"attachment_id": str(att.id)}, ) await db.commit() return success_response({"id": str(att.id)}, request=request) @router.get("/attachments/{attachment_id}/download") async def download_attachment( request: Request, attachment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) url, att = await svc.presign_attachment_download(db, attachment_id) # Minting the URL IS the egress event — the link works without Helix # auth for its whole lifetime, so the audit row goes in here, not at # some later "did they actually fetch it" point we cannot observe. await _audit( db, request, user, action="auditor.vendor_assessment.attachment.download", resource_id=str(att.assessment_id), detail={ "attachment_id": str(att.id), "filename": att.filename, "answer_id": str(att.answer_id) if att.answer_id else None, }, ) await db.commit() return success_response({"url": url}, request=request) @router.delete("/attachments/{attachment_id}") async def delete_attachment( request: Request, attachment_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) assessment_id = await svc.delete_attachment(db, attachment_id) await _audit( db, request, user, action="auditor.vendor_assessment.attachment.delete", resource_id=str(assessment_id), detail={"attachment_id": str(attachment_id)}, ) await db.commit() return success_response({"assessment_id": str(assessment_id)}, request=request)