"""多 IDP 信任边界注册表 (R1a, U4 SSO 集成)。 管理多个 IDP 配置(OIDC + SAML 并存,如飞书 / Azure AD / 阿里云 IDaaS): - **热证书轮换**:``update_certificate`` 直接替换内存配置,无需重启。 - **单 IDP 故障隔离**:某个 IDP 不可用不影响其他 IDP 的认证流程。 - **按 sub/NameID 关联**(R1a):禁止仅凭邮箱合并账户 — 见 :mod:`providers.oidc`。 配置来源:``agentkit.yaml`` 的 ``auth.idps`` 段(见 :func:`IDPRegistry.from_config`)。 单租户假设 (R1c):所有 IDP 共享同一本地用户表,无租户隔离。 """ from __future__ import annotations import logging import threading from typing import Literal from pydantic import BaseModel, ConfigDict, Field logger = logging.getLogger(__name__) IDPType = Literal["oidc", "saml"] class OIDCConfig(BaseModel): """单个 OIDC IDP 配置(authlib redirect flow)。""" model_config = ConfigDict(extra="forbid") client_id: str client_secret: str server_metadata_url: str # redirect_uri 是完整 URL,如 https://host/api/v1/auth/oauth/feishu/callback redirect_uri: str # 可选:scope 覆盖(默认 openid email profile) scope: str = "openid email profile" class SAMLConfig(BaseModel): """单个 SAML 2.0 IDP 配置(python3-saml / OneLogin)。""" model_config = ConfigDict(extra="forbid") # IDP 元数据:entity_id + 单点登录 URL(SP 发起时构造 AuthnRequest) entity_id: str sso_url: str # IDP 签名证书(PEM 格式,可能多张证书用于轮换) idp_cert: str # SP 自身配置 sp_entity_id: str acs_url: str # 可选:SLO URL slo_url: str | None = None class IDPConfig(BaseModel): """单个 IDP 完整配置(OIDC 或 SAML 之一)。""" model_config = ConfigDict(extra="forbid") name: str = Field(..., description="IDP 唯一标识,如 feishu / azure_ad / aliyun_idaas") type: IDPType display_name: str = "" enabled: bool = True oidc: OIDCConfig | None = None saml: SAMLConfig | None = None def is_healthy(self) -> bool: """IDP 是否可用(启用 + 配置完整)。 单 IDP 故障不影响其他 — 调用方逐个检查此方法后决定是否跳过。 """ if not self.enabled: return False if self.type == "oidc": return self.oidc is not None return self.saml is not None class IDPRegistry: """多 IDP 注册表 — 进程内单例,支持热证书轮换。 线程安全:所有写操作加锁(``_lock``)。读操作返回配置的浅拷贝引用 — 配置对象本身不可变(Pydantic),热轮换通过整体替换实现。 ponytail: 进程内 dict 注册表,不持久化。多实例部署时各进程独立加载 agentkit.yaml;证书热轮换需配合配置同步(config_sync.py 轮询)或重启。 上限:单进程数百 IDP 无压力(O(1) 查找)。 """ def __init__(self) -> None: self._idps: dict[str, IDPConfig] = {} self._lock = threading.Lock() def register(self, config: IDPConfig) -> None: """注册或覆盖一个 IDP(用于热加载 / 热证书轮换)。""" with self._lock: self._idps[config.name] = config logger.info(f"IDP 已注册: {config.name} (type={config.type}, enabled={config.enabled})") def remove(self, name: str) -> bool: """移除一个 IDP。返回是否实际移除。""" with self._lock: return self._idps.pop(name, None) is not None def get(self, name: str) -> IDPConfig | None: """按名称查找 IDP 配置。返回 ``None`` 表示不存在。""" with self._lock: return self._idps.get(name) def list_idps(self) -> list[IDPConfig]: """列出所有已注册 IDP(含禁用的)。""" with self._lock: return list(self._idps.values()) def list_healthy(self) -> list[IDPConfig]: """列出所有可用 IDP(启用 + 配置完整)。""" return [c for c in self.list_idps() if c.is_healthy()] def update_certificate(self, name: str, *, idp_cert: str | None = None) -> bool: """热轮换 SAML IDP 签名证书 — 不重启即时生效。 Args: name: IDP 名称。 idp_cert: 新的 IDP 签名证书(PEM)。 Returns: ``True`` 表示成功更新,``False`` 表示 IDP 不存在或非 SAML 类型。 OIDC 证书轮换通过 ``server_metadata_url`` 自动发现(authlib 缓存 TTL), 无需显式热轮换 — 调用 ``register`` 替换整个 :class:`OIDCConfig` 即可。 """ with self._lock: existing = self._idps.get(name) if existing is None or existing.type != "saml" or existing.saml is None: return False # Pydantic 不可变 — 构造新配置整体替换 new_saml = existing.saml.model_copy(update={"idp_cert": idp_cert or ""}) self._idps[name] = existing.model_copy(update={"saml": new_saml}) logger.info(f"IDP {name} 签名证书已热轮换") return True def load_from_config(self, idps: list[dict[str, object]]) -> None: """从 agentkit.yaml 的 ``auth.idps`` 列表批量加载(覆盖现有)。""" with self._lock: self._idps.clear() for raw in idps: try: cfg = IDPConfig.model_validate(raw) self.register(cfg) except Exception as exc: # noqa: BLE001 — 单个 IDP 配置错误不阻断其他 logger.error(f"IDP 配置加载失败,跳过: {raw.get('name', '?')}: {exc}") # --------------------------------------------------------------------------- # 进程级单例 # --------------------------------------------------------------------------- _registry: IDPRegistry | None = None def get_idp_registry() -> IDPRegistry: """返回进程级 IDP 注册表单例(惰性初始化)。""" global _registry if _registry is None: _registry = IDPRegistry() return _registry def reset_idp_registry() -> None: """重置注册表单例(测试用)。""" global _registry _registry = None