"""HTTP surface for the internal system registry (系统台账). Mounts under ``/api/v1/systems``. Reads are open to the security lane + HR + management (:func:`can_view_system_registry`); every write is ``global_admin`` only, because IT maintains the register — see the gate's docstring for why that is narrower than the security-mutation gate. The employee-facing "systems I own" view is a separate self-service router (``self_router``) keyed on being the owner, not on a role. """ from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.coordinators import dns_owner_coordinator as dns_owner from app.core.database import get_db from app.core.dependencies import get_current_user, get_real_ip from app.core.exceptions import ForbiddenError from app.core.exporting import xlsx_export_response from app.core.idempotent_route import idempotent from app.core.rbac import can_manage_system_registry, can_view_system_registry from app.core.response import paginated_response, success_response from app.domains.system_registry import constants as const from app.domains.system_registry import service as svc from app.domains.system_registry.schemas import ( OwnerTransferRequest, RetireRequest, SystemCreateRequest, SystemUpdateRequest, ) from app.domains.system_registry.serializers import ( serialize_event, serialize_system, serialize_system_option, ) from app.identity.person_models import IdentityPerson router = APIRouter(prefix="/api/v1/systems", tags=["system-registry"]) _RESOURCE = "registry_system" def _require_view(user: AuthUser) -> None: if not can_view_system_registry(user.role_names): raise ForbiddenError( "security, HR or global_admin role required to read the " "system registry", ) def _require_manage(user: AuthUser) -> None: if not can_manage_system_registry(user.role_names): raise ForbiddenError( "global_admin role required to maintain the system registry", ) async def _actor(db: AsyncSession, user: AuthUser) -> IdentityPerson | None: """The acting person row, or ``None`` if the JWT outlived the person. A null actor is recorded on the event rather than 500-ing the write.""" return await svc.load_person_optional(db, user.id) async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str | None, detail: dict[str, Any] | None = None, ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type=_RESOURCE, resource_id=resource_id, detail=detail or {}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) # ════════════════════════════════════════════════════════════════════ # Metadata # ════════════════════════════════════════════════════════════════════ @router.get("/meta") async def get_meta( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response( { "statuses": list(const.STATUSES), "live_statuses": sorted(const.LIVE_STATUSES), "kinds": list(const.KINDS), "exposures": list(const.EXPOSURES), "external_exposures": sorted(const.EXTERNAL_EXPOSURES), "budget_tracks": list(const.TRACKS), "event_types": list(const.EVENT_TYPES), "max_hosts": const.MAX_HOSTS, "summary": await svc.coverage_summary(db), "can_manage": can_manage_system_registry(user.role_names), }, request=request, ) # ════════════════════════════════════════════════════════════════════ # Reads # ════════════════════════════════════════════════════════════════════ @router.get("") async def list_systems( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), q: str | None = None, status: str | None = None, kind: str | None = None, exposure: str | None = None, owner_person_id: uuid.UUID | None = None, ownerless: bool | None = None, in_bounty_scope: bool | None = None, budget_track: str | None = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows, total = await svc.list_systems( db, q=q, status=status, kind=kind, exposure=exposure, owner_person_id=owner_person_id, ownerless=ownerless, in_bounty_scope=in_bounty_scope, budget_track=budget_track, page=page, page_size=page_size, ) return paginated_response( [serialize_system(r) for r in rows], total=total, page=page, page_size=page_size, request=request, ) @router.get("/options") async def list_options( request: Request, q: str | None = None, limit: int = Query(50, ge=1, le=200), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Picker feed for the bounty console. Retired systems are excluded — linking a new finding to a decommissioned system is always a mistake — but a report that already points at one keeps resolving, because the detail read never filters by status.""" _require_view(user) rows, _ = await svc.list_systems( db, q=q, page=1, page_size=limit, ) live = [r for r in rows if r.status in const.LIVE_STATUSES] return success_response( [serialize_system_option(r) for r in live], request=request, ) @router.get("/suggest") async def suggest( request: Request, host: str = Query(..., min_length=2, max_length=255), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Candidates for a free-text "affected system" string. Suggestion only — the operator confirms, because accepting a substring match silently decides a finding's owner and budget pot.""" _require_view(user) rows = await svc.suggest_for_host(db, host) return success_response( [serialize_system_option(r) for r in rows], request=request, ) @router.get("/export") async def export_systems( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows, _ = await svc.list_systems(db, page=1, page_size=1000) records = [ { "slug": r.slug, "name": r.name, "kind": r.kind, "status": r.status, "exposure": r.exposure, "hostnames": ", ".join(r.hostnames or []), "owner": (r.owner.display_name if r.owner else ""), "owner_email": (r.owner.work_email if r.owner else ""), "backup_owner": ( r.backup_owner.display_name if r.backup_owner else "" ), "owner_department": r.owner_department or "", "budget_track": r.budget_track, "in_bounty_scope": "yes" if r.in_bounty_scope else "no", "repo": r.repo or "", } for r in rows ] return await xlsx_export_response( db, request, user, columns=[ ("slug", "Slug"), ("name", "System"), ("kind", "Kind"), ("status", "Status"), ("exposure", "Exposure"), ("hostnames", "Hostnames"), ("owner", "Owner"), ("owner_email", "Owner email"), ("backup_owner", "Backup owner"), ("owner_department", "Owner dept (at assignment)"), ("budget_track", "Budget track"), ("in_bounty_scope", "In bounty scope"), ("repo", "Repo"), ], rows=records, total_matched=len(records), filename_stem="system-registry", sheet_title="系统台账", audit_action="system_registry.export", audit_resource_type=_RESOURCE, ) @router.get("/{system_id}") async def get_system( system_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) system = await svc.get_system(db, system_id) return success_response(serialize_system(system), request=request) @router.get("/{system_id}/events") async def get_events( system_id: uuid.UUID, request: Request, limit: int = Query(100, ge=1, le=500), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) await svc.get_system(db, system_id) events = await svc.list_events(db, system_id, limit=limit) return success_response( [serialize_event(e) for e in events], request=request, ) # ════════════════════════════════════════════════════════════════════ # Writes # ════════════════════════════════════════════════════════════════════ @router.post("") @idempotent("system_registry.create") async def create_system( payload: SystemCreateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) actor = await _actor(db, user) system = await svc.create_system( db, name=payload.name, slug=payload.slug, kind=payload.kind, exposure=payload.exposure, status=payload.status, hostnames=payload.hostnames, description=payload.description, repo=payload.repo, owner_person_id=payload.owner_person_id, backup_owner_person_id=payload.backup_owner_person_id, budget_track=payload.budget_track, in_bounty_scope=payload.in_bounty_scope, notes=payload.notes, actor=actor, ) await _audit( db, request, user, action="system_registry.create", resource_id=str(system.id), detail={"slug": system.slug, "name": system.name}, ) await db.commit() return success_response(serialize_system(system), request=request) @router.patch("/{system_id}") async def update_system( system_id: uuid.UUID, payload: SystemUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) actor = await _actor(db, user) fields = payload.model_dump(exclude_unset=True) system = await svc.update_system( db, system_id, payload=fields, actor=actor, ) await _audit( db, request, user, action="system_registry.update", resource_id=str(system_id), detail={"fields": sorted(fields)}, ) await db.commit() return success_response(serialize_system(system), request=request) @router.get("/{system_id}/owner-suggestions") async def owner_suggestions( system_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Who DNS thinks owns this system's hostnames. Read-only and advisory. Helix already syncs the DNS records and a third of them carry an owner, kept warm by ``cmdb.dns_owner_recertify`` — so for a system with no owner there is usually already a name in the building, and asking around is the slowest way to find it. Behind the READ gate, not the manage gate: this is the same ownership information ``GET /systems`` already returns, sourced differently. The adoption is a write and goes through ``transfer_owner``, which is ``global_admin`` and writes the event. """ _require_view(user) system = await svc.get_system(db, system_id) candidates = await dns_owner.suggest_owners_for_hostnames( db, list(system.hostnames or []), ) return success_response( { "system_id": str(system.id), "hostnames": list(system.hostnames or []), "candidates": candidates, }, request=request, ) @router.post("/{system_id}/owner") async def transfer_owner( system_id: uuid.UUID, payload: OwnerTransferRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Set, change or clear ownership. Separate from PATCH so the ownership trail is guaranteed: there is no code path that changes an owner without writing an event.""" _require_manage(user) actor = await _actor(db, user) system = await svc.transfer_owner( db, system_id, owner_person_id=payload.owner_person_id, backup_owner_person_id=payload.backup_owner_person_id, set_backup=payload.set_backup, note=payload.note, actor=actor, ) await _audit( db, request, user, action="system_registry.transfer_owner", resource_id=str(system_id), detail={ "owner_person_id": ( str(payload.owner_person_id) if payload.owner_person_id else None ), "set_backup": payload.set_backup, }, ) await db.commit() return success_response(serialize_system(system), request=request) @router.delete("/{system_id}") async def retire_system( system_id: uuid.UUID, request: Request, payload: RetireRequest | None = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Soft-delete (status → retired). Historical bounty reports keep resolving to the row; the FK is ``SET NULL``, never a cascade.""" _require_manage(user) actor = await _actor(db, user) system = await svc.retire_system( db, system_id, actor=actor, note=(payload.note if payload else None), ) await _audit( db, request, user, action="system_registry.retire", resource_id=str(system_id), detail={"slug": system.slug}, ) await db.commit() return success_response(serialize_system(system), request=request)