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:
@@ -0,0 +1,180 @@
|
||||
"""The public viewing surface (/api/v1/public).
|
||||
|
||||
The central guarantee: an ANONYMOUS viewer of a public tree never receives a
|
||||
possibly-living person's real name, dates, or alternate names — while deceased
|
||||
people are shown in full. Plus the access matrix for each visibility level.
|
||||
See docs/design/tree-visibility.md.
|
||||
"""
|
||||
|
||||
from tests.conftest import auth, register
|
||||
|
||||
# Distinctive strings so we can assert they never leak anywhere anonymously.
|
||||
LIVING_GIVEN = "Younglivingsecret"
|
||||
LIVING_SURNAME = "Hiddensurname"
|
||||
LIVING_ALIAS = "Secretmaidenalias"
|
||||
LIVING_BIRTH_YEAR = "2002"
|
||||
|
||||
|
||||
async def _person(client, tid, headers, given, surname, is_living):
|
||||
r = await client.post(
|
||||
f"/api/v1/trees/{tid}/persons",
|
||||
json={"given": given, "surname": surname, "is_living": is_living},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
async def _build_public_tree(client):
|
||||
owner = auth(await register(client, "pv-owner@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "Heritage", "visibility": "public"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
old = await _person(client, tid, owner, "Olda", "Ancestor", False)
|
||||
young = await _person(client, tid, owner, LIVING_GIVEN, LIVING_SURNAME, True)
|
||||
|
||||
# Birth events for each.
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/events",
|
||||
json={"event_type": "birth", "person_id": old, "date_value": "1850"},
|
||||
headers=owner,
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/events",
|
||||
json={"event_type": "birth", "person_id": young, "date_value": LIVING_BIRTH_YEAR},
|
||||
headers=owner,
|
||||
)
|
||||
# Alternate names for each.
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons/{old}/names",
|
||||
json={"name_type": "alias", "given": "Oldnickname"},
|
||||
headers=owner,
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||
json={"name_type": "alias", "given": LIVING_ALIAS},
|
||||
headers=owner,
|
||||
)
|
||||
# old --parent--> young
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/relationships",
|
||||
json={
|
||||
"type": "parent_child",
|
||||
"person_from_id": old,
|
||||
"person_to_id": young,
|
||||
"qualifier": "biological",
|
||||
},
|
||||
headers=owner,
|
||||
)
|
||||
return tid, old, young
|
||||
|
||||
|
||||
async def test_anonymous_public_view_never_leaks_living_pii(client):
|
||||
tid, old, young = await _build_public_tree(client)
|
||||
|
||||
# --- persons: deceased full, living redacted ---
|
||||
persons = (await client.get(f"/api/v1/public/trees/{tid}/persons")).json()
|
||||
by_id = {p["id"]: p for p in persons}
|
||||
assert by_id[old]["primary_name"] == "Olda Ancestor"
|
||||
assert by_id[young]["primary_name"] == "Living person"
|
||||
assert by_id[young]["gender"] is None
|
||||
|
||||
# --- the living person's real name/alias/birth year must appear NOWHERE ---
|
||||
for path in (
|
||||
f"/api/v1/public/trees/{tid}/persons",
|
||||
f"/api/v1/public/trees/{tid}/events",
|
||||
f"/api/v1/public/trees/{tid}/relationships",
|
||||
f"/api/v1/public/trees/{tid}/persons/{young}",
|
||||
f"/api/v1/public/trees/{tid}/persons/{young}/names",
|
||||
f"/api/v1/public/trees/{tid}/persons/{young}/events",
|
||||
):
|
||||
body = (await client.get(path)).text
|
||||
assert LIVING_GIVEN not in body, path
|
||||
assert LIVING_SURNAME not in body, path
|
||||
assert LIVING_ALIAS not in body, path
|
||||
assert LIVING_BIRTH_YEAR not in body, path
|
||||
|
||||
# --- events: deceased's date present, living's dropped entirely ---
|
||||
events = (await client.get(f"/api/v1/public/trees/{tid}/events")).json()
|
||||
assert any(e["person_id"] == old for e in events)
|
||||
assert not any(e["person_id"] == young for e in events)
|
||||
|
||||
# --- per-person endpoints for the living person are emptied/redacted ---
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/names")).json() == []
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/events")).json() == []
|
||||
assert (
|
||||
await client.get(f"/api/v1/public/trees/{tid}/persons/{young}")
|
||||
).json()["primary_name"] == "Living person"
|
||||
|
||||
# --- deceased person's names/events ARE exposed ---
|
||||
old_names = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/names")).json()
|
||||
assert any(n.get("given") == "Oldnickname" for n in old_names)
|
||||
old_events = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/events")).json()
|
||||
assert any(e["date_value"] == "1850" for e in old_events)
|
||||
|
||||
# --- relationship kept (links to the redacted person by id, no PII) ---
|
||||
rels = (await client.get(f"/api/v1/public/trees/{tid}/relationships")).json()
|
||||
assert any(r2["person_from_id"] == old and r2["person_to_id"] == young for r2 in rels)
|
||||
|
||||
|
||||
async def test_private_tree_is_404_anonymously(client):
|
||||
owner = auth(await register(client, "priv-owner@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "Secret", "visibility": "private"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}/persons")).status_code == 404
|
||||
|
||||
|
||||
async def test_unlisted_viewable_by_link_but_not_in_directory(client):
|
||||
owner = auth(await register(client, "unl-owner@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "ByLinkOnly", "visibility": "unlisted"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
# Direct link works anonymously...
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 200
|
||||
# ...but it is never listed in the directory.
|
||||
directory = (await client.get("/api/v1/public/trees")).json()
|
||||
assert all(t["id"] != tid for t in directory)
|
||||
|
||||
|
||||
async def test_site_members_requires_login(client):
|
||||
owner = auth(await register(client, "sm2-owner@ex.com"))
|
||||
stranger = auth(await register(client, "sm2-stranger@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "MembersOnly", "visibility": "site_members"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404 # anonymous
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}", headers=stranger)).status_code == 200
|
||||
|
||||
|
||||
async def test_directory_visibility(client):
|
||||
owner = auth(await register(client, "dir-owner@ex.com"))
|
||||
stranger = auth(await register(client, "dir-stranger@ex.com"))
|
||||
ids = {}
|
||||
for vis in ("public", "site_members", "unlisted", "private"):
|
||||
ids[vis] = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": f"dir-{vis}", "visibility": vis}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
anon = {t["id"] for t in (await client.get("/api/v1/public/trees")).json()}
|
||||
assert ids["public"] in anon
|
||||
for vis in ("site_members", "unlisted", "private"):
|
||||
assert ids[vis] not in anon
|
||||
|
||||
logged_in = {t["id"] for t in (await client.get("/api/v1/public/trees", headers=stranger)).json()}
|
||||
assert ids["public"] in logged_in
|
||||
assert ids["site_members"] in logged_in
|
||||
assert ids["unlisted"] not in logged_in
|
||||
assert ids["private"] not in logged_in
|
||||
Reference in New Issue
Block a user