"""HTTP surface for 乙方侧 安全问卷 — answer library, evidence documents, and inbound customer questionnaires. Three routers mounted under ``/api/v1/auditor``: * ``/answer-library`` — reusable Q&A knowledge base * ``/evidence-documents`` — our security artifacts (cert/policy/report) * ``/inbound-questionnaires`` — customer questionnaires we fill RBAC mirrors the rest of the auditor domain (``can_view_security`` read / ``can_manage_security`` write). The filled-questionnaire export streams an xlsx (and archives the exact sent bytes as a version). Helix never emails a customer — the export is downloaded and sent 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_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 ( answer_library_service as lib_svc, ) from app.domains.auditor import ( evidence_doc_service as doc_svc, ) from app.domains.auditor import ( inbound_service as inb_svc, ) from app.domains.auditor.inbound_schemas import ( AddQuestionsRequest, BackfillRequest, DocCreateRequest, DocPresignRequest, DocUpdateRequest, ExportRequest, InboundCreateRequest, InboundMetaUpdateRequest, InboundSourcePresignRequest, InboundSourceRegisterRequest, InboundStatusRequest, LibEntryCreateRequest, LibEntryUpdateRequest, ReviewQuestionRequest, SetAnswerRequest, ) _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_type: 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=resource_type, resource_id=resource_id, detail=detail or {}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) # ════════════════════════════════════════════════════════════════════ # Answer library # ════════════════════════════════════════════════════════════════════ answer_library_router = APIRouter( prefix="/api/v1/auditor/answer-library", tags=["auditor"], ) def _spawn_embed(entry_id: uuid.UUID) -> None: """Re-embed an entry for 安全 FAQ's semantic retrieval, in the background. Called AFTER the commit — the background task opens its own session and would not see an uncommitted row. Fire-and-forget on purpose: an embeddings round trip must never be able to fail (or slow) the security team's edit, and the daily backfill job re-tries anything that did not land. """ from app.core.background import spawn from app.domains.auditor.security_faq_rag import run_entry_embed spawn(run_entry_embed(entry_id), name="auditor.security_faq_embed") @answer_library_router.get("/overview") async def lib_overview( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response(await lib_svc.overview(db), request=request) @answer_library_router.get("") async def list_lib( request: Request, category: str | None = Query(None), status: str | None = Query("active"), search: str | None = Query(None), employee_visible: bool | None = Query(None), is_featured: bool | 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) return success_response( await lib_svc.list_entries( db, category=category, status=status, search=search, employee_visible=employee_visible, is_featured=is_featured, page=page, page_size=page_size, ), request=request, ) @answer_library_router.post("") async def create_lib( request: Request, payload: LibEntryCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) e = await lib_svc.create_entry(db, payload=payload.model_dump(), actor_id=user.id) await _audit(db, request, user, action="auditor.answer_library.create", resource_type="auditor_answer_library", resource_id=str(e.id)) await db.commit() _spawn_embed(e.id) return success_response(lib_svc.entry_view(e), request=request) @answer_library_router.patch("/{entry_id}") async def update_lib( request: Request, entry_id: uuid.UUID, payload: LibEntryUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) e = await lib_svc.update_entry(db, entry_id, payload=payload.model_dump(exclude_unset=True)) await _audit(db, request, user, action="auditor.answer_library.update", resource_type="auditor_answer_library", resource_id=str(e.id)) await db.commit() _spawn_embed(e.id) return success_response(lib_svc.entry_view(e), request=request) @answer_library_router.post("/{entry_id}/reviewed") async def review_lib( request: Request, entry_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) e = await lib_svc.mark_reviewed(db, entry_id) await _audit(db, request, user, action="auditor.answer_library.reviewed", resource_type="auditor_answer_library", resource_id=str(e.id)) await db.commit() return success_response({"id": str(e.id)}, request=request) @answer_library_router.delete("/{entry_id}") async def delete_lib( request: Request, entry_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await lib_svc.delete_entry(db, entry_id) await _audit(db, request, user, action="auditor.answer_library.delete", resource_type="auditor_answer_library", resource_id=str(entry_id)) await db.commit() return success_response({"deleted": True}, request=request) # ════════════════════════════════════════════════════════════════════ # Evidence documents # ════════════════════════════════════════════════════════════════════ evidence_doc_router = APIRouter( prefix="/api/v1/auditor/evidence-documents", tags=["auditor"], ) @evidence_doc_router.get("") async def list_docs( request: Request, category: str | None = Query(None), status: str | None = Query("active"), shareable_only: bool = Query(False), search: str | None = Query(None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) items = await doc_svc.list_docs( db, category=category, status=status, shareable_only=shareable_only, search=search, ) return success_response({"items": items}, request=request) @evidence_doc_router.post("/presign") async def presign_doc( request: Request, payload: DocPresignRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) result = await doc_svc.presign_upload( db, 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) @evidence_doc_router.post("") async def create_doc( request: Request, payload: DocCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) d = await doc_svc.create_doc(db, payload=payload.model_dump(), actor_id=user.id) await _audit(db, request, user, action="auditor.evidence_document.create", resource_type="auditor_evidence_document", resource_id=str(d.id)) await db.commit() return success_response(await doc_svc.doc_view(db, d), request=request) @evidence_doc_router.patch("/{doc_id}") async def update_doc( request: Request, doc_id: uuid.UUID, payload: DocUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) d = await doc_svc.update_doc(db, doc_id, payload=payload.model_dump(exclude_unset=True)) await _audit(db, request, user, action="auditor.evidence_document.update", resource_type="auditor_evidence_document", resource_id=str(d.id)) await db.commit() return success_response(await doc_svc.doc_view(db, d), request=request) @evidence_doc_router.get("/{doc_id}/download") async def download_doc( request: Request, doc_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) url = await doc_svc.presign_download(db, doc_id) return success_response({"url": url}, request=request) @evidence_doc_router.delete("/{doc_id}") async def delete_doc( request: Request, doc_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await doc_svc.delete_doc(db, doc_id) await _audit(db, request, user, action="auditor.evidence_document.delete", resource_type="auditor_evidence_document", resource_id=str(doc_id)) await db.commit() return success_response({"deleted": True}, request=request) # ════════════════════════════════════════════════════════════════════ # Inbound questionnaires # ════════════════════════════════════════════════════════════════════ inbound_router = APIRouter( prefix="/api/v1/auditor/inbound-questionnaires", tags=["auditor"], ) @inbound_router.get("/overview") async def inbound_overview( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response(await inb_svc.overview(db), request=request) @inbound_router.get("") async def list_inbound( request: Request, status: 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) return success_response( await inb_svc.list_questionnaires( db, status=status, owner_person_id=owner_person_id, search=search, page=page, page_size=page_size, ), request=request, ) @inbound_router.post("") async def create_inbound( request: Request, payload: InboundCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) q = await inb_svc.create_questionnaire(db, payload=payload.model_dump(), actor_id=user.id) await _audit(db, request, user, action="auditor.inbound.create", resource_type="auditor_inbound_questionnaire", resource_id=str(q.id), detail={"customer": q.customer_name}) await db.commit() return success_response({"id": str(q.id)}, request=request) @inbound_router.get("/{qid}") async def get_inbound( request: Request, qid: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response(await inb_svc.get_detail(db, qid), request=request) @inbound_router.patch("/{qid}") async def update_inbound( request: Request, qid: uuid.UUID, payload: InboundMetaUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) q = await inb_svc.update_meta(db, qid, payload=payload.model_dump(exclude_unset=True)) await _audit(db, request, user, action="auditor.inbound.update", resource_type="auditor_inbound_questionnaire", resource_id=str(q.id)) await db.commit() return success_response({"id": str(q.id)}, request=request) @inbound_router.post("/{qid}/status") async def status_inbound( request: Request, qid: uuid.UUID, payload: InboundStatusRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) q = await inb_svc.set_status(db, qid, status=payload.status) await _audit(db, request, user, action="auditor.inbound.status", resource_type="auditor_inbound_questionnaire", resource_id=str(q.id), detail={"status": q.status}) await db.commit() return success_response({"id": str(q.id), "status": q.status}, request=request) @inbound_router.delete("/{qid}") async def delete_inbound( request: Request, qid: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await inb_svc.delete_questionnaire(db, qid) await _audit(db, request, user, action="auditor.inbound.delete", resource_type="auditor_inbound_questionnaire", resource_id=str(qid)) await db.commit() return success_response({"deleted": True}, request=request) # ── source file ─────────────────────────────────────────────────────── @inbound_router.post("/{qid}/source/presign") async def presign_source( request: Request, qid: uuid.UUID, payload: InboundSourcePresignRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) result = await inb_svc.presign_source_upload( db, qid=qid, 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) @inbound_router.post("/{qid}/source") async def register_source( request: Request, qid: uuid.UUID, payload: InboundSourceRegisterRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) from app.core.storage_uploads import mark_uploads_referenced await mark_uploads_referenced( db, object_keys=[payload.object_key], person_id=user.id, allowed_purposes=frozenset({"inbound_questionnaire"}), resource_type="auditor_inbound_questionnaire", resource_id=str(qid), ) q = await inb_svc.update_meta(db, qid, payload={ "source_object_key": payload.object_key, "source_filename": payload.filename, }) # #1800: every sibling mutation writes an audit row; these four # (register_source / set_answer / review_question / delete_question) # did not — letting an auditor silently rewrite customer-facing answer # text or delete a question inside the compliance domain itself, which # violates the "audit by design" invariant. await _audit(db, request, user, action="auditor.inbound.source.register", resource_type="auditor_inbound_questionnaire", resource_id=str(qid), detail={"filename": payload.filename}) await db.commit() return success_response({"id": str(q.id)}, request=request) @inbound_router.get("/{qid}/source/download") async def download_source( request: Request, qid: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) url = await inb_svc.presign_source_download(db, qid) return success_response({"url": url}, request=request) # ── questions ───────────────────────────────────────────────────────── @inbound_router.post("/{qid}/questions") async def add_questions( request: Request, qid: uuid.UUID, payload: AddQuestionsRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) added = await inb_svc.add_questions( db, qid, items=[i.model_dump() for i in payload.items], ) await _audit(db, request, user, action="auditor.inbound.questions.add", resource_type="auditor_inbound_questionnaire", resource_id=str(qid), detail={"added": added}) await db.commit() return success_response({"added": added}, request=request) @inbound_router.post("/{qid}/questions/import") async def import_questions( request: Request, qid: 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") data = await read_upload_bounded( file, _MAX_IMPORT_BYTES, message="uploaded file exceeds 20 MB limit", close=True, ) result = await inb_svc.import_questions_xlsx(db, qid, payload=data) await _audit(db, request, user, action="auditor.inbound.questions.import", resource_type="auditor_inbound_questionnaire", resource_id=str(qid), detail=result) await db.commit() return success_response(result, request=request) @inbound_router.post("/{qid}/ai-draft") async def ai_draft( request: Request, qid: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) result = await inb_svc.ai_draft_pending(db, qid) await _audit(db, request, user, action="auditor.inbound.ai_draft", resource_type="auditor_inbound_questionnaire", resource_id=str(qid), detail=result) await db.commit() return success_response(result, request=request) @inbound_router.patch("/questions/{question_id}") async def set_answer( request: Request, question_id: uuid.UUID, payload: SetAnswerRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) q = await inb_svc.set_answer( db, question_id, answer_text=payload.answer_text, matched_library_entry_id=payload.matched_library_entry_id, ) await _audit(db, request, user, action="auditor.inbound.question.set_answer", resource_type="auditor_inbound_question", resource_id=str(question_id), detail={"matched_library_entry_id": ( str(payload.matched_library_entry_id) if payload.matched_library_entry_id else None)}) await db.commit() return success_response(inb_svc.question_view(q), request=request) @inbound_router.post("/questions/{question_id}/review") async def review_question( request: Request, question_id: uuid.UUID, payload: ReviewQuestionRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) q = await inb_svc.review_question(db, question_id, reviewer_id=user.id, reviewed=payload.reviewed) await _audit(db, request, user, action="auditor.inbound.question.reviewed", resource_type="auditor_inbound_question", resource_id=str(question_id), detail={"reviewed": payload.reviewed}) await db.commit() return success_response(inb_svc.question_view(q), request=request) @inbound_router.post("/questions/{question_id}/backfill") async def backfill_question( request: Request, question_id: uuid.UUID, payload: BackfillRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) result = await inb_svc.backfill_question_to_library( db, question_id, category=payload.category, actor_id=user.id, existing_entry_id=payload.existing_entry_id, ) await _audit(db, request, user, action="auditor.inbound.backfill", resource_type="auditor_answer_library", resource_id=result["entry_id"]) await db.commit() return success_response(result, request=request) @inbound_router.delete("/questions/{question_id}") async def delete_question( request: Request, question_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) qid = await inb_svc.delete_question(db, question_id) await _audit(db, request, user, action="auditor.inbound.question.delete", resource_type="auditor_inbound_question", resource_id=str(question_id), detail={"questionnaire_id": str(qid)}) await db.commit() return success_response({"questionnaire_id": str(qid)}, request=request) # ── export + versions ───────────────────────────────────────────────── @inbound_router.post("/{qid}/export") async def export_inbound( request: Request, qid: uuid.UUID, payload: ExportRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) buf, filename, version_no = await inb_svc.export_and_archive( db, qid, actor_id=user.id, note=payload.note, ) await _audit(db, request, user, action="auditor.inbound.export", resource_type="auditor_inbound_questionnaire", resource_id=str(qid), detail={"version_no": version_no}) await db.commit() returned = version_no return xlsx_response(buf, filename_stem=filename.rsplit(".", 1)[0], returned=returned, total_matched=returned) @inbound_router.get("/versions/{version_id}/download") async def download_version( request: Request, version_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) url = await inb_svc.presign_version_download(db, version_id) return success_response({"url": url}, request=request)