aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 22:41:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 22:41:02 +0200
commit8d8f85b4bf976249266a89f692408027216711b7 (patch)
tree79cdc211a7791e5f25f96879606329eee03ddb11 /packages/meshbay-hub/src/meshbay_hub/api/users.py
parent38f91818f876c51dcd7eb7911b65fc7bf5154c83 (diff)
downloadmeshbay-8d8f85b4bf976249266a89f692408027216711b7.tar.gz
feat(account): a user can delete their own account, an admin can delete one
Both go through the same erasure, so there is one description of what happens rather than two that drift. Gone: credentials, email, node key, group memberships, notifications, refresh tokens, node registrations. The username is released. Kept, on purpose and stated in the UI: the row itself, emptied, and the IP log that points at it. Those logs exist for a year to answer legal requests, and a log that can no longer say whose connection it recorded keeps the data while losing the only thing it is for. So the account becomes a tombstone rather than a hole in the table. Out of reach, also stated: files uploaded to nodes, and the identity keys nodes pinned. Those are on machines the hub does not command, and only their operators can remove them — `member unpin` and a delete on their own disk. Saying so in the confirmation matters more than the button. Owning groups blocks deletion, with the list. Cascading would delete other people's groups out from under them; the account holder can hand them over or delete them first, deliberately. Self-deletion re-checks the passphrase. A live token may be a borrowed laptop or a tab left open, and it is not consent to something irreversible. Admin deletion requires admin rather than moderator: suspension is the reversible moderation tool and stays one click away. A deleted account's access token stops working at once — the status check already refuses anything but "active", which the tests now pin down, because refresh tokens being gone would otherwise leave up to an hour of usable session. Tests: 8 covering what survives and what does not, plus a db_session fixture for assertions that cannot honestly be made through the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py80
1 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 af9141c..b7b402f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1,12 +1,13 @@
"""User endpoints — /v1/users/*"""
import base64
+import logging
import uuid
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, field_validator
-from sqlalchemy import select
+from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import (
@@ -25,9 +26,13 @@ from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User
+from meshbay_hub.db.models import (
+ Group, GroupMember, IPLog, Node, Notification, RefreshToken, User,
+)
from meshbay_hub.api.deps import get_current_user, require_user_scope
+log = logging.getLogger(__name__)
+
router = APIRouter(prefix="/v1/users", tags=["users"])
_cfg: HubConfig | None = None
@@ -324,6 +329,77 @@ async def register_node_key(
# call that silently changes what every node believes about someone.
+# ── Account deletion ─────────────────────────────────────────────────────────
+
+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.
+
+ 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
+ connection it recorded does not do that — detaching them would keep the data
+ and lose the only thing it is for. So the account becomes a tombstone rather
+ than a hole in the table.
+
+ Not touched, because the hub cannot: files this person uploaded to nodes, and
+ the identity keys nodes pinned for them. Those live on machines the hub does
+ not command, and only their operators can remove them.
+ """
+ owned = (await db.execute(
+ select(Group).where(Group.admin_id == user.id))).scalars().all()
+ if owned:
+ raise HTTPException(
+ status_code=409,
+ detail=("This account still owns groups: "
+ + ", ".join(g.name for g in owned)
+ + ". Delete them or hand them over first — deleting the "
+ "account would strand their members."),
+ )
+
+ await db.execute(delete(GroupMember).where(GroupMember.user_id == user.id))
+ 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))
+
+ username = user.username
+ user.username = f"deleted-{user.id[:8]}"
+ user.email = ""
+ user.pw_hash = b""
+ user.pw_salt = b""
+ user.pk_node_ed25519 = None
+ user.status = "deleted"
+ user.role = "user"
+ await db.commit()
+ log.info("Account erased: %s (%s)", username, user.id[:8])
+ return {"status": "deleted", "username": username}
+
+
+class DeleteAccountRequest(BaseModel):
+ auth_key: str
+
+
+@router.delete("/me")
+async def delete_own_account(
+ body: DeleteAccountRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Erase your own account. The passphrase is re-checked here.
+
+ A live access token is not enough for something irreversible: it may be a
+ borrowed laptop or a session left open. Same value as at sign-in, so the hub
+ still never sees the passphrase itself.
+ """
+ if not verify_password(body.auth_key, current_user.pw_hash, current_user.pw_salt,
+ current_user.pw_version):
+ raise HTTPException(status_code=403, detail="Passphrase does not match")
+ return await erase_account(db, current_user)
+
+
@router.get("/{username}/pubkeys")
async def get_user_pubkeys(
username: str,