"""Zoom Meetings — outbound identity-sync runner. Backs the single ``zoom`` sync-target kind. This is the *identity lifecycle* half of the Zoom integration — "give every in-scope employee a baseline Zoom account on hire, close it on leave" — and is deliberately **separate** from the seat/license flow in ``app.integrations.drivers.zoom_software.ZoomSoftwareDriver.grant_seat`` / ``revoke_seat`` (paid Licensed seat granted on approved request, LRU eviction, etc.). The two compose without fighting each other: * This runner **creates a baseline account** when a person is in scope and has no Zoom user yet, and **NEVER touches the type of an existing user** — so a paid Licensed seat granted by the seat flow is preserved when the person is re-synced here. The baseline is ``no_meetings`` (Zoom ``type=4``): an account that exists, with no Meetings license. **Not Basic (``type=1``)** — Basic is capped per tenant (20 here) and a create past the cap is refused with code 3412, which is exactly how the first production create sweep managed to create one account out of seventeen. Capacity refusals are reported ``blocked`` (an IT config task) rather than ``failed`` (retry me), and trip a per-run breaker so the rest of the sweep doesn't re-issue calls that cannot succeed. * **离职 = 停用, never delete** (``supports_delete=False``). Deactivate flips the Zoom user to ``inactive`` via ``ZoomSoftwareDriver.set_user_status`` — the user stays in the tenant (SSO does not silently re-create them), their recordings/meetings are retained, and any paid seat is freed. Hard delete of a Zoom account (with content handoff to a successor) remains the seat flow's ``revoke_seat action=delete`` job, not a directory-sync concern. Auth + all HTTP is delegated to the shared ``ZoomSoftwareDriver`` (same Server-to-Server OAuth app / token cache that backs the seat flow), so there is exactly one place that talks to Zoom's API. The runner owns only the create-if-missing / reactivate / no-op policy. See DECISIONS.md (2026-07-24) for why this lives in the identity-sync domain rather than extending the seat flow, and why it decouples Zoom account lifecycle from the (decommissioned) Authing SSO cascade. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from app.domains.identity_sync.runners.base import PersonOutcome, SyncRunner from app.integrations.drivers.zoom_software import ( _TYPE_BY_PLAN, is_basic_cap_error, ) if TYPE_CHECKING: from app.integrations.drivers.zoom_software import ZoomSoftwareDriver logger = logging.getLogger(__name__) # Baseline tier for a brand-new hire. NOT "basic" — see the note in # ``ZoomRunner.__init__``: Basic (type 1) is capped per tenant, while # ``no_meetings`` (type 4) is uncapped and is where the overwhelming # majority of this tenant's users already are. _DEFAULT_PLAN_TYPE = "no_meetings" # Default per-run create ceiling. Sized so that opening the gate on a # ~400-person backlog drains over roughly two weeks of daily sweeps # rather than emailing 400 people a Zoom invitation in one tick, while # still being far above the normal daily new-hire count (so steady-state # onboarding is never delayed by it). _DEFAULT_MAX_CREATES_PER_RUN = 25 # ``PersonOutcome.detail['blocked_reason']`` values — machine-readable # so the admin UI / a future notifier can group a capacity backlog # without parsing prose. BLOCKED_REASON_BASIC_POOL_FULL = "zoom_basic_pool_full" BLOCKED_REASON_CREATE_BUDGET = "zoom_create_budget_exhausted" class ZoomRunner(SyncRunner): """Create the baseline Zoom account on hire; deactivate on leave.""" supports_deactivate = True # 离职 = 停用. Hard-deleting a Zoom account can strand hosted content # and needs a successor for transfer — that is the seat flow's # ``revoke_seat action=delete`` path, never a directory-sync default. # Setting this False makes ``_validate_on_offboard`` reject a target # configured with ``on_offboard=delete`` at save time. supports_delete = False def __init__( self, *, driver: ZoomSoftwareDriver, plan_type: str = _DEFAULT_PLAN_TYPE, max_creates_per_run: int | None = _DEFAULT_MAX_CREATES_PER_RUN, ) -> None: self._driver = driver # Baseline tier to create for a brand-new hire. Default # ``no_meetings`` (Zoom type 4) — an account that exists (SSO # login, chat, calendar, joining others' meetings) with no # Meetings license. A paid Licensed seat is an entitlement granted # through the seat-request flow, not something every employee gets # automatically. Configurable via ``runner_options.zoom_plan_type``. # # It was "basic" (type 1) until 2026-07-30. That looked like the # obvious free tier and is not: Zoom grants a tenant a small fixed # number of Basic users (20 here) and refuses the 21st with code # 3412. Prod evidence — the first real create sweep (2026-07-24) # created exactly one account and had 16 refused for cap; every # in-scope person stayed unlinked. ``no_meetings`` has no cap and # is where 397 of the tenant's 612 users already sit, i.e. it is # what "a normal employee's baseline account" actually means here. self._plan_type = ( (plan_type or _DEFAULT_PLAN_TYPE).strip().lower() or _DEFAULT_PLAN_TYPE ) if self._plan_type not in _TYPE_BY_PLAN: # Fail at runner-build time (i.e. when the sweep starts, with a # message naming the target) rather than per person against the # vendor. A misconfigured tier is an admin typo to fix in the # target editor, not 400 identical vendor rejections. raise ValueError( f"zoom_plan_type={plan_type!r} is not a known Zoom tier; " f"expected one of {sorted(_TYPE_BY_PLAN)}" ) # Per-run ceiling on brand-new account creations. A create is # externally visible — Zoom emails the person an invitation — so # the first sweep after an admin opens the create gate on a # backlog of hundreds must not fan out hundreds of invitations in # one tick. Excess people are reported ``blocked`` with a "budget # exhausted, continues next run" reason and picked up by the next # sweep, so the backlog drains over days instead of arriving as a # storm. ``None`` / <=0 disables the ceiling. self._max_creates_per_run = ( max_creates_per_run if max_creates_per_run and max_creates_per_run > 0 else None ) # Per-run state (a runner instance is built per sweep by # ``IdentitySyncService._build_runner``). self._creates_done = 0 # Set once the tenant's Basic pool is known full, so the rest of # the sweep stops issuing creates that Zoom will certainly refuse # (2026-07-24: 16 identical doomed calls, each also triggering a # full user enumeration for error enrichment). self._basic_pool_full_note: str | None = None def describe(self) -> str: return f"Zoom Meetings (baseline {self._plan_type}) via S2S OAuth" async def list_users(self) -> list[dict[str, Any]]: """Read-only roster for the vendor-snapshot collector. Aggregates all account statuses (active / pending / inactive) and tags each row with ``_helix_status`` so the collector can set ``is_active`` correctly. Mirrors ``ScimRunner.list_users`` — the snapshot + the leaver-revocation reconcile (which frees licenses by deactivating departed users) consume this. Read-only; never writes. """ out: list[dict[str, Any]] = [] for status in ("active", "pending", "inactive"): for u in await self._driver.list_users(status=status): out.append({**u, "_helix_status": status}) return out async def upsert( self, *, person_email: str, payload: dict[str, Any], prior_external_id: str | None, ) -> PersonOutcome: first_name = str(payload.get("first_name") or "").strip() last_name = str(payload.get("last_name") or "").strip() # Look up the existing Zoom user first. A missing user (404 → # None) means "create the baseline account"; an existing one means # "do not clobber their tier — only reactivate if they were closed". try: existing = await self._driver.get_user(person_email) except Exception as e: # noqa: BLE001 — surface as a per-person failure return PersonOutcome( status="failed", external_id=prior_external_id, error=f"Zoom user lookup failed: {type(e).__name__}: {e}", ) if existing is None: # ── Pre-create gates (no vendor call) ──────────────────── # The Basic pool is already known full this run: every further # create would be refused identically. Report the same # actionable block without burning the call (and without the # per-failure user enumeration the driver does for enrichment). if self._basic_pool_full_note is not None: return PersonOutcome( status="blocked", external_id=prior_external_id, error=self._basic_pool_full_note, detail={ "blocked_reason": BLOCKED_REASON_BASIC_POOL_FULL, "note": "pool known full earlier this run; no call made", }, ) if ( self._max_creates_per_run is not None and self._creates_done >= self._max_creates_per_run ): return PersonOutcome( status="blocked", external_id=prior_external_id, error=( f"本次同步的建号配额已用完(上限 " f"{self._max_creates_per_run} 个/次)——该员工会在下" "一次同步继续建号。这是给存量补建做的限速,避免一次" "性向大量员工发出 Zoom 邀请邮件。" ), detail={ "blocked_reason": BLOCKED_REASON_CREATE_BUDGET, "max_creates_per_run": self._max_creates_per_run, }, ) res = await self._driver.create_user( email=person_email, first_name=first_name, last_name=last_name, plan_type=self._plan_type, ) if not res.ok: # A Basic-pool-full refusal is a tenant capacity / tier # configuration problem, not a transient failure: retrying # it every sweep just re-collects the identical 3412 and # (before this) painted the whole run red, drowning the # actionable signal. ``blocked`` keeps the run green and # surfaces a deduplicated backlog for IT to act on, and # trips the per-run breaker for everyone after this person. if is_basic_cap_error(res): note = res.error_message or "Zoom Basic 用户池已满" self._basic_pool_full_note = note logger.warning( "Zoom identity-sync: Basic pool full while creating " "%s — blocking the remaining creates this run. " "Switch runner_options.zoom_plan_type to " "'no_meetings' (uncapped) or free Basic seats.", person_email, ) blocked_detail = { "blocked_reason": BLOCKED_REASON_BASIC_POOL_FULL, "current_basic_users": ( (res.raw_response or {}).get("current_basic_users") ), "plan_type": self._plan_type, } return PersonOutcome( status="blocked", external_id=prior_external_id, error=note, # The ledger records ``response`` in preference to # ``detail`` (it's the real wire body), so fold the # machine-readable reason INTO it — otherwise the # blocked-backlog notifier would have to infer the # reason from the vendor payload's error_tag. response={**(res.raw_response or {}), **blocked_detail}, detail=blocked_detail, ) return PersonOutcome( status="failed", external_id=prior_external_id, error=res.error_message, response=res.raw_response, ) # 409-race (created between our GET and POST): fall through to # a benign "present" outcome rather than a spurious create. # Deliberately does NOT consume create budget — nothing was # created and nobody was emailed an invitation. if (res.raw_response or {}).get("already_exists"): return PersonOutcome( status="skipped", external_id=res.external_ref, detail={"note": "already present at provider (create race)"}, ) self._creates_done += 1 return PersonOutcome( status="success", external_id=res.external_ref, request_body={ "action": "create", "email": person_email, "type": self._plan_type, "first_name": first_name, "last_name": last_name, }, response=res.raw_response, ) external_id = str(existing.get("id") or person_email) status_str = str(existing.get("status") or "").lower() if status_str == "inactive": # Rejoiner / previously-deactivated account → reopen it. res = await self._driver.set_user_status( user_id_or_email=external_id, activate=True, ) if not res.ok: return PersonOutcome( status="failed", external_id=external_id, error=res.error_message, response=res.raw_response, ) return PersonOutcome( status="success", external_id=external_id, request_body={"action": "activate", "email": person_email}, response=res.raw_response, ) # Already active (or pending activation) → no-op. Crucially we do # NOT PATCH the type: a Licensed seat granted by the seat flow must # survive an identity-sync sweep. Display name is intentionally not # pushed for existing users (Zoom syncs it from SSO). return PersonOutcome( status="skipped", external_id=external_id, detail={ "note": "already present; tier left untouched", "zoom_status": status_str or "active", "zoom_type": existing.get("type"), }, ) async def deactivate( self, *, person_email: str, prior_external_id: str | None, ) -> PersonOutcome: if not prior_external_id: # Never linked to Zoom by this target → nothing to close. Treat # as an idempotent no-op success so the offboarding blocker # clears cleanly (goal — "inactive on the vendor" — is met or # was never this target's responsibility). Historical leavers # who predate the target are guarded upstream by the # connected-after cutoff, so this path stays conservative. return PersonOutcome( status="deactivated", external_id=None, detail={"note": "no prior external id; nothing to deactivate"}, ) # Address by the stable Zoom user id we stored, not the email # (survives an email change on the person). res = await self._driver.set_user_status( user_id_or_email=prior_external_id, activate=False, ) if not res.ok: return PersonOutcome( status="failed", external_id=prior_external_id, error=res.error_message, response=res.raw_response, ) return PersonOutcome( status="deactivated", external_id=prior_external_id, request_body={"action": "deactivate", "email": person_email}, response=res.raw_response, )