diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
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] |