Close citation/source living-person leak; add on-demand tree purge
Two changes. 1. Privacy fix (NN#2/NN#3) — the citation and source list endpoints gated only on can_view_tree, so a non-member on a public/unlisted/site_members tree could enumerate citations and sources tied to a redacted living person, leaking that the person exists and has sourced facts (and possibly their name via a source title). #46 closed this for events/media/names/relationships but not citations/sources. Now citation_service.list_citations and source_service.{list_sources,get_source} delegate non-member reads to public_view_service, mirroring the #46 pattern: - citations: shown only when the cited fact resolves to FULL-visibility person(s) — covers the person_id, name_id, event_id (person or both-partner), and relationship_id (both-partner) target paths. - sources: shown only when they back at least one visible citation; a withheld source 404s (don't reveal it exists). Tests cover all four citation target types + source withholding + member-sees-all. 2. On-demand tree purge — owners can permanently delete a soft-deleted tree now instead of waiting out the 30-day auto-purge window. POST /trees/{id}/purge (owner-only): the tree must already be in the trash, and the caller retypes its name to confirm. Media objects are deleted from storage, then a single DELETE on trees cascades all tree-owned rows via the tree_id ON DELETE CASCADE; the audit entry survives (tree_id SET NULL). Frontend adds a "Delete forever" button to the Recently-deleted list. No migration. Suite: 102 passing. Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -12,6 +12,8 @@ person's real name, dates, alternate names, or media. The rules:
|
||||
living partner's timeline otherwise).
|
||||
- names : only for FULL-visibility persons.
|
||||
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md).
|
||||
- citations : only when the cited fact resolves to FULL person(s).
|
||||
- sources : only when they back at least one visible citation.
|
||||
|
||||
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.
|
||||
@@ -27,6 +29,7 @@ from app.models.event import Event
|
||||
from app.models.media import Media
|
||||
from app.models.person import Name, Person
|
||||
from app.models.relationship import Relationship
|
||||
from app.models.source import Citation, Source
|
||||
from app.models.tree import Tree
|
||||
from app.services import privacy
|
||||
from app.services.exceptions import NotFound
|
||||
@@ -296,6 +299,95 @@ async def can_view_media(
|
||||
return vis == Visibility.full
|
||||
|
||||
|
||||
async def _full_person_ids(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||
) -> set[uuid.UUID]:
|
||||
persons = await _persons(session, tree)
|
||||
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||
return {pid for pid, v in vis.items() if v == Visibility.full}
|
||||
|
||||
|
||||
async def list_public_citations(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||
) -> list[Citation]:
|
||||
"""Only citations whose cited fact resolves to FULL-visibility person(s). A
|
||||
citation on a redacted/hidden person's fact (or a partnership where either
|
||||
partner isn't full) is dropped — its existence plus page/detail would leak
|
||||
that the person has that sourced fact. Mirrors the events/names rule (FULL
|
||||
only)."""
|
||||
full = await _full_person_ids(session, viewer_id=viewer_id, tree=tree)
|
||||
|
||||
async def _by_id(model):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(model).where(model.tree_id == tree.id, model.deleted_at.is_(None))
|
||||
)
|
||||
).scalars().all()
|
||||
return {r.id: r for r in rows}
|
||||
|
||||
names = await _by_id(Name)
|
||||
rels = await _by_id(Relationship)
|
||||
events = await _by_id(Event)
|
||||
|
||||
def target_is_full(c: Citation) -> bool:
|
||||
if c.person_id is not None:
|
||||
return c.person_id in full
|
||||
if c.name_id is not None:
|
||||
n = names.get(c.name_id)
|
||||
return n is not None and n.person_id in full
|
||||
if c.event_id is not None:
|
||||
e = events.get(c.event_id)
|
||||
if e is None:
|
||||
return False
|
||||
if e.person_id is not None:
|
||||
return e.person_id in full
|
||||
if e.relationship_id is not None:
|
||||
r = rels.get(e.relationship_id)
|
||||
return r is not None and r.person_from_id in full and r.person_to_id in full
|
||||
return False
|
||||
if c.relationship_id is not None:
|
||||
r = rels.get(c.relationship_id)
|
||||
return r is not None and r.person_from_id in full and r.person_to_id in full
|
||||
return False
|
||||
|
||||
citations = (
|
||||
await session.execute(
|
||||
select(Citation)
|
||||
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
|
||||
.order_by(Citation.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return [c for c in citations if target_is_full(c)]
|
||||
|
||||
|
||||
async def list_public_sources(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||
) -> list[Source]:
|
||||
"""Only sources backing at least one visible citation. A source used solely
|
||||
for a redacted/hidden person's facts is withheld — its title or notes could
|
||||
name that living person."""
|
||||
visible = await list_public_citations(session, viewer_id=viewer_id, tree=tree)
|
||||
cited = {c.source_id for c in visible}
|
||||
sources = (
|
||||
await session.execute(
|
||||
select(Source)
|
||||
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
|
||||
.order_by(Source.title)
|
||||
)
|
||||
).scalars().all()
|
||||
return [s for s in sources if s.id in cited]
|
||||
|
||||
|
||||
async def get_public_source(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, source_id: uuid.UUID
|
||||
) -> Source:
|
||||
for s in await list_public_sources(session, viewer_id=viewer_id, tree=tree):
|
||||
if s.id == source_id:
|
||||
return s
|
||||
# 404 (not 403): don't reveal that a withheld source exists.
|
||||
raise NotFound("source not found")
|
||||
|
||||
|
||||
async def list_public_trees(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user