aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py80
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css13
-rw-r--r--packages/meshbay-hub/tests/conftest.py14
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py182
7 files changed, 402 insertions, 5 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,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index d8b83ed..7b1b5fc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -2130,7 +2130,7 @@ function SearchPage() {
const THEME_OPTIONS = ['light', 'dark', 'system'];
-function SettingsPage({ user, theme, onThemeChange, groups }) {
+function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
const [locale, setLoc] = useState(getLocale);
const [muted, setMuted] = useState(() => {
try { return JSON.parse(localStorage.getItem('mb_muted') || '{}'); }
@@ -2142,6 +2142,29 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
const [nodeKeyLoading, setNodeKeyLoading] = useState(false);
const [pinCount, setPinCount] = useState(
() => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0));
+ const [delOpen, setDelOpen] = useState(false);
+ const [delPass, setDelPass] = useState('');
+ const [delError, setDelError] = useState('');
+ const [deleting, setDeleting] = useState(false);
+
+ const deleteAccount = useCallback(async (e) => {
+ e.preventDefault();
+ setDeleting(true);
+ setDelError('');
+ try {
+ // The passphrase is re-checked by the hub, not merely by this form: an
+ // open session is not consent to something irreversible.
+ const authKey = await window.MeshBayKeys.deriveAuthKey(delPass, user.username);
+ await hubFetch('/v1/users/me', {
+ method: 'DELETE', token: user.token, body: { auth_key: authKey },
+ });
+ onLogout();
+ } catch (err) {
+ setDelError(err.message);
+ } finally {
+ setDeleting(false);
+ }
+ }, [delPass, user]);
// 11.5.8: node identity pins are refused strictly on change, so users need a
// deliberate way to accept a legitimate rotation (operator reinstalled a node).
@@ -2251,6 +2274,33 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</div>
<div class="settings-section">
+ <h3 class="settings-heading">${t('settings.danger')}</h3>
+ <p class="settings-hint">${t('settings.delete_hint')}</p>
+ ${delError && html`<p class="error-msg">${delError}</p>`}
+ ${!delOpen
+ ? html`<button class="btn-danger" onClick=${() => setDelOpen(true)}>
+ ${t('settings.delete_account')}
+ </button>`
+ : html`
+ <form onSubmit=${deleteAccount}>
+ <p class="settings-hint">${t('settings.delete_confirm')}</p>
+ <div style="display:flex;gap:8px;margin-top:8px">
+ <input type="password" placeholder=${t('login.password')}
+ autocomplete="current-password"
+ value=${delPass} onInput=${e => setDelPass(e.target.value)} required />
+ <button class="btn-danger" type="submit" disabled=${deleting}>
+ ${deleting ? '…' : t('settings.delete_confirm_btn')}
+ </button>
+ <button class="btn-secondary" type="button"
+ onClick=${() => { setDelOpen(false); setDelPass(''); setDelError(''); }}>
+ ${t('settings.cancel')}
+ </button>
+ </div>
+ </form>
+ `}
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('settings.appearance')}</h3>
<div class="settings-row">
<span class="settings-label">${t('settings.theme')}</span>
@@ -2368,6 +2418,18 @@ function AdminPage({ token }) {
else if (tab === 'blocklist') loadBlocklist();
}, [tab]);
+ const deleteUser = useCallback(async (u) => {
+ // Suspension is the reversible tool and stays one click away; this one is
+ // not, so it names the account and says what it cannot reach.
+ if (!confirm(t('admin.delete_confirm', { user: u.username }))) return;
+ try {
+ await hubFetch(`/v1/admin/users/${u.id}`, { method: 'DELETE', token });
+ loadUsers(userSearch);
+ } catch (err) {
+ alert(err.message);
+ }
+ }, [token, userSearch, loadUsers]);
+
const patchUser = useCallback(async (userId, patch) => {
try {
await hubFetch(`/v1/admin/users/${userId}`, { method: 'PATCH', body: patch, token });
@@ -2510,6 +2572,10 @@ function AdminPage({ token }) {
? html`<button class="admin-btn" onClick=${() => patchUser(u.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
: null
}
+ ${u.status !== 'deleted' && html`
+ <button class="admin-btn danger"
+ onClick=${() => deleteUser(u)}>${t('admin.btn_delete')}</button>
+ `}
</td>
</tr>
`)}
@@ -2806,7 +2872,7 @@ function App() {
: html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
- onThemeChange=${setTheme} groups=${groups} />`;
+ onThemeChange=${setTheme} groups=${groups} onLogout=${authCtx.logout} />`;
} else {
page = html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index c19411b..1b03375 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -132,6 +132,16 @@ const en = {
'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.",
'settings.node_pins_count': '{n} pinned',
'settings.node_pins_clear': 'Clear pinned identities',
+ 'settings.danger': 'Delete account',
+ 'settings.delete_hint': 'Removes your account, your group memberships and your '
+ + 'notifications from the hub, and frees your username. It cannot reach what '
+ + 'lives on nodes: files you uploaded stay where their operator keeps them, '
+ + 'and each node keeps the identity it pinned for you until its operator '
+ + 'unpins it. Connection logs are kept for a year, as the law requires.',
+ 'settings.delete_account': 'Delete my account',
+ 'settings.delete_confirm': 'Enter your passphrase to confirm. This cannot be undone.',
+ 'settings.delete_confirm_btn': 'Delete for good',
+ 'settings.cancel': 'Cancel',
'settings.appearance': 'Appearance',
'settings.theme': 'Theme',
'settings.theme_light': 'Light',
@@ -190,6 +200,11 @@ const en = {
'admin.btn_suspend': 'Suspend',
'admin.btn_unsuspend': 'Unsuspend',
'admin.btn_details': 'Details',
+ 'admin.btn_delete': 'Delete',
+ 'admin.delete_confirm': 'Delete the account "{user}"? This cannot be undone. '
+ + 'Files they uploaded stay on the nodes that host them, and each node keeps '
+ + 'the identity it pinned until its operator unpins it. Suspending is the '
+ + 'reversible option.',
'admin.user_detail': 'User details',
'admin.btn_close': 'Close',
'admin.self_note': '(you)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 361ff7e..d948dc2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1420,3 +1420,16 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
border-radius: 8px;
background: var(--bg-surface);
}
+
+/* Irreversible actions look like it. */
+.btn-danger {
+ background: var(--error);
+ color: #fff;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9em;
+}
+.btn-danger:hover { filter: brightness(1.1); }
+.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py
index ffd6c2e..4593f86 100644
--- a/packages/meshbay-hub/tests/conftest.py
+++ b/packages/meshbay-hub/tests/conftest.py
@@ -70,3 +70,17 @@ async def client(app):
base_url="http://test",
) as c:
yield c
+
+
+@pytest_asyncio.fixture
+async def db_session(app):
+ """
+ A session on the same in-memory database the app is using.
+
+ For assertions that cannot be made through the API — what a deletion left
+ behind, for instance, which is exactly the sort of thing worth checking
+ directly rather than inferring.
+ """
+ from meshbay_hub.db.engine import get_session_factory
+ async with get_session_factory()() as session:
+ yield session
diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py
new file mode 100644
index 0000000..0ae70f4
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_account_deletion.py
@@ -0,0 +1,182 @@
+"""
+Account deletion, by the owner and by an administrator.
+
+Deletion is the one action here that cannot be undone from the UI, so the tests
+state what survives it as carefully as what does not. Two things survive on
+purpose: the IP log, which exists for a year to answer legal requests and would
+be useless if it could no longer say whose connection it recorded, and everything
+on a node — files and pinned identities live on machines the hub does not
+command.
+"""
+
+import hashlib
+
+import pytest
+from sqlalchemy import select
+
+from meshbay_hub.db.models import GroupMember, Notification, RefreshToken, User
+
+
+def _auth_key(password: str, username: str) -> str:
+ import base64
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+async def _register(client, username, password="a-long-enough-passphrase"):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": _auth_key(password, username),
+ })
+ assert r.status_code in (200, 201), r.text
+ login = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(password, username)})
+ return login.json()["access_token"], password
+
+
+@pytest.mark.asyncio
+async def test_owner_can_delete_their_account(client, db_session):
+ token, password = await _register(client, "leaver")
+ headers = {"Authorization": f"Bearer {token}"}
+
+ r = await client.request("DELETE", "/v1/users/me", headers=headers,
+ json={"auth_key": _auth_key(password, "leaver")})
+ assert r.status_code == 200, r.text
+
+ user = (await db_session.execute(
+ select(User).where(User.status == "deleted"))).scalar_one()
+ assert user.username.startswith("deleted-")
+ assert user.email == ""
+ assert user.pw_hash == b""
+ assert user.pk_node_ed25519 is None
+
+
+@pytest.mark.asyncio
+async def test_deleting_needs_the_passphrase_not_just_a_session(client):
+ """
+ A live token may be a borrowed laptop or a tab left open. Something
+ irreversible asks again.
+ """
+ token, _ = await _register(client, "careful")
+ r = await client.request("DELETE", "/v1/users/me",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"auth_key": _auth_key("wrong one", "careful")})
+ assert r.status_code == 403
+
+ me = await client.get("/v1/users/me",
+ headers={"Authorization": f"Bearer {token}"})
+ assert me.status_code == 200, "the account must survive a failed attempt"
+
+
+@pytest.mark.asyncio
+async def test_the_username_is_released(client):
+ token, password = await _register(client, "recycled")
+ await client.request("DELETE", "/v1/users/me",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"auth_key": _auth_key(password, "recycled")})
+
+ again = await client.post("/v1/users/register", json={
+ "username": "recycled", "email": "new@example.com",
+ "auth_key": _auth_key("another passphrase entirely", "recycled"),
+ })
+ assert again.status_code in (200, 201), "the name should be free again"
+
+
+@pytest.mark.asyncio
+async def test_owning_a_group_blocks_deletion(client):
+ """
+ Deleting an account that owns groups would strand their members, so it is
+ refused with the list rather than cascading into other people's data.
+ """
+ token, password = await _register(client, "owner")
+ headers = {"Authorization": f"Bearer {token}"}
+ r = await client.post("/v1/groups", json={"name": "orphans"}, headers=headers)
+ assert r.status_code in (200, 201), r.text
+
+ r = await client.request("DELETE", "/v1/users/me", headers=headers,
+ json={"auth_key": _auth_key(password, "owner")})
+ assert r.status_code == 409
+ assert "orphans" in r.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_deletion_clears_memberships_notifications_and_tokens(
+ client, db_session):
+ token, password = await _register(client, "member1")
+ owner_token, _ = await _register(client, "grouper")
+ g = await client.post("/v1/groups", json={"name": "shared"},
+ headers={"Authorization": f"Bearer {owner_token}"})
+ gid = g.json()["group_id"]
+ await client.post(f"/v1/groups/{gid}/members/member1", json={},
+ headers={"Authorization": f"Bearer {owner_token}"})
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "member1"))).scalar_one()
+
+ await client.request("DELETE", "/v1/users/me",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"auth_key": _auth_key(password, "member1")})
+
+ for model in (GroupMember, Notification, RefreshToken):
+ rows = (await db_session.execute(
+ select(model).where(model.user_id == uid))).scalars().all()
+ assert rows == [], f"{model.__name__} survived the deletion"
+
+
+@pytest.mark.asyncio
+async def test_the_ip_log_survives_and_stays_attributable(client, db_session):
+ """
+ Kept on purpose. These rows exist for a year to answer legal requests, and
+ detaching them would keep the data while losing the only thing it is for.
+ """
+ from meshbay_hub.db.models import IPLog
+
+ token, password = await _register(client, "logged")
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "logged"))).scalar_one()
+
+ before = (await db_session.execute(
+ select(IPLog).where(IPLog.user_id == uid))).scalars().all()
+ assert before, "registration should have been logged"
+
+ await client.request("DELETE", "/v1/users/me",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"auth_key": _auth_key(password, "logged")})
+
+ after = (await db_session.execute(
+ select(IPLog).where(IPLog.user_id == uid))).scalars().all()
+ assert len(after) >= len(before), "the compliance log must survive deletion"
+
+
+@pytest.mark.asyncio
+async def test_a_deleted_account_cannot_keep_using_its_token(client):
+ """
+ Refresh tokens are removed, but an access token lives up to an hour. The
+ status check refuses it straight away — a deleted account must not keep
+ reading groups until its token happens to expire.
+ """
+ token, password = await _register(client, "gone")
+ headers = {"Authorization": f"Bearer {token}"}
+ r = await client.request("DELETE", "/v1/users/me", headers=headers,
+ json={"auth_key": _auth_key(password, "gone")})
+ assert r.status_code == 200
+
+ after = await client.get("/v1/groups/mine", headers=headers)
+ assert after.status_code in (401, 403), "the session outlived the account"
+
+
+@pytest.mark.asyncio
+async def test_only_an_admin_may_delete_someone_else(client, db_session):
+ token, _ = await _register(client, "ordinary")
+ victim_token, _ = await _register(client, "victim")
+ victim_id = (await db_session.execute(
+ select(User.id).where(User.username == "victim"))).scalar_one()
+
+ r = await client.delete(f"/v1/admin/users/{victim_id}",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code in (401, 403), "a plain user must not delete accounts"
+
+ me = await client.get("/v1/users/me",
+ headers={"Authorization": f"Bearer {victim_token}"})
+ assert me.status_code == 200