"""Provisioning admin endpoints. All endpoints require IT Admin role; some also accept Asset Admin. """ import html import logging from uuid import UUID import jwt from fastapi import APIRouter, Depends, Form, Query, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( enforce_action_token_subject, get_current_user, require_active_employee_for_mutations, require_admin, require_hr_admin, require_lifecycle_read, ) from app.core.exceptions import AppException, ForbiddenError from app.core.idempotent_route import idempotent from app.core.person_scope import SubordinateScope, subordinate_person_ids from app.core.rbac import ( GLOBAL_ADMIN_ROLE, HR_ROLE, MANAGEMENT_ROLE, has_role, ) from app.core.response import paginated_response, success_response from app.core.security import decode_action_token from app.core.working_hours import ( PROVISION_LEAD_HOURS_MAX, PROVISION_LEAD_HOURS_MIN, ) from app.domains.provisioning.bundle_service import BundleService from app.domains.provisioning.models import ( ACTION_TOKEN_PURPOSE_MANUAL_COMPLETE, EXEC_MANUAL_NOTIFY, SOURCE_ASSET_DISPOSAL, TARGET_SSO_GRANT, ) from app.domains.provisioning.schemas import ( BulkExecuteRequest, ProvisionPolicyCreate, ProvisionPolicyUpdate, ReconcileApplyRequest, SoftwareConnectorCreate, SoftwareConnectorUpdate, TaskCompleteRequest, TaskFailRequest, TaskRetryRequest, ) from app.domains.provisioning.service import ProvisioningService # Leaver-runtime gate: matches permissions/asset routers. A user whose # offboarding has flipped employment_status into the leaver-pending # window cannot mutate provisioning state — including assignee # completion of in-flight tasks. Read endpoints (`get_current_user`) # stay accessible so the IT 宇宙 page still loads for a person mid- # offboarding. logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/v1/provisioning", tags=["provisioning"], dependencies=[Depends(require_active_employee_for_mutations)], ) # ─── Connector driver catalog (software_provisioning-kind drivers) ─── @router.get( "/connector-types", summary="List available software-provisioning drivers (for the binding form)", ) async def list_connector_types( request: Request, _admin: AuthUser = Depends(require_lifecycle_read()), db: AsyncSession = Depends(get_db), ): """Returns the same metadata shape as ``/api/v1/integrations/kinds`` filtered to ``kind='software_provisioning'`` — exposes each driver's tenant-level ``fields`` and per-software ``binding_fields`` so the admin UI can render the right forms.""" svc = ProvisioningService(db) return success_response( data=[m.model_dump() for m in svc.list_available_connector_types()], request=request, ) # ─── Connector CRUD ─── @router.get("/connectors") async def list_connectors( request: Request, _admin: AuthUser = Depends(require_lifecycle_read()), db: AsyncSession = Depends(get_db), ): svc = ProvisioningService(db) connectors = await svc.list_connectors() return success_response(data=connectors, request=request) @router.get("/onboarding-overview") async def onboarding_overview( request: Request, _admin: AuthUser = Depends(require_lifecycle_read()), db: AsyncSession = Depends(get_db), ): """Account-creation config snapshot for the onboarding-strategy page — Google ``auto_create_on_hire`` + Feishu connector ``operation`` mode, so the page can show the full onboarding picture (build-account switches + seat/permission policies) in one place. Read-only.""" svc = ProvisioningService(db) overview = await svc.onboarding_account_overview() return success_response(data=overview, request=request) # ─── Onboarding provisioning lead window (HR-configurable) ─── class OnboardingLeadSettingsBody(BaseModel): """How many hours BEFORE a hire's local start date their accounts (Google + Feishu) are created and the Day-1 welcome/password mails go out. Bounds mirror ``app.core.working_hours``: 0 = strictly on the local hire date; 168 (one week) is the ceiling — anything longer re-opens the "live mailbox + plaintext-password mail sitting unattended for weeks" failure mode.""" provision_lead_hours: int = Field( ge=PROVISION_LEAD_HOURS_MIN, le=PROVISION_LEAD_HOURS_MAX, ) @router.get( "/onboarding-lead-settings", summary="Read the onboarding provisioning lead window (hours)", ) async def get_onboarding_lead_settings( request: Request, _admin: AuthUser = Depends(require_lifecycle_read()), db: AsyncSession = Depends(get_db), ): from app.core.working_hours import ( DEFAULT_PROVISION_LEAD_HOURS, PROVISION_LEAD_HOURS_MAX, PROVISION_LEAD_HOURS_MIN, SETTING_PROVISION_LEAD_HOURS, _coerce_lead_hours, ) from app.integrations.service import get_platform_setting raw = await get_platform_setting( SETTING_PROVISION_LEAD_HOURS, default=DEFAULT_PROVISION_LEAD_HOURS, session=db, ) hours = _coerce_lead_hours(raw) return success_response( data={ "provision_lead_hours": ( hours if hours is not None else DEFAULT_PROVISION_LEAD_HOURS ), "default_hours": DEFAULT_PROVISION_LEAD_HOURS, "min_hours": PROVISION_LEAD_HOURS_MIN, "max_hours": PROVISION_LEAD_HOURS_MAX, }, request=request, ) @router.put( "/onboarding-lead-settings", summary="Update the onboarding provisioning lead window (hours)", ) async def update_onboarding_lead_settings( body: OnboardingLeadSettingsBody, request: Request, current_user: AuthUser = Depends(require_hr_admin()), db: AsyncSession = Depends(get_db), ): """HR + global_admin editable (``require_hr_admin``): the lead window is an HR-owned onboarding policy, not an integration credential. Every consumer (HRIS confirm hooks, identity-sync sweeps, the Google account minter, the hourly hire-day cron) resolves the value per flow, so a change takes effect on the next tick without a deploy.""" from app.core.working_hours import ( SETTING_PROVISION_LEAD_HOURS, set_provision_lead_hours_cache, ) from app.integrations.service import PlatformSettingService svc = PlatformSettingService(db) await svc.upsert( SETTING_PROVISION_LEAD_HOURS, body.provision_lead_hours, "Onboarding account-provisioning lead window in hours (Google + " "Feishu creation and Day-1 welcome mails open this many hours " "before the hire's local start date)", trace_id=str(request.state.trace_id), actor_user_id=current_user.id, ) await db.commit() # The writing process's own gates see the new value immediately; # sibling processes (worker) converge within the 60s TTL. set_provision_lead_hours_cache(body.provision_lead_hours) return success_response(data=body.model_dump(), request=request) @router.post("/connectors") async def create_connector( request: Request, body: SoftwareConnectorCreate, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = ProvisioningService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" connector = await svc.create_connector( body, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=connector, request=request) @router.patch("/connectors/{connector_id}") async def update_connector( request: Request, connector_id: UUID, body: SoftwareConnectorUpdate, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = ProvisioningService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" connector = await svc.update_connector( connector_id, body, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=connector, request=request) @router.post("/connectors/{connector_id}/test") async def test_connector( request: Request, connector_id: UUID, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Invoke the connector's test_connection to validate credentials.""" svc = ProvisioningService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" success, error, raw = await svc.test_connector( connector_id, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response( data={"success": success, "error_message": error, "raw_response": raw}, request=request, ) @router.delete("/connectors/{connector_id}") async def delete_connector( request: Request, connector_id: UUID, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): svc = ProvisioningService(db) trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" await svc.delete_connector(connector_id, trace_id=trace_id, actor_user_id=current_user.id) await db.commit() return success_response(data=None, request=request) # ─── Provisioning tasks (admin queue) ─── @router.get("/tasks") async def list_tasks( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), statuses: str | None = Query( None, description="Comma-separated statuses; takes precedence over `status`", ), source: str | None = Query(None), user_id: UUID | None = Query(None), software_id: UUID | None = Query(None), assignee_id: UUID | None = Query(None), action: str | None = Query( None, description="grant | revoke", ), execution_modes: str | None = Query( None, description="Comma-separated execution_mode values " "(auto_api,manual_notify,sso_auto)", ), keyword: str | None = Query( None, max_length=100, description=( "Case-insensitive substring match against the target " "person's display_name / work_email and the software's " "catalog name. Powers the Software Cancellations search " "box across the auto-done / manual-pending / failed " "segments. Empty / whitespace-only values are ignored. " "LIKE metacharacters (%, _, \\) are escaped server-side " "so they match literally. Capped at 100 chars to bound " "the worst-case LIKE-pattern scan." ), ), order_by: str | None = Query( None, description=( "Sort key. Default is ``created_at`` (newest enqueue " "first). Pass ``completed_at`` for the cancellations " "overview's Completed segment so rows that finished most " "recently surface first; NULL completed_at values land " "at the bottom. Unknown values silently fall back to " "``created_at`` — no 400." ), ), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List provisioning tasks. Global admin + management see everything (management is the read-only leadership view). Employees may only request their own slice (``user_id=``) — the IT 宇宙 page on /me uses this to surface in-flight grant / revoke jobs the platform is running on their behalf. Cross-user filters from a non-admin caller are 403. The ``statuses`` (multi-value) and ``execution_modes`` (multi-value) parameters power the Software Cancellations admin overview, where a single segment needs to combine e.g. ``status IN (pending,notified)`` or ``execution_mode IN (auto_api,sso_auto)`` without N round-trips. 2026-07-15: ``hr`` joins global_admin + management on the view-all path so the 任务队列 page (added to the HR 入离职管理 menu this PR) renders the full queue read-only. Mutations (execute / cancel / fail / bulk) stay ``require_admin`` — hr inspects, never acts. """ if not has_role( current_user.role_names, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, HR_ROLE, ): if user_id is None or user_id != current_user.id: raise ForbiddenError( "You may only view your own provisioning tasks.", ) # Strip whitespace so an MCP / curl caller typing the natural # "a, b" form doesn't silently match zero rows because " b" never # equals "b" in the SQL IN (...) clause. statuses_list = ( [s.strip() for s in (statuses or "").split(",") if s.strip()] or None ) exec_modes_list = ( [m.strip() for m in (execution_modes or "").split(",") if m.strip()] or None ) svc = ProvisioningService(db) tasks, total = await svc.list_tasks( page=page, page_size=page_size, status=status, statuses=statuses_list, source=source, user_id=user_id, software_id=software_id, assignee_id=assignee_id, action=action, execution_modes=exec_modes_list, keyword=keyword, order_by=order_by, ) return paginated_response( data=tasks, total=total, page=page, page_size=page_size, request=request, ) @router.get("/tasks/export.xlsx") async def export_tasks( request: Request, status: str | None = Query(None), statuses: str | None = Query(None), source: str | None = Query(None), user_id: UUID | None = Query(None), software_id: UUID | None = Query(None), assignee_id: UUID | None = Query(None), action: str | None = Query(None), execution_modes: str | None = Query(None), keyword: str | None = Query(None, max_length=100), order_by: str | None = Query(None), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Export the filtered provisioning queue as XLSX. Filter signature mirrors :func:`list_tasks` minus pagination — the UI passes the exact query params the queue page is showing, so the download matches the screen. RBAC mirrors the list endpoint too: non-admin callers may only export their own slice (hr / management join global_admin on the view-all path — 2026-07-15). Reuses ``ProvisioningService.list_tasks`` so any filter fix on the list applies here automatically. Capped at :data:`app.core.exporting.EXPORT_ROW_LIMIT` rows. """ from app.core.exporting import EXPORT_ROW_LIMIT, xlsx_export_response if not has_role( current_user.role_names, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, HR_ROLE, ): if user_id is None or user_id != current_user.id: raise ForbiddenError( "You may only export your own provisioning tasks.", ) statuses_list = ( [s.strip() for s in (statuses or "").split(",") if s.strip()] or None ) exec_modes_list = ( [m.strip() for m in (execution_modes or "").split(",") if m.strip()] or None ) svc = ProvisioningService(db) tasks, total = await svc.list_tasks( page=1, page_size=EXPORT_ROW_LIMIT, status=status, statuses=statuses_list, source=source, user_id=user_id, software_id=software_id, assignee_id=assignee_id, action=action, execution_modes=exec_modes_list, keyword=keyword, order_by=order_by, ) columns = [ ("user_name", "人员"), ("user_email", "邮箱"), ("software_name", "软件"), ("target_label", "对象"), ("target_type", "对象类型"), ("action", "动作"), ("source", "来源"), ("execution_mode", "执行方式"), ("status", "状态"), ("assignee_name", "处理人"), ("attempt_count", "尝试次数"), ("error_message", "错误信息"), ("external_ref", "外部引用"), ("not_before", "不早于"), ("started_at", "开始时间"), ("completed_at", "完成时间"), ("created_at", "创建时间"), ("id", "任务ID"), ] rows = [t.model_dump() for t in tasks] return await xlsx_export_response( db, request, current_user, columns=columns, rows=rows, total_matched=total, filename_stem="provisioning_tasks", sheet_title="Provisioning Tasks", audit_action="provisioning.tasks.export", audit_resource_type="provisioning_task", filters={ "status": status, "statuses": statuses, "source": source, "user_id": user_id, "software_id": software_id, "assignee_id": assignee_id, "action": action, "execution_modes": execution_modes, "keyword": keyword, }, ) @router.get("/analytics") async def provisioning_analytics( request: Request, window_days: int = Query(30, ge=1, le=90), _: AuthUser = Depends(require_lifecycle_read()), db: AsyncSession = Depends(get_db), ): """Single-trip analytics aggregate for the provisioning-queue charts. Admin-read gated (global_admin + management). Returns KPI totals + daily created-vs-completed trend + status/action/execution_mode/ source/target_type rollups — replacing the page's former 4× narrow count probes. Read-only augment; no MCP tool (mirrors the existing 定时任务/审计/审批 analytics tabs). """ svc = ProvisioningService(db) data = await svc.analytics_summary(window_days=window_days) return success_response(data=data, request=request) @router.get("/tasks/my") async def list_my_assigned_tasks( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), statuses: str | None = Query( None, description="Comma-separated statuses; takes precedence over `status`", ), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Tasks assigned to the current user as a software owner (manual_notify). ``statuses`` (multi-value) lets the 我的待办 owner queue combine its actionable set in one round-trip — ``notified,failed`` so a FAILED provisioning job (the executor couldn't grant/revoke) surfaces next to the still-pending ones instead of hiding under an unfiltered "show all" the owner has to remember to toggle. A failed task is an action item: its ``error_message`` says why, and the owner can retry or hand it off. """ statuses_list = ( [s.strip() for s in (statuses or "").split(",") if s.strip()] or None ) svc = ProvisioningService(db) tasks, total = await svc.list_tasks( page=page, page_size=page_size, status=status, statuses=statuses_list, assignee_id=current_user.id, ) return paginated_response( data=tasks, total=total, page=page, page_size=page_size, request=request, ) @router.get("/tasks/{task_id}") async def get_task( request: Request, task_id: UUID, _admin: AuthUser = Depends(require_lifecycle_read()), db: AsyncSession = Depends(get_db), ): svc = ProvisioningService(db) task = await svc.get_task_response(task_id) return success_response(data=task, request=request) @router.post("/tasks/{task_id}/complete") @idempotent( "provisioning.complete", key_params=("task_id", "current_user.id"), ) async def complete_task( request: Request, task_id: UUID, body: TaskCompleteRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Mark a manual_notify task as completed. Callable by either the task assignee (software owner) or an IT Admin. The router-level ``require_active_employee_for_mutations`` dep already blocks leaver_pending callers; the per-row ``assignee_id == current_user.id`` check below is re-validated inside ``complete_manual_task`` after a SELECT FOR UPDATE so a concurrent admin reassignment can't race past it. """ svc = ProvisioningService(db) task = await svc._get_task(task_id) is_admin = has_role(current_user.role_names, GLOBAL_ADMIN_ROLE) is_assignee = task.assignee_id == current_user.id if not (is_admin or is_assignee): raise ForbiddenError() trace_id = str(request.state.trace_id) if hasattr(request.state, "trace_id") else "" updated = await svc.complete_manual_task( task_id, note=body.note, external_ref=body.external_ref, trace_id=trace_id, actor_user_id=current_user.id, actor_is_admin=is_admin, ) await db.commit() return success_response( data=await svc.get_task_response(updated.id), request=request, ) # ── One-click no-login completion from the manual-notify link ─────── # # These two routes are intentionally UNAUTHENTICATED — the signed # action token in the URL is the authorisation (it was minted for one # task + one recipient and expires). The router-level # ``require_active_employee_for_mutations`` dep lets a credential-less # POST through to the endpoint's own (absent) auth, and GET is always # allowed, so no JWT is required here. See # ``service._notify_manual_assignees`` for where the link is minted. def _qc_page( *, title: str, heading: str, body_html: str, tone: str = "info", icon: str | None = None, ) -> HTMLResponse: """Render a tiny self-contained bilingual result page (no SPA, no login) for the one-click flow. Works in the Feishu in-app browser and any email client's external browser. ``tone`` (ok/err/warn/info) drives the accent colour + default badge icon shown in a circular header badge above the heading.""" accents = { "ok": ("#1a7f37", "#e7f6ec", "✓"), "err": ("#cf222e", "#fdecec", "!"), "warn": ("#9a6700", "#fdf6e3", "!"), "info": ("#0969da", "#eaf2fb", "i"), } accent, badge_bg, default_icon = accents.get(tone, accents["info"]) badge_glyph = icon if icon is not None else default_icon doc = ( "" "" "" f"{html.escape(title)}" f"
{badge_glyph}" f"

{heading}

{body_html}" "

Helix IT 平台 · 此链接由系统通知签发

" "
" ) # Page-level CSP: the global SecurityHeadersMiddleware default # (``default-src 'none'``) would make the browser ignore the inline #