"""The privacy engine — the single enforcement point for visibility. INVARIANT (CLAUDE.md #2): every read path resolves visibility here. Do not add a query path that returns rows to a caller without first passing through this module. Effective visibility is a function of the viewer's role on the tree, the tree's visibility, the per-person override, and (Phase 2) living-person status. """ import enum import uuid from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility from app.models.event import Event from app.models.person import Person from app.models.tree import Tree, TreeMembership # A person with no death fact whose birth is within this window (or unknown) is # treated as possibly living and redacted from non-members (ARCHITECTURE §6). LIVING_RECENCY_YEARS = 100 class Visibility(enum.StrEnum): full = "full" redacted = "redacted" hidden = "hidden" async def get_membership_role( session: AsyncSession, user_id: uuid.UUID | None, tree_id: uuid.UUID ) -> MembershipRole | None: if user_id is None: return None stmt = select(TreeMembership.role).where( TreeMembership.tree_id == tree_id, TreeMembership.user_id == user_id, ) return (await session.execute(stmt)).scalar_one_or_none() async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool: if tree.deleted_at is not None: return False if await get_membership_role(session, user_id, tree.id) is not None: return True return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted) async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool: role = await get_membership_role(session, user_id, tree.id) return role in (MembershipRole.owner, MembershipRole.editor) async def is_possibly_living(session: AsyncSession, person: Person) -> bool: """True if the person should be treated as living: explicit flag, or (absent a death fact) a birth within the recency window or an unknown birth.""" if person.is_living is True: return True if person.is_living is False: return False death = ( await session.execute( select(Event.id) .where( Event.person_id == person.id, Event.event_type == "death", Event.deleted_at.is_(None), ) .limit(1) ) ).scalar_one_or_none() if death is not None: return False birth = ( await session.execute( select(Event.date_start) .where( Event.person_id == person.id, Event.event_type == "birth", Event.date_start.is_not(None), Event.deleted_at.is_(None), ) .order_by(Event.date_start) .limit(1) ) ).scalar_one_or_none() if birth is None: return True # unknown birth → treat as possibly living return (datetime.now(UTC).year - birth.year) < LIVING_RECENCY_YEARS async def person_visibility( session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person ) -> Visibility: if not await can_view_tree(session, user_id=user_id, tree=tree): return Visibility.hidden if await get_membership_role(session, user_id, tree.id) is not None: return Visibility.full # members see everyone in their tree # Non-member viewing a public/unlisted tree: if person.privacy == PersonPrivacy.private: return Visibility.hidden if person.privacy == PersonPrivacy.public: return Visibility.full # explicit per-person opt-in if await is_possibly_living(session, person): return Visibility.redacted # living people are protected by default return Visibility.full