diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/admin.py | 38 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 20 |
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): |