"""HTTP surface for the VPN (Feilian) Full-Mode application 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.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( get_current_user, require_admin, ) from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.core.response import success_response from app.domains.vpn_application.models import ( VPN_APP_STATUS_APPROVED, VPN_APP_STATUS_CANCELLED, VPN_APP_STATUS_PENDING, VPN_APP_STATUS_REJECTED, VPN_APP_STATUS_REVOKED, VPN_NODES, VpnApplication, ) from app.domains.vpn_application.service import VpnApplicationService from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/vpn-applications", tags=["vpn-application"], # Pre-launch hardening 2026-05-04: leaver_pending users cannot # submit / cancel / handover VPN applications. dependencies=[Depends(_leaver_runtime_gate)], ) _NODE_PATTERN = "^(" + "|".join(VPN_NODES) + ")$" class VpnApplicationSubmitBody(BaseModel): vpn_node: str = Field(pattern=_NODE_PATTERN) usage_description: str = Field(min_length=1, max_length=2000) resource_checklist: str = Field(min_length=1, max_length=2000) class VpnApplicationRevokeBody(BaseModel): reason: str | None = Field(default=None, max_length=255) def _serialize( row: VpnApplication, persons: dict[str, IdentityPerson], ) -> dict[str, Any]: holder = persons.get(row.person_id) return { "id": row.id, "person_id": row.person_id, "person_label": ( (holder.display_name or holder.work_email) if holder else None ), "vpn_node": row.vpn_node, "usage_description": row.usage_description, "resource_checklist": row.resource_checklist, "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, "created_at": row.created_at.isoformat(), "updated_at": row.updated_at.isoformat(), } async def _hydrate( db: AsyncSession, rows: list[VpnApplication], ) -> list[dict[str, Any]]: if not rows: return [] person_ids = {r.person_id for r in rows} 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: VpnApplicationSubmitBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = VpnApplicationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.submit( requester=user, vpn_node=body.vpn_node, usage_description=body.usage_description, resource_checklist=body.resource_checklist, trace_id=trace_id, ) await db.commit() [data] = await _hydrate(db, [row]) return success_response(data=data, request=request) @router.get("") async def list_applications( 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"^({VPN_APP_STATUS_PENDING}|{VPN_APP_STATUS_APPROVED}|" f"{VPN_APP_STATUS_REJECTED}|{VPN_APP_STATUS_CANCELLED}|" f"{VPN_APP_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(VpnApplication) if scope == "mine": q = q.where(VpnApplication.person_id == str(user.id)) if status: q = q.where(VpnApplication.status == status) count_q = select(_func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar() or 0 q = ( q.order_by(VpnApplication.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("/{app_id}/cancel") async def cancel( app_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = VpnApplicationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.cancel(app_id=app_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("/{app_id}/revoke") async def revoke( app_id: str, body: VpnApplicationRevokeBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = VpnApplicationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") row = await svc.revoke( app_id=app_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)