summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/purge.py52
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py17
-rw-r--r--packages/meshbay-hub/tests/test_group_purge.py184
17 files changed, 327 insertions, 41 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 472d5ab..39a24da 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1615,9 +1615,11 @@ A user can delete their own account from Settings behind a **passphrase re-entry
radius is proof of the passphrase. An admin can delete one too.
The row is **tombstoned rather than dropped**: username released, email and password
-hash cleared, node linking key dropped, memberships, notifications and refresh
-tokens removed, active tokens refused at once by a status check rather than left to
-expire.
+hash cleared, node linking key dropped, memberships, notifications, refresh tokens,
+node registrations, device keys and public-swarm sources removed, active tokens
+refused at once by a status check rather than left to expire. Device keys go
+because the desktop client keeps its half: left on the tombstone, the key would
+refuse that installation to the next account created from it.
Two things survive on purpose:
@@ -1630,8 +1632,20 @@ Two things survive on purpose:
work. **Deleting a hub account is not an erasure request to the operators who
host you**; the operator surface is where that happens, and the docs must say so.
-Deletion is **refused while the account still owns groups**, rather than cascading
-into other people's data.
+**Groups the account owns** decide between the two routes:
+
+- **The owner's own deletion is refused** while the account still owns groups,
+ rather than cascading into other people's data — the owner can hand them over
+ first.
+- **An administrator's deletion deletes them with the account.** It is the route
+ an erasure ordered by an authority takes, and it cannot wait on the person it is
+ about. Everything on the hub that references those groups goes too
+ (`db/purge.py`, which finds the referencing tables from the schema, so none is
+ left to fail a foreign key on PostgreSQL). Then a **signed revocation** for the
+ account and for each group goes 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. A node that is
+ offline misses it.
Registration is gated by a CAPTCHA whenever one is configured — **unconditionally**,
not only when some other field is absent, or the real client's ordinary request
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]
diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py
new file mode 100644
index 0000000..e05c374
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_group_purge.py
@@ -0,0 +1,184 @@
+"""
+Deleting a group, and deleting the account that owns it, with foreign keys on.
+
+PostgreSQL enforces foreign keys and SQLite does not unless asked, so a cascade
+that left one row pointing at a deleted group passed every test here and would
+fail in production with an IntegrityError. These tests turn enforcement on for
+their connection (the in-memory engine is a single shared one) and fill every
+table that references `groups.id` before deleting — the list of tables comes
+from the schema, so one added later is covered, and the seeding below has to
+be extended or `test_the_seeding_covers_every_reference` says so.
+
+An administrator can erase an account that owns groups; its groups go with it,
+and the connected nodes receive a signed revocation for the account and for
+each group. The owner's own deletion still asks them to hand the groups over
+first (test_account_deletion.py).
+"""
+
+import base64
+import hashlib
+from datetime import UTC, datetime, timedelta
+
+import jwt
+import pytest
+from sqlalchemy import delete, func, select, text
+from sqlalchemy.exc import IntegrityError
+
+from meshbay_hub.db.models import (
+ ContentReport, EmailVerification, Group, GroupMember, IPLog, Notification, User,
+)
+from meshbay_hub.db.purge import _referencing
+
+SEEDED = {"group_members", "notifications", "email_verifications", "content_reports"}
+
+
+def _auth_key(password: str, username: str) -> str:
+ 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 _account(client, username: str) -> str:
+ 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
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(password, username)})
+ return r.json()["access_token"]
+
+
+async def _uid(db, username: str) -> str:
+ return (await db.execute(select(User.id).where(User.username == username))).scalar_one()
+
+
+async def _enforce_foreign_keys(db):
+ await db.execute(text("PRAGMA foreign_keys=ON"))
+ await db.commit()
+ assert (await db.execute(text("PRAGMA foreign_keys"))).scalar() == 1
+
+
+async def _group_with_everything(client, db, owner_token: str, member: str, name: str) -> str:
+ """A group, a member, and a row in every table that points at groups."""
+ r = await client.post("/v1/groups", json={"name": name},
+ headers={"Authorization": f"Bearer {owner_token}"})
+ assert r.status_code in (200, 201), r.text
+ gid = r.json()["group_id"]
+ r = await client.post(f"/v1/groups/{gid}/members/{member}", json={},
+ headers={"Authorization": f"Bearer {owner_token}"})
+ assert r.status_code in (200, 201), r.text
+ member_id = await _uid(db, member)
+ db.add(Notification(user_id=member_id, kind="chat", group_id=gid, title="a message"))
+ db.add(EmailVerification(email_hash="0" * 64, code="123456", purpose="invitation",
+ group_id=gid,
+ expires_at=datetime.now(UTC) + timedelta(days=1)))
+ db.add(ContentReport(reporter_id=member_id, content_hash="ab" * 32, group_id=gid,
+ ip_address="192.0.2.1"))
+ await db.commit()
+ return gid
+
+
+async def _nothing_points_at(db, gid: str) -> None:
+ db.expire_all()
+ assert await db.get(Group, gid) is None, "the group survived"
+ for table, column in _referencing():
+ n = (await db.execute(
+ select(func.count()).select_from(table).where(column == gid))).scalar()
+ assert n == 0, f"{table.name} still has {n} row(s) pointing at the deleted group"
+ reports = (await db.execute(select(ContentReport))).scalars().all()
+ assert reports and all(r.group_id is None for r in reports), \
+ "a report is evidence about content and must outlive the group, detached"
+
+
+def test_the_seeding_covers_every_reference():
+ """If a table gains a foreign key to groups, the tests below must seed it,
+ or they stop proving the deletion survives enforcement."""
+ assert {t.name for t, _ in _referencing()} == SEEDED
+
+
+@pytest.mark.asyncio
+async def test_enforcement_is_really_on(client, db_session):
+ """The control: without it, every test below would pass on SQLite's
+ default and prove nothing about PostgreSQL."""
+ owner = await _account(client, "control")
+ await _account(client, "control_member")
+ gid = await _group_with_everything(client, db_session, owner, "control_member", "control")
+ await _enforce_foreign_keys(db_session)
+ with pytest.raises(IntegrityError):
+ await db_session.execute(delete(Group).where(Group.id == gid))
+ await db_session.commit()
+ await db_session.rollback()
+
+
+@pytest.mark.asyncio
+async def test_an_admin_erases_an_account_that_owns_groups(client, db_session, monkeypatch):
+ sent = []
+
+ async def capture(token: str) -> int:
+ sent.append(jwt.decode(token, options={"verify_signature": False}))
+ return 0
+ monkeypatch.setattr("meshbay_hub.api.revocation.broadcast_revocation", capture)
+
+ admin_token = await _account(client, "the_admin")
+ owner_token = await _account(client, "owner")
+ await _account(client, "member")
+ admin = await db_session.get(User, await _uid(db_session, "the_admin"))
+ admin.role = "admin"
+ await db_session.commit()
+ first = await _group_with_everything(client, db_session, owner_token, "member", "first")
+ r = await client.post("/v1/groups", json={"name": "second"},
+ headers={"Authorization": f"Bearer {owner_token}"})
+ second = r.json()["group_id"]
+ owner_id = await _uid(db_session, "owner")
+
+ await _enforce_foreign_keys(db_session)
+ r = await client.delete(f"/v1/admin/users/{owner_id}",
+ headers={"Authorization": f"Bearer {admin_token}"})
+ assert r.status_code == 200, r.text
+ assert {g["name"] for g in r.json()["groups_deleted"]} == {"first", "second"}
+
+ await _nothing_points_at(db_session, first)
+ assert await db_session.get(Group, second) is None
+ owner = await db_session.get(User, owner_id)
+ assert owner.status == "deleted" and owner.email == ""
+
+ targets = {(p["target"], p["target_id"]) for p in sent}
+ assert targets == {("user", owner_id), ("group", first), ("group", second)}, (
+ "the nodes must be told to refuse the account and close its groups")
+
+ logged = (await db_session.execute(
+ select(IPLog).where(IPLog.event == "admin_user_delete"))).scalars().all()
+ assert len(logged) == 1 and "first" in logged[0].detail and "owner" in logged[0].detail
+
+
+@pytest.mark.asyncio
+async def test_the_owner_deletes_a_group_with_everything_pointing_at_it(client, db_session):
+ """The owner's route left `email_verifications` behind — an IntegrityError
+ on PostgreSQL the first time an invited group was deleted."""
+ owner_token = await _account(client, "keeper")
+ await _account(client, "guest")
+ gid = await _group_with_everything(client, db_session, owner_token, "guest", "doomed")
+
+ await _enforce_foreign_keys(db_session)
+ r = await client.delete(f"/v1/groups/{gid}",
+ headers={"Authorization": f"Bearer {owner_token}"})
+ assert r.status_code == 200, r.text
+ await _nothing_points_at(db_session, gid)
+ assert (await db_session.execute(
+ select(GroupMember).where(GroupMember.group_id == gid))).first() is None
+
+
+@pytest.mark.asyncio
+async def test_the_cleanup_of_unhosted_groups_removes_everything_too(client, db_session):
+ """Its own cascade deleted memberships only."""
+ from meshbay_hub.tasks.cleanup import prune_unhosted_groups
+
+ owner_token = await _account(client, "abandoner")
+ await _account(client, "bystander")
+ gid = await _group_with_everything(client, db_session, owner_token, "bystander", "never hosted")
+
+ await _enforce_foreign_keys(db_session)
+ pruned = await prune_unhosted_groups(db_session, grace_days=0)
+ assert gid in {g for g, _ in pruned}
+ await _nothing_points_at(db_session, gid)