"""SSO Apps catalog scheduled handlers. Just one cron registered now (2026-05-07 pivot — see migration 127): * ``sso.execute_pending_google_grants`` (every 5 minutes) — finds approved SSO grants whose catalog row hasn't landed yet, writes the local ``sso_apps`` row + notifies IT to manually create the matching SAML app in Google admin. NEVER calls Google. The previously-planned ``sso.google_saml_app_sync`` daily catalog refresh was removed — Google has no public API for the outbound "Apps → Web and mobile apps" SAML catalog (verified empirically; ``inboundSamlSsoProfiles`` returns INBOUND profiles, the wrong direction). The catalog is now admin-managed: admin enters apps via the "+ Add app" modal in the SSO Apps page or the CSV bulk-import endpoint. See memory `feedback_verify_third_party_api_direction.md`. """ from __future__ import annotations import logging from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.scheduled_jobs.registry import register_scheduled_job logger = logging.getLogger(__name__) @register_scheduled_job( key="sso.execute_pending_google_grants", display_name="SSO · 已批准的 Google 申请落入目录 + 通知 IT", description=( "每 5 分钟扫描 status='approved' 且 sso_provider='Google' 且 " "created_app_id IS NULL 的 sso_grants 行,创建对应的本地 " "sso_apps 目录行(source='helix_created'),并向 IT(global_admin) " "推送 sso.app_create_pending 通知,提示 IT 在 Google admin 手动 " "创建对应的 SAML 应用。Helix 不调用 Google API,因为 Google 没有 " "公开 API 暴露 'Apps → Web and mobile apps' 列表的写入。失败行 " "保留在 approved 状态等下次重试。" ), ) async def run_sso_execute_pending_google_grants( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.sso.app_catalog_service import ( execute_pending_google_grants, ) summary = await execute_pending_google_grants(db) # Mirror the seat-sync handlers' raise-on-total-failure discipline # (feedback_handler_status_must_raise). The inner helper returns a # ``{considered, executed, failed, errors}`` summary regardless of # outcome — without this gate, a malformed SAML XML at the source # makes every grant fail forever while scheduled_job_runs.status # stays 'ok' and the failure_notifier never fires. considered = int(summary.get("considered") or 0) executed = int(summary.get("executed") or 0) failed = int(summary.get("failed") or 0) if considered > 0 and executed == 0 and failed > 0: preview_parts: list[str] = [] for err in (summary.get("errors") or [])[:3]: if isinstance(err, dict): grant_id = err.get("grant_id") or "?" err_text = str(err.get("error") or "")[:200] preview_parts.append(f"{grant_id}: {err_text}") preview = " | ".join(preview_parts) or "(no error details captured)" raise RuntimeError( f"sso.execute_pending_google_grants: all {failed}/{considered} " f"approved grants failed catalog creation. First errors: {preview}" ) return summary @register_scheduled_job( key="sso.removal_reconcile", display_name="SSO 撤销落地 · 复核与催办", description=( "每日 08:40(Asia/Shanghai)。① 每条 removal_status='pending' 的 SSO 撤销" "必须有一条打开的 provisioning 任务(manual_notify,完成要凭证),没有就补开;" "② 按阶梯(默认 3 / 7 / 14 天,之后每 7 天)催系统负责人,第二次起抄送 IT," "到 14 天开一条 compliance 事件(sso_removal_overdue);③ 对 Google 提供方的" "授权拉 Admin Reports 的 SAML 登录审计:撤销之后仍有登录 → contradicted 并" "重开任务;已确认满 N 天无登录 → verified;IdP 账号已不存在 → verified。" "缺 Reports 权限时不判失败,把原因写在 removal_check_error 上。" ), ) async def run_sso_removal_reconcile( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: """Drive the SSO removal lifecycle forward once a day (migration 603). Uses its own session per phase so a Reports API stall never holds the handler's connection: settings + driver are resolved first, then the sweep runs on a fresh session that is committed at the end. Raises when EVERY scanned grant errored (systemic — DB or Reports outage), mirroring the seat-sync handlers' raise-on-total-failure discipline. """ import uuid from datetime import UTC, datetime from app.core.database import async_session_factory from app.domains.sso.removal import ( SsoRemovalService, google_driver_for_org, load_removal_settings, ) trace_id = uuid.uuid4().hex now = datetime.now(UTC) async with async_session_factory() as session: settings = await load_removal_settings(session) driver = None driver_error: str | None = None if settings.verify_via_google_reports: try: driver = await google_driver_for_org(session) except Exception as exc: # noqa: BLE001 — recorded, not fatal driver_error = f"{type(exc).__name__}: {exc}"[:200] logger.warning("sso.removal_reconcile: driver resolve failed: %s", exc) summary = await SsoRemovalService(session).reconcile( now=now, settings=settings, driver=driver, trace_id=trace_id, ) await session.commit() summary["google_driver"] = ( "configured" if driver is not None else (driver_error or "not_configured") ) summary["settings"] = settings.to_setting() scanned = int(summary.get("scanned") or 0) errors = summary.get("errors") or [] if scanned > 0 and len(errors) == scanned: preview = " | ".join( f"{e.get('grant_id')}: {e.get('error')}" for e in errors[:3] ) raise RuntimeError( f"sso.removal_reconcile: all {scanned} grants errored. First: {preview}" ) return summary