"""SOC2 邮件组授权 — auto-grant handler. The SOC2 ``soc2@pingcap.com`` Google Group governs SOC2-related access. The org's Feishu approval flow ``9A42C433-6A4B-411C-9B2E-9182BEEBC6BD`` routes each request through a manual review chain whose final step USED to be an IT admin pasting the applicant into the Google Group by hand. This service automates that last step. Feishu pushes the ``approval_instance`` webhook for ALL instances of the subscribed template (including those submitted directly in Feishu, not via Helix's ``instance/create``); the approval router's external-origin shim synthesizes the parent ``approval_requests`` row, then the dispatcher hands the instance to :meth:`handle_feishu_callback`. Only the ``approved`` terminal state triggers a write. Rejected / cancelled / pending are recorded by the parent ``approval_requests`` row's ``status`` column but produce no Google Workspace mutation. The Google ``members.insert`` call is idempotent on the driver: HTTP 409 (already a member) is mapped to ``ok=True``, so a Feishu redelivery re-running this handler does not error. """ from __future__ import annotations import json import logging from typing import Any from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.approval.feishu_client import FeishuApprovalClient from app.approval.models import ApprovalRequest from app.audit.service import AuditService from app.core.exceptions import AppException from app.identity.person_models import IdentityPerson from app.integrations.compat import get_feishu_config from app.integrations.driver_base import ProvisioningContext from app.integrations.drivers.google_workspace import GoogleWorkspaceDriver from app.integrations.models import Integration from app.integrations.service import _resolve # noqa: PLC2701 logger = logging.getLogger(__name__) # Fixed group address — confirmed with IT 2026-05-12 ("就这个地址 不用改动"). # If a second SOC2-style group is ever added we'd generalize via a # ``platform_settings`` row, but the YAGNI default is one constant. SOC2_GROUP_EMAIL = "soc2@pingcap.com" class Soc2EmailGroupService: def __init__(self, db: AsyncSession) -> None: self.db = db self.audit = AuditService(db) self._feishu_client: FeishuApprovalClient | None = None async def _feishu(self) -> FeishuApprovalClient: if self._feishu_client is None: cfg = await get_feishu_config(session=self.db) self._feishu_client = FeishuApprovalClient( app_id=str(cfg.get("app_id") or ""), app_secret=str(cfg.get("app_secret") or ""), ) return self._feishu_client async def _get_google_driver(self) -> GoogleWorkspaceDriver: row = ( await self.db.execute( select(Integration) .where(Integration.kind == "software_provisioning") .where(Integration.provider == "google_workspace") .where(Integration.enabled.is_(True)) .order_by( Integration.is_primary.desc(), Integration.created_at, ) ) ).scalars().first() if row is None: raise AppException( code="GOOGLE_WORKSPACE_NOT_CONFIGURED", message=( "Google Workspace 集成未配置或已禁用 — SOC2 自动加邮件组 " "无法执行。请在 /admin/integrations 启用 Google Workspace。" ), status_code=503, ) resolved = await _resolve(self.db, row) return GoogleWorkspaceDriver( integration_id=str(row.id), config=resolved.config, ) async def _resolve_applicant_email( self, instance_detail: dict[str, Any], ) -> tuple[str, IdentityPerson | None]: """Return ``(email, person)`` for the Feishu applicant. ``person`` is the Helix ``identity_persons`` row if we can match by ``work_email`` — used to populate ``approval_request.applicant_id`` for audit. ``None`` when the applicant is a Feishu user with no Helix Person record (rare but possible during contractor onboarding races). """ # Feishu's instance detail puts the applicant's open_id on # the top-level envelope. ``user_id`` is also present but is # the same value rendered differently depending on the # ``user_id_type`` Feishu used at create time — we lean on # ``contact/v3/users/{id}`` to resolve either to a canonical # user record carrying ``email``. candidate_id: str | None = None candidate_type = "open_id" if instance_detail.get("open_id"): candidate_id = str(instance_detail["open_id"]) candidate_type = "open_id" elif instance_detail.get("user_id"): candidate_id = str(instance_detail["user_id"]) candidate_type = "user_id" if not candidate_id: raise AppException( code="SOC2_APPLICANT_MISSING", message=( "Feishu instance_detail has neither open_id nor user_id; " "cannot resolve SOC2 applicant." ), status_code=502, ) client = await self._feishu() user_rec = await client.get_user_with_custom_attrs( candidate_id, user_id_type=candidate_type, ) email = str(user_rec.get("email") or "").strip().lower() if not email: raise AppException( code="SOC2_APPLICANT_EMAIL_MISSING", message=( f"Feishu user {candidate_id} has no email; cannot add to " f"{SOC2_GROUP_EMAIL}." ), status_code=502, ) # Round-22 review (P1): match Person by EITHER lowercased # ``work_email`` OR an entry in ``email_aliases``. Without the # case-insensitive compare + alias check, a leaver whose # Feishu account uses a casing-variant or alias address would # bypass the leaver gate (Person not found → fail-open warning # path → SOC2 group add proceeds). Mirrors the resolver in # [`server/app/identity/email_lookup.py`](server/app/identity/ # email_lookup.py) which uses the same predicate shape for # cross-system identity matching. ``func.lower()`` makes the # compare independent of column collation (utf8mb4_general_ci # default is case-insensitive but we don't rely on that — a # collation flip in a future migration could otherwise silently # disable the gate). person = ( await self.db.execute( select(IdentityPerson).where( or_( func.lower(IdentityPerson.work_email) == email, func.json_contains( IdentityPerson.email_aliases, json.dumps(email), ), ), ) ) ).scalar_one_or_none() return email, person async def handle_feishu_callback( self, *, approval_request: ApprovalRequest, instance_detail: dict[str, Any], ) -> None: feishu_status = (instance_detail.get("status") or "").upper() if feishu_status != "APPROVED": logger.info( "soc2_email_group_grant: instance=%s status=%s — no Google " "Workspace mutation (only APPROVED triggers add).", approval_request.external_id, feishu_status, ) return applicant_email, person = await self._resolve_applicant_email( instance_detail, ) # Leaver gate (round-21 review). Mirrors the SSO / VPN / # AI-tool / announcement / scope-request handlers — adding a # leaver (or someone mid-offboarding) to the SOC2 group is a # real SOC2 audit finding regardless of whether Feishu # approved the request when they were still active. Refuse # AFTER resolving the person so the rejection audit cites the # correct identity. ``person is None`` (Feishu user without a # Helix Person row — contractor pre-HRIS-sync) bypasses the # gate WITH A WARNING because we can't classify their status. if person is not None and ( person.deleted_at is not None or person.employment_status in ("leaver_pending", "left") ): # Round-22 review (P1): the refused_leaver audit row MUST # survive the rollback triggered when this AppException # bubbles up through ``get_db()``. Writing into # ``self.audit`` (which uses ``self.db``) would land the # row in the doomed request transaction. Use a fresh # session committed standalone, then raise. Mirrors the # dispatcher-level failure-audit pattern in # ``approval/service.py`` introduced in the same review. try: from app.core.database import ( async_session_factory as _afs, ) async with _afs() as _audit_session: _audit = AuditService(_audit_session) await _audit.log( trace_id=( f"feishu_callback:{approval_request.external_id}" ), actor_type="integration", actor_user_id=None, action="soc2_email_group_grant.refused_leaver", status="failure", resource_type="email_group", resource_id=SOC2_GROUP_EMAIL, detail={ "applicant_email": applicant_email, "applicant_person_id": str(person.id), "employment_status": person.employment_status, "feishu_instance_code": ( approval_request.external_id ), }, ) await _audit_session.commit() except Exception: # noqa: BLE001 # Audit-write failure must never mask the leaver # decision — log + drop and continue to the raise. logger.exception( "soc2_email_group_grant: failed to persist " "refused-leaver audit for instance %s", approval_request.external_id, ) raise AppException( code="SOC2_APPLICANT_LEFT", message=( f"{applicant_email} is in offboarding " f"(employment_status={person.employment_status}); refusing " f"automatic add to {SOC2_GROUP_EMAIL}. IT must replay " f"manually if access is still required." ), status_code=409, ) if person is None: logger.warning( "soc2_email_group_grant: applicant %s has no Helix Person " "row — cannot apply leaver gate; proceeding on Feishu's " "approval. instance=%s", applicant_email, approval_request.external_id, ) driver = await self._get_google_driver() ctx = ProvisioningContext( user_email=applicant_email, extra={"group_email": SOC2_GROUP_EMAIL}, ) # ``GoogleWorkspaceDriver._group_add`` is the same private # entrypoint the existing software-provisioning queue uses # for seat-binding group adds (see PR #154 driver refactor). # SOC2 deliberately bypasses ``ProvisioningService`` because # this is a one-shot membership grant, not a billable seat — # no plan, no allocation row, no monthly bill. The audit row # below + the ``approval_requests`` row are the records of # truth. Documented in PLATFORM.md §9. result = await driver._group_add(ctx) # noqa: SLF001 if not result.ok: # Round-22 ordering: the dispatcher calls this service # BEFORE mutating ``approval_request.status``, so on raise # the parent row's status is still in its pre-callback # state. The outer ``get_db()`` rolls back the request # transaction, which also wipes the synthesized row (it # was inserted in the same outer tx via the synthesizer's # SAVEPOINT path). Feishu retries → row missing again → # synthesize again → dispatch re-enters → driver's 409 # makes the duplicate side effect a no-op once a prior # attempt actually succeeded against Google. The # dispatcher's failure-audit (server/app/approval/ # service.py round-22 fresh-session write) survives the # rollback so ops sees the failed dispatch. raise AppException( code="SOC2_GROUP_ADD_FAILED", message=( f"Failed to add {applicant_email} to {SOC2_GROUP_EMAIL}: " f"{result.error_message or 'unknown error'}" ), status_code=502, ) # Move applicant_id write to AFTER the successful driver call # (round-21 review). If the driver had raised, rolling back # the applicant_id write would otherwise leave a "wrote it, # then undid it" pattern that obscures the audit lineage on # subsequent retries. if person is not None and approval_request.applicant_id is None: approval_request.applicant_id = person.id already_member = bool( (result.raw_response or {}).get("already_member"), ) await self.audit.log( trace_id=f"feishu_callback:{approval_request.external_id}", actor_type="integration", actor_user_id=None, action="soc2_email_group_grant.member_added", status="success", resource_type="email_group", resource_id=SOC2_GROUP_EMAIL, detail={ "applicant_email": applicant_email, "applicant_person_id": str(person.id) if person else None, "feishu_instance_code": approval_request.external_id, "already_member": already_member, "external_ref": result.external_ref, }, ) logger.info( "soc2_email_group_grant: %s added to %s (already_member=%s, " "instance=%s)", applicant_email, SOC2_GROUP_EMAIL, already_member, approval_request.external_id, )