"""Compliance incidents — open tickets when the platform's data- freshness invariant is violated. An incident is created when an automated sweep (post-leaver verify, weekly full scan, etc.) finds data that shouldn't exist. Each row names exactly one offending resource: - ``person_id``: who the resource (wrongly) still points at (usually a ``left`` leaver) - ``resource_type`` / ``resource_id``: the specific record with the stale owner ref (``asset_items.assigned_to``, ``software_seat_allocations.user_id``, etc.) - ``resource_label``: human-readable name so the admin UI doesn't need to JOIN at render time - ``severity``: ``critical`` for hardware / API keys / cloud (hard guarantee — owner must exist), ``warning`` for softer ones Incidents stay ``open`` until someone resolves them through the admin page, either by transferring the resource to a new owner, revoking it, or flagging it as known-false. The :meth:`DailyDigestService` surfaces newly-open incidents in the morning summary. """ from __future__ import annotations import uuid from datetime import datetime from typing import Any from sqlalchemy import ( JSON, DateTime, ForeignKey, Index, String, Text, func, ) from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base from app.core.sql_types import GUID as Uuid # Status values INCIDENT_OPEN = "open" INCIDENT_RESOLVED = "resolved" INCIDENT_ACCEPTED = "accepted" # admin acknowledges as known/expected # Severity SEVERITY_CRITICAL = "critical" SEVERITY_WARNING = "warning" # Incident types — narrow enum so admin filters stay predictable TYPE_LEAVER_ORPHAN = "leaver_orphan" # resource still owned by a 'left' person TYPE_MANAGER_MISSING = "manager_missing" # force-transfer had no valid target TYPE_NOTIFICATION_DEAD = "notification_dead" # outbox row exhausted retries # 2026-06-01 onboarding-symmetry PR-1: a new hire has been at the # company > 7 days but still has at least one Day-1 dimension blocker # pending (no SSO / no hardware / no software seat). Created by the # ``onboarding.t7_verify`` cron. TYPE_ONBOARDING_DAY1_GAP = "onboarding_day1_gap" # 2026-06-01 PR-5 of symmetric-onboarding (access-review track): a # privileged-review holder row has a decided_at_deadline that's > 14 # days in the past with status still 'pending'. Created by the # auditor.privileged_review_escalate cron. TYPE_ACCESS_REVIEW_OVERDUE = "access_review_overdue" class ComplianceIncident(Base): __tablename__ = "compliance_incidents" __table_args__ = ( Index("ix_compliance_incidents_status", "status"), Index("ix_compliance_incidents_person", "person_id"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) incident_type: Mapped[str] = mapped_column(String(64), nullable=False) severity: Mapped[str] = mapped_column( String(16), nullable=False, server_default=SEVERITY_CRITICAL, ) status: Mapped[str] = mapped_column( String(16), nullable=False, server_default=INCIDENT_OPEN, ) person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) resource_type: Mapped[str | None] = mapped_column(String(64)) # ``GUID`` (= CHAR(36) on the wire, so NO schema migration vs the prior # raw ``CHAR(36)``), NOT a bare ``CHAR(36)``. Every other UUID column in # this table uses ``Uuid``; the bare CHAR left ``resource_id`` without # ``GUID.process_bind_param``'s ``uuid.UUID → str`` coercion, so cmdb # callers passing a raw ``uuid.UUID`` (e.g. ``uuid.uuid5`` dedup keys) # bound the wrong representation and the idempotency SELECT # ``resource_id == `` silently matched 0 rows — every sweep opened a # fresh duplicate incident instead of bumping ``detected_at``. With the # decorator both ``uuid.UUID`` and ``str`` callers bind identically. resource_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) resource_label: Mapped[str | None] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255), nullable=False) detail: Mapped[dict[str, Any] | None] = mapped_column(JSON) detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) resolved_by_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) resolution_note: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) __all__ = [ "INCIDENT_ACCEPTED", "INCIDENT_OPEN", "INCIDENT_RESOLVED", "SEVERITY_CRITICAL", "SEVERITY_WARNING", "TYPE_ACCESS_REVIEW_OVERDUE", "TYPE_LEAVER_ORPHAN", "TYPE_MANAGER_MISSING", "TYPE_NOTIFICATION_DEAD", "TYPE_ONBOARDING_DAY1_GAP", "ComplianceIncident", ]