ariadne/ariadne/services/comms_guest.py

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__}