From da972215d3bdb1bd93ec4a300578048b9f557aec Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 1 Jan 2026 17:14:27 -0300 Subject: [PATCH] comms: force leave old rooms (v2) --- .../communication/bstein-force-leave-job.yaml | 123 ++++++++++-------- 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/services/communication/bstein-force-leave-job.yaml b/services/communication/bstein-force-leave-job.yaml index 1b8476d..94f1886 100644 --- a/services/communication/bstein-force-leave-job.yaml +++ b/services/communication/bstein-force-leave-job.yaml @@ -2,7 +2,7 @@ apiVersion: batch/v1 kind: Job metadata: - name: bstein-force-leave-1 + name: bstein-force-leave-2 namespace: comms spec: backoffLimit: 0 @@ -67,7 +67,6 @@ spec: SYNAPSE_BASE = os.environ["SYNAPSE_BASE"] AUTH_BASE = os.environ["AUTH_BASE"] - SERVER_NAME = os.environ.get("SERVER_NAME", "live.bstein.dev") SEEDER_USER = os.environ["SEEDER_USER"] SEEDER_PASS = os.environ["SEEDER_PASS"] TARGET_USER_ID = os.environ["TARGET_USER_ID"] @@ -82,11 +81,12 @@ spec: row = cur.fetchone() if not row: raise RuntimeError(f"user not found in synapse db: {user_id}") - return bool(row[0]) + # Synapse stores admin as an int (0/1) + return int(row[0]) def db_set_admin(conn, user_id, is_admin): with conn.cursor() as cur: - cur.execute("UPDATE users SET admin = %s WHERE name = %s", (bool(is_admin), user_id)) + cur.execute("UPDATE users SET admin = %s WHERE name = %s", (1 if is_admin else 0, user_id)) def login(user, password): r = requests.post( @@ -102,76 +102,91 @@ spec: raise RuntimeError(f"login failed: {r.status_code} {r.text}") return r.json()["access_token"] - def admin_get_room_members(token, room_id): - url = f"{SYNAPSE_BASE}/_synapse/admin/v1/rooms/{urllib.parse.quote(room_id)}/members" - r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=20) - if r.status_code == 404: - return None - r.raise_for_status() - data = r.json() - return data.get("members") or data.get("memberships") or data - - def admin_kick(token, room_id, user_id): - url = f"{SYNAPSE_BASE}/_synapse/admin/v1/rooms/{urllib.parse.quote(room_id)}/kick" - r = requests.post( - url, + def whoami(token): + r = requests.get( + f"{SYNAPSE_BASE}/_matrix/client/v3/whoami", headers={"Authorization": f"Bearer {token}"}, - json={"user_id": user_id, "reason": "room cleanup"}, timeout=20, ) - if r.status_code == 404: - raise RuntimeError(f"kick endpoint not found on synapse ({url})") - if r.status_code not in (200, 202): - raise RuntimeError(f"kick failed for {room_id}: {r.status_code} {r.text}") + r.raise_for_status() + return r.json()["user_id"] - seeder_user_id = f"@{SEEDER_USER}:{SERVER_NAME}" + def admin_login_as(token, user_id): + url = f"{SYNAPSE_BASE}/_synapse/admin/v1/users/{urllib.parse.quote(user_id)}/login" + r = requests.post(url, headers={"Authorization": f"Bearer {token}"}, json={}, timeout=20) + if r.status_code != 200: + raise RuntimeError(f"admin login-as failed: {r.status_code} {r.text}") + return r.json()["access_token"] - results = {"seeder_user_id": seeder_user_id, "target_user_id": TARGET_USER_ID, "rooms": {}} + def client_leave(token, room_id): + url = f"{SYNAPSE_BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/leave" + r = requests.post(url, headers={"Authorization": f"Bearer {token}"}, json={}, timeout=20) + if r.status_code in (200, 202): + return True, None + # If already left/unknown membership, treat as non-fatal. + return False, f"{r.status_code} {r.text}" + + def client_forget(token, room_id): + url = f"{SYNAPSE_BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/forget" + r = requests.post(url, headers={"Authorization": f"Bearer {token}"}, json={}, timeout=20) + if r.status_code in (200, 202): + return True, None + return False, f"{r.status_code} {r.text}" + + def admin_joined_rooms(token, user_id): + url = f"{SYNAPSE_BASE}/_synapse/admin/v1/users/{urllib.parse.quote(user_id)}/joined_rooms" + r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=20) + if r.status_code != 200: + raise RuntimeError(f"admin joined_rooms failed: {r.status_code} {r.text}") + return r.json().get("joined_rooms", []) + + results = {"target_user_id": TARGET_USER_ID, "rooms": {}} conn = db_connect() conn.autocommit = False try: - was_admin = db_get_admin(conn, seeder_user_id) - results["seeder_was_admin"] = was_admin - if not was_admin: + token = login(SEEDER_USER, SEEDER_PASS) + seeder_user_id = whoami(token) + results["seeder_user_id"] = seeder_user_id + + try: + seeder_admin = db_get_admin(conn, seeder_user_id) + except Exception as e: + seeder_admin = None + results["seeder_admin_db_error"] = str(e) + results["seeder_admin_db"] = seeder_admin + + # If the seeder user isn't marked admin in Synapse DB, promote it. + # This is safe and reversible, and required for Synapse admin APIs. + if seeder_admin == 0: db_set_admin(conn, seeder_user_id, True) conn.commit() + results["seeder_admin_db_promoted"] = True + else: + results["seeder_admin_db_promoted"] = False - token = login(SEEDER_USER, SEEDER_PASS) + # Use admin endpoint to mint a puppet token for @bstein so we can + # perform a normal leave+forget (instead of deleting rooms). + bstein_token = admin_login_as(token, TARGET_USER_ID) - # Verify admin access now works. - # (If this still 403s, we fail and restore admin flag.) for room_id in TARGET_ROOMS: room_res = {} results["rooms"][room_id] = room_res - try: - before = admin_get_room_members(token, room_id) - room_res["members_before"] = "unavailable" if before is None else "ok" - except Exception as e: - room_res["members_before_error"] = str(e) - try: - admin_kick(token, room_id, TARGET_USER_ID) - room_res["kicked"] = True - except Exception as e: - room_res["kicked"] = False - room_res["kick_error"] = str(e) + ok, err = client_leave(bstein_token, room_id) + room_res["left"] = ok + if err: + room_res["leave_error"] = err - try: - after = admin_get_room_members(token, room_id) - room_res["members_after"] = "unavailable" if after is None else "ok" - except Exception as e: - room_res["members_after_error"] = str(e) + ok2, err2 = client_forget(bstein_token, room_id) + room_res["forgot"] = ok2 + if err2: + room_res["forget_error"] = err2 + + # Verify the user is no longer joined to the rooms (best effort). + results["target_joined_rooms_after"] = admin_joined_rooms(token, TARGET_USER_ID) print(json.dumps(results, indent=2, sort_keys=True)) finally: - # Restore previous admin flag - try: - if "results" in locals(): - desired = results.get("seeder_was_admin", False) - db_set_admin(conn, seeder_user_id, desired) - conn.commit() - except Exception as e: - print(f"WARNING: failed to restore seeder admin flag: {e}", file=sys.stderr) conn.close() PY