Visibility phase 3: redaction-safe public read API + leak test
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>
This commit is contained in:
@@ -40,6 +40,18 @@ async def get_current_user(request: Request, session: SessionDep) -> User:
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
async def get_current_user_or_none(request: Request, session: SessionDep) -> User | None:
|
||||
"""Optional auth for public read endpoints — never raises. Returns the user
|
||||
when a valid session is present, else None (anonymous viewer)."""
|
||||
raw_token = extract_session_token(request)
|
||||
if raw_token is None:
|
||||
return None
|
||||
return await auth_service.resolve_session_user(session, raw_token=raw_token)
|
||||
|
||||
|
||||
CurrentUserOrNone = Annotated[User | None, Depends(get_current_user_or_none)]
|
||||
|
||||
|
||||
def get_mailer() -> Mailer:
|
||||
settings = get_settings()
|
||||
if settings.mailer == "smtp" and settings.smtp_host:
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.api.v1 import (
|
||||
media,
|
||||
names,
|
||||
persons,
|
||||
public,
|
||||
relationships,
|
||||
sources,
|
||||
trees,
|
||||
@@ -30,3 +31,4 @@ api_router.include_router(citations.router)
|
||||
api_router.include_router(media.router)
|
||||
api_router.include_router(gedcom.router)
|
||||
api_router.include_router(cleanup.router)
|
||||
api_router.include_router(public.router)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Public, read-only viewing surface.
|
||||
|
||||
Optional auth (anonymous allowed). Every response is built by
|
||||
``public_view_service``, which routes through the privacy engine and redacts
|
||||
possibly-living people. No create/update/delete here.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUserOrNone, SessionDep
|
||||
from app.schemas.event import EventRead
|
||||
from app.schemas.name import NameRead
|
||||
from app.schemas.person import PersonRead
|
||||
from app.schemas.relationship import RelationshipRead
|
||||
from app.schemas.tree import PublicTreeRead
|
||||
from app.services import public_view_service
|
||||
|
||||
router = APIRouter(prefix="/public", tags=["public"])
|
||||
|
||||
|
||||
def _vid(viewer: CurrentUserOrNone) -> uuid.UUID | None:
|
||||
return viewer.id if viewer else None
|
||||
|
||||
|
||||
@router.get("/trees", response_model=list[PublicTreeRead])
|
||||
async def public_directory(
|
||||
session: SessionDep,
|
||||
viewer: CurrentUserOrNone,
|
||||
q: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[PublicTreeRead]:
|
||||
trees = await public_view_service.list_public_trees(
|
||||
session, viewer_id=_vid(viewer), q=q, limit=limit, offset=offset
|
||||
)
|
||||
return [PublicTreeRead.model_validate(t) for t in trees]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}", response_model=PublicTreeRead)
|
||||
async def public_tree(
|
||||
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||
) -> PublicTreeRead:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
return PublicTreeRead.model_validate(tree)
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/persons", response_model=list[PersonRead])
|
||||
async def public_persons(
|
||||
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||
) -> list[PersonRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
persons = await public_view_service.list_public_persons(
|
||||
session, viewer_id=_vid(viewer), tree=tree
|
||||
)
|
||||
return [PersonRead.model_validate(p) for p in persons]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/relationships", response_model=list[RelationshipRead])
|
||||
async def public_relationships(
|
||||
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||
) -> list[RelationshipRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
rels = await public_view_service.list_public_relationships(
|
||||
session, viewer_id=_vid(viewer), tree=tree
|
||||
)
|
||||
return [RelationshipRead.model_validate(r) for r in rels]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/events", response_model=list[EventRead])
|
||||
async def public_events(
|
||||
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||
) -> list[EventRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
events = await public_view_service.list_public_events(
|
||||
session, viewer_id=_vid(viewer), tree=tree
|
||||
)
|
||||
return [EventRead.model_validate(e) for e in events]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||
async def public_person(
|
||||
tree_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
viewer: CurrentUserOrNone,
|
||||
) -> PersonRead:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
person = await public_view_service.get_public_person(
|
||||
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||
)
|
||||
return PersonRead.model_validate(person)
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
|
||||
async def public_person_names(
|
||||
tree_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
viewer: CurrentUserOrNone,
|
||||
) -> list[NameRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
names = await public_view_service.list_public_person_names(
|
||||
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||
)
|
||||
return [NameRead.model_validate(n) for n in names]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||
async def public_person_events(
|
||||
tree_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
viewer: CurrentUserOrNone,
|
||||
) -> list[EventRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
events = await public_view_service.list_public_person_events(
|
||||
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||
)
|
||||
return [EventRead.model_validate(e) for e in events]
|
||||
@@ -29,3 +29,16 @@ class TreeRead(BaseModel):
|
||||
owner_id: uuid.UUID
|
||||
home_person_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PublicTreeRead(BaseModel):
|
||||
"""Tree projection for the public surface — deliberately omits owner_id so a
|
||||
public/unlisted tree doesn't reveal which account owns it."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
visibility: TreeVisibility
|
||||
home_person_id: uuid.UUID | None = None
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user