"""Admin HTTP API for the integration framework. Endpoints: GET /api/v1/integrations list all POST /api/v1/integrations create GET /api/v1/integrations/kinds driver catalog GET /api/v1/integrations/{id_or_slug} PATCH /api/v1/integrations/{id_or_slug} DELETE /api/v1/integrations/{id_or_slug} POST /api/v1/integrations/{id_or_slug}/test run test_connection GET /api/v1/integrations/{id_or_slug}/approval-flows PUT /api/v1/integrations/{id_or_slug}/approval-flows (upsert) DELETE /api/v1/integrations/{id_or_slug}/approval-flows/{flow_id} GET /api/v1/platform-settings GET /api/v1/platform-settings/{key} PUT /api/v1/platform-settings/{key} DELETE /api/v1/platform-settings/{key} """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Query, Request 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, require_browser_session, require_config_read, ) from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.core.response import success_response from app.integrations.registry import list_drivers from app.integrations.schemas import ( ApprovalFlowUpsertRequest, IntegrationUpsertRequest, PlatformSettingUpsertRequest, WebhookIpAllowlistRequest, ) from app.integrations.service import ( IntegrationService, PlatformSettingService, ) router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) settings_router = APIRouter( prefix="/api/v1/platform-settings", tags=["platform-settings"], ) # ─── Integrations ────────────────────────────────────────────────── @router.get("/kinds", summary="List all registered (kind, provider) drivers") async def list_kinds( request: Request, _admin: AuthUser = Depends(require_config_read()), ): return success_response( data=[m.model_dump() for m in list_drivers()], request=request, ) @router.get("", summary="List integrations") async def list_integrations( request: Request, kind: str | None = Query(default=None), enabled: bool | None = Query(default=None), _admin: AuthUser = Depends(require_config_read()), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) items = await svc.list(kind=kind, enabled=enabled) return success_response(data=items, request=request) @router.get("/{key}", summary="Get one integration by id or slug") async def get_integration( request: Request, key: str, _admin: AuthUser = Depends(require_config_read()), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) item = await svc.get(key) return success_response(data=item, request=request) @router.post("", summary="Create a new integration") async def create_integration( body: IntegrationUpsertRequest, request: Request, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") if not body.kind or not body.provider or not body.name or not body.slug: from app.core.exceptions import AppException raise AppException( code="INTEGRATION_CREATE_MISSING_FIELDS", message="kind, provider, name, and slug are all required to create an integration", status_code=400, ) item = await svc.create( kind=body.kind, provider=body.provider, name=body.name, slug=body.slug, description=body.description, is_primary=bool(body.is_primary), enabled=bool(body.enabled if body.enabled is not None else True), capabilities=body.capabilities, config=body.config, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=item, request=request) @router.patch("/{key}", summary="Update integration") async def update_integration( request: Request, key: str, body: IntegrationUpsertRequest, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") item = await svc.update( key, name=body.name, description=body.description, enabled=body.enabled, is_primary=body.is_primary, capabilities=body.capabilities, config=body.config if body.config else None, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=item, request=request) @router.delete("/{key}", summary="Delete integration") async def delete_integration( request: Request, key: str, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") await svc.delete(key, trace_id=trace_id, actor_user_id=current_user.id) await db.commit() return success_response(data={"ok": True}, request=request) @router.post( "/{key}/webhook-secret/rotate", summary=( "Generate a new webhook_hmac_secret; previous remains valid for " "the overlap window (default 24h). Plaintext returned exactly once." ), ) async def rotate_webhook_secret( request: Request, key: str, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") result = await svc.rotate_webhook_hmac_secret( key, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=result, request=request) @router.get( "/{key}/webhook-secret/status", summary="Read whether a webhook_hmac_secret + previous overlap secret are configured (no plaintext).", ) async def get_webhook_secret_status( request: Request, key: str, _admin: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) return success_response( data=await svc.webhook_secret_status(key), request=request, ) @router.post( "/{key}/webhook-basic-credential/rotate", summary=( "Generate an HTTP Basic-Auth credential (username + fresh password) " "for an inbound webhook receiver. Simple alternative to HMAC for " "zero-code upstream tools. Plaintext password returned exactly once." ), ) async def rotate_webhook_basic_credential( request: Request, key: str, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") result = await svc.rotate_webhook_basic_credential( key, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=result, request=request) @router.put( "/{key}/webhook-ip-allowlist", summary=( "Set the inbound-webhook source-IP allowlist (bare IPs or CIDRs). " "Empty list clears it (= allow all source IPs)." ), ) async def set_webhook_ip_allowlist( request: Request, key: str, body: WebhookIpAllowlistRequest, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") result = await svc.set_webhook_ip_allowlist( key, cidrs=body.cidrs, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=result, request=request) @router.get( "/{key}/webhook-auth/status", summary=( "Read which webhook auth schemes are configured (HMAC / Basic) plus " "the Basic username and IP allowlist. Never returns secrets." ), ) async def get_webhook_auth_status( request: Request, key: str, _admin: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) return success_response( data=await svc.webhook_auth_status(key), request=request, ) @router.post("/{key}/test", summary="Run the driver's test_connection") async def test_integration( request: Request, key: str, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") result = await svc.test_connection( key, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=result.model_dump(), request=request) # ─── Approval flow sub-routes ─ @router.get( "/{key}/approval-flows", summary="List approval flow mappings on a chat_approval integration", ) async def list_approval_flows( request: Request, key: str, _admin: AuthUser = Depends(require_config_read()), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) items = await svc.list_approval_flows(key) return success_response( data=[i.model_dump(mode="json") for i in items], request=request, ) @router.put("/{key}/approval-flows", summary="Create or update an approval flow mapping") async def upsert_approval_flow( request: Request, key: str, body: ApprovalFlowUpsertRequest, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") item = await svc.upsert_approval_flow( key, flow_key=body.flow_key, label=body.label, external_code=body.external_code, payload_schema=body.payload_schema, active=body.active, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=item.model_dump(mode="json"), request=request) @router.delete("/{key}/approval-flows/{flow_id}", summary="Delete an approval flow mapping") async def delete_approval_flow( request: Request, key: str, flow_id: UUID, current_user: AuthUser = Depends(require_admin()), # Pre-launch hardening 2026-05-16 (SS1 P1): same browser-session # gate as POST/PUT/PATCH on this resource. Without it, a stolen # IT-admin PAT carried by an MCP client could one-shot delete any # DNS / VPN / SSO / AI-tool approval-flow mapping — severing the # routing path with no UI confirmation. _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = IntegrationService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") await svc.delete_approval_flow( key, flow_id, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data={"ok": True}, request=request) # ─── Platform settings ──────────────────────────────────────────── @settings_router.get("", summary="List platform settings") async def list_settings( request: Request, _admin: AuthUser = Depends(require_config_read()), db: AsyncSession = Depends(get_db), ): svc = PlatformSettingService(db) items = await svc.list() return success_response(data=items, request=request) # Setting keys whose value is meant to be visible to every authenticated # user (help-center copy, per-request-type help URLs, etc.). Any key NOT # matching one of these prefixes still requires global_admin to read — # the platform-settings store also holds privileged blobs (LLM API keys, # email templates with internal copy, curated cloud-role lists) that # must not leak to plain employees. # # Pulled from the two non-admin frontend call sites: # * ``RequestHelpBanner`` → ``request_help_url.`` # * ``HelpCenterPage`` → ``help_center.custom_url`` # Before this allowlist, both calls 403'd silently — the in-app banner # / iframe override never rendered for ~99% of users (everyone except # global_admin), defeating the point of the feature. _PUBLIC_SETTING_PREFIXES: tuple[str, ...] = ( "request_help_url.", "help_center.", ) _EMAIL_TEMPLATE_PREFIX = "email_template." def _reject_builtin_only_email_template(key: str) -> None: """Refuse to store an override for a builtin-only email template. Two credential mails (``google_account_password``, ``tencent_cam_initial_password``) are formatted at their call site from the in-code builtin and never consult ``platform_settings`` — see ``app.notifications.template_catalog.BUILTIN_ONLY_KEYS``. Storing an override for them writes copy that can never ship while every surface (the editor, this API) reports a successful save. The 邮件模板 editor already disables saving on these rows; this is the same rule at the API so curl / MCP / a stale tab can't tell the same lie. Imported lazily: ``template_catalog`` pulls in the notification service, which is a much heavier import than this router needs at module load. """ if not key.startswith(_EMAIL_TEMPLATE_PREFIX): return from app.notifications.template_catalog import BUILTIN_ONLY_KEYS if key[len(_EMAIL_TEMPLATE_PREFIX):] in BUILTIN_ONLY_KEYS: raise ValidationError( f"'{key}' is a builtin-only email template: it is sent directly " "from code and never reads this override, so saving one here " "would have no effect. Change the copy in the codebase instead.", ) @settings_router.get("/{key}", summary="Get one setting") async def get_setting( request: Request, key: str, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Read one platform setting. Public-prefix keys (``request_help_url.*``, ``help_center.*``) are readable by any authenticated user; everything else requires global_admin (matches the legacy ``require_admin`` posture for privileged keys like ``ai_spending.llm.api_key``, ``email_template.*``, ``cloud..``). """ is_public_key = any(key.startswith(p) for p in _PUBLIC_SETTING_PREFIXES) if not is_public_key and not has_role(user.role_names, GLOBAL_ADMIN_ROLE): raise ForbiddenError() svc = PlatformSettingService(db) item = await svc.get(key) return success_response(data=item, request=request) @settings_router.put("/{key}", summary="Upsert a setting") async def upsert_setting( request: Request, key: str, body: PlatformSettingUpsertRequest, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): """Upsert one platform setting. Builtin-only email templates are rejected: their send path resolves the in-code builtin and never reads this override layer, so accepting the write would persist copy that can never ship and report success for it. DELETE stays allowed — clearing a stale override is always safe. """ _reject_builtin_only_email_template(key) svc = PlatformSettingService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") item = await svc.upsert( key, body.value, body.description, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=item, request=request) @settings_router.delete("/{key}", summary="Delete a setting") async def delete_setting( request: Request, key: str, current_user: AuthUser = Depends(require_admin()), _gate: None = Depends(require_browser_session), db: AsyncSession = Depends(get_db), ): svc = PlatformSettingService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") await svc.delete(key, trace_id=trace_id, actor_user_id=current_user.id) await db.commit() return success_response(data={"ok": True}, request=request)