diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-11 19:27:27 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-11 19:27:27 +0200 |
| commit | d8885c8df17c60927cb8d1f77ce1745814c6d3b4 (patch) | |
| tree | fafa41edfc7adcb25d6c46d4bbf2d0fba93ed010 /packages/meshbay-hub/src | |
| parent | edbff1768054afa80efda721cd1011b29c7fe355 (diff) | |
| download | meshbay-d8885c8df17c60927cb8d1f77ce1745814c6d3b4.tar.gz | |
fix(hub): an administrator can erase an account that owns groups
An administrator's deletion answered 409 for any account owning a group,
so an erasure ordered by an authority had to wait on the person it was
about. It now deletes the account's groups with it, then pushes a signed
revocation for the account and for each group to every connected node:
an access token already issued stays valid on a node until it expires,
and the revocation is what makes the nodes refuse the account and close
the groups' sessions now. The action is written to the IP log, and the
confirmation dialog says the groups go too, in all ten catalogues.
The owner's own deletion is unchanged: refused while they own groups,
which they can hand over first (CGU 3.4, privacy statement).
Deleting a group had three partial cascades. The owner's route left
email_verifications behind, and the cleanup of unhosted groups left
notifications, invitations and reports - each an IntegrityError on
PostgreSQL, invisible on SQLite, which does not enforce foreign keys by
default. db/purge.py is now the one implementation: it finds every table
referencing groups.id from the schema, deletes the group's rows and
detaches content reports, which are evidence and outlive the group.
test_group_purge.py turns foreign-key enforcement on for its connection,
seeds every referencing table, and fails without the fix on all three
routes. MESHBAY_DESIGN.md 7.7 states the rule, and now lists the device
keys and swarm sources that e3c68b3 erases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
Diffstat (limited to 'packages/meshbay-hub/src')
15 files changed, 124 insertions, 36 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 4960674..219e8a9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -269,13 +269,26 @@ async def admin_delete_user( db: AsyncSession = Depends(get_db), ): """ - Erase an account. Same erasure a user performs on themselves. + Erase an account, and every group it owns. + + The same erasure a user performs on themselves, with one difference: a user + is asked to hand their groups over first, an administrator is not. This is + the route an erasure ordered by an authority goes through, and it cannot + wait on the person it is about. + + Then a signed revocation goes to every connected node, for the account and + for each group deleted with it. The hub's records are gone at that point, + but an access token already issued stays valid on a node until it expires; + the revocation is what makes the nodes refuse the account and close the + groups' sessions now. A node that is offline misses it — the hub cannot + reach a machine it does not command. 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 import revocation from meshbay_hub.api.users import erase_account user = await db.get(User, user_id) @@ -288,9 +301,26 @@ async def admin_delete_user( 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 + groups = (await db.execute( + select(Group.name).where(Group.admin_id == user.id))).scalars().all() + db.add(IPLog( + user_id=current_user.id, + event="admin_user_delete", + ip_address="admin", + detail=f"{user.username} ({user.id}); groups deleted: {', '.join(groups) or 'none'}"[:256], + )) + result = await erase_account(db, user, owned_groups="delete") + + reason = "account deleted by an administrator" + sent = await revocation.broadcast_revocation( + revocation._sign_revocation("user", result["user_id"], reason)) + for g in result["groups_deleted"]: + await revocation.broadcast_revocation( + revocation._sign_revocation("group", g["id"], reason)) + log.warning("Account %s erased by admin %s, %d owned group(s) deleted, " + "revocations sent to %d node(s)", result["username"], + current_user.username, len(result["groups_deleted"]), sent) + return {**result, "nodes_notified": sent} @router.get("/groups") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 07d3ec0..88125c0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -13,8 +13,7 @@ from meshbay_hub.api.deps import get_current_user, require_user_scope from meshbay_hub.api.netutil import client_ip from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( - ContentReport, FederatedGroup, Group, GroupMember, - IPLog, Notification, SwarmSource, User, + FederatedGroup, Group, GroupMember, IPLog, SwarmSource, User, ) router = APIRouter(prefix="/v1/groups", tags=["groups"]) @@ -665,16 +664,10 @@ async def delete_group( if group.admin_id != current_user.id: raise HTTPException(status_code=403, detail="Only the group creator can delete") - from sqlalchemy import delete as sa_delete - await db.execute(sa_delete(Notification).where(Notification.group_id == group_id)) - await db.execute(sa_delete(GroupMember).where(GroupMember.group_id == group_id)) - await db.execute( - update(ContentReport) - .where(ContentReport.group_id == group_id) - .values(group_id=None)) + from meshbay_hub.db.purge import purge_groups db.add(IPLog(user_id=current_user.id, event="group_delete", ip_address=client_ip(request), detail=group.name)) - await db.delete(group) + await purge_groups(db, [group_id]) await db.commit() return {"status": "deleted", "group_id": group_id} diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index f291f59..a1b436e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -1079,10 +1079,16 @@ async def unlink_node_key( # ── Account deletion ───────────────────────────────────────────────────────── -async def erase_account(db: AsyncSession, user: User) -> dict: +async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refuse") -> dict: """ Erase an account, keeping only what the law asked us to keep. + Groups the account owns: `"refuse"` (the owner's own deletion) answers 409 + with their names, because deleting them strands their members and the + owner can hand them over first. `"delete"` (an administrator's) deletes + them with the account — an erasure an authority has ordered cannot wait on + the person it is about. + Gone: credentials, email, node key, group memberships, notifications, refresh tokens, node registrations, device keys, public-swarm sources. The username is released. @@ -1105,7 +1111,8 @@ async def erase_account(db: AsyncSession, user: User) -> dict: """ owned = (await db.execute( select(Group).where(Group.admin_id == user.id))).scalars().all() - if owned: + deleted_groups = [{"id": g.id, "name": g.name} for g in owned] + if owned and owned_groups != "delete": raise HTTPException( status_code=409, detail=("This account still owns groups: " @@ -1113,6 +1120,9 @@ async def erase_account(db: AsyncSession, user: User) -> dict: + ". Delete them or hand them over first — deleting the " "account would strand their members."), ) + if owned: + from meshbay_hub.db.purge import purge_groups + await purge_groups(db, [g["id"] for g in deleted_groups]) await db.execute(delete(UserPreference).where(UserPreference.user_id == user.id)) await db.execute(delete(GroupMember).where(GroupMember.user_id == user.id)) @@ -1138,8 +1148,10 @@ async def erase_account(db: AsyncSession, user: User) -> dict: user.status = "deleted" user.role = "user" await db.commit() - log.info("Account erased: %s (%s)", username, user.id[:8]) - return {"status": "deleted", "username": username} + log.info("Account erased: %s (%s), %d owned group(s) deleted", + username, user.id[:8], len(deleted_groups)) + return {"status": "deleted", "username": username, "user_id": user.id, + "groups_deleted": deleted_groups} class DeleteAccountRequest(BaseModel): diff --git a/packages/meshbay-hub/src/meshbay_hub/db/purge.py b/packages/meshbay-hub/src/meshbay_hub/db/purge.py new file mode 100644 index 0000000..02d2914 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/purge.py @@ -0,0 +1,52 @@ +"""Deleting a group from the hub, completely. + +One implementation for every caller: the owner deleting a group, an +administrator erasing the account that owns it, and the cleanup of groups no +node ever hosted. Each used to carry its own partial cascade, and a row still +pointing at a deleted group is not an orphan on PostgreSQL but a foreign-key +error — the deletion fails. SQLite, which the tests run on, does not enforce +foreign keys by default, so nothing showed it. + +The tables are found from the schema rather than listed: every table with a +foreign key to `groups.id` is emptied of the group's rows, including one added +after this was written. A table whose rows must outlive the group goes in +`_DETACHED`, and has its reference set to null instead. + +Nothing on a node is touched. The hub does not command those machines; the +administrator's route pushes a signed revocation to the connected ones. +""" + +from sqlalchemy import delete, update +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub.db.models import Base, ContentReport, Group + +# Rows that outlive their group, detached rather than deleted: a report is +# evidence about content, and it is still needed once the group is gone. +_DETACHED = {ContentReport.__tablename__} + + +def _referencing() -> list[tuple]: + """(table, column) for every foreign key onto `groups.id`.""" + refs = [] + for table in Base.metadata.sorted_tables: + for fk in table.foreign_keys: + if fk.column.table.name == Group.__tablename__ and fk.column.name == "id": + refs.append((table, fk.parent)) + return refs + + +async def purge_groups(db: AsyncSession, group_ids: list[str]) -> None: + """Delete these groups and everything on the hub that points at them. + + The caller commits: this is one step of a larger transaction (an account + erasure deletes its groups and then empties the account row). + """ + if not group_ids: + return + for table, column in _referencing(): + if table.name in _DETACHED: + await db.execute(update(table).where(column.in_(group_ids)).values({column.name: None})) + else: + await db.execute(delete(table).where(column.in_(group_ids))) + await db.execute(delete(Group).where(Group.id.in_(group_ids))) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 7eed742..a41eeee 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -510,7 +510,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': 'Details', 'admin.btn_delete': 'Löschen', - 'admin.delete_confirm': 'Das Konto „{user}“ löschen? Das lässt sich nicht ' + 'admin.delete_confirm': 'Das Konto „{user}“ und alle seine Gruppen löschen? Das lässt sich nicht ' + 'rückgängig machen. Die hochgeladenen Dateien bleiben auf den Nodes, die sie ' + 'hosten, und jeder Node behält die gemerkte Identität, bis sein Betreiber sie ' + 'entfernt. Sperren ist die umkehrbare Option.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 143f462..4561663 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -500,7 +500,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': 'Details', 'admin.btn_delete': 'Delete', - 'admin.delete_confirm': 'Delete the account "{user}"? This cannot be undone. ' + 'admin.delete_confirm': 'Delete the account "{user}" and every group it owns? 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.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index f35d05d..bbfcfa5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -506,7 +506,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': 'Detalles', 'admin.btn_delete': 'Eliminar', - 'admin.delete_confirm': '¿Eliminar la cuenta «{user}»? Esta acción no se puede ' + 'admin.delete_confirm': '¿Eliminar la cuenta «{user}» y todos sus grupos? Esta acción no se puede ' + 'deshacer. Los archivos que subió permanecen en los nodes que los alojan, y cada ' + 'node conserva la identidad que fijó hasta que su operador la retire. La ' + 'suspensión es la opción reversible.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index e8ac577..b5560fa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -509,7 +509,7 @@ export default { 'admin.revoke_group_confirm': "Révoquer le groupe « {group} » ? Une révocation signée est envoyée à chaque node qui l'héberge, et c'est irréversible depuis cette page. Suspends-le plutôt si tu veux seulement le mettre en pause.", 'admin.btn_details': 'Détails', 'admin.btn_delete': 'Supprimer', - 'admin.delete_confirm': 'Supprimer le compte « {user} » ? Cette action est ' + 'admin.delete_confirm': 'Supprimer le compte « {user} » et tous les groupes qu’il possède ? Cette action est ' + 'irréversible. Les fichiers qu’il a envoyés restent sur les nodes qui les ' + 'hébergent, et chaque node conserve l’identité qu’il a épinglée jusqu’à ce ' + 'que son opérateur la retire. La suspension est l’option réversible.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 76caa51..358e824 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -509,7 +509,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': 'Dettagli', 'admin.btn_delete': 'Elimina', - 'admin.delete_confirm': 'Eliminare l’account «{user}»? L’operazione non può essere ' + 'admin.delete_confirm': 'Eliminare l’account «{user}» e tutti i gruppi che possiede? L’operazione non può essere ' + 'annullata. I file che ha caricato restano sui node che li ospitano, e ogni node ' + 'mantiene l’identità che ha fissato finché il suo operatore non la rimuove. La ' + 'sospensione è l’opzione reversibile.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index a6c39ee..e28635a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -502,7 +502,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': '詳細', 'admin.btn_delete': '削除', - 'admin.delete_confirm': 'アカウント「{user}」を削除しますか?この操作は取り消せません。' + 'admin.delete_confirm': 'アカウント「{user}」と、所有するすべてのグループを削除しますか?この操作は取り消せません。' + 'このユーザーがアップロードしたファイルは、それをホストする node に残り、' + '各 node は固定した識別情報を、その運営者が解除するまで保持します。' + '停止であれば元に戻せます。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index e4a7914..4d35bdc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -510,7 +510,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': 'Details', 'admin.btn_delete': 'Verwijderen', - 'admin.delete_confirm': 'Het account "{user}" verwijderen? Dit kan niet ongedaan ' + 'admin.delete_confirm': 'Het account "{user}" en al zijn groepen verwijderen? Dit kan niet ongedaan ' + 'worden gemaakt. De bestanden die deze persoon heeft geüpload blijven op de nodes ' + 'die ze hosten, en elke node houdt de vastgezette identiteit tot zijn beheerder ' + 'die weghaalt. Schorsen is de omkeerbare optie.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index d13e3ef..85015b0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -522,7 +522,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': 'Szczegóły', 'admin.btn_delete': 'Usuń', - 'admin.delete_confirm': 'Usunąć konto „{user}”? Tej operacji nie da się cofnąć. ' + 'admin.delete_confirm': 'Usunąć konto „{user}” i wszystkie jego grupy? Tej operacji nie da się cofnąć. ' + 'Wysłane przez tę osobę pliki pozostaną na hostujących je nodes, a każdy node ' + 'zachowa przypiętą tożsamość do czasu, aż jego operator ją odepnie. Zawieszenie ' + 'jest opcją odwracalną.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 1b517bb..9b9e80c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -508,7 +508,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': 'Detalhes', 'admin.btn_delete': 'Excluir', - 'admin.delete_confirm': 'Excluir a conta "{user}"? Esta ação não pode ser desfeita. ' + 'admin.delete_confirm': 'Excluir a conta "{user}" e todos os grupos dela? Esta ação não pode ser desfeita. ' + 'Os arquivos que essa pessoa enviou permanecem nos nodes que os hospedam, e cada ' + 'node mantém a identidade que fixou até que o operador dele a remova. Suspender é ' + 'a opção reversível.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 9922a2d..99872f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -494,7 +494,7 @@ export default { 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.", 'admin.btn_details': '详情', 'admin.btn_delete': '删除', - 'admin.delete_confirm': '删除账户“{user}”?此操作无法撤销。' + 'admin.delete_confirm': '删除账户“{user}”及其拥有的所有群组?此操作无法撤销。' + '该用户上传的文件仍留在托管它们的 node 上,每个 node 也会保留它固定的身份,' + '直到其运营者取消固定。停用是可撤销的选项。', 'admin.user_detail': '用户详情', diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py index 6fa62a4..7f8a5f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py +++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from meshbay_hub.db.models import EmailVerification, Group, GroupMember, IPLog, User +from meshbay_hub.db.models import EmailVerification, Group, IPLog, User log = logging.getLogger(__name__) @@ -90,18 +90,19 @@ async def prune_unhosted_groups(db: AsyncSession, grace_days: int = UNHOSTED_GRA dry_run: bool = False) -> list[tuple[str, str]]: """Delete abandoned groups. Returns [(id, name)] of what was (or would be) removed. - Memberships go with the group — there is no cascade configured, and leaving - orphan rows behind would keep the group in everyone's /mine query through the - join. Nothing on a node is touched: the hub does not command those machines, - and by definition no node ever claimed this group anyway. + Everything on the hub that points at the group goes with it (`purge_groups`): + there is no cascade configured, an orphan membership would keep the group in + everyone's /mine query through the join, and on PostgreSQL any remaining + reference refuses the deletion outright. Nothing on a node is touched: the + hub does not command those machines, and by definition no node ever claimed + this group anyway. """ doomed = await find_unhosted_groups(db, grace_days) if not doomed or dry_run: return [(g.id, g.name) for g in doomed] - ids = [g.id for g in doomed] - await db.execute(delete(GroupMember).where(GroupMember.group_id.in_(ids))) - await db.execute(delete(Group).where(Group.id.in_(ids))) + from meshbay_hub.db.purge import purge_groups + await purge_groups(db, [g.id for g in doomed]) await db.commit() log.info("Pruned %d group(s) that no node ever hosted", len(doomed)) return [(g.id, g.name) for g in doomed] |