Files
provenance/backend/app/services/privacy.py
T
justin 84a743f5b9 Visibility phase 2: privacy-engine branches on viewer auth state
can_view_tree() now distinguishes anonymous vs authenticated non-members so the
four-level model is enforceable:
- public / unlisted → anyone, including anonymous (unlisted gated only by the
  link, so the API must never *list* it)
- site_members → any authenticated account (denies anonymous)
- private → members only
Members (any role) always view; soft-deleted trees stay hidden from everyone.
person_visibility (living-person redaction) is unchanged.

Tests: a full can_view_tree matrix across {anonymous, logged-in non-member,
member} × {public, unlisted, site_members, private}, plus deleted-tree-hidden
and the site_members anon-vs-logged-in case. Adds `engine`/`db_session` fixtures
(refactored out of `client`) so the engine can be unit-tested directly,
including the anonymous path that has no HTTP endpoint yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:08:04 -04:00

119 lines
4.4 KiB
Python

"""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 # members always (any role)
# Non-members. Branch on the viewer's auth state:
# public / unlisted → anyone, including anonymous (unlisted is gated only
# by knowing the link, so the API must never *list* it).
# site_members → any authenticated account on this instance.
# private → no one.
if tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted):
return True
if tree.visibility == TreeVisibility.site_members:
return user_id is not None
return False
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