aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 11:25:38 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 11:25:38 +0200
commite3c68b3416f8ea2c033d41ac263552833370ba18 (patch)
treeb0549de294ea5c66466152d6c9465826633323f9 /packages
parente0607a9cee6ce9596e775baaed43eb67e1e5a293 (diff)
downloadmeshbay-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.py13
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py67
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):
"""