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:
2026-06-10 22:38:59 -04:00
parent 7ed3ddd448
commit a6179037c2
12 changed files with 558 additions and 12 deletions
@@ -106,6 +106,142 @@ async def test_authed_nonmember_does_not_see_living_pii(client):
).status_code == 200
async def _setup_sources(client):
owner = auth(await register(client, "anmcs-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "PubCS", "visibility": "public"}, headers=owner
)
).json()["id"]
old = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Oldcs", "surname": "Gonecs", "is_living": False},
headers=owner,
)
).json()["id"]
young = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Youngcs", "surname": "Csleaksurname", "is_living": True},
headers=owner,
)
).json()["id"]
for pid, year in ((old, "1851"), (young, "2004")):
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": pid, "date_value": year},
headers=owner,
)
s_old = (
await client.post(
f"/api/v1/trees/{tid}/sources", json={"title": "Oldsource record"}, headers=owner
)
).json()["id"]
s_young = (
await client.post(
f"/api/v1/trees/{tid}/sources",
json={"title": "Youngsource Csleaktitle"}, # title names the living person
headers=owner,
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/citations",
json={"source_id": s_old, "person_id": old, "page": "p.1"},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/citations",
json={"source_id": s_young, "person_id": young, "page": "p.2"},
headers=owner,
)
return owner, tid, old, young, s_old, s_young
async def test_authed_nonmember_citation_source_redaction(client):
"""A non-member must not see citations on a redacted living person's facts,
nor sources used only for them."""
owner, tid, old, young, s_old, s_young = await _setup_sources(client)
stranger = auth(await register(client, "anmcs-stranger@ex.com"))
cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json()
cited = {c.get("person_id") for c in cites}
assert old in cited
assert young not in cited # living person's citation dropped
srcs = (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger))
src_ids = {s["id"] for s in srcs.json()}
assert s_old in src_ids
assert s_young not in src_ids # source used only for the living person withheld
assert "Csleaktitle" not in srcs.text # its title (which names them) must not leak
# The withheld source 404s — don't reveal it exists; the visible one is fine.
assert (
await client.get(f"/api/v1/trees/{tid}/sources/{s_young}", headers=stranger)
).status_code == 404
assert (
await client.get(f"/api/v1/trees/{tid}/sources/{s_old}", headers=stranger)
).status_code == 200
# Members still see everything.
mc = {c.get("person_id") for c in (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json()}
assert {old, young} <= mc
ms = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=owner)).json()}
assert {s_old, s_young} <= ms
async def test_citation_redaction_via_indirect_targets(client):
"""Citations targeting a living person *indirectly* (via their event or name,
not person_id) must also be dropped for non-members."""
owner = auth(await register(client, "anmind-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "PubInd", "visibility": "public"}, headers=owner
)
).json()["id"]
young = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Youngind", "surname": "Indsurname", "is_living": True},
headers=owner,
)
).json()["id"]
ev = (
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": young, "date_value": "2005"},
headers=owner,
)
).json()["id"]
nm = (
await client.post(
f"/api/v1/trees/{tid}/persons/{young}/names",
json={"name_type": "alias", "given": "Indalias"},
headers=owner,
)
).json()["id"]
s_ev = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "EvSrc"}, headers=owner)).json()["id"]
s_nm = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "NmSrc"}, headers=owner)).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/citations", json={"source_id": s_ev, "event_id": ev}, headers=owner
)
await client.post(
f"/api/v1/trees/{tid}/citations", json={"source_id": s_nm, "name_id": nm}, headers=owner
)
stranger = auth(await register(client, "anmind-stranger@ex.com"))
cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json()
# Neither the event-citation nor the name-citation may surface.
assert not any(c.get("event_id") == ev for c in cites)
assert not any(c.get("name_id") == nm for c in cites)
src_ids = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger)).json()}
assert s_ev not in src_ids and s_nm not in src_ids
# Owner (member) sees both citations and both sources.
mc = (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json()
assert any(c.get("event_id") == ev for c in mc) and any(c.get("name_id") == nm for c in mc)
async def test_member_still_sees_everything(client):
owner, tid, old, young, om, ym = await _setup(client)
+78
View File
@@ -0,0 +1,78 @@
"""On-demand purge of a soft-deleted tree: permanent, owner-only, name-confirmed,
and cascades to all tree data."""
import uuid
from sqlalchemy import func, select
from app.models.person import Person
from app.models.tree import Tree
from tests.conftest import auth, register
async def _tree_with_person(client, owner):
tid = (await client.post("/api/v1/trees", json={"name": "Purge Me"}, headers=owner)).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Doomed", "surname": "Soul"}, headers=owner
)
return tid
async def test_purge_requires_soft_delete_first(client):
owner = auth(await register(client, "purge-a@ex.com"))
tid = await _tree_with_person(client, owner)
# A live tree can't be purged — it must be trashed first.
r = await client.post(
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner
)
assert r.status_code == 409
async def test_purge_name_must_match(client):
owner = auth(await register(client, "purge-b@ex.com"))
tid = await _tree_with_person(client, owner)
await client.delete(f"/api/v1/trees/{tid}", headers=owner) # soft-delete
r = await client.post(
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "WRONG"}, headers=owner
)
assert r.status_code == 403
# Still in the trash — nothing destroyed.
deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json()
assert any(t["id"] == tid for t in deleted)
async def test_purge_owner_only(client):
owner = auth(await register(client, "purge-c@ex.com"))
other = auth(await register(client, "purge-c2@ex.com"))
tid = await _tree_with_person(client, owner)
await client.delete(f"/api/v1/trees/{tid}", headers=owner)
r = await client.post(
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=other
)
assert r.status_code in (403, 404)
async def test_purge_removes_tree_and_cascades(client, db_session):
owner = auth(await register(client, "purge-d@ex.com"))
tid = await _tree_with_person(client, owner)
await client.delete(f"/api/v1/trees/{tid}", headers=owner)
r = await client.post(
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner
)
assert r.status_code == 204
# Gone from the trash...
deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json()
assert not any(t["id"] == tid for t in deleted)
# ...and cascaded: no tree row, no person rows.
tuuid = uuid.UUID(tid)
assert (
await db_session.execute(select(func.count()).select_from(Tree).where(Tree.id == tuuid))
).scalar() == 0
assert (
await db_session.execute(
select(func.count()).select_from(Person).where(Person.tree_id == tuuid)
)
).scalar() == 0