from __future__ import annotations import ipaddress import re import uuid from datetime import date, datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator # Pre-launch (Round-28) hardening: every employee-reachable input schema # rejects unknown keys. Forward-compatibility foothold (silent mass- # assignment) was MEDIUM in the audit; with ``extra="forbid"`` a future # schema-field add that an attacker could otherwise slip a value into # surfaces as a 422 on the first request that tries it. _STRICT = ConfigDict(extra="forbid") # ─── DNS record-value validators ───────────────────────────────────── # Used by both DnsRecordCreate (admin direct-write) and the # self-service DnsRecordRequestCreate. Matches the legacy CMDB's # `CheckDnsRecordValue` so an A record can't be saved with a CNAME-shaped # value and vice versa — the cloud provider would reject it but the # request would already be sitting in someone's Feishu approval inbox # with a misleading "looks fine" description. # RFC 1123 / 5891 — labels 1-63 chars, alnum + hyphen (not at edges). # Underscores are permitted inside (and at the start of) non-TLD labels: # they are not valid host-name chars per RFC 1123 but ARE legal and common # in real DNS targets — ACM cert-validation CNAMEs # (``_..acm-validations.aws``), DKIM (``_domainkey``), DMARC # (``_dmarc``), and SRV (``_sip._tcp``) all rely on underscore-prefixed # labels. The cloud providers accept them; rejecting them here only blocks # legitimate requests (e.g. ACM certificate issuance). The TLD stays # alpha-only — TLDs never contain underscores. Trailing dot allowed (FQDN). # Kept in lock-step with ``_HOSTNAME_RE`` in dns_request_service.py. _FQDN_RE = re.compile( r"^(?=.{1,253}\.?$)((?!-)[A-Za-z0-9_-]{1,63}(? bool: try: ipaddress.IPv4Address(value.strip()) return True except (ValueError, ipaddress.AddressValueError): return False def _is_ipv6(value: str) -> bool: try: ipaddress.IPv6Address(value.strip()) return True except (ValueError, ipaddress.AddressValueError): return False def _is_fqdn(value: str) -> bool: return bool(_FQDN_RE.match(value.strip())) def _validate_dns_value(record_type: str, value: str) -> str: """Validate ``value`` against the rules implied by ``record_type``. Returns the original (untrimmed) value on success; raises ValueError with a precise message on failure so the API client + Feishu approval card see a useful complaint, not just "validation failed". """ t = record_type.upper() v = (value or "").strip() if not v: raise ValueError(f"DNS {t} record value must be non-empty.") if t == "A": if not _is_ipv4(v): raise ValueError(f"A record value must be an IPv4 address, got {value!r}.") elif t == "AAAA": if not _is_ipv6(v): raise ValueError(f"AAAA record value must be an IPv6 address, got {value!r}.") elif t in ("CNAME", "NS"): if not _is_fqdn(v): raise ValueError( f"{t} record value must be a fully-qualified domain " f"name (e.g. 'example.com.'), got {value!r}.", ) elif t == "MX": # `` `` — priority 0-65535. parts = v.split() if len(parts) != 2 or not parts[0].isdigit(): raise ValueError( "MX record value must be ' ' " f"(e.g. '10 mx.example.com.'), got {value!r}.", ) prio = int(parts[0]) if not (0 <= prio <= 65535): raise ValueError( f"MX priority must be between 0 and 65535, got {prio}.", ) if not _is_fqdn(parts[1]): raise ValueError( f"MX target {parts[1]!r} is not a valid FQDN.", ) elif t == "SRV": # `` `` — three uint16 + FQDN. parts = v.split() if len(parts) != 4: raise ValueError( "SRV record value must be ' " f" ', got {value!r}.", ) for label, raw in (("priority", parts[0]), ("weight", parts[1]), ("port", parts[2])): if not raw.isdigit() or not (0 <= int(raw) <= 65535): raise ValueError( f"SRV {label} must be uint16 (0-65535), got {raw!r}.", ) if not _is_fqdn(parts[3]): raise ValueError( f"SRV target {parts[3]!r} is not a valid FQDN.", ) elif t == "CAA": # `` <"value">`` per RFC 8659. parts = v.split(None, 2) if len(parts) != 3 or not parts[0].isdigit(): raise ValueError( "CAA record value must be ' <\"value\">', " f"got {value!r}.", ) flags = int(parts[0]) if not (0 <= flags <= 255): raise ValueError(f"CAA flags must be 0-255, got {flags}.") if parts[1] not in ("issue", "issuewild", "iodef"): raise ValueError( f"CAA tag must be one of issue / issuewild / iodef, " f"got {parts[1]!r}.", ) elif t == "TXT": # TXT records accept any string (including quoted segments). # The DNSPod / Aliyun APIs cap at 255 bytes per string; the # Pydantic field already bounds to 4096 so multi-string TXT # records can fit. Nothing else to check here. if len(v.encode("utf-8")) > 4096: raise ValueError( f"TXT record value is too long ({len(v.encode('utf-8'))} bytes); " "DNS providers cap at 4096 bytes.", ) # else: unknown type — Pydantic Literal on the schema catches that # before we get here. The validator is intentionally permissive on # types it doesn't know. return value # ``ProviderAccountCreate / Response / Update`` removed in migration 045 # — cloud tenants are now managed through /admin/integrations # (``Integration`` kind='cloud'). See ``app/integrations/router.py``. ResourceType = Literal["servers", "domains", "dns_records", "load_balancers", "cdn", "object_storage", "iam"] class SyncTriggerRequest(BaseModel): resource_types: list[ResourceType] = ["servers", "domains"] class ForceSweepRequest(BaseModel): """Admin-confirmed override for the cmdb resource-sync safety floor. ``resource_types`` is REQUIRED and must be non-empty — no wildcard. Each entry forces the matching sync's ``_safe_sweep`` to bypass the 5%-drop / 0-rows guards. Soft-deletes are reversible but the audit trail (``cmdb.sync.force_sweep_override``) is forever; the admin picks the affected capabilities explicitly. ``reason`` is plumbed into the audit detail for human context. """ resource_types: list[ResourceType] = Field(..., min_length=1) reason: str | None = Field(default=None, max_length=512) class SyncJobResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str triggered_by: str resource_types: list[str] status: str capability_results: dict | None total_upserted: int total_deleted: int total_errors: int error_message: str | None queued_at: datetime started_at: datetime | None finished_at: datetime | None model_config = {"from_attributes": True} class ServerResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str instance_id: str name: str | None status: str region: str | None zone: str | None vcpu: int | None memory_mb: int | None public_ips: list[str] | None private_ips: list[str] | None os_type: str | None tags: dict | None # owner_raw dropped in migration 042; owner_person_id is the # canonical FK + owner_label is a router-populated display string # (work_email / display_name) for direct UI rendering without a # second API call. owner_person_id: str | None owner_label: str | None = None # Leaver visibility: surface 工号 + 在职状态 alongside the owner label so # PersonTag can strike-through + 「已离职」-tag departed owners. owner_employee_no: str | None = None owner_employment_status: str | None = None # Inherited owner (live-resolved, never stored): when there is no # explicit ``owner_person_id``, the server falls back to the owner of # its cloud subscription (scope). Populated router-side; the frontend # renders it as the 负责人 with a 「继承自订阅」 tag. ``owner_person_id`` # itself stays null (the edit control still shows "unassigned"). owner_inherited: bool = False effective_owner_person_id: str | None = None effective_owner_label: str | None = None effective_owner_employee_no: str | None = None effective_owner_employment_status: str | None = None inherited_from_scope_id: str | None = None inherited_from_scope_name: str | None = None # Legacy CMDB fields (migration 173). app: str | None = None remark: str | None = None instance_type: str | None = None image_name: str | None = None vpc_id: str | None = None subnet_id: str | None = None project_id: str | None = None system_disk_gb: int | None = None data_disk_gb: int | None = None bandwidth_mbps: int | None = None expired: bool = False # Modifier tracking (migration 173). last_modified_by_person_id: str | None = None last_modified_by_label: str | None = None last_modified_at: datetime | None = None last_synced_at: datetime # When Helix first saw this VM via cloud sync. Proxy for "how long # has this been around" — drives the "已使用 N 天" column and the # half-yearly owner confirmation cron's newborn-guard. Understates # the real cloud-side creation time for VMs older than Helix's # first sync of that tenant, which is acceptable for governance. first_seen_at: datetime | None = None # Joined from ``integrations`` row server-side so the frontend can # gate the "更多 → 开机/关机/重启/删除" action menu per-provider # (only ucloud / aliyun / aws / alicloud_intl implement the cloud # lifecycle methods today) and render the cloud-vendor pill in the # detail page without a second round-trip. Null when the integration # row was deleted since the cmdb row landed (orphan). provider: str | None = None integration_name: str | None = None model_config = {"from_attributes": True} class DomainResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str domain_name: str registrar: str | None expiry_date: date | None auto_renew: bool icp_filing_no: str | None status: str owner_person_id: str | None owner_label: str | None = None # Leaver visibility: 工号 + 在职状态 for PersonTag. owner_employee_no: str | None = None owner_employment_status: str | None = None last_synced_at: datetime environment: str | None = None allow_dns_request: bool = True # Legacy CMDB fields (migration 173). grade: str | None = None possessor: str | None = None remark: str | None = None # Modifier tracking (migration 173). last_modified_by_person_id: str | None = None last_modified_by_label: str | None = None last_modified_at: datetime | None = None # Provenance flag (migration 218). ``synced`` = last write from a # cloud driver; ``migrated`` = legacy CMDB import, expiry / auto_renew # are stale; ``manual`` = admin edit via DomainAnnotation. data_source: Literal["synced", "migrated", "manual"] = "synced" model_config = {"from_attributes": True} class DomainSelectableForDns(BaseModel): domain_name: str # ``integration_id`` is the resolved **authoritative-DNS provider** # for this zone, NOT the registrar. Resolved by # ``CmdbService.resolve_dns_integration_for_zone`` from the # provider that holds the most records in # ``cmdb_dns_records``. ``None`` means "no synced DNS provider # currently owns this zone" — employee self-service cannot route # the write, so the FE filters these out of the picker (admin # must seed the first record from the cloud console first, or # the IT admin uses the admin DNS page to direct-create). # Naming kept as ``integration_id`` for backward compat with the # existing FE callers; semantics corrected (was: registrar id # from cmdb_domains, which silently routed DNS writes to the # wrong provider when registrar != DNS provider). integration_id: str | None integration_name: str | None = None provider: str | None = None class DnsRecordResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str zone_name: str record_id: str name: str record_type: str value: str ttl: int line: str | None status: str owner_person_id: str | None owner_label: str | None = None # Leaver visibility: 工号 + 在职状态 for PersonTag. owner_employee_no: str | None = None owner_employment_status: str | None = None # Legacy CMDB field (migration 173). remark: str | None = None # Modifier tracking (migration 173). last_modified_by_person_id: str | None = None last_modified_by_label: str | None = None last_modified_at: datetime | None = None last_synced_at: datetime # 2026-06-02: surfaced to the frontend so the "我还在用" button can # be gated to records actually approaching the 90-day recertify # window (within 14 days of due / already overdue). Without this, # MyCloudDnsPage rendered the recertify button on every owned row, # turning a "保鲜确认" affordance into permanent visual noise. last_owner_review_at: datetime | None = None model_config = {"from_attributes": True} def _strip_zone_suffix(name: str, zone_name: str) -> str: """Normalize a pasted FQDN down to the provider-relative host name. Providers (DNSPod / Aliyun) treat the submitted name as RELATIVE to the zone, so an un-stripped FQDN creates ``.`` — a double-suffixed record that resolves nowhere the requester expects (2026-06-10 prod incident: an ACM validation CNAME landed at ``_x.ops-dev.pingcap.net.pingcap.net`` and the request still read "executed"). Bare zone collapses to ``@`` (apex). Returns the original input when normalization would leave an empty name — the downstream hostname validators produce the precise error message. """ name = name.strip().rstrip(".") zone = zone_name.strip().rstrip(".").lower() if zone: while name.lower().endswith("." + zone): name = name[: -(len(zone) + 1)] if name.lower() == zone: name = "@" return name class DnsRecordCreate(BaseModel): model_config = _STRICT integration_id: uuid.UUID zone_name: str = Field(min_length=1, max_length=253) name: str = Field(min_length=1, max_length=253) # "@" for apex, "www" for subdomain record_type: Literal["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA"] value: str = Field(min_length=1, max_length=4096) # 60 s = lowest sane TTL; 86400 s = 1 day (most providers cap higher # but 1 day already represents a slow propagation window for # operational use). Bound here so admin direct-write can't hit the # cloud with ttl=0/-1 and get an opaque provider error. ttl: int = Field(default=600, ge=60, le=86400) line: str | None = Field(default=None, max_length=64) owner_person_id: uuid.UUID | None = None remark: str | None = Field(default=None, max_length=2000) @model_validator(mode="after") def _normalize_name(self): # Admin direct-write shares the FQDN-paste hazard with the # self-service request flow — see ``_strip_zone_suffix``. normalized = _strip_zone_suffix(self.name, self.zone_name) if normalized: self.name = normalized return self @model_validator(mode="after") def _check_value(self): # Cross-field validation: enforce the record_type → value format # contract on every admin direct-create. Provider rejection happens # later but produces opaque "InvalidParameter" errors; failing # here gives the admin a precise message before any cloud call. _validate_dns_value(self.record_type, self.value) return self class DnsRecordUpdate(BaseModel): # ``extra="forbid"`` on this schema is the server-side enforcement of # the rule that DNS host (``name`` / SubDomain) is immutable on # update — any caller that PATCHes with a ``name`` field gets a 422 # rejection from Pydantic instead of silently rewriting the record's # identity. Was the root cause of the 2026-05-28 prod incident where # a frontend Form-state desync sent the previously-opened record's # host into a different record's update. DNSPod / Aliyun / Route 53 # all treat host as part of the record's identity (delete-and- # recreate to rename); keeping the schema in lockstep prevents any # MCP / mobile / future-self-service path from re-introducing the # footgun without an explicit schema migration. model_config = _STRICT value: str | None = Field(default=None, min_length=1, max_length=4096) ttl: int | None = Field(default=None, ge=60, le=86400) line: str | None = Field(default=None, max_length=64) # Migration 173: owner / remark are also editable via admin PATCH # to match the legacy CMDB feature surface. owner_person_id: uuid.UUID | None = None remark: str | None = Field(default=None, max_length=2000) # When ``owner_person_id`` is explicitly null in the JSON body, the # caller wants to *clear* the owner. Pydantic alone can't tell apart # "field omitted" vs "field set to null", so callers should use # ``model_fields_set`` on the service side — the schema is permissive # and the resolution happens there. class DnsRecordValueProbe(BaseModel): """Single-field probe schema used by the value-format validator. Kept public so tests can exercise the validator in isolation without constructing a full create payload.""" record_type: Literal["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA"] value: str @field_validator("value") @classmethod def _probe(cls, v: str, info): _validate_dns_value(info.data.get("record_type", ""), v) return v class DnsRecordBatchOwnerUpdate(BaseModel): """Body for ``POST /cmdb/dns-records/batch-owner``. Mirrors the legacy CMDB's ``record_ids[]`` mode (PATCH zones/.../records with a populated ``record_ids`` field) where the only change-set was the owner. Pure DB-side write — no cloud call (DNS providers don't know about Helix's owner FK). """ model_config = _STRICT record_ids: list[uuid.UUID] = Field(..., min_length=1, max_length=500) owner_person_id: uuid.UUID | None = None class DnsRecordBatchOwnerTransfer(BaseModel): """Body for ``POST /cmdb/dns-records/batch-owner-transfer``. Self-service variant of ``batch-owner``. Unlike the admin-only ``DnsRecordBatchOwnerUpdate`` above: * caller must own EVERY record in ``record_ids`` (admin bypass); * target owner is required (no "clear owner" mode — that's admin-only); * the new owner gets a Feishu + email notification "X 把 N 条 DNS 记录转给你了" via the notification outbox. The endpoint is the employee-side replacement for "find IT to re-bind my records before I go on leave / hand a project to a teammate". No approval — the caller already owns the records, so handing them off does not escalate any privilege. """ model_config = _STRICT record_ids: list[uuid.UUID] = Field(..., min_length=1, max_length=500) new_owner_person_id: uuid.UUID reason: str | None = Field(default=None, max_length=500) class ServerBatchOwnerTransfer(BaseModel): """Body for ``POST /cmdb/servers/batch-owner-transfer``. Self-service server owner handoff. Same shape + semantics as the DNS variant above — caller must own every selected server (admin bypass), target owner is mandatory, new owner gets a notification. """ model_config = _STRICT server_ids: list[uuid.UUID] = Field(..., min_length=1, max_length=500) new_owner_person_id: uuid.UUID reason: str | None = Field(default=None, max_length=500) # ─── DNS self-service request schemas ───────────────────────────── # These back the employee-facing /my/network-requests flow. The # narrower record-type literal here is on purpose — see # ``DNS_REQ_SELF_SERVICE_TYPES`` in models.py. DnsRequestAction = Literal["create", "update", "delete", "enable", "disable"] DnsRequestStatus = Literal[ "pending", "approved", "rejected", "executing", "executed", "failed", "cancelled", ] DnsRequestRecordType = Literal["A", "AAAA", "CNAME", "TXT", "NS"] class DnsRecordRequestCreate(BaseModel): """What the employee POSTs to /api/v1/cmdb/dns-record-requests. For ``create``, ``target_record_id`` MUST be null — it's a brand-new record and the value/name/type are taken straight off this body. For ``update`` / ``delete`` / ``enable`` / ``disable``, ``target_record_id`` MUST resolve to a DNS record the requester currently owns. The service validates both ownership and that the chosen action is supported on the record's type (CNAME → can update, A → can update, etc.). For ``enable`` / ``disable``, ``value`` / ``ttl`` / ``name`` / ``record_type`` are still required for display purposes (the Feishu approval card embeds them so the approver can see "你要关掉的是 foo.example.com A 1.2.3.4") but they're not pushed to the cloud — the executor only flips the cloud-side status flag. """ model_config = _STRICT action: DnsRequestAction integration_id: uuid.UUID zone_name: str = Field(min_length=1, max_length=253) name: str = Field(min_length=1, max_length=253) record_type: DnsRequestRecordType value: str = Field(min_length=1, max_length=4096) ttl: int = Field(default=600, ge=60, le=86400) target_record_id: uuid.UUID | None = None purpose: str = Field(min_length=1, max_length=2000) attachment_url: str | None = Field(default=None, max_length=1024) @model_validator(mode="after") def _normalize_name(self): # Requesters routinely paste the full FQDN ("foo.ops-dev.pingcap.net") # into the host field — see ``_strip_zone_suffix``. Single choke # point for both the HTTP router and the MCP tool. normalized = _strip_zone_suffix(self.name, self.zone_name) if normalized: self.name = normalized return self @model_validator(mode="after") def _check_value(self): # Only value-changing actions need the record_type → value format # contract. ``enable`` / ``disable`` carry the value purely for # display, so a stale or oddly-formatted value (e.g. a previous # CNAME target now expired) shouldn't block re-enabling. if self.action in ("create", "update"): _validate_dns_value(self.record_type, self.value) return self class DnsRecordRequestResponse(BaseModel): id: uuid.UUID requester_person_id: str requester_label: str | None = None action: DnsRequestAction integration_id: uuid.UUID | str zone_name: str name: str record_type: str value: str ttl: int line: str | None target_record_id: str | None purpose: str attachment_url: str | None status: DnsRequestStatus feishu_instance_code: str | None error_message: str | None submitted_at: datetime | None decided_at: datetime | None executed_at: datetime | None executed_record_pk: str | None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class DnsRecertifyResponse(BaseModel): """Returned after an owner clicks "still in use" on the recertify Feishu card or hits the recertify endpoint directly. ``recertified`` is the count of records the call actually updated (the caller may have lost ownership of some between the card render and the click, in which case those are silently skipped — the audit log carries the per-record outcome).""" recertified: int class LoadBalancerResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str lb_id: str name: str | None lb_type: str network_type: str ip_address: str | None region: str | None status: str owner_raw: str | None # Migration 173: ``owner_person_id`` joins LB to the FK pattern used # by servers / domains / DNS / CDN. ``owner_raw`` stays as the # sync-driver-written audit fallback. owner_person_id: str | None = None owner_label: str | None = None # Leaver visibility: 工号 + 在职状态 for PersonTag. owner_employee_no: str | None = None owner_employment_status: str | None = None # Legacy CMDB fields (migration 173). app: str | None = None remark: str | None = None tag: str | None = None vpc_id: str | None = None subnet_id: str | None = None project_id: str | None = None listener_count: int | None = None # Modifier tracking (migration 173). last_modified_by_person_id: str | None = None last_modified_by_label: str | None = None last_modified_at: datetime | None = None last_synced_at: datetime model_config = {"from_attributes": True} class CdnDomainResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str domain_name: str cname: str | None status: str source_type: str | None source_address: str | None is_https: bool owner_person_id: str | None owner_label: str | None = None # Leaver visibility: 工号 + 在职状态 for PersonTag. owner_employee_no: str | None = None owner_employment_status: str | None = None # Legacy CMDB fields (migration 173). cert_id: str | None = None remark: str | None = None # Modifier tracking (migration 173). last_modified_by_person_id: str | None = None last_modified_by_label: str | None = None last_modified_at: datetime | None = None last_synced_at: datetime model_config = {"from_attributes": True} # ─── Annotation (PATCH) schemas ──────────────────────────────────── # Each schema extends the previous (owner_raw only) shape with the # legacy-CMDB editable fields. Every field is optional; the service # layer uses ``model_fields_set`` to tell apart "field omitted" vs # "field set to null". ``owner_person_id`` is the canonical input # (UUID picker on the frontend); ``owner_raw`` stays for back-compat # with admins who paste an email into the legacy free-text input. class ServerAnnotation(BaseModel): # ``extra="forbid"`` matches sibling DomainAnnotation / # LoadBalancerAnnotation / CdnDomainAnnotation and blocks # forward-mass-assignment of sync-owned columns (instance_type, # image_name, vpc_id, …) through the PATCH body. model_config = _STRICT owner_raw: str | None = Field(default=None, max_length=255) owner_person_id: uuid.UUID | None = None app: str | None = Field(default=None, max_length=255) remark: str | None = Field(default=None, max_length=2000) class DomainAnnotation(BaseModel): # Round-28: every annotation column gets an explicit max_length matching # the backing DB column (owner_raw 255, icp_filing_no 64, environment 16). # Without these, an admin paste of a long string lands a DataError mid- # transaction and modifier intent is lost. ``extra="forbid"`` blocks # forward-mass-assignment of server-only columns through the PATCH body. model_config = _STRICT owner_raw: str | None = Field(default=None, max_length=255) owner_person_id: uuid.UUID | None = None icp_filing_no: str | None = Field(default=None, max_length=64) environment: str | None = Field(default=None, max_length=16) allow_dns_request: bool | None = None # Migration 173: legacy CMDB lets admins edit grade / possessor / remark. grade: str | None = Field(default=None, max_length=16) possessor: str | None = Field(default=None, max_length=255) remark: str | None = Field(default=None, max_length=2000) # Migration 218: admin override of sync-owned columns for rows where # no live cloud driver writes them (``data_source='migrated'`` — # legacy CMDB import, e.g. the 50+ domains tagged under UCloud which # has no ``list_domains`` driver, or domains registered at GoDaddy / # Markmonitor / Namecheap where Helix has no integration). # The service layer flips ``data_source`` to ``manual`` whenever ANY # of these three are present in ``model_fields_set`` so the next # cloud sync still wins (sync re-stamps ``synced``) — admin edits # only persist for as long as no real driver speaks. # ``registrar`` is the cloud-provider slug (``aliyun``/``tencent``…) # OR a free-form registrar string ("GoDaddy" / "Markmonitor") for # off-platform registrations — same column, two namespaces; the UI # disambiguates via ``data_source``. registrar: str | None = Field(default=None, max_length=64) expiry_date: date | None = None auto_renew: bool | None = None class LoadBalancerAnnotation(BaseModel): model_config = _STRICT owner_raw: str | None = Field(default=None, max_length=255) owner_person_id: uuid.UUID | None = None app: str | None = Field(default=None, max_length=255) remark: str | None = Field(default=None, max_length=2000) class CdnDomainAnnotation(BaseModel): model_config = _STRICT owner_raw: str | None = Field(default=None, max_length=255) owner_person_id: uuid.UUID | None = None cert_id: str | None = Field(default=None, max_length=128) remark: str | None = Field(default=None, max_length=2000) class ServerBatchAnnotation(BaseModel): """Body for ``POST /cmdb/servers/batch-annotate``. Apply the same set of changes (any non-omitted field on the inner annotation) to every server in ``server_ids``. Used by the admin UI when bulk-assigning a freshly-imported KSY/UCloud server cohort to their new owner / app label. """ server_ids: list[uuid.UUID] = Field(..., min_length=1, max_length=500) annotation: ServerAnnotation class LoadBalancerBatchAnnotation(BaseModel): """Body for ``POST /cmdb/load-balancers/batch-annotate``.""" load_balancer_ids: list[uuid.UUID] = Field(..., min_length=1, max_length=500) annotation: LoadBalancerAnnotation class ObjectStorageResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str bucket_name: str region: str | None storage_class: str | None access_type: str | None size_bytes: int | None object_count: int | None status: str owner_raw: str | None # Migration 366: OSS joins the owner FK pattern used by LB/CDN/DNS. # ``owner_raw`` stays as the sync-driver-written cloud-tag fallback. owner_person_id: str | None = None owner_label: str | None = None # Leaver visibility: 工号 + 在职状态 for PersonTag. owner_employee_no: str | None = None owner_employment_status: str | None = None last_synced_at: datetime model_config = {"from_attributes": True} class ObjectStorageAnnotation(BaseModel): # ``extra="forbid"`` blocks forward-mass-assignment of sync-owned # columns (bucket_name, region, size_bytes, …) — matches sibling # annotation schemas. model_config = _STRICT owner_raw: str | None = Field(default=None, max_length=255) owner_person_id: uuid.UUID | None = None class IamUserResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str user_name: str arn: str | None user_id_external: str | None create_date: datetime | None last_login_at: datetime | None attached_policies: list | None groups: list | None mfa_enabled: bool # Tri-state: 'enabled' | 'disabled' | 'unknown' | None (row predates # the column). ``mfa_enabled`` alone cannot say "we never asked this # provider", which is how 25 Tencent CAM users were reported as # having no MFA when several had it on. mfa_state: str | None = None # 'human' | 'service' | None. Set by a human; never inferred. account_kind: str | None = None account_kind_note: str | None = None account_kind_set_at: datetime | None = None # Provider's console-login switch, lifted out of raw_data. # ``False`` is the authoritative service-account signal — the # UI offers it as a HINT for classification and never writes a # classification from it, because a guess rendered as a # decision is how this kind of data goes wrong. console_login: bool | None = None tags: dict | None owner_person_id: str | None owner_label: str | None = None # Leaver visibility: 工号 + 在职状态 for the mapped Helix owner so # PersonTag can mark a departed owner of a still-live IAM user. owner_employee_no: str | None = None owner_employment_status: str | None = None # Provider + integration display fields are joined in by the router # (not on the model row). They are essential on the employee # ``/my/cloud/iam`` view to tell which cloud each row belongs to — # ARN prefix alone is unreadable across AWS / Tencent / Aliyun. provider: str | None = None integration_name: str | None = None last_synced_at: datetime # Set when Helix actively deleted the IAM user on the cloud side # (offboarding revoke / scope-decommission evacuation). Null on # currently-live users. Surfaced on the IAM users page so admins # can see "this user was deleted by us, when, and why" without # the row disappearing. revoked_at: datetime | None = None revoke_reason: str | None = None model_config = {"from_attributes": True} class IamUserOwnerUpdate(BaseModel): """Body for ``PATCH /cmdb/iam-users/{id}/owner``. ``owner_person_id`` set to ``None`` (or the JSON literal null) explicitly clears the mapping — useful when an IAM user has been reassigned cloud-side and the previous Helix mapping is stale. ``reason`` ≥ 4 characters; surfaces in the audit log so future reviewers can tell *why* an admin overrode the auto-mapping. """ owner_person_id: uuid.UUID | None = None reason: str = Field(..., min_length=4, max_length=500) class IamUserCleanupRequest(BaseModel): """Body for ``POST /cmdb/iam-users/{id}/cleanup``. Two modes; see the route docstring for full semantics. * **Disown** (``transfer_to_person_id`` null) — Helix loads the cloud driver and runs the full IAM revoke chain (login profile, access keys, MFA, attached policies, group memberships, signing certs, SSH keys, then ``DeleteUser``). Only on driver ``ok=True`` does Helix clear ``owner_person_id`` and stamp ``revoked_at`` + ``revoke_reason``. **Irreversible cloud-side.** * **Transfer** (``transfer_to_person_id`` set) — Helix-side rebind only; the IAM user keeps working on the cloud side. Driver-level errors surface as 502 ``IAM_CLEANUP_PROVIDER_ERROR`` (the row stays untouched so the admin can fix the precondition and retry); a missing ``revoke_account`` impl surfaces as 501 ``IAM_CLEANUP_NOT_SUPPORTED``. """ reason: str = Field(..., min_length=4, max_length=500) transfer_to_person_id: uuid.UUID | None = Field( None, description=( "Optional: re-bind ownership to this Helix person instead " "of running the destructive cloud-side delete. Pass null " "to disown — that path runs the driver's IAM revoke chain " "and is irreversible." ), ) class BillingMonthlyResponse(BaseModel): id: uuid.UUID integration_id: uuid.UUID | str integration_name: str # Non-null: ``integrations.provider`` is a NOT NULL column. Orphan # billing rows (integration row hard-deleted) are dropped by the # router before we reach this schema — see the loop in # ``list_monthly_billing``. provider: str month: date total_cost_usd: float cost_by_service: dict | None currency: str fetched_at: datetime model_config = {"from_attributes": True} class BillingSummary(BaseModel): """Aggregated multi-account view for the /admin/cloud/overview?tab=billing page.""" month: date total_cost_usd: float by_account: list[dict] # [{integration_id, integration_name, total_cost_usd}] top_services: list[dict] # [{service, cost_usd}] class BillingProviderBreakdown(BaseModel): """One provider's contribution to the period total + the matching contribution for the immediately-preceding period of the same length. ``delta_pct`` is ``None`` when the prior period's total was zero — a divide-by-zero would fabricate "+∞%" growth, which reads as a UI bug rather than the correct "no comparable history" answer. """ provider: str total_cost_usd: float prev_total_cost_usd: float delta_pct: float | None share_pct: float account_count: int class BillingDailyPoint(BaseModel): """One point on the daily-trend stacked-area chart. Keys of ``by_provider`` are the same provider strings used in ``by_provider`` above; the frontend zips them so colours stay consistent across the cards and the chart. """ date: date by_provider: dict[str, float] total: float class BillingOverview(BaseModel): """Range-aware multi-cloud overview powering the redesigned billing page. A single endpoint returns everything the page needs (totals, per-provider breakdown with prior-period delta, daily series, top services, by-account table) so the page renders in one round-trip rather than firing four parallel requests and stitching them client-side. The daily series is dense — the endpoint backfills missing days with zero-cost points so the chart's x-axis lines up with the calendar rather than jumping over weekends and sync-failure gaps. ``unregistered_cost_usd`` — portion of ``total_cost_usd`` that comes from AWS linked accounts not yet registered as cmdb_cloud_scopes. Zero when all accounts are registered or the integration does not expose org-level CE access. Non-zero signals a registration gap. ``owner_breakdown`` — aggregated iamPrincipal/Owner (or equivalent) cost map across all scopes in the period. Keys are the raw tag values returned by the provider (e.g. ``chensiyu@pingcap.com``, ``BedrockAPIKey-95gg``). Empty dict when no scope returned owner data. """ from_date: date = Field(..., alias="from") to_date: date = Field(..., alias="to") days: int total_cost_usd: float prev_total_cost_usd: float delta_pct: float | None avg_daily_cost_usd: float unregistered_cost_usd: float by_provider: list[BillingProviderBreakdown] by_account: list[dict] # [{integration_id, integration_name, provider, total_cost_usd, share_pct, is_unregistered?, linked_account_id?}] top_services: list[dict] # [{service, cost_usd}] owner_breakdown: dict[str, float] # {owner_tag_value: cost_usd} daily: list[BillingDailyPoint] model_config = {"populate_by_name": True} # ── TLS/SSL certificates ──────────────────────────────────────────── class CertificateResponse(BaseModel): id: uuid.UUID source: Literal["probed", "cloud", "manual"] integration_id: str | None = None cloud_cert_id: str | None = None probe_host: str | None = None probe_port: int = 443 common_name: str | None = None sans: list[str] | None = None issuer: str | None = None serial_number: str | None = None fingerprint_sha256: str | None = None not_before: datetime | None = None not_after: datetime | None = None key_algorithm: str | None = None key_bits: int | None = None signature_algorithm: str | None = None # Freshness-aware read of the coarse lifecycle status: the stored column is # written at probe time and never re-derived, so a host that stopped being # probed keeps a status contradicting its own ``not_after`` (see # ``cert_service.effective_cert_status``). ``unreachable`` is preserved — # that records the probe outcome, not the lifecycle. status: str # True when the stored column disagreed with the derived lifecycle, i.e. # this row's probe has gone stale. Surfaces the broken probe instead of # silently papering over it. status_stale: bool = False last_error: str | None = None # Ownership is LIVE-RESOLVED (DNS record → zone domain → network owner), # not the stored snapshot. ``owner_person_id`` here is the resolved # effective owner (本人); ``owner_source`` says where it came from. owner_person_id: str | None = None owner_label: str | None = None # Provenance: manual | dns_record | domain | network | none. owner_source: str | None = None # True when ``owner_person_id`` is a deliberate admin override. owner_is_manual: bool = False # The zone the cert was attributed to (longest domain suffix match). matched_zone: str | None = None # 域名负责人 (zone domain owner) — the escalation target, distinct # from 本人 when the cert has a record-level owner. domain_owner_person_id: str | None = None domain_owner_label: str | None = None # Expiry-alert snooze: when set and >= today, the owner marked this cert # "renewing" and reminders/escalation are paused through this date. acknowledged_until: str | None = None # Leaver visibility: 工号 + 在职状态 for PersonTag. owner_employee_no: str | None = None owner_employment_status: str | None = None environment: str | None = None last_probed_at: datetime | None = None last_synced_at: datetime | None = None first_seen_at: datetime | None = None remark: str | None = None last_modified_by_person_id: str | None = None last_modified_by_label: str | None = None last_modified_at: datetime | None = None model_config = {"from_attributes": True} class CertificateAnnotation(BaseModel): """Admin PATCH of the operator-editable fields on any certificate row (owner / environment / remark). Sync- and probe-owned fields (not_after, fingerprint, …) are never accepted here.""" model_config = _STRICT owner_person_id: uuid.UUID | None = None environment: str | None = Field(default=None, max_length=16) remark: str | None = Field(default=None, max_length=2000) class CertificateCreate(BaseModel): """Admin manual-entry of a certificate Helix can't reach over the network (e.g. an appliance on an isolated segment).""" model_config = _STRICT common_name: str = Field(min_length=1, max_length=256) not_after: datetime not_before: datetime | None = None issuer: str | None = Field(default=None, max_length=256) sans: list[str] | None = None owner_person_id: uuid.UUID | None = None environment: str | None = Field(default=None, max_length=16) remark: str | None = Field(default=None, max_length=2000) # ─── 专线 (dedicated / leased line) ────────────────────────────────── # A manually-curated register of inter-office / VPN circuits. No upstream # provider sync; admins own create/update/delete via ``can_manage_cloud``. class DedicatedLineResponse(BaseModel): """Read response for a leased-line row.""" id: uuid.UUID location: str contracting_entity: str | None carrier: str | None payment_method: str | None monthly_cost: float | None annual_cost: float | None currency: str renewal_date: str | None purpose: str | None remark: str | None last_modified_by_person_id: str | None = None last_modified_by_label: str | None = None last_modified_at: datetime | None = None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class DedicatedLineCreate(BaseModel): """Admin create payload.""" model_config = _STRICT location: str = Field(min_length=1, max_length=128) contracting_entity: str | None = Field(default=None, max_length=255) carrier: str | None = Field(default=None, max_length=128) payment_method: str | None = Field(default=None, max_length=64) monthly_cost: float | None = Field(default=None, ge=0) annual_cost: float | None = Field(default=None, ge=0) currency: str = Field(default="CNY", min_length=1, max_length=8) renewal_date: str | None = Field(default=None, max_length=128) purpose: str | None = Field(default=None, max_length=4000) remark: str | None = Field(default=None, max_length=2000) class DedicatedLineUpdate(BaseModel): """Admin PATCH payload — only the fields present are changed.""" model_config = _STRICT location: str | None = Field(default=None, min_length=1, max_length=128) contracting_entity: str | None = Field(default=None, max_length=255) carrier: str | None = Field(default=None, max_length=128) payment_method: str | None = Field(default=None, max_length=64) monthly_cost: float | None = Field(default=None, ge=0) annual_cost: float | None = Field(default=None, ge=0) currency: str | None = Field(default=None, min_length=1, max_length=8) renewal_date: str | None = Field(default=None, max_length=128) purpose: str | None = Field(default=None, max_length=4000) remark: str | None = Field(default=None, max_length=2000)