"""审批通过之后发生什么 —— 两个来源共用的唯一一份实现。 Helix 发起的单子和员工直接在飞书上提交的单子,最终都落到 :func:`execute_approved` 上。少了外来那一半,过渡期里从飞书提交的人会 **静默地**拿不到自动化 —— 而那正是本来要修的那个问题。 ## 四种结局,不是两种 「审批通过 → 调 GitHub」这一步的失败方式不止一种,而它们的处置完全不同: | 结局 | 申请单状态 | 台账 | 谁该动 | |---|---|---|---| | 邀请已发出 | approved | pending_invite | 申请人去 GitHub 点接受 | | 本来就在里面 | approved | active | 没人 | | **没配凭证 / 驱动坏了** | approved | **awaiting_manual** | GitHub 管理员手工点邀请 | | handle 不存在 / 权限不足 / 网络失败 | **failed** | 不落 | 看 ``last_error`` | 第三行是这套东西能在凭证到位之前就上线的原因:**没配凭证不是失败**。 审批确实通过了,访问权确实该给,只是这一步由人来完成 —— 台账照常记, 通知发给 GitHub 管理员。把它记成 failed,会让「上线第一周」看起来像 一连串故障。 第四行的三种都进 ``failed``,但 ``last_error`` 说清楚是哪一种:handle 打错要去找申请人,权限不足要去修集成,网络失败等下一次重试就行。 """ from __future__ import annotations import logging from dataclasses import dataclass from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.identity.person_models import IdentityPerson from . import constants as const from . import service as svc from .driver_access import resolve_org_driver from .models import GithubOrg, GithubOrgMembership logger = logging.getLogger(__name__) #: 结局取值。与申请单状态分开:一次「转人工」在申请单上是 approved, #: 在运维视角里却是一件待办 —— 两个问题,两个词表。 OUTCOME_INVITED = "invited" OUTCOME_ALREADY_MEMBER = "already_member" #: 移除成功。``revoke_membership`` 以前在这条路上返回 ``OUTCOME_INVITED`` #: —— 唯一的调用方只看 FAILED / MANUAL,所以一直没出事,但一旦这个 #: 结果被显示给人(手工移除按钮),界面上就会在一次「移除」之后写着 #: 「已邀请」。同一个枚举值同时表示两个相反的动作,是那种平时无害、 #: 被显示出来的那天变成事故的东西。 OUTCOME_REMOVED = "removed" OUTCOME_MANUAL = "manual" OUTCOME_FAILED = "failed" @dataclass class ExecutionOutcome: outcome: str membership: GithubOrgMembership | None = None error: str | None = None #: 失败为什么值得人看一眼(handle 打错 / 权限不足 / 只是网络抖)。 #: 空表示没失败。 failure_kind: str | None = None @property def ok(self) -> bool: return self.outcome != OUTCOME_FAILED async def execute_approved( db: AsyncSession, *, org: GithubOrg, github_login: str, person: IdentityPerson | None, source: str, trace_id: str, join_request_id: str | None = None, auto_join_rule_id: str | None = None, actor_person_id: str | None = None, ) -> ExecutionOutcome: """把一个 handle 送进一个组织,并把结果落进台账。 幂等:重复调用最终收敛到同一行台账(自然键是 ``(org, handle)``), 驱动层的 ``already_done`` 也被如实翻译成 ``active`` 而不是重复邀请。 这正是「飞书重投一次回调」需要的性质。 ``person`` 可以为空 —— 从飞书直接提交的单子里,提交人未必对得上 Helix 的人员行(外部协作者、邮箱别名没登记)。认不出人不阻止执行: org owner 已经批了,访问权该给;台账留 ``person_id=NULL`` 让它出现在 「认不出是谁」那个数里,由人去认。 """ audit = AuditService(db) resolution = await resolve_org_driver(db, org) if not resolution.automatic: # 转人工。台账照常落 —— 少了这一行,离职回收就不知道这个人拿到过 # 访问权,而人工加进去的成员正是最容易被忘掉的那批。 membership = await svc.upsert_membership( db, org=org, github_login=github_login, person_id=(str(person.id) if person else None), # ``awaiting_manual`` 而不是 ``pending_invite``:后者的意思是 # 「邀请已经发出去了」,而这里**一封都没发**。合并两者会让 # 邀请对账在 7 天内把它判成「本人拒绝」(不可重发的终态)—— # 一条本该等人去点的记录就此冻死。复审第 2 轮抓到的真缺陷。 state=const.MEMBERSHIP_STATE_AWAITING_MANUAL, source=source, join_request_id=join_request_id, auto_join_rule_id=auto_join_rule_id, actor_person_id=actor_person_id, trace_id=trace_id, detail={ "manual": True, "reason": resolution.reason, "detail": resolution.detail, }, ) await audit.log( trace_id=trace_id, actor_type="system", action="github_org.invite.manual_fallback", status="success", resource_type="github_org_membership", resource_id=membership.id, detail={ "org": org.slug, "github_login": github_login, "reason": resolution.reason, "detail": resolution.detail, }, ) return ExecutionOutcome(OUTCOME_MANUAL, membership=membership) driver = resolution.driver assert driver is not None # noqa: S101 — automatic 已经保证 result = await driver.invite_member( org.slug, github_login, role=org.default_role, ) if not result.ok: kind = ( "unknown_handle" if result.not_found else "permission_denied" if result.permission_denied else "transient" ) # **不落台账。** 没发出去的邀请不是一条成员资格,写进去就是在 # 台账上留一条谁也不对应的记录,而离职回收会去撤它。 await audit.log( trace_id=trace_id, actor_type="integration", action="github_org.invite", status="failure", resource_type="github_org", resource_id=org.id, detail={ "org": org.slug, "github_login": github_login, "failure_kind": kind, "error": result.error_message, }, ) return ExecutionOutcome( OUTCOME_FAILED, error=result.error_message, failure_kind=kind, ) state = ( const.MEMBERSHIP_STATE_ACTIVE if result.already_done else const.MEMBERSHIP_STATE_PENDING_INVITE ) membership = await svc.upsert_membership( db, org=org, github_login=github_login, person_id=(str(person.id) if person else None), state=state, source=source, join_request_id=join_request_id, auto_join_rule_id=auto_join_rule_id, actor_person_id=actor_person_id, actor_kind="integration", trace_id=trace_id, detail={"external_ref": result.external_ref}, ) await audit.log( trace_id=trace_id, actor_type="integration", action="github_org.invite", status="success", resource_type="github_org_membership", resource_id=membership.id, detail={ "org": org.slug, "github_login": github_login, "already_member": result.already_done, }, ) return ExecutionOutcome( OUTCOME_ALREADY_MEMBER if result.already_done else OUTCOME_INVITED, membership=membership, ) async def revoke_membership( db: AsyncSession, *, membership: GithubOrgMembership, org: GithubOrg, reason: str, removal_source: str, trace_id: str, actor_person_id: str | None = None, ) -> ExecutionOutcome: """把一个人移出组织,并把结果落进台账。 离职回收与管理员手工移除共用它。**驱动失败时不标 removed** —— 那会在台账上留一条假的回收记录,而那条记录正是以后所有人判断 「这个人还有没有访问权」的依据。失败向上抛给调用方,由它决定是 留 pending 下个 tick 重试(离职)还是报错给管理员(手工)。 """ audit = AuditService(db) resolution = await resolve_org_driver(db, org) if not resolution.automatic: await audit.log( trace_id=trace_id, actor_type="system", action="github_org.remove.manual_fallback", status="success", resource_type="github_org_membership", resource_id=membership.id, detail={ "org": org.slug, "github_login": membership.github_login, "reason": resolution.reason, "detail": resolution.detail, }, ) return ExecutionOutcome( OUTCOME_MANUAL, membership=membership, error=resolution.detail, failure_kind=resolution.reason, ) driver = resolution.driver assert driver is not None # noqa: S101 result = await driver.remove_member(org.slug, membership.github_login) if not result.ok: kind = "permission_denied" if result.permission_denied else "transient" await audit.log( trace_id=trace_id, actor_type="integration", action="github_org.remove", status="failure", resource_type="github_org_membership", resource_id=membership.id, detail={ "org": org.slug, "github_login": membership.github_login, "failure_kind": kind, "error": result.error_message, }, ) return ExecutionOutcome( OUTCOME_FAILED, membership=membership, error=result.error_message, failure_kind=kind, ) await svc.mark_removed( db, membership, reason=reason, removal_source=removal_source, actor_person_id=actor_person_id, actor_kind="integration", trace_id=trace_id, detail={"already_absent": result.already_done}, ) await audit.log( trace_id=trace_id, actor_type="integration", action="github_org.remove", status="success", resource_type="github_org_membership", resource_id=membership.id, detail={ "org": org.slug, "github_login": membership.github_login, "already_absent": result.already_done, "reason": reason, }, ) return ExecutionOutcome(OUTCOME_REMOVED, membership=membership)