summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
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):