"""Feishu-approval external-options webhooks — the software-catalog + software-permission pickers for approval-form dropdowns. POST /api/v1/open/feishu/software-options software.catalog:read POST /api/v1/open/feishu/permission-options software.permissions:read These are part of the External API feature but do **not** follow the standard Helix contract, because the caller is Feishu (Lark) approval, not a generic integrator: * **Auth is token-in-body, not a bearer header.** Feishu's "associate external options" (关联外部选项) config lets you set a *URL*, a *token*, and an optional *key*; on every option fetch Feishu ``POST``s the URL with ``{"token": , "linkage_params": {...}}``. We make that configured token a ``helix_api_`` API-client token and verify it through the same credential plane (rotation / revocation / scope / audit) as the bearer surface — see :func:`authorize_api_client_body_token`. * **The response envelope is Feishu's, not Helix's.** Feishu expects ``{"code": 0, "data": {"result": {"options": [...], "i18nResources": [...], "hasMore": ..., "nextPageToken": ...}}}`` (V1, unencrypted). A non-zero ``code`` (or a non-200) tells Feishu the fetch failed. We return the standard Helix error envelope on auth/scope failure (raised) so a misconfigured token fails loudly during setup; the success path is pure Feishu shape. * **No response encryption.** The optional Feishu *key* (AES-256-CBC of ``data.result``) is left unset — the payload is a public product / permission name list, and the token + TLS already authenticate and protect it. If a key is ever required, add encryption here. **Cascade.** The permission dropdown is *linked* to the software dropdown: in the Feishu designer you map a dependent parameter (依赖参数) to the software field, so Feishu sends the selected software's option id inside ``linkage_params``. The software option id is the software **name** (see :func:`feishu_software_options`), so the permission endpoint resolves the software by name and returns that software's permissions. Until a software is picked, ``linkage_params`` carries no software and the endpoint returns an empty option list. Feishu times out an external-options fetch at 3s, so each endpoint stays a single indexed query + in-memory shaping. Both endpoints live together here (rather than splitting the permission one into the permission domain) because they share the auth + Feishu envelope shaping and are one External-API surface. The permission endpoint reads ``software_permissions`` — a read-only picker over a table that is definitionally a child of ``asset_software_catalog`` (FK ``software_id``), the same "open router reads the models it serves" pattern as ``cmdb/open_cloud_router`` (which reads identity + integration models). No cross-domain *service* / write coupling is introduced. """ from __future__ import annotations import logging import uuid as uuid_mod from typing import Any from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.api_clients.auth import authorize_api_client_body_token from app.api_clients.scopes import ( SCOPE_SOFTWARE_CATALOG_READ, SCOPE_SOFTWARE_PERMISSIONS_READ, ) from app.core.database import get_db from app.domains.asset.models import AssetSoftwareCatalog from app.domains.permission.models import SoftwarePermission logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/v1/open/feishu", tags=["open-feishu"], ) #: Options per page. The catalogs are low-volume, admin-curated lists #: (typically 50–300 software / a handful of permissions each); one page #: covers most tenants and Feishu paginates the rest via ``nextPageToken``. _PAGE_SIZE = 100 #: Locales Feishu may request. We keep a single name per row, so every #: locale maps to the same text — but declaring all three keeps Feishu #: from falling back to the raw ``@i18n@`` key when it asks for en_us/ja. _LOCALES = ("zh_cn", "en_us", "ja_jp") #: ``linkage_params`` keys Feishu always sends — everything else is a #: dependent form field (e.g. the linked software value). _RESERVED_LINKAGE_KEYS = {"query", "page_token", "locale"} #: Dependent-parameter keys we look for the selected software under, in #: priority order, before falling back to the sole extra linkage param. #: Document ``software_name`` as the recommended key to configure. _SOFTWARE_LINKAGE_KEYS = ("software_name", "software", "software_id") class _LinkageParams(BaseModel): """The linked-field / search / pagination bag Feishu sends. Every field optional; unknown keys (dependent form fields) are kept in ``extra`` so the cascade can read the linked software value.""" model_config = {"extra": "allow"} query: str | None = Field(default=None, max_length=256) page_token: str | None = Field(default=None, max_length=64) locale: str | None = Field(default=None, max_length=16) class FeishuOptionsRequest(BaseModel): """Feishu external-options request body (V1).""" model_config = {"extra": "ignore"} token: str = Field(default="", max_length=256) user_id: str | None = Field(default=None, max_length=128) employee_id: str | None = Field(default=None, max_length=128) linkage_params: _LinkageParams = Field(default_factory=_LinkageParams) def _parse_offset(page_token: str | None) -> int: """Feishu echoes back the ``nextPageToken`` we handed it; we encode the next offset as a decimal string. Anything unparseable → page 0.""" if not page_token: return 0 try: offset = int(page_token) except (TypeError, ValueError): return 0 return offset if offset >= 0 else 0 def _shape_result( pairs: list[tuple[str, str]], *, offset: int, total: int, ) -> dict[str, Any]: """Shape ``(id, display_text)`` pairs into Feishu's ``data.result``. ``id`` is the stable identity Feishu stores + passes downstream (a software name / permission key); the display text is referenced via an index-based ``@i18n@`` key resolved in ``i18nResources`` for every locale (index-based keys stay valid even when a name holds spaces or CJK characters).""" options: list[dict[str, Any]] = [] texts: dict[str, str] = {} for i, (opt_id, display) in enumerate(pairs): i18n_key = f"@i18n@opt_{offset + i}" options.append({"id": opt_id, "value": i18n_key, "isDefault": False}) texts[i18n_key] = display i18n_resources = [ {"locale": locale, "isDefault": locale == "zh_cn", "texts": texts} for locale in _LOCALES ] next_offset = offset + len(pairs) has_more = next_offset < total return { "options": options, "i18nResources": i18n_resources, "hasMore": has_more, "nextPageToken": str(next_offset) if has_more else "", } def _feishu_ok(result: dict[str, Any]) -> dict[str, Any]: return {"code": 0, "msg": "success", "data": {"result": result}} def _selected_software(linkage: _LinkageParams) -> str | None: """Pull the linked software value out of ``linkage_params``. Tries the recommended dependent-parameter keys first, then falls back to the single non-reserved param if the admin named the key something else. Returns ``None`` when nothing is selected yet.""" extra = getattr(linkage, "__pydantic_extra__", None) or {} for key in _SOFTWARE_LINKAGE_KEYS: val = extra.get(key) if isinstance(val, str) and val.strip(): return val.strip() # Fallback: exactly one extra (dependent) param → treat it as the link. candidates = [ v for k, v in extra.items() if k not in _RESERVED_LINKAGE_KEYS and isinstance(v, str) and v.strip() ] if len(candidates) == 1: return candidates[0].strip() return None @router.post( "/software-options", summary="Feishu approval external options — software catalog", include_in_schema=False, ) async def feishu_software_options( body: FeishuOptionsRequest, request: Request, db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: # Auth: the Feishu-configured "token" is a helix_api_ credential; run # the full External-API defence stack on it. A bad/absent token or a # key missing software.catalog:read raises (401/403) — visible during # Feishu setup, and Feishu treats a non-200 as a failed fetch. await authorize_api_client_body_token( request=request, db=db, token=body.token, required_scopes=(SCOPE_SOFTWARE_CATALOG_READ,), ) offset = _parse_offset(body.linkage_params.page_token) query = (body.linkage_params.query or "").strip() conds = [AssetSoftwareCatalog.status == "active"] if query: pattern = f"%{query}%" conds.append(or_( AssetSoftwareCatalog.name.ilike(pattern), AssetSoftwareCatalog.version.ilike(pattern), )) total = int((await db.execute( select(func.count()).select_from(AssetSoftwareCatalog).where(*conds) )).scalar() or 0) rows = (await db.execute( select(AssetSoftwareCatalog) .where(*conds) .order_by(AssetSoftwareCatalog.name.asc(), AssetSoftwareCatalog.id.asc()) .offset(offset) .limit(_PAGE_SIZE) )).scalars().all() # option id = software name (the value the linked permission dropdown # and any approval routing condition matches on). pairs = [(r.name, r.name) for r in rows] return _feishu_ok(_shape_result(pairs, offset=offset, total=total)) async def _resolve_software_ids(db: AsyncSession, selected: str) -> list[uuid_mod.UUID]: """Resolve the linked software value to catalog id(s). Matches on name (the software option id) and, defensively, on a UUID id — so it works whichever identity the software dropdown was configured with. Returns all matches (a name is de-facto but not DB-unique).""" match_id: uuid_mod.UUID | None = None try: match_id = uuid_mod.UUID(selected) except (ValueError, TypeError): match_id = None identity_match = ( or_(AssetSoftwareCatalog.name == selected, AssetSoftwareCatalog.id == match_id) if match_id is not None else AssetSoftwareCatalog.name == selected ) rows = (await db.execute( select(AssetSoftwareCatalog.id).where( AssetSoftwareCatalog.status == "active", identity_match, ) )).scalars().all() return list(rows) @router.post( "/permission-options", summary="Feishu approval external options — software permissions (cascade)", include_in_schema=False, ) async def feishu_permission_options( body: FeishuOptionsRequest, request: Request, db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: await authorize_api_client_body_token( request=request, db=db, token=body.token, required_scopes=(SCOPE_SOFTWARE_PERMISSIONS_READ,), ) offset = _parse_offset(body.linkage_params.page_token) query = (body.linkage_params.query or "").strip() selected = _selected_software(body.linkage_params) if not selected: # No software chosen yet — nothing to cascade to. return _feishu_ok(_shape_result([], offset=0, total=0)) software_ids = await _resolve_software_ids(db, selected) if not software_ids: return _feishu_ok(_shape_result([], offset=0, total=0)) conds = [ SoftwarePermission.software_id.in_(software_ids), SoftwarePermission.is_active.is_(True), SoftwarePermission.deleted_at.is_(None), ] if query: pattern = f"%{query}%" conds.append(or_( SoftwarePermission.display_name.ilike(pattern), SoftwarePermission.permission_key.ilike(pattern), )) total = int((await db.execute( select(func.count()).select_from(SoftwarePermission).where(*conds) )).scalar() or 0) rows = (await db.execute( select(SoftwarePermission) .where(*conds) .order_by( SoftwarePermission.sort_order.asc(), SoftwarePermission.display_name.asc(), SoftwarePermission.id.asc(), ) .offset(offset) .limit(_PAGE_SIZE) )).scalars().all() # option id = permission_key (stable within a software; combined with # the linked software value it uniquely identifies the permission for # a downstream grant); display = the human-readable name. pairs = [(r.permission_key, r.display_name) for r in rows] return _feishu_ok(_shape_result(pairs, offset=offset, total=total)) __all__ = ["router"]