9820a77d25
Adds the anonymous read surface (/api/v1/public) — the privacy-critical core. - CurrentUserOrNone dependency: optional auth that never 401s (anonymous OK). - public_view_service: every projection passes through privacy.person_visibility. persons redacted (living → "Living person", hidden dropped); relationships only when both endpoints non-hidden; events only for FULL-visibility persons (partnership events only when both partners full); names only for FULL persons. Not-viewable trees raise 404 (not 403) so the surface can't probe for private trees. Media deferred (higher-sensitivity; own pass later). - public router: read-only directory + tree + persons/relationships/events + person detail/names/events. Directory lists `public` to all and adds `site_members` for authenticated callers; never lists unlisted/private. - PublicTreeRead omits owner_id. Tests (ran locally — CI does not run pytest): an anonymous end-to-end leak test asserting a living person's real name, alias, and birth year appear in NO public response while the deceased person's data does; plus private=404, unlisted viewable-by-link-but-unlisted, site_members requires login, and directory visibility. Full suite: 70 passed. Regenerated openapi.json + TS client. Note: the AUTHED list endpoints still leak per-person for non-members (pre-existing) — fixed next, separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
257 lines
8.5 KiB
Python
257 lines
8.5 KiB
Python
"""Read-only, redaction-safe projections for the public viewing surface.
|
|
|
|
INVARIANT (CLAUDE.md #2): everything returned here has passed through
|
|
``privacy.person_visibility``. A non-member must never receive a possibly-living
|
|
person's real name, dates, alternate names, or media. The rules:
|
|
|
|
- persons : redacted (living → "Living person"); hidden dropped.
|
|
- relationships : only when BOTH endpoints are non-hidden (a link to a
|
|
redacted person is fine — the name is already hidden).
|
|
- events : only for FULL-visibility persons; partnership events only
|
|
when BOTH partners are full (a marriage date would leak a
|
|
living partner's timeline otherwise).
|
|
- names : only for FULL-visibility persons.
|
|
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md).
|
|
|
|
A tree that isn't viewable raises NotFound (never Forbidden) so the public
|
|
surface can't be used to probe whether a private tree exists.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.enums import TreeVisibility
|
|
from app.models.event import Event
|
|
from app.models.person import Name, Person
|
|
from app.models.relationship import Relationship
|
|
from app.models.tree import Tree
|
|
from app.services import privacy
|
|
from app.services.exceptions import NotFound
|
|
from app.services.person_service import _attach_primary_name, _redact
|
|
from app.services.privacy import Visibility
|
|
|
|
|
|
async def get_public_tree(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree_id: uuid.UUID
|
|
) -> Tree:
|
|
tree = (
|
|
await session.execute(
|
|
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
|
|
)
|
|
).scalar_one_or_none()
|
|
# 404 (not 403) when not viewable: don't reveal that a private tree exists.
|
|
if tree is None or not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
|
raise NotFound("tree not found")
|
|
return tree
|
|
|
|
|
|
async def _persons(session: AsyncSession, tree: Tree) -> list[Person]:
|
|
return list(
|
|
(
|
|
await session.execute(
|
|
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
|
|
async def _visibility_map(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, persons: list[Person]
|
|
) -> dict[uuid.UUID, Visibility]:
|
|
return {
|
|
p.id: await privacy.person_visibility(
|
|
session, user_id=viewer_id, tree=tree, person=p
|
|
)
|
|
for p in persons
|
|
}
|
|
|
|
|
|
async def list_public_persons(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
|
) -> list[Person]:
|
|
out: list[Person] = []
|
|
for p in await _persons(session, tree):
|
|
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=p)
|
|
if vis == Visibility.hidden:
|
|
continue
|
|
if vis == Visibility.redacted:
|
|
_redact(p)
|
|
else:
|
|
await _attach_primary_name(session, p)
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
async def get_public_person(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
|
) -> Person:
|
|
person = (
|
|
await session.execute(
|
|
select(Person).where(
|
|
Person.id == person_id,
|
|
Person.tree_id == tree.id,
|
|
Person.deleted_at.is_(None),
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if person is None:
|
|
raise NotFound("person not found")
|
|
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
|
|
if vis == Visibility.hidden:
|
|
raise NotFound("person not found")
|
|
if vis == Visibility.redacted:
|
|
_redact(person)
|
|
else:
|
|
await _attach_primary_name(session, person)
|
|
return person
|
|
|
|
|
|
async def _person_visibility(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
|
) -> Visibility | None:
|
|
person = (
|
|
await session.execute(
|
|
select(Person).where(
|
|
Person.id == person_id,
|
|
Person.tree_id == tree.id,
|
|
Person.deleted_at.is_(None),
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if person is None:
|
|
return None
|
|
return await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
|
|
|
|
|
|
async def list_public_relationships(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
|
) -> list[Relationship]:
|
|
persons = await _persons(session, tree)
|
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
|
nonhidden = {pid for pid, v in vis.items() if v != Visibility.hidden}
|
|
rels = list(
|
|
(
|
|
await session.execute(
|
|
select(Relationship).where(
|
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return [
|
|
r for r in rels if r.person_from_id in nonhidden and r.person_to_id in nonhidden
|
|
]
|
|
|
|
|
|
async def list_public_events(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
|
) -> list[Event]:
|
|
persons = await _persons(session, tree)
|
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
|
full = {pid for pid, v in vis.items() if v == Visibility.full}
|
|
rels = {
|
|
r.id: r
|
|
for r in (
|
|
await session.execute(
|
|
select(Relationship).where(
|
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
}
|
|
events = list(
|
|
(
|
|
await session.execute(
|
|
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
out: list[Event] = []
|
|
for e in events:
|
|
if e.person_id is not None:
|
|
if e.person_id in full:
|
|
out.append(e)
|
|
elif e.relationship_id is not None:
|
|
r = rels.get(e.relationship_id)
|
|
if r is not None and r.person_from_id in full and r.person_to_id in full:
|
|
out.append(e)
|
|
return out
|
|
|
|
|
|
async def list_public_person_names(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
|
) -> list[Name]:
|
|
vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id)
|
|
if vis is None:
|
|
raise NotFound("person not found")
|
|
if vis != Visibility.full:
|
|
return [] # redacted/hidden → no names (the real name must not leak)
|
|
return list(
|
|
(
|
|
await session.execute(
|
|
select(Name)
|
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
|
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
|
|
async def list_public_person_events(
|
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
|
) -> list[Event]:
|
|
vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id)
|
|
if vis is None:
|
|
raise NotFound("person not found")
|
|
if vis != Visibility.full:
|
|
return [] # redacted/hidden → no dates
|
|
return list(
|
|
(
|
|
await session.execute(
|
|
select(Event)
|
|
.where(
|
|
Event.person_id == person_id,
|
|
Event.tree_id == tree.id,
|
|
Event.deleted_at.is_(None),
|
|
)
|
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
|
|
async def list_public_trees(
|
|
session: AsyncSession,
|
|
*,
|
|
viewer_id: uuid.UUID | None,
|
|
q: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> list[Tree]:
|
|
# Anonymous: only `public`. Authenticated: also `site_members`. Never list
|
|
# `unlisted` (reachable by link only) or `private`.
|
|
allowed = [TreeVisibility.public]
|
|
if viewer_id is not None:
|
|
allowed.append(TreeVisibility.site_members)
|
|
stmt = select(Tree).where(
|
|
Tree.deleted_at.is_(None), Tree.visibility.in_(allowed)
|
|
)
|
|
if q and q.strip():
|
|
stmt = stmt.where(Tree.name.ilike(f"%{q.strip()}%"))
|
|
stmt = stmt.order_by(Tree.name).limit(min(limit, 100)).offset(max(offset, 0))
|
|
return list((await session.execute(stmt)).scalars().all())
|