summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/db
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 19:27:27 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 19:27:27 +0200
commitd8885c8df17c60927cb8d1f77ce1745814c6d3b4 (patch)
treefafa41edfc7adcb25d6c46d4bbf2d0fba93ed010 /packages/meshbay-hub/src/meshbay_hub/db
parentedbff1768054afa80efda721cd1011b29c7fe355 (diff)
downloadmeshbay-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/meshbay_hub/db')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/purge.py52
1 files changed, 52 insertions, 0 deletions
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)))