"""Append-only owner history for network resources. The pre-launch audit (2026-05-17) found that every owner change on ``cmdb_domains`` / ``cmdb_dns_records`` / ``cmdb_cdn_domains`` / ``cmdb_load_balancers`` overwrites the ``owner_person_id`` column in place. ``audit_logs`` records the change with ``actor_user_id`` but the per-resource history is impossible to reconstruct without a SQL scan across heterogeneous ``detail`` JSON shapes. This module exposes a single ``record_owner_change()`` helper that the service / router layer calls anywhere an owner mutates. It writes one row into ``cmdb_network_owner_history`` per change. Contract -------- * No-op (returns silently) when ``previous == new``. Callers don't need a separate "did anything change" check. * No commit / flush-and-trust — caller owns the transaction. The history write participates in whatever outer transaction the owner mutation runs under so an outer rollback rolls back the history row too. This is intentional: a history row pointing at a never- committed owner change would lie. * ``actor_person_id=None`` is system-attributed (cron force-transfer, sweep, resurrection). * Round-28 (2026-05-18): every history row snapshots ``previous_label`` and ``new_label`` into ``context`` so the audit trail survives a later hard-delete of the IdentityPerson FK target. Without this the forensic question "who owned dns.example.com in March?" returns NULL once the leaver row is GC'd, even though the history table still has the change. Authoritative writers --------------------- ``RESOURCE_TYPE_*`` and ``CHANGE_REASON_*`` are the canonical IDs used by the admin UI's ownership-timeline view + audit exports. Adding a new resource type means: (1) add the constant, (2) call ``record_owner_change`` from every code path that mutates that table's owner FK, (3) extend the UI legend. Sync attribution is intentionally NOT a writer — cloud providers carry no reliable owner metadata for domains / DNS / CDN / LB, so resources land with ``owner_person_id = NULL`` and an admin assigns explicitly (which DOES write a history row via ``CHANGE_REASON_ADMIN_PATCH`` / ``CHANGE_REASON_BATCH_OWNER``). """ from __future__ import annotations from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.models import CmdbNetworkOwnerHistory # Canonical resource-type strings — keep aligned with migration 175's seed. RESOURCE_TYPE_DOMAIN = "domain" RESOURCE_TYPE_DNS_RECORD = "dns_record" RESOURCE_TYPE_CDN_DOMAIN = "cdn_domain" RESOURCE_TYPE_LOAD_BALANCER = "load_balancer" RESOURCE_TYPE_CERTIFICATE = "certificate" # Pre-launch P1 fix (Round 45, CR-205): cloud_server was the only # cmdb resource without a per-resource ownership history table. The # offboarding force-transfer of a cloud_server therefore left only a # generic audit_logs row (no chronology, no easy reconciliation). # Now ``record_owner_change(resource_type='cloud_server', ...)`` is # legal and the offboarding T+0 path writes one per server. RESOURCE_TYPE_CLOUD_SERVER = "cloud_server" # Canonical change-reason strings — keep aligned with migration 175's # column comment so admin UIs can render a human-readable label. CHANGE_REASON_ADMIN_PATCH = "admin_patch" CHANGE_REASON_ADMIN_CREATE = "admin_create" CHANGE_REASON_BATCH_OWNER = "batch_owner" CHANGE_REASON_DNS_REQUEST_EXECUTE = "dns_request_execute" CHANGE_REASON_DNS_REQUEST_CREATE = "dns_request_create" CHANGE_REASON_DNS_REQUEST_DELETE = "dns_request_delete" CHANGE_REASON_OFFBOARDING_FORCE_TRANSFER = "offboarding_force_transfer" CHANGE_REASON_SYNC_SWEPT = "sync_swept" CHANGE_REASON_SYNC_RESURRECTED = "sync_resurrected" # Self-service handoff: an active employee hands off DNS records or # servers they own to another active employee without IT involvement. # Distinct from CHANGE_REASON_BATCH_OWNER (admin-initiated bulk fixup # of a freshly-imported cloud account) so audit can split "user # initiated their own handoff" from "admin re-bound rows during a # cleanup". Added 2026-06-02 alongside POST /cmdb/{dns-records, # servers}/batch-owner-transfer. CHANGE_REASON_SELF_SERVICE_TRANSFER = "self_service_transfer" # Initial-attribution row written when sync FIRST lands a row that # already carries an ``owner_person_id`` (e.g. a future provider that # resolves cloud-side resource owner → Helix person directly inside # the upsert path). Migration 175 used this exact string to seed # pre-existing owned resources; the sync wiring re-introduces it as # a Python constant so future code paths writing this reason use the # same canonical string instead of a fresh stringly-typed literal. CHANGE_REASON_SYNC_INITIAL_ATTRIBUTION = "sync_initial_attribution" async def _resolve_label(db: AsyncSession, person_id: str | None) -> str | None: """Return a forensic display string for ``person_id`` — work_email if present, else display_name, else None. Captured into history ``context`` at write time so the trail survives a later hard-delete of the IdentityPerson row (the FK column goes NULL but the denormalized label remains).""" if not person_id: return None from app.identity.person_models import IdentityPerson person = await db.scalar( select(IdentityPerson).where(IdentityPerson.id == str(person_id)), ) if person is None: return None return person.work_email or person.display_name async def record_owner_change( db: AsyncSession, *, resource_type: str, resource_id: str, previous_owner_person_id: str | None, new_owner_person_id: str | None, actor_person_id: str | None, change_reason: str, trace_id: str | None = None, context: dict[str, Any] | None = None, ) -> None: """Append one row to ``cmdb_network_owner_history`` if the owner changed. No-op when ``previous == new`` (covers the "PATCH that didn't touch owner" case so callers can call unconditionally). Caller owns the transaction — the helper only ``add()``s + flushes. Both ``previous_owner_person_id`` and ``new_owner_person_id`` may be NULL (clearing an owner / first attribution). Round-28 (2026-05-18): the resolved ``previous_label`` / ``new_label`` are denormalized into the ``context`` JSON so the forensic trail survives a later IdentityPerson hard-delete (the FK becomes NULL but the label string is preserved). ``actor_label`` is similarly preserved. """ prev = str(previous_owner_person_id) if previous_owner_person_id else None new = str(new_owner_person_id) if new_owner_person_id else None if prev == new: return # Snapshot labels for forensic durability. Best-effort: a failure # to resolve (e.g. caller passes an ID that no longer exists) # leaves the label NULL but does not block the history write. # ``setdefault`` does NOT short-circuit ``await`` — the lookup # would always run. Explicit `if not in enriched` lets batch # callers prefill once and skip per-row lookups. enriched: dict[str, Any] = dict(context or {}) if "previous_label" not in enriched: enriched["previous_label"] = await _resolve_label(db, prev) if "new_label" not in enriched: enriched["new_label"] = await _resolve_label(db, new) if actor_person_id is not None and "actor_label" not in enriched: enriched["actor_label"] = await _resolve_label( db, str(actor_person_id), ) row = CmdbNetworkOwnerHistory( resource_type=resource_type, resource_id=str(resource_id), previous_owner_person_id=prev, new_owner_person_id=new, actor_person_id=str(actor_person_id) if actor_person_id else None, change_reason=change_reason, trace_id=trace_id, context=enriched, ) db.add(row) await db.flush()