aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py80
2 files changed, 110 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 164885d..efebb75 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -14,7 +14,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import decrypt_email
-from meshbay_hub.api.deps import require_moderator
+from meshbay_hub.api.deps import require_admin, require_moderator
from meshbay_hub.api.revocation import get_connected_node_count
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
@@ -181,6 +181,37 @@ async def admin_patch_user(
# ── Groups ───────────────────────────────────────────────────────────────────
+@router.delete("/users/{user_id}")
+async def admin_delete_user(
+ user_id: str,
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Erase an account. Same erasure a user performs on themselves.
+
+ Admin rather than moderator: suspension is reversible and is the moderation
+ tool; this is not. Refused for one's own account — an administrator locking
+ themselves out is a support incident, and there is `DELETE /v1/users/me` for
+ someone who means it.
+ """
+ from meshbay_hub.api.users import erase_account
+
+ user = await db.get(User, user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if user.id == current_user.id:
+ raise HTTPException(
+ status_code=400,
+ detail="Use your own account settings to delete your account")
+ if user.status == "deleted":
+ raise HTTPException(status_code=410, detail="Account already deleted")
+
+ result = await erase_account(db, user)
+ log.info("Account %s erased by admin %s", result["username"], current_user.username)
+ return result
+
+
@router.get("/groups")
async def admin_list_groups(
current_user: User = Depends(require_moderator),
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,