"""VPN (Feilian) Full-Mode application flow. Mirrors ``app.domains.domain_registration.service``. Differences: - Form has three fields: vpn_node (single choice), usage_description (textarea), resource_checklist (textarea). - No leaver handover; admin can revoke when the person leaves. Feishu flow_key: ``vpn_application_request``. The external_code (template GUID) is registered via ``/admin/integrations//flows``. """ from __future__ import annotations import json import logging import uuid from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.approval.cancel_desync import notify_feishu_cancel_desync from app.approval.feishu_client import FeishuApprovalClient from app.approval.models import ApprovalRequest, ApprovalSubmissionIntent from app.approval.submission_intents import ( compact_terminal_intent_payload, defer_submission_intent_retry, reconciliation_due_predicate, reconciliation_order, submit_with_durable_intent, terminal_intent_resource_id, ) from app.audit.service import AuditService from app.core.exceptions import AppException, NotFoundError 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.identity.person_models import IdentityPerson from app.integrations.compat import get_feishu_config from app.integrations.service import get_approval_flow logger = logging.getLogger(__name__) _FEISHU_FLOW_KEY = "vpn_application_request" # Widget keyword fragments — substring match, case-insensitive. The # upstream Feishu template uses English labels ("VPN node", "Usage # Description", "Resource Review Checklist"); we also match common # Chinese phrasings in case the template gets localized later. _VPN_FIELD_KEYWORDS: dict[str, tuple[str, ...]] = { "vpn_node": ("vpn", "node", "节点", "线路", "exit"), "usage_description": ("usage", "用途", "使用", "description", "说明", "申请说明"), "resource_checklist": ("resource", "checklist", "网站", "ip", "资源"), } _VPN_SKIP_WIDGET_TYPES = frozenset( {"text", "section", "image", "attachmentV2"}, ) def _match_widget(widget_name: str, field_key: str) -> bool: name_lower = (widget_name or "").lower() return any(kw.lower() in name_lower for kw in _VPN_FIELD_KEYWORDS.get(field_key, ())) def _resolve_single_choice_value( widget: dict[str, Any], display_text: str, ) -> str | None: """For ``radioV2`` widgets Feishu expects the option's ``value`` field rather than its display ``text``. Look up by case-insensitive substring so "CN4-HK" matches "CN4-HK out【For use in mainland China only】".""" options = widget.get("option") or widget.get("options") or [] needle = display_text.strip().lower() if not needle: return None for opt in options: if not isinstance(opt, dict): continue opt_text = str(opt.get("text", "")).strip().lower() if opt_text == needle or needle in opt_text: return str(opt.get("value", "")) return None def _build_vpn_form_payload( widgets: list[dict[str, Any]], values: dict[str, Any], ) -> str: # Honour Feishu ``display_condition`` — hidden widgets skipped # (no required-check, no serialisation). Shared evaluator in # ``app.integrations.feishu_form_visibility``. from app.integrations.feishu_form_visibility import ( evaluate_display_conditions, ) visibility = evaluate_display_conditions( widgets, {}, assume_visible_when_answers_unsourced=True ) serialized: list[dict[str, Any]] = [] for widget in widgets: wtype = widget.get("type", "input") if wtype in _VPN_SKIP_WIDGET_TYPES: continue wid = widget.get("id") if not wid: continue if not visibility.get(wid, True): continue wname = widget.get("name") or "" matched_value: Any = None for field_key in _VPN_FIELD_KEYWORDS: if _match_widget(wname, field_key) and field_key in values: matched_value = values[field_key] break if matched_value is None and widget.get("required"): # Required free-text widgets we couldn't match fall back # to the usage description so the approver doesn't see an # empty card. if wtype in ("textarea", "input"): matched_value = values.get("usage_description", "") if matched_value in (None, "", [], {}): continue text = ( str(matched_value) if not isinstance(matched_value, (dict, list)) else json.dumps(matched_value, ensure_ascii=False) ) if wtype in ("radioV2", "radio"): resolved = _resolve_single_choice_value(widget, text) if resolved: text = resolved serialized.append({"id": wid, "type": wtype, "value": text}) return json.dumps(serialized, ensure_ascii=False) def _parse_template_widgets(raw_form: Any) -> list[dict[str, Any]]: if isinstance(raw_form, str): return json.loads(raw_form or "[]") if isinstance(raw_form, list): return raw_form return [] class VpnApplicationService: # Outcome of the most recent ``cancel()`` call's best-effort Feishu # side-cancel. Routers / MCP tools read it after ``cancel()`` to # surface ``feishu_cancel_synced`` to the requester. last_feishu_cancel_synced: bool = True def __init__(self, db: AsyncSession) -> None: self.db = db self.audit = AuditService(db) self._feishu_client: FeishuApprovalClient | None = None async def _client(self) -> FeishuApprovalClient: if self._feishu_client is None: cfg = await get_feishu_config(session=self.db) self._feishu_client = FeishuApprovalClient( app_id=str(cfg.get("app_id") or ""), app_secret=str(cfg.get("app_secret") or ""), ) return self._feishu_client # ── Submit ────────────────────────────────────────────────── async def submit( self, *, requester: IdentityPerson, vpn_node: str, usage_description: str, resource_checklist: str, trace_id: str, ) -> VpnApplication: # Round 2b audit fix-up 2026-05-04: service-layer leaver gate. # The router-level ``_leaver_runtime_gate`` blocks FastAPI HTTP # paths but MCP tools (PAT-authenticated agents) call the # service directly. Without this check, a leaver_pending user # with a valid PAT could submit via MCP and bypass the entire # offboarding lock-out. if requester.employment_status in ("leaver_pending", "left"): raise AppException( code="VPN_APP_REQUESTER_NOT_ACTIVE", message=("Your account is in offboarding; new VPN applications are not accepted."), status_code=403, ) if vpn_node not in VPN_NODES: raise AppException( code="VPN_APP_NODE_INVALID", message=(f"vpn_node must be one of {list(VPN_NODES)}; got {vpn_node!r}."), status_code=400, ) clean_usage = (usage_description or "").strip() if not clean_usage: raise AppException( code="VPN_APP_USAGE_REQUIRED", message="Please describe what you need Full Mode for.", status_code=400, ) clean_checklist = (resource_checklist or "").strip() if not clean_checklist: raise AppException( code="VPN_APP_CHECKLIST_REQUIRED", message=( "Please list at least one overseas website or IP " "you actually need Full Mode to reach." ), status_code=400, ) # Atomicity (review batch 33): the duplicate-pending guard # below is check-then-act with no lock and no backing unique # constraint, so two concurrent submits (double-click / agent # retry) could both pass the check and each fire a Feishu # approval instance. Mirror the canonical ``apikey.submit`` # pattern: take a per-requester distributed lock and re-check # pending INSIDE the lock so only one submission proceeds. from app.core.distributed_lock import ( # noqa: PLC0415 LockBusy, distributed_lock, ) lock_key = f"vpn_app_submit:{requester.id}" try: async with distributed_lock(lock_key, ttl=120): # Pre-launch hardening 2026-05-04: reject duplicate pending # submissions by the same requester. Without this guard a # double-click (or an LLM agent retrying on transient errors) # creates parallel Feishu approval instances and floods the # approver. Cancel-or-wait is the documented user action. existing_pending = await self.db.scalar( select(VpnApplication.id) .where( VpnApplication.person_id == str(requester.id), VpnApplication.status == VPN_APP_STATUS_PENDING, ) .limit(1) .with_for_update() ) if existing_pending: raise AppException( code="VPN_APP_ALREADY_PENDING", message=( "You already have a pending VPN application. " "Cancel it first or wait for it to be processed." ), status_code=409, ) flow = await get_approval_flow(_FEISHU_FLOW_KEY, session=self.db) approval_code = flow.external_code client = await self._client() definition = await client.get_approval_definition(approval_code) widgets = _parse_template_widgets(definition.get("form", "[]")) field_values = { "vpn_node": vpn_node, "usage_description": clean_usage, "resource_checklist": clean_checklist, } form_payload = _build_vpn_form_payload(widgets, field_values) feishu_user_id = await client.resolve_user_id_by_email( requester.work_email, ) app_id = str(uuid.uuid4()) approval_request_id = str(uuid.uuid4()) intent = await submit_with_durable_intent( self.db, client=client, scope=_FEISHU_FLOW_KEY, applicant_id=requester.id, semantic_payload={ "vpn_node": vpn_node, "usage_description": clean_usage, "resource_checklist": clean_checklist, }, provider_payload={ "approval_code": approval_code, "identity_type": "user_id", "identity_value": feishu_user_id, "form": form_payload, }, finalization_payload={ "application_id": app_id, "approval_request_id": approval_request_id, "approval_code": approval_code, "vpn_node": vpn_node, "usage_description": clean_usage, "resource_checklist": clean_checklist, }, trace_id=trace_id, ) return await self._finalize_submission_intent( intent.id, recovered=intent.attempt_count > 1, ) except LockBusy as exc: raise AppException( code="VPN_APP_SUBMIT_IN_FLIGHT", message=( "Another submission of yours is being processed. Please retry in a few seconds." ), status_code=409, ) from exc async def _finalize_submission_intent( self, intent_id: uuid.UUID, *, recovered: bool, ) -> VpnApplication: intent = await self.db.scalar( select(ApprovalSubmissionIntent) .where(ApprovalSubmissionIntent.id == intent_id) .with_for_update() .execution_options(populate_existing=True) ) if intent is None or intent.scope != _FEISHU_FLOW_KEY: raise RuntimeError("VPN submission intent disappeared") if intent.state == "completed": app_id = terminal_intent_resource_id( intent, resource_type="vpn_application", ) existing = await self.db.get(VpnApplication, app_id) if existing is None: raise RuntimeError("completed VPN intent has no row") return existing payload = dict((intent.payload or {}).get("finalization") or {}) app_id = str(payload["application_id"]) existing = await self.db.get(VpnApplication, app_id) if intent.state != "provider_created" or not intent.external_id: raise RuntimeError(f"cannot finalize VPN intent in {intent.state!r}") now = datetime.now(UTC) if existing is None: row = VpnApplication( id=app_id, person_id=str(intent.applicant_id), vpn_node=str(payload["vpn_node"]), usage_description=str(payload["usage_description"]), resource_checklist=str(payload["resource_checklist"]), status=VPN_APP_STATUS_PENDING, feishu_instance_code=intent.external_id, created_at=now, updated_at=now, ) self.db.add(row) await self.db.flush() approval_request = ApprovalRequest( id=uuid.UUID(str(payload["approval_request_id"])), external_id=intent.external_id, approval_type=_FEISHU_FLOW_KEY, applicant_id=intent.applicant_id, status=VPN_APP_STATUS_PENDING, source_payload={ "vpn_application_id": app_id, "approval_code": payload["approval_code"], "vpn_node": payload["vpn_node"], }, ) self.db.add(approval_request) await self.db.flush() row.approval_request_id = str(approval_request.id) row.updated_at = now await self.db.flush() else: row = existing intent.approval_request_id = uuid.UUID(str(payload["approval_request_id"])) intent.state = "completed" intent.completed_at = now intent.last_error = None await self.audit.log( trace_id=( f"approval-intent-reconcile:{intent.id}" if recovered else str((intent.payload or {}).get("trace_id")) ), actor_type="system" if recovered else "user", actor_user_id=None if recovered else intent.applicant_id, action=( "approval.submission_intent.reconciled" if recovered else "vpn_application.submit" ), status="success", resource_type="vpn_application", resource_id=app_id, detail={ "intent_id": str(intent.id), "provider_request_uuid": intent.provider_request_uuid, "feishu_instance_code": intent.external_id, "vpn_node": payload["vpn_node"], "recovered": recovered, }, ) compact_terminal_intent_payload( intent, resource_type="vpn_application", resource_id=app_id, ) await self.db.commit() await self.db.refresh(row) return row async def reconcile_submission_intents( self, *, min_age_minutes: int = 2, batch_limit: int = 100, ) -> dict[str, int]: cutoff = datetime.now(UTC) - timedelta(minutes=min_age_minutes) states = ("prepared", "submitting", "unknown", "provider_created") intent_refs = list( ( await self.db.execute( select( ApprovalSubmissionIntent.id, ApprovalSubmissionIntent.applicant_id, ) .where( ApprovalSubmissionIntent.scope == _FEISHU_FLOW_KEY, ApprovalSubmissionIntent.state.in_(states), (ApprovalSubmissionIntent.state == "provider_created") | (ApprovalSubmissionIntent.updated_at < cutoff), reconciliation_due_predicate(datetime.now(UTC)), ) .order_by(*reconciliation_order()) .limit(batch_limit) ) ).all() ) await self.db.rollback() summary = {"checked": len(intent_refs), "reconciled": 0, "still_unknown": 0, "failed": 0} if not intent_refs: return summary client = await self._client() from app.core.distributed_lock import ( # noqa: PLC0415 LockBusy, distributed_lock, ) for intent_id, applicant_id in intent_refs: try: async with distributed_lock( f"vpn_app_submit:{applicant_id}", ttl=120, ): intent = await self.db.get(ApprovalSubmissionIntent, intent_id) if intent is None or intent.state == "completed": continue stored = dict(intent.payload or {}) intent = await submit_with_durable_intent( self.db, client=client, scope=intent.scope, applicant_id=intent.applicant_id, recovery_key=stored.get("recovery_key"), semantic_payload=dict(stored.get("semantic") or {}), provider_payload=dict(stored.get("provider") or {}), finalization_payload=dict(stored.get("finalization") or {}), trace_id=str(stored.get("trace_id") or f"approval-intent:{intent.id}"), ) await self._finalize_submission_intent(intent.id, recovered=True) summary["reconciled"] += 1 except LockBusy as exc: await defer_submission_intent_retry(self.db, intent_id, exc) summary["still_unknown"] += 1 except AppException as exc: if exc.code == "APPROVAL_SUBMISSION_RESULT_UNKNOWN": await self.db.rollback() else: await defer_submission_intent_retry(self.db, intent_id, exc) summary[ "still_unknown" if exc.code == "APPROVAL_SUBMISSION_RESULT_UNKNOWN" else "failed" ] += 1 except Exception as exc: # noqa: BLE001 await defer_submission_intent_retry(self.db, intent_id, exc) summary["failed"] += 1 return summary # ── Cancel ────────────────────────────────────────────────── async def cancel( self, *, app_id: str, actor: IdentityPerson, trace_id: str, ) -> VpnApplication: row = await self._lock(app_id) if row.person_id != str(actor.id): raise AppException( code="VPN_APP_NOT_OWNER", message="Only the original requester can cancel.", status_code=403, ) if row.status != VPN_APP_STATUS_PENDING: raise AppException( code="VPN_APP_INVALID_STATE", message=(f"Cannot cancel an application in {row.status!r} state."), status_code=409, ) self.last_feishu_cancel_synced = True if row.feishu_instance_code: try: client = await self._client() flow = await get_approval_flow( _FEISHU_FLOW_KEY, session=self.db, ) feishu_user_id = await client.resolve_user_id_by_email( actor.work_email, ) await client.cancel_instance( instance_code=row.feishu_instance_code, user_id=feishu_user_id, approval_code=flow.external_code, ) except Exception as e: # noqa: BLE001 logger.warning( "Feishu cancel failed for vpn_application %s (instance=%s): %s", row.id, row.feishu_instance_code, e, ) self.last_feishu_cancel_synced = False await notify_feishu_cancel_desync( self.db, flow_label="VPN 申请", resource_type="vpn_application", resource_id=str(row.id), instance_code=row.feishu_instance_code, error=e, trace_id=trace_id, ) row.status = VPN_APP_STATUS_CANCELLED row.updated_at = datetime.now(UTC) await self._mirror_approval_request_status(row, "cancelled") await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor.id, action="vpn_application.cancel", status="success", resource_type="vpn_application", resource_id=row.id, ) return row # ── Feishu callback ───────────────────────────────────────── async def handle_feishu_callback( self, *, approval_request: ApprovalRequest, instance_detail: dict[str, Any], ) -> None: source = approval_request.source_payload or {} app_id = source.get("vpn_application_id") if not app_id: logger.warning( "vpn_application callback missing id: ar=%s", approval_request.id, ) return feishu_status = (instance_detail.get("status") or "").upper() try: row = await self._lock(app_id) except NotFoundError: logger.warning( "vpn_application callback for missing row %s — ignoring", app_id, ) return if row.status != VPN_APP_STATUS_PENDING: return now = datetime.now(UTC) if feishu_status in ("APPROVED", "PASS"): # Pre-launch hardening 2026-05-06: re-validate the requester # is still an active employee — Feishu approvals can sit # pending for days, and a leaver getting VPN approved after # offboarding is exactly the kind of soft-launch the # employment_status gate is meant to prevent. requester = await self.db.get(IdentityPerson, row.person_id) if ( requester is None or requester.deleted_at is not None or (requester.employment_status in ("left", "leaver_pending")) ): row.status = VPN_APP_STATUS_REJECTED row.updated_at = now requester_status = requester.employment_status if requester else None await self.audit.log( trace_id=f"feishu_callback:{approval_request.external_id}", actor_type="integration", actor_user_id=None, action="vpn_application.callback_refused_leaver", status="failure", resource_type="vpn_application", resource_id=row.id, detail={ "instance_code": approval_request.external_id, "feishu_status": feishu_status, "requester_status": requester_status, "reason": "requester_offboarded_during_pending", }, ) return row.status = VPN_APP_STATUS_APPROVED row.granted_at = now elif feishu_status in ("REJECTED", "REFUSED"): row.status = VPN_APP_STATUS_REJECTED elif feishu_status in ("CANCELED", "CANCELLED", "DELETED"): row.status = VPN_APP_STATUS_CANCELLED else: return row.updated_at = now await self.audit.log( trace_id=f"feishu_callback:{approval_request.external_id}", actor_type="integration", actor_user_id=None, action=f"vpn_application.{row.status}", status="success", resource_type="vpn_application", resource_id=row.id, detail={ "instance_code": approval_request.external_id, "feishu_status": feishu_status, }, ) # ── Admin revoke ──────────────────────────────────────────── async def revoke( self, *, app_id: str, actor: IdentityPerson, reason: str | None, trace_id: str, ) -> VpnApplication: row = await self._lock(app_id) if row.status != VPN_APP_STATUS_APPROVED: raise AppException( code="VPN_APP_NOT_REVOKABLE", message=(f"Cannot revoke an application in {row.status!r} state."), status_code=409, ) now = datetime.now(UTC) row.status = VPN_APP_STATUS_REVOKED row.revoked_at = now row.revoke_reason = (reason or "").strip()[:255] or None row.updated_at = now await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor.id, action="vpn_application.revoke", status="success", resource_type="vpn_application", resource_id=row.id, detail={"reason": row.revoke_reason}, ) return row # ── Helpers ───────────────────────────────────────────────── async def _lock(self, app_id: str) -> VpnApplication: # SELECT ... FOR UPDATE so a concurrent cancel + Feishu approval # webhook can't race into a split-brain. Previously this was a # plain ``db.get`` — the helper's name implied a lock that # wasn't there. # ``populate_existing`` overwrites any stale copy of this row already in # the session identity map with the freshly-locked DB state — the # documented locked-get identity-map trap. Matches the convention in # provisioning/offboarding ``_lock`` helpers. r = ( await self.db.execute( select(VpnApplication) .where(VpnApplication.id == app_id) .with_for_update() .execution_options(populate_existing=True) ) ).scalar_one_or_none() if r is None: raise NotFoundError("VpnApplication", app_id) return r async def _mirror_approval_request_status( self, row: VpnApplication, new_status: str, ) -> None: if not row.feishu_instance_code: return ar = ( await self.db.execute( select(ApprovalRequest).where( ApprovalRequest.external_id == row.feishu_instance_code, ApprovalRequest.approval_type == _FEISHU_FLOW_KEY, ) ) ).scalar_one_or_none() if ar is None: return ar.status = new_status ar.updated_at = datetime.now(UTC)