"""HTTP surface for the domain registration flow.""" from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy import func as _func from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( check_approval_submit_rate_limit, get_current_user, require_admin, require_admin_read, ) from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import AppException, is_transaction_aborting_db_error from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.core.response import success_response from app.core.safe_logging import get_safe_logger from app.domains.domain_registration.models import ( DOMAIN_REG_STATUS_APPROVED, DOMAIN_REG_STATUS_CANCELLED, DOMAIN_REG_STATUS_PENDING, DOMAIN_REG_STATUS_REJECTED, DOMAIN_REG_STATUS_REVOKED, DomainRegistration, ) from app.domains.domain_registration.service import ( DomainRegistrationService, ) from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/domain-registrations", tags=["domain-registration"], # Pre-launch hardening 2026-05-04: leaver_pending users cannot # submit / cancel / handover domain registrations. dependencies=[Depends(_leaver_runtime_gate)], ) logger = get_safe_logger(__name__) class DomainRegistrationSubmitBody(BaseModel): domain_name: str = Field(min_length=1, max_length=253) purpose: str = Field(min_length=1, max_length=2000) class DomainRegistrationHandoverBody(BaseModel): successor_person_id: str class DomainRegistrationRevokeBody(BaseModel): reason: str | None = Field(default=None, max_length=255) class DomainRegistrationBatchHandoverBody(BaseModel): reg_ids: list[str] = Field(min_length=1, max_length=500) successor_person_id: str class DomainRegistrationBatchRevokeBody(BaseModel): reg_ids: list[str] = Field(min_length=1, max_length=500) reason: str | None = Field(default=None, max_length=255) # Terminal states for batch idempotency: a row already past its # lifecycle end can neither be handed over nor revoked again — batch # callers re-running a selection should see ``skipped``, not an error. _TERMINAL_STATUSES = frozenset({ DOMAIN_REG_STATUS_REJECTED, DOMAIN_REG_STATUS_CANCELLED, DOMAIN_REG_STATUS_REVOKED, }) def _serialize( row: DomainRegistration, persons: dict[str, IdentityPerson], ) -> dict[str, Any]: holder = persons.get(row.person_id) handover_from = ( persons.get(row.handover_from_person_id) if row.handover_from_person_id else None ) return { "id": row.id, "person_id": row.person_id, "person_label": ( (holder.display_name or holder.work_email) if holder else None ), "domain_name": row.domain_name, "purpose": row.purpose, "status": row.status, "feishu_instance_code": row.feishu_instance_code, "granted_at": row.granted_at.isoformat() if row.granted_at else None, "revoked_at": row.revoked_at.isoformat() if row.revoked_at else None, "revoke_reason": row.revoke_reason, "handover_from_person_id": row.handover_from_person_id, "handover_from_label": ( (handover_from.display_name or handover_from.work_email) if handover_from else None ), "handover_at": ( row.handover_at.isoformat() if row.handover_at else None ), "created_at": row.created_at.isoformat(), "updated_at": row.updated_at.isoformat(), } async def _hydrate( db: AsyncSession, rows: list[DomainRegistration], ) -> list[dict[str, Any]]: if not rows: return [] person_ids: set[str] = set() for r in rows: person_ids.add(r.person_id) if r.handover_from_person_id: person_ids.add(r.handover_from_person_id) persons: dict[str, IdentityPerson] = {} if person_ids: rows_p = (await db.execute( select(IdentityPerson).where( IdentityPerson.id.in_([uuid.UUID(p) for p in person_ids]), ) )).scalars().all() persons = {str(p.id): p for p in rows_p} return [_serialize(r, persons) for r in rows] @router.post("", status_code=201) async def submit( body: DomainRegistrationSubmitBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_approval_submit_rate_limit), ): svc = DomainRegistrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.submit( requester=user, domain_name=body.domain_name, purpose=body.purpose, trace_id=trace_id, ) await db.commit() [data] = await _hydrate(db, [row]) return success_response(data=data, request=request) @router.get("") async def list_registrations( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), scope: str = Query("auto", pattern="^(auto|mine|all)$"), status: str | None = Query( None, pattern=( f"^({DOMAIN_REG_STATUS_PENDING}|{DOMAIN_REG_STATUS_APPROVED}|" f"{DOMAIN_REG_STATUS_REJECTED}|{DOMAIN_REG_STATUS_CANCELLED}|" f"{DOMAIN_REG_STATUS_REVOKED})$" ), ), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ): is_admin = has_role(user.role_names, GLOBAL_ADMIN_ROLE) if scope == "all" and not is_admin: scope = "mine" if scope == "auto": scope = "all" if is_admin else "mine" q = select(DomainRegistration) if scope == "mine": q = q.where(DomainRegistration.person_id == str(user.id)) if status: q = q.where(DomainRegistration.status == status) count_q = select(_func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar() or 0 q = ( q.order_by(DomainRegistration.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) rows = (await db.execute(q)).scalars().all() return success_response( data=await _hydrate(db, list(rows)), request=request, meta={"total": total, "page": page, "page_size": page_size}, ) @router.post("/{reg_id}/cancel") async def cancel( reg_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = DomainRegistrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.cancel(reg_id=reg_id, actor=user, trace_id=trace_id) await db.commit() [data] = await _hydrate(db, [row]) # False ⇒ Helix cancelled but the parallel Feishu instance is still # pending (e.g. template withdraw disabled) — frontend warns. data["feishu_cancel_synced"] = svc.last_feishu_cancel_synced return success_response(data=data, request=request) @router.post("/{reg_id}/handover") async def handover( reg_id: str, body: DomainRegistrationHandoverBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = DomainRegistrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.handover( reg_id=reg_id, successor_person_id=body.successor_person_id, actor=user, trace_id=trace_id, ) await db.commit() [data] = await _hydrate(db, [row]) return success_response(data=data, request=request) @router.post("/{reg_id}/revoke") async def revoke( reg_id: str, body: DomainRegistrationRevokeBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = DomainRegistrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.revoke( reg_id=reg_id, actor=user, reason=body.reason, trace_id=trace_id, ) await db.commit() [data] = await _hydrate(db, [row]) return success_response(data=data, request=request) @router.post("/batch-handover") async def batch_handover( body: DomainRegistrationBatchHandoverBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Hand over many registrations to one successor. Per-row SAVEPOINT isolation: one bad row (raced into a terminal state, successor became a leaver mid-loop, …) only fails that row. Terminal rows and rows already held by the successor → skipped. """ svc = DomainRegistrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") results: list[dict[str, Any]] = [] succeeded = skipped = failed = 0 for reg_id in body.reg_ids: row = await db.get(DomainRegistration, reg_id) if row is None: failed += 1 results.append({"id": reg_id, "status": "failed", "error": "not_found"}) continue if row.status in _TERMINAL_STATUSES: skipped += 1 results.append({"id": reg_id, "status": "skipped"}) continue if row.person_id == body.successor_person_id: # Already held by the successor — idempotent retry. skipped += 1 results.append({"id": reg_id, "status": "skipped"}) continue try: async with db.begin_nested(): await svc.handover( reg_id=reg_id, successor_person_id=body.successor_person_id, actor=user, trace_id=trace_id, ) succeeded += 1 results.append({"id": reg_id, "status": "succeeded"}) except AppException as e: failed += 1 results.append({"id": reg_id, "status": "failed", "error": e.code}) except Exception as e: # noqa: BLE001 — single-row failure must not sink the batch # 1205/1213 roll back the whole tx server-side; swallowing it # cascades into a SAVEPOINT-gone skip of every remaining row. # Re-raise so the caller can retry on a fresh transaction. if is_transaction_aborting_db_error(e): raise failed += 1 logger.error( "domain batch_handover row failed registration_id=%s " "trace_id=%s error_type=%s", reg_id, trace_id, type(e).__name__, ) results.append({ "id": reg_id, "status": "failed", "error": "INTERNAL_ERROR", }) audit = AuditService(db) await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=user.id, action="domain_registration.batch_handover", status="success", resource_type="domain_registration", resource_id=body.reg_ids[0], detail={ "total": len(body.reg_ids), "succeeded": succeeded, "skipped": skipped, "failed": failed, "ids": body.reg_ids[:50], "successor_person_id": body.successor_person_id, }, ) await db.commit() return success_response( data={ "succeeded": succeeded, "skipped": skipped, "failed": failed, "results": results, }, request=request, ) @router.post("/batch-revoke") async def batch_revoke( body: DomainRegistrationBatchRevokeBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Revoke many registrations. Terminal rows (incl. already revoked) → skipped; per-row SAVEPOINT isolation like batch-handover.""" svc = DomainRegistrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") results: list[dict[str, Any]] = [] succeeded = skipped = failed = 0 for reg_id in body.reg_ids: row = await db.get(DomainRegistration, reg_id) if row is None: failed += 1 results.append({"id": reg_id, "status": "failed", "error": "not_found"}) continue if row.status in _TERMINAL_STATUSES: skipped += 1 results.append({"id": reg_id, "status": "skipped"}) continue try: async with db.begin_nested(): await svc.revoke( reg_id=reg_id, actor=user, reason=body.reason, trace_id=trace_id, ) succeeded += 1 results.append({"id": reg_id, "status": "succeeded"}) except AppException as e: failed += 1 results.append({"id": reg_id, "status": "failed", "error": e.code}) except Exception as e: # noqa: BLE001 — single-row failure must not sink the batch # 1205/1213 roll back the whole tx server-side; swallowing it # cascades into a SAVEPOINT-gone skip of every remaining row. # Re-raise so the caller can retry on a fresh transaction. if is_transaction_aborting_db_error(e): raise failed += 1 logger.error( "domain batch_revoke row failed registration_id=%s " "trace_id=%s error_type=%s", reg_id, trace_id, type(e).__name__, ) results.append({ "id": reg_id, "status": "failed", "error": "INTERNAL_ERROR", }) audit = AuditService(db) await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=user.id, action="domain_registration.batch_revoke", status="success", resource_type="domain_registration", resource_id=body.reg_ids[0], detail={ "total": len(body.reg_ids), "succeeded": succeeded, "skipped": skipped, "failed": failed, "ids": body.reg_ids[:50], "reason": body.reason, }, ) await db.commit() return success_response( data={ "succeeded": succeeded, "skipped": skipped, "failed": failed, "results": results, }, request=request, ) @router.get("/leaver/{person_id}") async def list_leaver_handovers( person_id: str, request: Request, user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ): svc = DomainRegistrationService(db) rows = await svc.list_pending_handovers_for_person(person_id) return success_response( data=await _hydrate(db, rows), request=request, )