488 lines
19 KiB
Python
488 lines
19 KiB
Python
"""Guest name randomizer helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
import base64
|
|
import time
|
|
import urllib.parse
|
|
|
|
import httpx
|
|
import psycopg
|
|
|
|
from ..utils.logging import get_logger
|
|
from ..utils.name_generator import NameGenerator
|
|
from .comms_support import (
|
|
CommsServiceBase,
|
|
CommsSummary,
|
|
DisplayNameTarget,
|
|
MasGuestResult,
|
|
SynapseGuestResult,
|
|
SynapseUserRef,
|
|
_auth,
|
|
_needs_rename_display,
|
|
_needs_rename_username,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
HTTP_NOT_FOUND = 404
|
|
|
|
class CommsGuestService(CommsServiceBase):
|
|
"""Rename and prune guest accounts across MAS, Synapse, and the database."""
|
|
|
|
def __init__(self, client_factory: type[httpx.Client], settings: Any, name_generator: NameGenerator) -> None:
|
|
super().__init__(client_factory, settings)
|
|
self._name_generator = name_generator
|
|
|
|
def _pick_guest_name(self, existing: set[str]) -> str | None:
|
|
return self._name_generator.unique(existing)
|
|
|
|
def _mas_admin_token(self, client: httpx.Client) -> str:
|
|
if not self._settings.comms_mas_admin_client_id or not self._settings.comms_mas_admin_client_secret:
|
|
raise RuntimeError("mas admin client credentials missing")
|
|
basic = base64.b64encode(
|
|
f"{self._settings.comms_mas_admin_client_id}:{self._settings.comms_mas_admin_client_secret}".encode()
|
|
).decode()
|
|
last_err: Exception | None = None
|
|
for attempt in range(5):
|
|
try:
|
|
resp = client.post(
|
|
self._settings.comms_mas_token_url,
|
|
headers={"Authorization": f"Basic {basic}"},
|
|
data={"grant_type": "client_credentials", "scope": "urn:mas:admin"},
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
token = payload.get("access_token")
|
|
if not isinstance(token, str) or not token:
|
|
raise RuntimeError("missing mas access token")
|
|
return token
|
|
except Exception as exc: # noqa: BLE001
|
|
last_err = exc
|
|
time.sleep(2**attempt)
|
|
raise RuntimeError(str(last_err) if last_err else "mas admin token failed")
|
|
|
|
def _mas_user_id(self, client: httpx.Client, token: str, username: str) -> str:
|
|
url = f"{self._settings.comms_mas_admin_api_base}/users/by-username/{urllib.parse.quote(username)}"
|
|
resp = client.get(url, headers=_auth(token))
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
return payload["data"]["id"]
|
|
|
|
def _mas_personal_session(self, client: httpx.Client, token: str, user_id: str) -> tuple[str, str]:
|
|
resp = client.post(
|
|
f"{self._settings.comms_mas_admin_api_base}/personal-sessions",
|
|
headers=_auth(token),
|
|
json={
|
|
"actor_user_id": user_id,
|
|
"human_name": "guest-name-randomizer",
|
|
"scope": "urn:matrix:client:api:*",
|
|
"expires_in": 300,
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json().get("data", {})
|
|
session_id = payload.get("id")
|
|
attrs = (payload.get("attributes") or {}) if isinstance(payload, dict) else {}
|
|
access_token = attrs.get("access_token")
|
|
if not isinstance(access_token, str) or not isinstance(session_id, str):
|
|
raise RuntimeError("invalid personal session response")
|
|
return access_token, session_id
|
|
|
|
def _mas_revoke_session(self, client: httpx.Client, token: str, session_id: str) -> None:
|
|
try:
|
|
client.post(
|
|
f"{self._settings.comms_mas_admin_api_base}/personal-sessions/{urllib.parse.quote(session_id)}/revoke",
|
|
headers=_auth(token),
|
|
json={},
|
|
)
|
|
except Exception:
|
|
return
|
|
|
|
def _mas_list_users(self, client: httpx.Client, token: str) -> list[dict[str, Any]]:
|
|
users: list[dict[str, Any]] = []
|
|
cursor = None
|
|
while True:
|
|
url = f"{self._settings.comms_mas_admin_api_base}/users?page[size]=100"
|
|
if cursor:
|
|
url += f"&page[after]={urllib.parse.quote(cursor)}"
|
|
resp = client.get(url, headers=_auth(token))
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
data = payload.get("data") or []
|
|
if not isinstance(data, list) or not data:
|
|
break
|
|
users.extend([item for item in data if isinstance(item, dict)])
|
|
last = data[-1]
|
|
cursor = (
|
|
last.get("meta", {})
|
|
if isinstance(last, dict)
|
|
else {}
|
|
).get("page", {}).get("cursor")
|
|
if not cursor:
|
|
break
|
|
return users
|
|
|
|
def _synapse_list_users(self, client: httpx.Client, token: str) -> list[dict[str, Any]]:
|
|
users: list[dict[str, Any]] = []
|
|
from_token = None
|
|
admin_token = self._admin_token(token)
|
|
while True:
|
|
url = "{}/_synapse/admin/v2/users?local=true&deactivated=false&limit=100".format(
|
|
self._settings.comms_synapse_base
|
|
)
|
|
if from_token:
|
|
url += f"&from={urllib.parse.quote(from_token)}"
|
|
resp = client.get(url, headers=_auth(admin_token))
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
users.extend([item for item in payload.get("users", []) if isinstance(item, dict)])
|
|
from_token = payload.get("next_token")
|
|
if not from_token:
|
|
break
|
|
return users
|
|
|
|
def _should_prune_guest(self, entry: dict[str, Any], now_ms: int) -> bool:
|
|
if not entry.get("is_guest"):
|
|
return False
|
|
last_seen = entry.get("last_seen_ts")
|
|
if last_seen is None:
|
|
return False
|
|
try:
|
|
last_seen = int(last_seen)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
stale_ms = int(self._settings.comms_guest_stale_days) * 24 * 60 * 60 * 1000
|
|
return now_ms - last_seen > stale_ms
|
|
|
|
def _prune_guest(self, client: httpx.Client, token: str, user_id: str) -> bool:
|
|
admin_token = self._admin_token(token)
|
|
try:
|
|
resp = client.delete(
|
|
f"{self._settings.comms_synapse_base}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}",
|
|
headers=_auth(admin_token),
|
|
params={"erase": "true"},
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.info(
|
|
"guest prune failed",
|
|
extra={"event": "comms_guest_prune", "status": "error", "detail": str(exc)},
|
|
)
|
|
return False
|
|
if resp.status_code in (200, 202, 204, 404):
|
|
return True
|
|
logger.info(
|
|
"guest prune failed",
|
|
extra={
|
|
"event": "comms_guest_prune",
|
|
"status": "error",
|
|
"detail": f"{resp.status_code} {resp.text}",
|
|
},
|
|
)
|
|
return False
|
|
|
|
def _get_displayname(self, client: httpx.Client, token: str, user_id: str) -> str | None:
|
|
resp = client.get(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/profile/{urllib.parse.quote(user_id)}",
|
|
headers=_auth(token),
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json().get("displayname")
|
|
|
|
def _get_displayname_admin(self, client: httpx.Client, token: str, user_id: str) -> str | None:
|
|
admin_token = self._admin_token(token)
|
|
resp = client.get(
|
|
f"{self._settings.comms_synapse_base}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}",
|
|
headers=_auth(admin_token),
|
|
)
|
|
if resp.status_code == HTTP_NOT_FOUND:
|
|
return None
|
|
resp.raise_for_status()
|
|
return resp.json().get("displayname")
|
|
|
|
def _set_displayname(
|
|
self,
|
|
client: httpx.Client,
|
|
token: str,
|
|
target: DisplayNameTarget,
|
|
) -> None:
|
|
resp = client.put(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/profile/{urllib.parse.quote(target.user_id)}/displayname",
|
|
headers=_auth(token),
|
|
json={"displayname": target.name},
|
|
)
|
|
resp.raise_for_status()
|
|
if not target.in_room:
|
|
return
|
|
state_url = (
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(target.room_id)}"
|
|
f"/state/m.room.member/{urllib.parse.quote(target.user_id)}"
|
|
)
|
|
client.put(
|
|
state_url,
|
|
headers=_auth(token),
|
|
json={"membership": "join", "displayname": target.name},
|
|
)
|
|
|
|
def _set_displayname_admin(self, client: httpx.Client, token: str, user_id: str, name: str) -> bool:
|
|
admin_token = self._admin_token(token)
|
|
resp = client.put(
|
|
f"{self._settings.comms_synapse_base}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}",
|
|
headers=_auth(admin_token),
|
|
json={"displayname": name},
|
|
)
|
|
return resp.status_code in (200, 201, 204)
|
|
|
|
def _db_rename_numeric(self, existing: set[str]) -> int:
|
|
if not getattr(self._settings, "comms_synapse_db_password", ""):
|
|
return 0
|
|
renamed = 0
|
|
conn = psycopg.connect(
|
|
host=self._settings.comms_synapse_db_host,
|
|
port=self._settings.comms_synapse_db_port,
|
|
dbname=self._settings.comms_synapse_db_name,
|
|
user=self._settings.comms_synapse_db_user,
|
|
password=self._settings.comms_synapse_db_password,
|
|
)
|
|
try:
|
|
with conn:
|
|
with conn.cursor() as cur:
|
|
pattern = f"^@\\d+:{self._settings.comms_server_name}$"
|
|
cur.execute(
|
|
"SELECT user_id, full_user_id, displayname FROM profiles WHERE full_user_id ~ %s",
|
|
(pattern,),
|
|
)
|
|
profile_rows = cur.fetchall()
|
|
profile_index = {row[1]: row for row in profile_rows}
|
|
for _user_id, full_user_id, display in profile_rows:
|
|
if display and not _needs_rename_display(display):
|
|
continue
|
|
new_name = self._pick_guest_name(existing)
|
|
if not new_name:
|
|
continue
|
|
cur.execute(
|
|
"UPDATE profiles SET displayname = %s WHERE full_user_id = %s",
|
|
(new_name, full_user_id),
|
|
)
|
|
renamed += 1
|
|
|
|
cur.execute(
|
|
"SELECT name FROM users WHERE name ~ %s",
|
|
(pattern,),
|
|
)
|
|
users = [row[0] for row in cur.fetchall()]
|
|
if not users:
|
|
return renamed
|
|
cur.execute(
|
|
"SELECT user_id, full_user_id FROM profiles WHERE full_user_id = ANY(%s)",
|
|
(users,),
|
|
)
|
|
for existing_full in cur.fetchall():
|
|
profile_index.setdefault(existing_full[1], existing_full)
|
|
|
|
for full_user_id in users:
|
|
if full_user_id in profile_index:
|
|
continue
|
|
localpart = full_user_id.split(":", 1)[0].lstrip("@")
|
|
new_name = self._pick_guest_name(existing)
|
|
if not new_name:
|
|
continue
|
|
cur.execute(
|
|
"INSERT INTO profiles (user_id, displayname, full_user_id) VALUES (%s, %s, %s) "
|
|
"ON CONFLICT (full_user_id) DO UPDATE SET displayname = EXCLUDED.displayname",
|
|
(localpart, new_name, full_user_id),
|
|
)
|
|
renamed += 1
|
|
finally:
|
|
conn.close()
|
|
return renamed
|
|
|
|
def _validate_guest_name_settings(self) -> None:
|
|
if not self._settings.comms_mas_admin_client_id or not self._settings.comms_mas_admin_client_secret:
|
|
raise RuntimeError("comms mas admin secret missing")
|
|
if not self._settings.comms_synapse_base:
|
|
raise RuntimeError("comms synapse base missing")
|
|
|
|
def _room_context(self, client: httpx.Client, token: str) -> tuple[str, set[str], set[str]]:
|
|
room_id = self._resolve_alias(client, token, self._settings.comms_room_alias)
|
|
members, existing = self._room_members(client, token, room_id)
|
|
return room_id, members, existing
|
|
|
|
def _rename_mas_guests(
|
|
self,
|
|
client: httpx.Client,
|
|
admin_token: str,
|
|
room_id: str,
|
|
members: set[str],
|
|
existing: set[str],
|
|
) -> MasGuestResult:
|
|
renamed = 0
|
|
skipped = 0
|
|
mas_usernames: set[str] = set()
|
|
users = self._mas_list_users(client, admin_token)
|
|
for user in users:
|
|
attrs = user.get("attributes") or {}
|
|
username = attrs.get("username") or ""
|
|
if isinstance(username, str) and username:
|
|
mas_usernames.add(username)
|
|
legacy_guest = attrs.get("legacy_guest")
|
|
if not isinstance(username, str) or not username:
|
|
skipped += 1
|
|
continue
|
|
if not (legacy_guest or _needs_rename_username(username)):
|
|
skipped += 1
|
|
continue
|
|
user_id = user.get("id")
|
|
if not isinstance(user_id, str) or not user_id:
|
|
skipped += 1
|
|
continue
|
|
full_user = f"@{username}:{self._settings.comms_server_name}"
|
|
access_token, session_id = self._mas_personal_session(client, admin_token, user_id)
|
|
try:
|
|
display = self._get_displayname(client, access_token, full_user)
|
|
if display and not _needs_rename_display(display):
|
|
skipped += 1
|
|
continue
|
|
new_name = self._pick_guest_name(existing)
|
|
if not new_name:
|
|
skipped += 1
|
|
continue
|
|
self._set_displayname(
|
|
client,
|
|
access_token,
|
|
DisplayNameTarget(
|
|
room_id=room_id,
|
|
user_id=full_user,
|
|
name=new_name,
|
|
in_room=full_user in members,
|
|
),
|
|
)
|
|
renamed += 1
|
|
finally:
|
|
self._mas_revoke_session(client, admin_token, session_id)
|
|
return MasGuestResult(renamed=renamed, skipped=skipped, usernames=mas_usernames)
|
|
|
|
def _synapse_entries(self, client: httpx.Client, token: str) -> list[dict[str, Any]]:
|
|
try:
|
|
return self._synapse_list_users(client, token)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.info(
|
|
"synapse admin list skipped",
|
|
extra={"event": "comms_guest_list", "status": "error", "detail": str(exc)},
|
|
)
|
|
return []
|
|
|
|
def _synapse_user_id(self, entry: dict[str, Any]) -> SynapseUserRef | None:
|
|
user_id = entry.get("name") or ""
|
|
if not isinstance(user_id, str) or not user_id.startswith("@"):
|
|
return None
|
|
localpart = user_id.split(":", 1)[0].lstrip("@")
|
|
return SynapseUserRef(entry=entry, user_id=user_id, localpart=localpart)
|
|
|
|
def _maybe_prune_synapse_guest(
|
|
self,
|
|
client: httpx.Client,
|
|
token: str,
|
|
entry: dict[str, Any],
|
|
user_id: str,
|
|
now_ms: int,
|
|
) -> bool:
|
|
if not entry.get("is_guest"):
|
|
return False
|
|
if not self._should_prune_guest(entry, now_ms):
|
|
return False
|
|
return self._prune_guest(client, token, user_id)
|
|
|
|
def _needs_synapse_rename(
|
|
self,
|
|
client: httpx.Client,
|
|
token: str,
|
|
user: SynapseUserRef,
|
|
mas_usernames: set[str],
|
|
) -> bool:
|
|
if user.localpart in mas_usernames:
|
|
return False
|
|
is_guest = user.entry.get("is_guest")
|
|
if not (is_guest or _needs_rename_username(user.localpart)):
|
|
return False
|
|
display = self._get_displayname_admin(client, token, user.user_id)
|
|
if display and not _needs_rename_display(display):
|
|
return False
|
|
return True
|
|
|
|
def _rename_synapse_user(
|
|
self,
|
|
client: httpx.Client,
|
|
token: str,
|
|
existing: set[str],
|
|
user_id: str,
|
|
) -> bool:
|
|
new_name = self._pick_guest_name(existing)
|
|
if not new_name:
|
|
return False
|
|
return self._set_displayname_admin(client, token, user_id, new_name)
|
|
|
|
def _rename_synapse_guests(
|
|
self,
|
|
client: httpx.Client,
|
|
token: str,
|
|
existing: set[str],
|
|
mas_usernames: set[str],
|
|
) -> SynapseGuestResult:
|
|
renamed = 0
|
|
pruned = 0
|
|
entries = self._synapse_entries(client, token)
|
|
|
|
now_ms = int(time.time() * 1000)
|
|
for entry in entries:
|
|
user_ref = self._synapse_user_id(entry)
|
|
if not user_ref:
|
|
continue
|
|
if self._maybe_prune_synapse_guest(client, token, user_ref.entry, user_ref.user_id, now_ms):
|
|
pruned += 1
|
|
continue
|
|
if not self._needs_synapse_rename(client, token, user_ref, mas_usernames):
|
|
continue
|
|
if self._rename_synapse_user(client, token, existing, user_ref.user_id):
|
|
renamed += 1
|
|
return SynapseGuestResult(renamed=renamed, pruned=pruned)
|
|
|
|
def run_guest_name_randomizer(self, wait: bool = True) -> dict[str, Any]:
|
|
"""Rename guest accounts in the guest room and related backing stores."""
|
|
self._validate_guest_name_settings()
|
|
|
|
with self._client() as client:
|
|
admin_token = self._mas_admin_token(client)
|
|
seeder_id = self._mas_user_id(client, admin_token, self._settings.comms_seeder_user)
|
|
seeder_token, seeder_session = self._mas_personal_session(client, admin_token, seeder_id)
|
|
try:
|
|
room_id, members, existing = self._room_context(client, seeder_token)
|
|
mas_result = self._rename_mas_guests(client, admin_token, room_id, members, existing)
|
|
synapse_result = self._rename_synapse_guests(
|
|
client,
|
|
seeder_token,
|
|
existing,
|
|
mas_result.usernames,
|
|
)
|
|
db_renamed = self._db_rename_numeric(existing)
|
|
finally:
|
|
self._mas_revoke_session(client, admin_token, seeder_session)
|
|
|
|
renamed = mas_result.renamed + synapse_result.renamed + db_renamed
|
|
pruned = synapse_result.pruned
|
|
skipped = mas_result.skipped
|
|
processed = renamed + pruned + skipped
|
|
summary = CommsSummary(processed, renamed, pruned, skipped)
|
|
logger.info(
|
|
"comms guest name sync finished",
|
|
extra={
|
|
"event": "comms_guest_name",
|
|
"status": "ok",
|
|
"processed": summary.processed,
|
|
"renamed": summary.renamed,
|
|
"pruned": summary.pruned,
|
|
"skipped": summary.skipped,
|
|
},
|
|
)
|
|
return {"status": "ok", **summary.__dict__}
|