"""安全问卷 · 证据文档库 (evidence document register) — service layer. OUR own security artifacts that customers ask for during a questionnaire: ISO 27701 / SOC 2 certificates, DR plan, pentest reports, policies. S3- backed (presign upload/download), with a shareable flag + validity date so expiring certs surface. Answer-library entries link these by id. """ from __future__ import annotations import uuid from datetime import UTC, date, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError, ValidationError from app.core.storage import presign_get, presign_put from app.domains.auditor.models import AuditorEvidenceDocument from app.identity.person_models import IdentityPerson CATEGORIES: tuple[str, ...] = ("certificate", "policy", "report", "dr_plan", "other") STATUSES: tuple[str, ...] = ("active", "archived") ALLOWED_CONTENT_TYPES: frozenset[str] = frozenset({ "application/pdf", "image/jpeg", "image/png", "image/webp", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/msword", }) _KEY_PREFIX = "evidence_documents" _MAX_BYTES = 100 * 1024 * 1024 # 100 MB — a pentest report / signed cert PDF. def _naive_now() -> datetime: return datetime.now(UTC).replace(tzinfo=None) def _to_naive(dt: datetime | None) -> datetime | None: if dt is None: return None if dt.tzinfo is not None: return dt.astimezone(UTC).replace(tzinfo=None) return dt async def _person_brief( db: AsyncSession, pid: uuid.UUID | None, ) -> dict[str, Any] | None: if pid is None: return None p = await db.get(IdentityPerson, pid) if p is None: return None return {"id": str(p.id), "display_name": p.display_name, "work_email": p.work_email} async def doc_view(db: AsyncSession, d: AuditorEvidenceDocument) -> dict[str, Any]: valid_until = d.valid_until # Expiry is a HQ-local calendar judgement, not a UTC one: with the # UTC date, a cert whose valid_until was yesterday still showed # 未过期 to CN admins until 08:00 Beijing time. from zoneinfo import ZoneInfo today_local = datetime.now(ZoneInfo("Asia/Shanghai")).date() days_to_expiry = (valid_until - today_local).days if valid_until else None return { "id": str(d.id), "title": d.title, "category": d.category, "description": d.description, "filename": d.filename, "content_type": d.content_type, "size_bytes": d.size_bytes, "version": d.version, "owner": await _person_brief(db, d.owner_person_id), "valid_until": valid_until.isoformat() if valid_until else None, "days_to_expiry": days_to_expiry, "is_expired": days_to_expiry is not None and days_to_expiry < 0, "is_shareable": bool(d.is_shareable), "status": d.status, "created_at": _to_naive(d.created_at).isoformat() if d.created_at else None, "updated_at": _to_naive(d.updated_at).isoformat() if d.updated_at else None, } async def get_doc(db: AsyncSession, doc_id: uuid.UUID) -> AuditorEvidenceDocument: d = await db.get(AuditorEvidenceDocument, doc_id) if d is None: raise NotFoundError("evidence document not found") return d async def list_docs( db: AsyncSession, *, category: str | None = None, status: str | None = "active", shareable_only: bool = False, search: str | None = None, ) -> list[dict[str, Any]]: conds = [] if category: conds.append(AuditorEvidenceDocument.category == category) if status: conds.append(AuditorEvidenceDocument.status == status) if shareable_only: conds.append(AuditorEvidenceDocument.is_shareable.is_(True)) if search: conds.append(AuditorEvidenceDocument.title.ilike(f"%{search.strip()}%")) stmt = select(AuditorEvidenceDocument) for c in conds: stmt = stmt.where(c) stmt = stmt.order_by( AuditorEvidenceDocument.category, AuditorEvidenceDocument.title, ) rows = (await db.execute(stmt)).scalars().all() return [await doc_view(db, d) for d in rows] def _relative_key(*, person_id: uuid.UUID | None, filename: str) -> str: from app.storage_router import _safe_filename today = _naive_now().strftime("%Y/%m/%d") rand = uuid.uuid4().hex[:12] pid = str(person_id) if person_id else "system" return f"{_KEY_PREFIX}/{today}/{pid}/{rand}-{_safe_filename(filename)}" def _validate_key(object_key: str) -> None: segments = object_key.split("/") if ".." in segments: raise ValidationError("object_key must not contain '..' segments") if _KEY_PREFIX not in segments: raise ValidationError("object_key is not under the evidence-documents namespace") async def presign_upload( db: AsyncSession, *, person_id: uuid.UUID, filename: str, content_type: str, size_bytes: int, checksum_sha256: str, ) -> dict[str, Any]: if content_type not in ALLOWED_CONTENT_TYPES: raise ValidationError( f"unsupported content type '{content_type}' — allowed: pdf / image / office doc", ) from app.core.storage_uploads import create_scoped_upload_grant grant = await create_scoped_upload_grant( db, person_id=person_id, purpose=_KEY_PREFIX, relative_key=_relative_key(person_id=person_id, filename=filename), content_type=content_type, size_bytes=size_bytes, checksum_sha256=checksum_sha256, allowed_content_types=ALLOWED_CONTENT_TYPES, max_bytes=_MAX_BYTES, ) await db.commit() return await presign_put( db, object_key=grant.object_key, content_type=content_type, size_bytes=grant.declared_size_bytes, checksum_sha256=grant.checksum_sha256, ) async def create_doc( db: AsyncSession, *, payload: dict[str, Any], actor_id: uuid.UUID | None, ) -> AuditorEvidenceDocument: if not (payload.get("title") or "").strip(): raise ValidationError("title is required") object_key = payload.get("object_key") or "" _validate_key(object_key) content_type = payload.get("content_type") or "" if content_type not in ALLOWED_CONTENT_TYPES: raise ValidationError(f"unsupported content type '{content_type}'") size_bytes = payload.get("size_bytes") if size_bytes is not None and size_bytes > _MAX_BYTES: raise ValidationError("document exceeds 100 MB limit") category = payload.get("category") or "other" if category not in CATEGORIES: raise ValidationError(f"category must be one of {CATEGORIES}") valid_until = payload.get("valid_until") if isinstance(valid_until, str) and valid_until: valid_until = date.fromisoformat(valid_until) d = AuditorEvidenceDocument( title=payload["title"].strip(), category=category, description=(payload.get("description") or None), object_key=object_key, filename=(payload.get("filename") or "document").strip()[:255], content_type=content_type, size_bytes=size_bytes, version=(payload.get("version") or None), owner_person_id=payload.get("owner_person_id") or actor_id, valid_until=valid_until or None, is_shareable=bool(payload.get("is_shareable", True)), status="active", uploaded_by_person_id=actor_id, ) db.add(d) await db.flush() if actor_id is not None: from app.core.storage_uploads import mark_uploads_referenced grants = await mark_uploads_referenced( db, object_keys=[object_key], person_id=actor_id, allowed_purposes=frozenset({_KEY_PREFIX}), resource_type="auditor_evidence_document", resource_id=str(d.id), ) d.size_bytes = grants[object_key].declared_size_bytes d.content_type = grants[object_key].content_type return d async def update_doc( db: AsyncSession, doc_id: uuid.UUID, *, payload: dict[str, Any], ) -> AuditorEvidenceDocument: d = await get_doc(db, doc_id) if "title" in payload and payload["title"]: d.title = payload["title"].strip() if "category" in payload and payload["category"]: if payload["category"] not in CATEGORIES: raise ValidationError(f"category must be one of {CATEGORIES}") d.category = payload["category"] if "description" in payload: d.description = payload["description"] or None if "version" in payload: d.version = payload["version"] or None if "owner_person_id" in payload: d.owner_person_id = payload["owner_person_id"] if "valid_until" in payload: vu = payload["valid_until"] d.valid_until = date.fromisoformat(vu) if isinstance(vu, str) and vu else (vu or None) if "is_shareable" in payload and payload["is_shareable"] is not None: d.is_shareable = bool(payload["is_shareable"]) if "status" in payload and payload["status"]: if payload["status"] not in STATUSES: raise ValidationError(f"status must be one of {STATUSES}") d.status = payload["status"] await db.flush() return d async def presign_download(db: AsyncSession, doc_id: uuid.UUID) -> str: d = await get_doc(db, doc_id) _validate_key(d.object_key) return await presign_get(db, object_key=d.object_key) async def delete_doc(db: AsyncSession, doc_id: uuid.UUID) -> None: d = await get_doc(db, doc_id) await db.delete(d) await db.flush()