"""Resolve a Feishu message sender to the Helix person the server believes it is. The identity a turn runs as is decided HERE, from the signed event envelope — never from message text and never from model output. Prompt injection therefore cannot change who the caller is; the worst it can do is ask for data the caller was already entitled to. The lookup deliberately mirrors the Feishu SSO login path (``AuthService.find_user_by_external_subject``): union_id first, open_id as fallback, and only rows that are good enough to LOG IN with — active, undeleted, ``is_login_identity``. A contact-sync row that was never a login identity does not identify a chat sender, which also makes the "log in to Helix once" guidance literally accurate rather than a guess. """ from __future__ import annotations import logging from dataclasses import dataclass from enum import StrEnum from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.identity.person_models import IdentityExternalIdentity, IdentityPerson logger = logging.getLogger(__name__) FEISHU_PROVIDER = "feishu" STATUS_LEFT = "left" class SenderStatus(StrEnum): """Why a sender may or may not talk to the assistant.""" OK = "ok" UNBOUND = "unbound" """No active Feishu login identity — the person has never signed in.""" DEPARTED = "departed" """Resolved, but no longer an employee who may read Helix data.""" MISMATCH = "mismatch" """The envelope's two sender ids name different people — never trusted.""" @dataclass(frozen=True) class ResolvedSender: status: SenderStatus person: IdentityPerson | None = None @property def allowed(self) -> bool: return self.status is SenderStatus.OK and self.person is not None def _may_use_assistant(person: IdentityPerson) -> bool: """Read authority for the assistant channel. Stricter than :func:`app.core.rbac.is_active_employee` in one direction and looser in another, on purpose. Looser: ``leaver_pending`` people are still working out their notice period and must keep read access to their own laptop / account / handover state — that is exactly the window in which they need it most. Stricter is not needed: ``left`` and soft-deleted people are refused outright, which is the same line the leaver-aware email lookups draw. """ if person.deleted_at is not None: return False return person.employment_status != STATUS_LEFT async def resolve_sender( db: AsyncSession, *, union_id: str | None, open_id: str | None, ) -> ResolvedSender: """Map a Feishu sender id to the Helix person, or explain why we cannot.""" subjects = [s for s in (union_id, open_id) if s] if not subjects: return ResolvedSender(SenderStatus.UNBOUND) # Both ids travel in the same signed envelope, and the REPLY is addressed to # ``open_id`` while the ANSWER is computed for whoever the identity resolves # to. If the two resolve to different people, an answer about one employee # would be delivered to another's inbox — so a disagreement is refused # outright rather than resolved by precedence. resolved: list[tuple[str, IdentityPerson]] = [] for subject in subjects: stmt = ( select(IdentityPerson) .join( IdentityExternalIdentity, IdentityExternalIdentity.person_id == IdentityPerson.id, ) .where( IdentityExternalIdentity.provider == FEISHU_PROVIDER, IdentityExternalIdentity.provider_subject == subject, IdentityExternalIdentity.deleted_at.is_(None), IdentityExternalIdentity.identity_status == "active", IdentityExternalIdentity.is_login_identity.is_(True), IdentityPerson.deleted_at.is_(None), ) .limit(1) ) person = (await db.execute(stmt)).scalars().first() if person is not None: resolved.append((subject, person)) if not resolved: return ResolvedSender(SenderStatus.UNBOUND) if len({str(person.id) for _subject, person in resolved}) > 1: logger.warning( "assistant: sender ids disagree (union_id=%r open_id=%r) — refusing", union_id, open_id, ) return ResolvedSender(SenderStatus.MISMATCH) person = resolved[0][1] if not _may_use_assistant(person): return ResolvedSender(SenderStatus.DEPARTED, person) return ResolvedSender(SenderStatus.OK, person)