diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-11 11:25:38 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-11 11:25:38 +0200 |
| commit | e3c68b3416f8ea2c033d41ac263552833370ba18 (patch) | |
| tree | b0549de294ea5c66466152d6c9465826633323f9 /packages | |
| parent | e0607a9cee6ce9596e775baaed43eb67e1e5a293 (diff) | |
| download | meshbay-e3c68b3416f8ea2c033d41ac263552833370ba18.tar.gz | |
fix(hub): account deletion left device keys and swarm sources behind
erase_account cleared memberships, notifications, tokens and node
registrations, but not user_devices or swarm_sources.
A device key left on the tombstone still belonged to it, so an account
created later from the same desktop installation - which keeps its
private half - was refused that device with a 409 that only reached the
console. swarm_sources is keyed by the user id despite its column name
and carries the node's ip:port.
Both are now erased, which is what the privacy statement promises: every
account row goes except the one-year IP log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_account_deletion.py | 67 |
2 files changed, 78 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 56208a0..f291f59 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -33,7 +33,7 @@ from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( EmailVerification, Group, GroupMember, IPLog, Node, Notification, - RefreshToken, User, UserDevice, UserPreference, + RefreshToken, SwarmSource, User, UserDevice, UserPreference, ) log = logging.getLogger(__name__) @@ -1084,7 +1084,14 @@ async def erase_account(db: AsyncSession, user: User) -> dict: Erase an account, keeping only what the law asked us to keep. Gone: credentials, email, node key, group memberships, notifications, refresh - tokens, node registrations. The username is released. + tokens, node registrations, device keys, public-swarm sources. The username + is released. + + Device keys go even though the desktop client keeps its private half: left + behind, the key still belongs to this tombstone, so an account created later + from the same installation is refused that device ("belongs to another + account"). Swarm sources are keyed by the *user* id and carry the node's + ip:port. Kept: the row itself, emptied, and the IP log that points at it. Those logs exist for one year to answer legal requests, and a log that cannot say whose @@ -1112,6 +1119,8 @@ async def erase_account(db: AsyncSession, user: User) -> dict: await db.execute(delete(Notification).where(Notification.user_id == user.id)) await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id)) await db.execute(delete(Node).where(Node.user_id == user.id)) + await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) + await db.execute(delete(SwarmSource).where(SwarmSource.node_id == user.id)) await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id)) username = user.username diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py index cddb5d0..b97417d 100644 --- a/packages/meshbay-hub/tests/test_account_deletion.py +++ b/packages/meshbay-hub/tests/test_account_deletion.py @@ -124,6 +124,73 @@ async def test_deletion_clears_memberships_notifications_and_tokens( assert rows == [], f"{model.__name__} survived the deletion" +def _device_pk() -> str: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from meshbay_common.crypto import pk_to_b64 + return pk_to_b64(Ed25519PrivateKey.generate().public_key()) + + +@pytest.mark.asyncio +async def test_deletion_clears_device_keys_and_swarm_sources(client, db_session): + """ + The privacy statement says every account row goes but the IP log. Swarm + sources are keyed by the *user* id despite the column's name, and carry the + node's ip:port. + """ + from meshbay_hub.db.models import SwarmSource, UserDevice + + token, password = await _register(client, "devicer") + headers = {"Authorization": f"Bearer {token}"} + uid = (await db_session.execute( + select(User.id).where(User.username == "devicer"))).scalar_one() + + r = await client.post("/v1/users/devices", headers=headers, + json={"pk_auth_ed25519": _device_pk(), "label": "desktop"}) + assert r.status_code == 201, r.text + r = await client.post("/v1/swarm/register", headers=headers, + json={"content_hash": "ab" * 32, "endpoint": "192.0.2.7:4433"}) + assert r.status_code == 201, r.text + + # Present before, or the emptiness asserted below proves nothing. + assert (await db_session.execute( + select(UserDevice).where(UserDevice.user_id == uid))).scalars().all() + assert (await db_session.execute( + select(SwarmSource).where(SwarmSource.node_id == uid))).scalars().all() + + r = await client.request("DELETE", "/v1/users/me", headers=headers, + json={"auth_key": _auth_key(password, "devicer")}) + assert r.status_code == 200, r.text + + db_session.expire_all() + assert (await db_session.execute( + select(UserDevice).where(UserDevice.user_id == uid))).scalars().all() == [] + assert (await db_session.execute( + select(SwarmSource).where(SwarmSource.node_id == uid))).scalars().all() == [] + + +@pytest.mark.asyncio +async def test_a_new_account_can_reuse_the_deleted_accounts_device(client): + """ + The desktop client keeps its device key after the account is deleted. Left + on the tombstone, that key was "another account's", and the next account + created from the same installation was refused its device with a 409 that + only reached the console. + """ + pk = _device_pk() + token, password = await _register(client, "firstlife") + r = await client.post("/v1/users/devices", json={"pk_auth_ed25519": pk}, + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 201, r.text + await client.request("DELETE", "/v1/users/me", + headers={"Authorization": f"Bearer {token}"}, + json={"auth_key": _auth_key(password, "firstlife")}) + + token2, _ = await _register(client, "secondlife") + r = await client.post("/v1/users/devices", json={"pk_auth_ed25519": pk}, + headers={"Authorization": f"Bearer {token2}"}) + assert r.status_code == 201, r.text + + @pytest.mark.asyncio async def test_the_ip_log_survives_and_stays_attributable(client, db_session): """ |