summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
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/api
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/api')
-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
3 files changed, 53 insertions, 18 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):