"""SAP SuccessFactors HRIS driver. Wraps the existing :class:`SuccessFactorsClient` behind the unified driver interface. The client already supports both Basic Auth and OAuth 2.0 SAML Bearer; this driver simply constructs the client from the integration's config dict. """ from __future__ import annotations from datetime import date from typing import Any from app.identity.successfactors_auth import build_auth_from_config from app.identity.successfactors_client import SuccessFactorsClient from app.integrations.driver_base import HrisDriver from app.integrations.registry import register_driver from app.integrations.schemas import ( IntegrationFieldInfo, IntegrationTestResult, ) # Field keys shared by all SF auth modes + auth_method selector _SF_AUTH_BASIC = "basic" _SF_AUTH_OAUTH2_SAML = "oauth2_saml" _SF_AUTH_OAUTH2_CC = "oauth2_client_credentials" _CONFIG_SCHEMA = [ IntegrationFieldInfo( key="region", label="数据中心 / 区域", is_secret=False, required=False, default="cn", options=[ {"value": "cn", "label": "中国(SF D环境 · sapcloud.cn)"}, {"value": "eu", "label": "欧洲/德国(SF D环境 · ondemand.com)"}, {"value": "us", "label": "美洲(sapsf.com)"}, {"value": "other", "label": "其他 / 自定义"}, ], hint="标识此 SF 实例的数据合规区域,用于多实例同步路由与日志分辨。不填默认为 cn。", ), IntegrationFieldInfo( key="auth_method", label="认证方式", is_secret=False, required=True, default=_SF_AUTH_OAUTH2_CC, options=[ {"value": _SF_AUTH_OAUTH2_CC, "label": "OAuth 2.0 Client Credentials(SAP IAS,D 环境)"}, {"value": _SF_AUTH_BASIC, "label": "Basic Auth(用户名@公司ID + 密码,仅本地开发)"}, {"value": _SF_AUTH_OAUTH2_SAML, "label": "OAuth 2.0 SAML Bearer(SAP 生产推荐)"}, ], hint="D 环境 (开发/测试) 通常使用 Client Credentials。生产推荐 SAML Bearer。", ), IntegrationFieldInfo( key="base_url", label="OData API Base URL", is_secret=False, required=True, placeholder="https://apisalesdemo2.successfactors.cn/odata/v2", hint="SuccessFactors 数据中心 OData 根地址,末尾不含斜线。中国区形如 *.successfactors.cn,欧洲 *.successfactors.eu。", ), # OAuth 2.0 Client Credentials (SAP IAS) IntegrationFieldInfo( key="token_url", label="IAS Token URL", is_secret=False, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_CC}, placeholder="https://.accounts.sapcloud.cn/oauth2/token", hint="SAP Identity Authentication Service 的 token 端点,通常形如 https://.accounts.sapcloud.cn/oauth2/token (中国区) 或 https://.accounts.ondemand.com/oauth2/token (欧洲)。", ), IntegrationFieldInfo( key="client_id", label="Client ID", is_secret=True, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_CC}, hint="IAS 应用注册时分配的 client_id(UUID)。", ), IntegrationFieldInfo( key="client_secret", label="Client Secret", is_secret=True, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_CC}, hint="IAS 应用 client_secret。", ), IntegrationFieldInfo( key="resource", label="Resource(应用 Provider URN)", is_secret=False, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_CC}, placeholder="urn:sap:identity:application:provider:name:SF_TECG_ACESS", hint="目标 SF 应用的 resource URN,形如 urn:sap:identity:application:provider:name:。", ), IntegrationFieldInfo( key="scope", label="Scope(可选)", is_secret=False, required=False, visible_when={"auth_method": _SF_AUTH_OAUTH2_CC}, hint="若 IAS 要求额外 scope 请填写,多数 D 环境留空即可。", ), # Basic auth IntegrationFieldInfo( key="username", label="Username(user@companyId)", is_secret=True, required=True, visible_when={"auth_method": _SF_AUTH_BASIC}, placeholder="apiuser@MYCOMPANY", hint="SF Basic Auth 要求用户名带 @公司ID 后缀。", ), IntegrationFieldInfo( key="password", label="Password", is_secret=True, required=True, visible_when={"auth_method": _SF_AUTH_BASIC}, ), # OAuth SAML Bearer IntegrationFieldInfo( key="token_url", label="OAuth Token URL", is_secret=False, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_SAML}, placeholder="https://api15.sapsf.cn/oauth/token", ), IntegrationFieldInfo( key="company_id", label="Company ID", is_secret=False, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_SAML}, placeholder="MYCOMPANY", ), IntegrationFieldInfo( key="api_key", label="API Key(client_id)", is_secret=True, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_SAML}, hint="SF Admin Center > Manage OAuth2 Client Applications 里注册应用拿到的 API Key。", ), IntegrationFieldInfo( key="technical_user_id", label="Technical User ID", is_secret=False, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_SAML}, hint="SAML 断言 Subject 的 NameID,应用代理的 SF 用户。", ), IntegrationFieldInfo( key="private_key", label="Private Key(PEM)", is_secret=True, required=True, visible_when={"auth_method": _SF_AUTH_OAUTH2_SAML}, is_textarea=True, placeholder="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", hint="RSA 私钥,对应 SF Admin Center 中注册的 X.509 证书。", ), # Webhook receiver secret (SF → Helix) IntegrationFieldInfo( key="webhook_hmac_secret", label="Webhook HMAC Secret", is_secret=True, required=False, hint="SF 回调 webhook 签名校验共享密钥。未配置时 webhook 端点会拒收。", ), # Termination reasons that must NOT close accounts (2026-07-30, # HRIS-aligned: 人员分类转换 / 调往中国·德国数据中心 / 兼岗结束 keep the # human employed — SF still writes an EmpEmploymentTermination row + # flips User.status to 'f', so without this list every sync/webhook # path would treat them as leavers and revoke access). IntegrationFieldInfo( key="excluded_termination_reason_codes", label="不关号的离职原因(externalCode)", is_secret=False, required=False, placeholder="D03, D04, D07, D08", hint="SF EmpEmploymentTermination.customString85(EcEvenReason2 选项列表)" "中「不需要关闭账号」的离职原因 externalCode,逗号/空格分隔。命中的" "offboard 事件与全量/增量同步都会跳过离职流程并落台账。与 HRIS 对齐的" "当前名单:D03 调往中国数据中心、D04 兼岗结束、D07 人员分类转换、" "D08 调往德国数据中心。也接受原始选项 ID(如 2566)作为兜底。" "留空=不排除任何原因(改动前的行为)。", ), # Multi-instance canonical ownership (onboard-poach guard) IntegrationFieldInfo( key="owned_company_codes", label="归属法律实体(FOCompany 代码)", is_secret=False, required=False, placeholder="DP00001, DP00002", hint="多实例部署(CN+EU 镜像同一组织)时,声明本实例canonically拥有哪些" "法律实体(EmpJob.company 的 FOCompany 代码,逗号/空格分隔)。同步在" "onboard 建人时据此判定归属:公司代码归其他实例的镜像员工会延后到属主" "实例自己的同步创建,已被错误实例抢建的行由属主实例收回。留空=不参与" "归属判定(单实例部署无需配置)。", ), ] @register_driver class SuccessFactorsDriver(HrisDriver): provider = "successfactors" display_name = "SAP SuccessFactors" description = ( "SAP SuccessFactors OData v2 API。人员主数据、组织结构、入离职事件来源。" "支持 Basic Auth(开发/本地)和 OAuth 2.0 SAML Bearer(生产推荐)。" ) supports_multi_instance = True config_schema = _CONFIG_SCHEMA def _client_unchecked(self) -> SuccessFactorsClient: """Build the SF client without the async SSRF DNS check. Callers that need SSRF validation must call ``_validate_url_ssrf`` first (which is async). Keeping client construction sync avoids cascading async refactors in every method that builds a client. """ base_url = (self.config.get("base_url") or "").rstrip("/") if not base_url: raise ValueError("SuccessFactors base_url is not set") auth = build_auth_from_config(self.config) return SuccessFactorsClient(base_url=base_url, auth=auth) async def test_connection(self) -> IntegrationTestResult: try: result = await self._client_unchecked().ping() except Exception as e: # noqa: BLE001 return IntegrationTestResult( ok=False, message=f"{type(e).__name__}: {e}", detail={"type": type(e).__name__}, ) return IntegrationTestResult( ok=True, message=( f"OK · auth={result.get('auth', {}).get('type')}" + (f" · sample_company={result.get('sample_company')}" if result.get("sample_company") else "") ), detail=result, ) async def list_active_users(self) -> list[dict[str, Any]]: return await self._client_unchecked().list_active_users() async def list_hires_in_range( self, start_date: date, end_date: date, ) -> list[dict[str, Any]]: return await self._client_unchecked().list_hire_employments(start_date, end_date) async def list_terminations_in_range( self, start_date: date, end_date: date, ) -> list[dict[str, Any]]: return await self._client_unchecked().list_termination_employments(start_date, end_date) async def get_users(self, user_ids: list[str]) -> list[dict[str, Any]]: return await self._client_unchecked().get_users(user_ids) async def get_jobs_for_users(self, user_ids: list[str]) -> list[dict[str, Any]]: return await self._client_unchecked().get_jobs_for_users(user_ids) def webhook_hmac_secret(self) -> str | None: """Used by the SF webhook endpoint to verify incoming callbacks.""" secret = self.config.get("webhook_hmac_secret") return secret.strip() if isinstance(secret, str) and secret.strip() else None