"""Pydantic schemas for the auditor router. Each schema is intentionally narrow — list endpoints return paginated DTOs, detail endpoints return the full snapshot with ``raw_payload`` included so the auditor can drill into upstream evidence. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator # ── Common ───────────────────────────────────────────────────────────── class PaginatedMeta(BaseModel): page: int page_size: int total: int # ── 1. MFA ───────────────────────────────────────────────────────────── class MfaStatusRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID person_id: UUID person_full_name: str | None = None person_email: str | None = None employment_status: str | None = None department_path: str | None = None provider: str provider_email: str | None = None is_enrolled: bool is_enforced: bool # NULL/False = the provider never answered this question, so the two # booleans above are defaults rather than readings. The UI must render # "未知" for these instead of "未启用" — the whole Atlassian population # sits here. enrollment_observed: bool | None = None enforcement_observed: bool | None = None enrollment_methods: list[str] | None = None last_verified_at: datetime | None = None last_synced_at: datetime class MfaStatusListResponse(BaseModel): items: list[MfaStatusRow] meta: PaginatedMeta summary: dict[str, Any] class MfaSyncTriggerRequest(BaseModel): providers: list[str] | None = Field(default=None) class MfaSyncResponse(BaseModel): triggered_providers: list[str] person_count: int synced_at: datetime # ── 2. Privileged review ─────────────────────────────────────────────── class PrivilegedReviewCycleSummary(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID cycle_label: str status: str started_at: datetime due_at: datetime completed_at: datetime | None snapshot_holder_count: int decided_count: int revoked_count: int notes: str | None = None class CreateReviewCycleRequest(BaseModel): cycle_label: str = Field(min_length=4, max_length=32) due_at: datetime notes: str | None = Field(default=None, max_length=2000) @field_validator("cycle_label") @classmethod def _label_format(cls, v: str) -> str: v = v.strip() if not v: raise ValueError("cycle_label cannot be blank") return v class PrivilegedRoleHolderRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID cycle_id: UUID person_id: UUID | None person_full_name: str | None = None scope: str role_name: str holder_external_id: str | None holder_email: str | None holder_display_name: str | None snapshot_at: datetime decision: str decided_by_person_id: UUID | None decided_at: datetime | None decision_note: str | None revocation_completed_at: datetime | None class PrivilegedHolderListResponse(BaseModel): items: list[PrivilegedRoleHolderRow] meta: PaginatedMeta cycle: PrivilegedReviewCycleSummary class HolderDecisionRequest(BaseModel): decision: str = Field(pattern=r"^(keep|revoke)$") note: str = Field(min_length=10, max_length=2000) # PR-5 of symmetric-onboarding (2026-06-01). class HolderAssignmentRequest(BaseModel): """Designate (or clear) a reviewer + per-row deadline.""" assigned_to_person_id: UUID | None = None decided_at_deadline: datetime | None = None # ── 3. Public exposure ───────────────────────────────────────────────── class PublicExposureRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID source: str region: str | None resource_id: str resource_arn: str | None resource_name: str | None exposure_type: str severity: str status: str first_seen_at: datetime last_seen_at: datetime resolved_at: datetime | None resolved_by_person_id: UUID | None exposure_detail: dict[str, Any] | None = None class PublicExposureListResponse(BaseModel): items: list[PublicExposureRow] meta: PaginatedMeta summary: dict[str, Any] class ExposureResolveRequest(BaseModel): resolution: str = Field(pattern=r"^(resolved|accepted_risk)$") note: str = Field(min_length=10, max_length=2000) class ExposureScanTriggerResponse(BaseModel): scan_run_id: UUID sources_scanned: list[str] # Sources that answered only partially this round (one AWS account # unresolvable, a bucket whose ACL+policy reads both failed, …). # These are deliberately absent from ``sources_scanned`` so # auto-resolve skips them — surfaced here so the operator can see # the coverage hole instead of reading a clean-looking scan. partial_sources: dict[str, list[str]] = Field(default_factory=dict) findings_count: int started_at: datetime completed_at: datetime # ── 4. Secret rotation ───────────────────────────────────────────────── class SecretRotationRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID secret_kind: str source: str resource_id: str resource_name: str | None owner_person_id: UUID | None owner_email: str | None last_rotated_at: datetime | None rotation_period_days: int next_rotation_due_at: datetime | None status: str days_until_due: int | None class SecretRotationListResponse(BaseModel): items: list[SecretRotationRow] meta: PaginatedMeta summary: dict[str, Any] class SecretRotationSyncResponse(BaseModel): scanned_sources: list[str] record_count: int overdue_count: int synced_at: datetime # ── 5. Signin events ─────────────────────────────────────────────────── class SigninEventRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID person_id: UUID | None person_full_name: str | None = None provider: str provider_email: str | None event_at: datetime event_kind: str status: str source_ip: str | None source_country: str | None source_city: str | None user_agent: str | None anomaly_score: int anomaly_level: str anomaly_reasons: list[str] | None acknowledged_at: datetime | None acknowledged_by_person_id: UUID | None class SigninEventListResponse(BaseModel): items: list[SigninEventRow] meta: PaginatedMeta summary: dict[str, Any] class AcknowledgeSigninRequest(BaseModel): note: str = Field(min_length=10, max_length=2000) class SigninPullResponse(BaseModel): pulled_providers: list[str] new_event_count: int anomaly_count: int pulled_at: datetime # ── 6. PAT audit ─────────────────────────────────────────────────────── class PatAuditRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID kind: str source: str token_external_id: str person_id: UUID | None owner_email: str | None token_name: str | None scopes: list[str] | None created_at_source: datetime | None last_used_at: datetime | None expires_at: datetime | None status: str risk_flags: list[str] | None class PatAuditListResponse(BaseModel): items: list[PatAuditRow] meta: PaginatedMeta summary: dict[str, Any] class PatAuditSyncResponse(BaseModel): pulled_sources: list[str] record_count: int risky_count: int synced_at: datetime # ── 7. Cert expiry ───────────────────────────────────────────────────── class CertExpiryRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID asset_kind: str source: str external_id: str name: str subject: str | None issuer: str | None issued_at: datetime | None expires_at: datetime days_until_expiry: int | None alert_level: str status: str owner_person_id: UUID | None owner_email: str | None environment: str | None notes: str | None class CertExpiryListResponse(BaseModel): items: list[CertExpiryRow] meta: PaginatedMeta summary: dict[str, Any] class CertManualUpsertRequest(BaseModel): asset_kind: str = Field(pattern=r"^(ssl_cert|domain|icp_filing)$") external_id: str = Field(min_length=1, max_length=512) name: str = Field(min_length=1, max_length=512) subject: str | None = Field(default=None, max_length=512) issuer: str | None = Field(default=None, max_length=255) issued_at: datetime | None = None expires_at: datetime owner_person_id: UUID | None = None owner_email: str | None = None environment: str | None = None notes: str | None = Field(default=None, max_length=2000) @field_validator("issued_at", "expires_at") @classmethod def _ensure_aware(cls, v: datetime | None) -> datetime | None: # A date-picker value / offset-less ISO string parses to a NAIVE # datetime; the service computes ``expires_at - datetime.now(UTC)`` # (aware), and ``naive - aware`` raises TypeError → unhandled 500 on # POST .../cert-expiries/manual. Stamp UTC when naive so the manual # path matches the sync path's tz handling. if v is not None and v.tzinfo is None: return v.replace(tzinfo=UTC) return v class CertExpirySyncResponse(BaseModel): pulled_sources: list[str] record_count: int alerting_count: int synced_at: datetime # ── 8a. Risk score ───────────────────────────────────────────────────── class RiskScoreRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID person_id: UUID person_full_name: str | None = None person_email: str | None = None employment_status: str | None = None department_path: str | None = None score: int level: str factors: dict[str, int] breakdown: dict[str, Any] computed_at: datetime class RiskScoreListResponse(BaseModel): items: list[RiskScoreRow] meta: PaginatedMeta summary: dict[str, Any] class RiskScoreRecomputeResponse(BaseModel): person_count: int high_count: int critical_count: int computed_at: datetime # ── 8b. Evidence pack ────────────────────────────────────────────────── class EvidencePackRow(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID requested_by_person_id: UUID title: str period_start: datetime period_end: datetime modules: list[str] status: str file_size: int | None checksum_sha256: str | None started_at: datetime | None completed_at: datetime | None error_message: str | None download_count: int last_downloaded_at: datetime | None retention_until: datetime | None created_at: datetime class EvidencePackListResponse(BaseModel): items: list[EvidencePackRow] meta: PaginatedMeta class EvidencePackBuildRequest(BaseModel): title: str = Field(min_length=3, max_length=255) period_start: datetime period_end: datetime modules: list[str] = Field(min_length=1) @field_validator("modules") @classmethod def _modules_unique(cls, v: list[str]) -> list[str]: if len(set(v)) != len(v): raise ValueError("modules must not contain duplicates") return v # ── Unified findings (cross-control worklist) ────────────────────────── class FindingRow(BaseModel): id: str control_key: str finding_type: str severity: str status: str title: str subject_type: str subject_ref: str | None = None subject_display: str | None = None person_id: str | None = None person_full_name: str | None = None person_email: str | None = None detail: dict[str, Any] | None = None source_table: str | None = None first_seen_at: str | None = None last_seen_at: str | None = None decided_at: str | None = None decision_note: str | None = None accepted_until: str | None = None class FindingListResponse(BaseModel): items: list[FindingRow] meta: PaginatedMeta summary: dict[str, Any] class FindingDecisionRequest(BaseModel): decision: str = Field(pattern=r"^(acknowledge|accept_risk|resolve|reopen)$") note: str = Field(min_length=1, max_length=2000) # Optional auto-expiry for accept_risk — re-opens the finding when past. accepted_days: int | None = Field(default=None, ge=1, le=365) # ── Dashboard summary ────────────────────────────────────────────────── class AuditorDashboardSummary(BaseModel): """Top-level KPI strip for the auditor dashboard landing page.""" mfa_unenrolled_count: int mfa_total_count: int privileged_open_cycle: PrivilegedReviewCycleSummary | None public_exposure_open_count: int public_exposure_critical_count: int secret_overdue_count: int signin_high_anomaly_24h: int pat_risky_count: int cert_expiring_30d: int risk_critical_count: int risk_high_count: int last_evidence_pack: EvidencePackRow | None