diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 80 |
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, |