"""Live certificate owner resolution. A TLS certificate does not independently own a person — its responsible owner follows the DNS record / zone domain it is served on. This module resolves, for any certificate, the responsible chain WITHOUT relying on a frozen ``owner_person_id`` snapshot: 本人 (primary owner) = manual override (if owner_is_manual) ?? probe_host's DNS record owner ?? the zone domain owner ?? network owner (兜底, optional) 域名负责人 (domain owner) = the zone domain's owner — the escalation target the expiry-alert engine pages in the last-mile window, distinct from 本人. The resolver builds two in-memory indices once (DNS records by FQDN, domains by zone name) and then answers ``resolve(cert)`` in O(1) plus a bounded longest-suffix scan. Certificate volume is small (hundreds), so the read/list/scope path resolves the full set in memory rather than pushing owner filtering into SQL — the stored column is no longer the source of truth for ownership. Why a service, not a column: the source of truth for "who owns this hostname" is the DNS record / domain. Copying it onto the cert (the old behaviour) drifts the instant the record owner changes. Resolving live means the answer is always current and there is nothing to backfill. """ from __future__ import annotations from dataclasses import dataclass from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.cert_service import PROBEABLE_RECORD_TYPES, fqdn_for from app.domains.cmdb.models import CmdbCertificate, CmdbDnsRecord, CmdbDomain # Owner provenance — surfaced to the UI as a badge so an operator can see # WHY a given person is on the hook for a cert (and whether it is an # explicit override vs derived from DNS/domain vs the catch-all fallback). OWNER_SOURCE_MANUAL = "manual" OWNER_SOURCE_DNS_RECORD = "dns_record" OWNER_SOURCE_DOMAIN = "domain" OWNER_SOURCE_NETWORK = "network" OWNER_SOURCE_NONE = "none" @dataclass(frozen=True) class CertOwnerResolution: """The resolved ownership chain for one certificate.""" #: Effective primary owner (本人), including the configured fallbacks. #: ``None`` only when the cert has no manual owner, no DNS/domain owner, #: and no network owner is configured. owner_person_id: str | None #: Where ``owner_person_id`` came from (one of ``OWNER_SOURCE_*``). owner_source: str #: Whether a deliberate admin override is in effect. owner_is_manual: bool #: The zone domain owner (域名负责人) — escalation target. May equal #: ``owner_person_id`` when the cert has no record-level owner. domain_owner_person_id: str | None #: The registrable zone the cert was attributed to (longest matching #: ``cmdb_domains.domain_name`` suffix), or ``None`` if unmatched. matched_zone: str | None #: The DNS record id whose owner became 本人 (when source is #: ``dns_record``), else ``None``. matched_record_id: str | None def _normalize_host(host: str | None) -> str: return (host or "").strip().rstrip(".").lower() def _candidate_names(cert: CmdbCertificate) -> list[str]: """Hostnames a cloud/manual cert (no ``probe_host``) covers, used to attribute it to a zone. Strips wildcard labels (``*.x`` → ``x``).""" names: list[str] = [] for raw in [*(cert.sans or []), cert.common_name]: n = _normalize_host(raw) if not n: continue if n.startswith("*."): n = n[2:] names.append(n) return names class CertOwnerResolver: """Resolve certificate ownership live from DNS records + domains. Build once per request / cron tick via :meth:`build`, then call :meth:`resolve` for each certificate. """ def __init__( self, *, dns_owner_by_fqdn: dict[str, tuple[str | None, str]], domain_owner_by_zone: dict[str, str | None], zone_names: list[str], network_owner_id: str | None, ) -> None: self._dns_owner_by_fqdn = dns_owner_by_fqdn self._domain_owner_by_zone = domain_owner_by_zone # Pre-sort zones longest-first so the first suffix hit is the most # specific (``a.b.example.com`` prefers ``b.example.com`` over # ``example.com`` when both are registered). self._zones_longest_first = sorted(zone_names, key=len, reverse=True) self._network_owner_id = network_owner_id @classmethod async def build( cls, db: AsyncSession, *, network_owner_id: str | None = None, ) -> CertOwnerResolver: """Load DNS-record and domain ownership indices. ``network_owner_id`` (the 网络负责人 catch-all) is appended as the final fallback in the resolved chain when provided; pass it so the fallback person sees owner-less certs in scoped views and the UI can badge them as 兜底. """ domains = list( ( await db.execute( select( CmdbDomain.domain_name, CmdbDomain.owner_person_id ).where(CmdbDomain.deleted_at.is_(None)) ) ).all() ) domain_owner_by_zone: dict[str, str | None] = {} for domain_name, owner in domains: zone = _normalize_host(domain_name) if not zone: continue # A zone can appear under multiple cloud integrations; prefer a # non-null owner so a tenant that set the owner wins over one # that didn't. existing = domain_owner_by_zone.get(zone) if existing is None and owner is not None: domain_owner_by_zone[zone] = str(owner) elif zone not in domain_owner_by_zone: domain_owner_by_zone[zone] = str(owner) if owner else None records = list( ( await db.execute( select( CmdbDnsRecord.id, CmdbDnsRecord.name, CmdbDnsRecord.zone_name, CmdbDnsRecord.owner_person_id, ).where( and_( CmdbDnsRecord.deleted_at.is_(None), CmdbDnsRecord.status == "enable", CmdbDnsRecord.record_type.in_(PROBEABLE_RECORD_TYPES), ) ) ) ).all() ) dns_owner_by_fqdn: dict[str, tuple[str | None, str]] = {} for rec_id, name, zone_name, owner in records: host = fqdn_for(name, zone_name) if host is None: continue host = host.lower() existing = dns_owner_by_fqdn.get(host) # Prefer a record that actually carries an owner over one that # doesn't (multiple records can resolve to the same FQDN across # integrations / record types). if existing is None or (existing[0] is None and owner is not None): dns_owner_by_fqdn[host] = ( str(owner) if owner else None, str(rec_id), ) return cls( dns_owner_by_fqdn=dns_owner_by_fqdn, domain_owner_by_zone=domain_owner_by_zone, zone_names=list(domain_owner_by_zone.keys()), network_owner_id=network_owner_id, ) def _zone_for_host(self, host: str) -> str | None: if not host: return None for zone in self._zones_longest_first: if host == zone or host.endswith("." + zone): return zone return None def _zone_for_cert(self, cert: CmdbCertificate, host: str) -> str | None: if host: zone = self._zone_for_host(host) if zone: return zone # Cloud / manual certs have no probe_host — attribute via the # covered names (CN + SANs). for name in _candidate_names(cert): zone = self._zone_for_host(name) if zone: return zone return None def resolve(self, cert: CmdbCertificate) -> CertOwnerResolution: manual_owner = ( str(cert.owner_person_id) if (cert.owner_is_manual and cert.owner_person_id) else None ) host = _normalize_host(cert.probe_host) record_owner: str | None = None matched_record_id: str | None = None if host: hit = self._dns_owner_by_fqdn.get(host) if hit is not None and hit[0]: record_owner, matched_record_id = hit zone = self._zone_for_cert(cert, host) domain_owner = self._domain_owner_by_zone.get(zone) if zone else None # Effective 本人: override → record → domain → network fallback. if manual_owner: owner, source = manual_owner, OWNER_SOURCE_MANUAL elif record_owner: owner, source = record_owner, OWNER_SOURCE_DNS_RECORD elif domain_owner: owner, source = domain_owner, OWNER_SOURCE_DOMAIN elif self._network_owner_id: owner, source = self._network_owner_id, OWNER_SOURCE_NETWORK else: owner, source = None, OWNER_SOURCE_NONE return CertOwnerResolution( owner_person_id=owner, owner_source=source, owner_is_manual=bool(manual_owner), domain_owner_person_id=domain_owner, matched_zone=zone, matched_record_id=matched_record_id, )