ctfy.sdk.admin_resources.users
client.admin.users — user roster + role mutation (admin).
1"""``client.admin.users`` — user roster + role mutation (admin).""" 2 3from __future__ import annotations 4 5import builtins 6from typing import Literal 7from urllib.parse import quote 8 9from ctfy.core.related_accounts import RelatedAccounts 10from ctfy.core.state.models import RestrictionKind 11from ctfy.core.user_stats import AdminUserStats, StatsKind, StatsWindow 12from ctfy.core.user_timeline import UserTimeline 13from ctfy.sdk._helpers import _raise_for_status 14from ctfy.sdk.base import BaseHttpClient 15from ctfy.server.models import ( 16 AccountBanInfo, 17 AccountRestrictionInfo, 18 AdminProfileResetResult, 19 AdminSignInRemovalResult, 20 AdminSignOutResult, 21 AdminTokenRevokeResponse, 22 AdminUserInfo, 23 TokenTraceResponse, 24) 25from ctfy.server.models.admin_user_detail import ( 26 AdminUserDetail, 27 AdminUserNote, 28 AdminUserNoteHistory, 29 AdminUserNotes, 30) 31 32 33class AdminUsersResource: 34 """Admin-or-better user listing, lookup and promotion.""" 35 36 def __init__(self, http: BaseHttpClient) -> None: 37 self._http = http 38 39 def list( 40 self, 41 offset: int = 0, 42 limit: int = 50, 43 *, 44 q: str = "", 45 role: str = "", 46 kind: str = "", 47 verified: str = "", 48 institution: str = "", 49 joined: str = "", 50 domain: str = "", 51 email_category: str = "", 52 method: str = "", 53 school: str = "", 54 school_role: str = "", 55 school_region: str = "", 56 school_tier: str = "", 57 intent: str = "", 58 active: str = "", 59 competitions: str = "", 60 country: str = "", 61 sort: str = "", 62 dir: str = "", 63 ) -> builtins.list[AdminUserInfo]: 64 """Users known to the platform, with role + promotion audit. 65 66 ``q`` (matches display name / email / id), ``role``, ``kind`` 67 (``human`` / ``agent`` / ``service``), ``verified`` 68 (``verified`` / ``unverified``), ``sort`` (``name`` / ``email`` / 69 ``created`` / ``last_active`` / ``solves`` / ``attempts``) and 70 ``dir`` (``asc`` / ``desc``) are applied **server-side**, before 71 the page is sliced. 72 73 ⚠️ ``kind`` and ``verified`` are separate axes from ``role``, not 74 refinements of it: every synthetic agent entrant is role 75 ``user``, and whether an address was proved says nothing about 76 privilege. A caller narrowing with ``role`` alone cannot reach 77 either question. 78 79 ``joined`` … ``country`` are the keys :meth:`stats` hands back on 80 each bar (#2772); an unknown value narrows to nobody. 81 """ 82 params: dict[str, str | int] = {"offset": offset, "limit": limit} 83 params.update( 84 { 85 k: v 86 for k, v in ( 87 ("q", q), 88 ("role", role), 89 ("kind", kind), 90 ("verified", verified), 91 ("institution", institution), 92 ("joined", joined), 93 ("domain", domain), 94 ("email_category", email_category), 95 ("method", method), 96 ("school", school), 97 ("school_role", school_role), 98 ("school_region", school_region), 99 ("school_tier", school_tier), 100 ("intent", intent), 101 ("active", active), 102 ("competitions", competitions), 103 ("country", country), 104 ("sort", sort), 105 ("dir", dir), 106 ) 107 if v 108 } 109 ) 110 resp = self._http.request("GET", "/admin/users", params=params) 111 _raise_for_status(resp) 112 return [AdminUserInfo.model_validate(u) for u in resp.json()["items"]] 113 114 def stats(self, *, kind: StatsKind = "human", window: StatsWindow = "all") -> AdminUserStats: 115 """Aggregate statistics over the platform's accounts (#2772). 116 117 Counts only: a domain, school or country held by fewer than five 118 accounts is folded into its ``*_other`` total on the server. 119 """ 120 resp = self._http.request( 121 "GET", "/admin/users/stats", params={"kind": kind, "window": window} 122 ) 123 _raise_for_status(resp) 124 return AdminUserStats.model_validate(resp.json()) 125 126 def resend_verification(self, user_id: str) -> None: 127 """Mail this account's own address a fresh verification code. 128 129 ⚠️ **Writes nothing.** The account still has to receive the code 130 and exchange it, so a bounce leaves it exactly as unproved as it 131 was — which is why this is a separate call from 132 :meth:`verify_email` rather than a flag on it. 133 """ 134 resp = self._http.request("POST", f"/admin/users/{user_id}/resend-verification") 135 _raise_for_status(resp) 136 137 def verify_email(self, user_id: str) -> AdminUserInfo: 138 """Vouch for an address the deployment cannot reach at all. 139 140 ⚠️ **This writes a permanent claim in somebody else's name**, and 141 there is no call that undoes it: the stamp fills and never 142 overwrites. The row records which operator vouched, so a vouch 143 stays legible as a vouch rather than passing for evidence. 144 """ 145 resp = self._http.request("POST", f"/admin/users/{user_id}/verify-email") 146 _raise_for_status(resp) 147 return AdminUserInfo.model_validate(resp.json()) 148 149 def get(self, user_id: str) -> AdminUserInfo: 150 """Admin-or-better: single-user lookup with the audit fields 151 (role, promoted_by, promoted_at, current team summary). Skips 152 the list scan :meth:`list` would otherwise force.""" 153 resp = self._http.request("GET", f"/admin/users/{user_id}") 154 _raise_for_status(resp) 155 return AdminUserInfo.model_validate(resp.json()) 156 157 def detail(self, user_id: str) -> AdminUserDetail: 158 """Everything the admin user page draws but its timeline and its 159 audited 风控 reads (#2772): tiles, addresses, tokens, sittings, 160 authored challenges, risk cases and ban history.""" 161 resp = self._http.request("GET", f"/admin/users/{user_id}/detail") 162 _raise_for_status(resp) 163 return AdminUserDetail.model_validate(resp.json()) 164 165 def timeline(self, user_id: str) -> UserTimeline: 166 """One account's history, newest first, from the records the 167 platform keeps (#2772). ``truncated`` says older audit rows exist.""" 168 resp = self._http.request("GET", f"/admin/users/{user_id}/timeline") 169 _raise_for_status(resp) 170 return UserTimeline.model_validate(resp.json()) 171 172 def related_accounts(self, user_id: str) -> RelatedAccounts: 173 """Other accounts seen from one of this account's IPs or browsers: 174 counts of shared values, never the values. **Every read is an audit 175 row** (``account_sightings_read``). An IP is a lead, never evidence.""" 176 resp = self._http.request("GET", f"/admin/users/{user_id}/related-accounts") 177 _raise_for_status(resp) 178 return RelatedAccounts.model_validate(resp.json()) 179 180 def set_standings_excluded(self, user_id: str, excluded: bool) -> AdminUserInfo: 181 """Take an account off the public standings, or put it back. 182 183 Requires **super-admin**. For the deployment's own staff and test 184 accounts, which otherwise sit on the leaderboard beside the 185 people the event is for. 186 187 ⚠️ **Not a disqualification.** The account keeps playing, keeps 188 its solves and still sees its own rank; what changes is whether 189 anybody else does. The route's own docstring makes the same 190 distinction — conflating the two would put a scarlet letter on 191 an organiser's test account. 192 193 ⚠️ Idempotent: setting it to the value it already holds returns 194 the row unchanged rather than re-stamping it. 195 """ 196 resp = self._http.request( 197 "PATCH", 198 f"/admin/users/{user_id}/standings", 199 json={"excluded": excluded}, 200 ) 201 _raise_for_status(resp) 202 return AdminUserInfo.model_validate(resp.json()) 203 204 def trace_token(self, token: str) -> TokenTraceResponse: 205 """Attribute a leaked bearer token to its owner and its lineage. 206 207 Requires **super-admin**. The sibling of 208 :meth:`RecordsResource.trace_answer`, on the other kind of 209 secret that ends up in a paste bin: ``team_tokens`` stores only 210 a SHA-256 hash, so an operator holding a ``pf_…`` from a log has 211 nothing to grep for and no way to learn whose it is. 212 213 ``chain`` is the whole **rotation lineage**, oldest first — 214 ``rotate`` mints a replacement row linked by ``rotated_from`` 215 rather than re-secreting the old one, so a secret found in a 216 six-month-old log resolves to a revoked row whose successor is 217 the credential still authenticating requests. ``live`` marks the 218 one worth revoking; a token that was never rotated yields a 219 chain of one. 220 221 ⚠️ A string this deployment never minted comes back 222 ``found=False`` at 200 rather than 404 — "not ours" is an answer 223 somebody asked for, and a 404 reads as a wrong URL. 224 225 ⚠️ Nothing derived from the secret comes back, not even its 226 hash: that digest is unsalted, so it *is* the stored verifier. 227 228 The token travels in the request body, never a query string, so 229 a live credential does not land in an access log on the way. 230 """ 231 resp = self._http.request("POST", "/admin/token-trace", json={"token": token}) 232 _raise_for_status(resp) 233 return TokenTraceResponse.model_validate(resp.json()) 234 235 def revoke_token(self, token_id: str) -> AdminTokenRevokeResponse: 236 """End a token this super-admin does not own — the revoke half of 237 :meth:`trace_token`. 238 239 Requires **super-admin**. Owner-revoke (``DELETE 240 /auth/tokens/{id}``) refuses a token whose owner is not the 241 caller; this is the operator door for a leaked ``pf_…`` — pass 242 the ``token_id`` that ``trace_token`` marked *live*. Especially 243 for a synthetic **agent** entrant, whose owner is an ``agent:…`` 244 account nobody can log in as, this is the only sanctioned path. 245 246 ⚠️ Idempotent: re-revoking an already-revoked row returns 247 ``already_revoked=True`` at 200, not an error. A ``token_id`` 248 that resolves to nothing is a 404 (unlike ``trace_token``'s 249 200 miss — here the id came from a trace, so a miss is a typo). 250 """ 251 resp = self._http.request("POST", f"/admin/tokens/{token_id}/revoke") 252 _raise_for_status(resp) 253 return AdminTokenRevokeResponse.model_validate(resp.json()) 254 255 def set_role(self, user_id: str, role: str) -> AdminUserInfo: 256 """Promote / demote between ``"user"`` and ``"admin"``. 257 258 Requires **super-admin**. ``super_admin`` role is env-anchored and 259 cannot be changed via API; trying returns 403. 260 """ 261 resp = self._http.request( 262 "PATCH", 263 f"/admin/users/{user_id}/role", 264 json={"role": role}, 265 ) 266 _raise_for_status(resp) 267 return AdminUserInfo.model_validate(resp.json()) 268 269 def list_bans(self, *, active_only: bool = False) -> builtins.list[AccountBanInfo]: 270 """Platform-wide account bans, newest first (#2068). Lifted and 271 lapsed rows stay listed unless ``active_only``.""" 272 resp = self._http.request( 273 "GET", 274 "/admin/account-bans", 275 params={"active_only": "true" if active_only else "false"}, 276 ) 277 _raise_for_status(resp) 278 return [AccountBanInfo.model_validate(row) for row in resp.json()] 279 280 def ban(self, user_id: str, *, reason: str, expires_at: str | None = None) -> AccountBanInfo: 281 """Ban an account from the whole platform: sign-in, every bearer and 282 every open event stream stop working. 283 284 ``reason`` (>= 10 characters) is shown to the account and is what 285 an emailed appeal is argued from; ``expires_at`` (an ISO instant 286 with a timezone) lets it lapse by itself. Idempotent. An ordinary 287 admin cannot ban another admin; nobody bans a super-admin or 288 themselves. 289 """ 290 resp = self._http.request( 291 "POST", 292 f"/admin/users/{user_id}/ban", 293 json={"reason": reason, "expires_at": expires_at}, 294 ) 295 _raise_for_status(resp) 296 return AccountBanInfo.model_validate(resp.json()) 297 298 def lift_ban(self, user_id: str) -> AccountBanInfo: 299 """Lift an account's ban early. Its bearers work again.""" 300 resp = self._http.request("DELETE", f"/admin/users/{user_id}/ban") 301 _raise_for_status(resp) 302 return AccountBanInfo.model_validate(resp.json()) 303 304 def sign_out( 305 self, user_id: str, *, reason: str, reset_password: bool = False 306 ) -> AdminSignOutResult: 307 """Sign an account out everywhere without banning it (#2773). 308 309 Every session and token is revoked and every open stream and shell 310 closed; the account works again the moment its owner signs in. 311 ``reset_password`` also removes the password (refused with 312 ``sign_out_would_lock_out`` when that leaves no way in). ``reason`` 313 (>= 10 characters) is mailed to the person. 314 """ 315 resp = self._http.request( 316 "POST", 317 f"/admin/users/{user_id}/sign-out", 318 json={"reason": reason, "reset_password": reset_password}, 319 ) 320 _raise_for_status(resp) 321 return AdminSignOutResult.model_validate(resp.json()) 322 323 def unlink_identity( 324 self, user_id: str, identity_id: str, *, reason: str, sign_out: bool = True 325 ) -> AdminSignInRemovalResult: 326 """Unlink one of an account's sign-in identities (#2777). 327 328 Refused with ``unlink_would_lock_out`` when it is the account's last 329 way in. ``reason`` (>= 10 characters) is mailed to the person; 330 ``sign_out`` also revokes every session and token. 331 """ 332 resp = self._http.request( 333 "POST", 334 f"/admin/users/{user_id}/identities/{identity_id}/unlink", 335 json={"reason": reason, "sign_out": sign_out}, 336 ) 337 _raise_for_status(resp) 338 return AdminSignInRemovalResult.model_validate(resp.json()) 339 340 def remove_email( 341 self, user_id: str, email: str, *, reason: str, sign_out: bool = True 342 ) -> AdminSignInRemovalResult: 343 """Remove a non-primary address from an account (#2777). 344 345 Never the primary (``cannot_remove_primary``) and never the last way 346 in (``unlink_would_lock_out``). ``reason`` is mailed to the person. 347 """ 348 resp = self._http.request( 349 "POST", 350 f"/admin/users/{user_id}/emails/{quote(email, safe='')}/remove", 351 json={"reason": reason, "sign_out": sign_out}, 352 ) 353 _raise_for_status(resp) 354 return AdminSignInRemovalResult.model_validate(resp.json()) 355 356 # -- administrators' private notes (#2776) ------------------------------- 357 358 def notes(self, user_id: str) -> AdminUserNotes: 359 """Every administrator note on the account, oldest first.""" 360 resp = self._http.request("GET", f"/admin/users/{user_id}/notes") 361 _raise_for_status(resp) 362 return AdminUserNotes.model_validate(resp.json()) 363 364 def add_note(self, user_id: str, body: str) -> AdminUserNote: 365 """Write a private note on the account. Never shown to it.""" 366 resp = self._http.request("POST", f"/admin/users/{user_id}/notes", json={"body": body}) 367 _raise_for_status(resp) 368 return AdminUserNote.model_validate(resp.json()) 369 370 def edit_note(self, user_id: str, note_id: str, body: str) -> AdminUserNote: 371 """Edit a note (its author only); the words it replaces are kept.""" 372 resp = self._http.request( 373 "PATCH", f"/admin/users/{user_id}/notes/{note_id}", json={"body": body} 374 ) 375 _raise_for_status(resp) 376 return AdminUserNote.model_validate(resp.json()) 377 378 def delete_note(self, user_id: str, note_id: str) -> None: 379 """Delete a note and its earlier wordings (its author or a super-admin).""" 380 resp = self._http.request("DELETE", f"/admin/users/{user_id}/notes/{note_id}") 381 _raise_for_status(resp) 382 383 def note_history(self, user_id: str, note_id: str) -> AdminUserNoteHistory: 384 """A note with every wording its edits replaced, oldest first.""" 385 resp = self._http.request("GET", f"/admin/users/{user_id}/notes/{note_id}/history") 386 _raise_for_status(resp) 387 return AdminUserNoteHistory.model_validate(resp.json()) 388 389 def reset_profile_field( 390 self, 391 user_id: str, 392 field: Literal["display_name", "avatar", "bio", "links"], 393 *, 394 reason: str, 395 lock_days: int = 0, 396 ) -> AdminProfileResetResult: 397 """Reset one field of an account's public profile, without a ban (#2775). 398 399 ``display_name`` becomes a neutral ``Player <id>``; ``avatar``, 400 ``bio`` and ``links`` are cleared. ``lock_days`` keeps the person 401 from setting it again for that long. ``reason`` (>= 10 characters) 402 is mailed to the person. 403 """ 404 resp = self._http.request( 405 "POST", 406 f"/admin/users/{user_id}/profile-reset", 407 json={"field": field, "reason": reason, "lock_days": lock_days}, 408 ) 409 _raise_for_status(resp) 410 return AdminProfileResetResult.model_validate(resp.json()) 411 412 def restrict( 413 self, 414 user_id: str, 415 kinds: builtins.list[RestrictionKind], 416 *, 417 reason: str, 418 days: int = 0, 419 ) -> builtins.list[AccountRestrictionInfo]: 420 """Take capabilities away from an account, short of a ban (#2774). 421 422 Each of ``kinds`` is stored as its own restriction with this reason 423 and expiry; ``days`` lapses them by the clock (``0`` = until lifted); 424 ``reason`` (>= 10 characters) is what the person reads at every 425 refusal and in the mail. Returns the restrictions now in force. 426 """ 427 resp = self._http.request( 428 "POST", 429 f"/admin/users/{user_id}/restrictions", 430 json={"kinds": list(kinds), "reason": reason, "days": days}, 431 ) 432 _raise_for_status(resp) 433 return [AccountRestrictionInfo.model_validate(r) for r in resp.json()] 434 435 def lift_restriction( 436 self, user_id: str, kind: RestrictionKind 437 ) -> builtins.list[AccountRestrictionInfo]: 438 """Lift one restriction before its clock does (#2774).""" 439 resp = self._http.request("DELETE", f"/admin/users/{user_id}/restrictions/{kind}") 440 _raise_for_status(resp) 441 return [AccountRestrictionInfo.model_validate(r) for r in resp.json()]
34class AdminUsersResource: 35 """Admin-or-better user listing, lookup and promotion.""" 36 37 def __init__(self, http: BaseHttpClient) -> None: 38 self._http = http 39 40 def list( 41 self, 42 offset: int = 0, 43 limit: int = 50, 44 *, 45 q: str = "", 46 role: str = "", 47 kind: str = "", 48 verified: str = "", 49 institution: str = "", 50 joined: str = "", 51 domain: str = "", 52 email_category: str = "", 53 method: str = "", 54 school: str = "", 55 school_role: str = "", 56 school_region: str = "", 57 school_tier: str = "", 58 intent: str = "", 59 active: str = "", 60 competitions: str = "", 61 country: str = "", 62 sort: str = "", 63 dir: str = "", 64 ) -> builtins.list[AdminUserInfo]: 65 """Users known to the platform, with role + promotion audit. 66 67 ``q`` (matches display name / email / id), ``role``, ``kind`` 68 (``human`` / ``agent`` / ``service``), ``verified`` 69 (``verified`` / ``unverified``), ``sort`` (``name`` / ``email`` / 70 ``created`` / ``last_active`` / ``solves`` / ``attempts``) and 71 ``dir`` (``asc`` / ``desc``) are applied **server-side**, before 72 the page is sliced. 73 74 ⚠️ ``kind`` and ``verified`` are separate axes from ``role``, not 75 refinements of it: every synthetic agent entrant is role 76 ``user``, and whether an address was proved says nothing about 77 privilege. A caller narrowing with ``role`` alone cannot reach 78 either question. 79 80 ``joined`` … ``country`` are the keys :meth:`stats` hands back on 81 each bar (#2772); an unknown value narrows to nobody. 82 """ 83 params: dict[str, str | int] = {"offset": offset, "limit": limit} 84 params.update( 85 { 86 k: v 87 for k, v in ( 88 ("q", q), 89 ("role", role), 90 ("kind", kind), 91 ("verified", verified), 92 ("institution", institution), 93 ("joined", joined), 94 ("domain", domain), 95 ("email_category", email_category), 96 ("method", method), 97 ("school", school), 98 ("school_role", school_role), 99 ("school_region", school_region), 100 ("school_tier", school_tier), 101 ("intent", intent), 102 ("active", active), 103 ("competitions", competitions), 104 ("country", country), 105 ("sort", sort), 106 ("dir", dir), 107 ) 108 if v 109 } 110 ) 111 resp = self._http.request("GET", "/admin/users", params=params) 112 _raise_for_status(resp) 113 return [AdminUserInfo.model_validate(u) for u in resp.json()["items"]] 114 115 def stats(self, *, kind: StatsKind = "human", window: StatsWindow = "all") -> AdminUserStats: 116 """Aggregate statistics over the platform's accounts (#2772). 117 118 Counts only: a domain, school or country held by fewer than five 119 accounts is folded into its ``*_other`` total on the server. 120 """ 121 resp = self._http.request( 122 "GET", "/admin/users/stats", params={"kind": kind, "window": window} 123 ) 124 _raise_for_status(resp) 125 return AdminUserStats.model_validate(resp.json()) 126 127 def resend_verification(self, user_id: str) -> None: 128 """Mail this account's own address a fresh verification code. 129 130 ⚠️ **Writes nothing.** The account still has to receive the code 131 and exchange it, so a bounce leaves it exactly as unproved as it 132 was — which is why this is a separate call from 133 :meth:`verify_email` rather than a flag on it. 134 """ 135 resp = self._http.request("POST", f"/admin/users/{user_id}/resend-verification") 136 _raise_for_status(resp) 137 138 def verify_email(self, user_id: str) -> AdminUserInfo: 139 """Vouch for an address the deployment cannot reach at all. 140 141 ⚠️ **This writes a permanent claim in somebody else's name**, and 142 there is no call that undoes it: the stamp fills and never 143 overwrites. The row records which operator vouched, so a vouch 144 stays legible as a vouch rather than passing for evidence. 145 """ 146 resp = self._http.request("POST", f"/admin/users/{user_id}/verify-email") 147 _raise_for_status(resp) 148 return AdminUserInfo.model_validate(resp.json()) 149 150 def get(self, user_id: str) -> AdminUserInfo: 151 """Admin-or-better: single-user lookup with the audit fields 152 (role, promoted_by, promoted_at, current team summary). Skips 153 the list scan :meth:`list` would otherwise force.""" 154 resp = self._http.request("GET", f"/admin/users/{user_id}") 155 _raise_for_status(resp) 156 return AdminUserInfo.model_validate(resp.json()) 157 158 def detail(self, user_id: str) -> AdminUserDetail: 159 """Everything the admin user page draws but its timeline and its 160 audited 风控 reads (#2772): tiles, addresses, tokens, sittings, 161 authored challenges, risk cases and ban history.""" 162 resp = self._http.request("GET", f"/admin/users/{user_id}/detail") 163 _raise_for_status(resp) 164 return AdminUserDetail.model_validate(resp.json()) 165 166 def timeline(self, user_id: str) -> UserTimeline: 167 """One account's history, newest first, from the records the 168 platform keeps (#2772). ``truncated`` says older audit rows exist.""" 169 resp = self._http.request("GET", f"/admin/users/{user_id}/timeline") 170 _raise_for_status(resp) 171 return UserTimeline.model_validate(resp.json()) 172 173 def related_accounts(self, user_id: str) -> RelatedAccounts: 174 """Other accounts seen from one of this account's IPs or browsers: 175 counts of shared values, never the values. **Every read is an audit 176 row** (``account_sightings_read``). An IP is a lead, never evidence.""" 177 resp = self._http.request("GET", f"/admin/users/{user_id}/related-accounts") 178 _raise_for_status(resp) 179 return RelatedAccounts.model_validate(resp.json()) 180 181 def set_standings_excluded(self, user_id: str, excluded: bool) -> AdminUserInfo: 182 """Take an account off the public standings, or put it back. 183 184 Requires **super-admin**. For the deployment's own staff and test 185 accounts, which otherwise sit on the leaderboard beside the 186 people the event is for. 187 188 ⚠️ **Not a disqualification.** The account keeps playing, keeps 189 its solves and still sees its own rank; what changes is whether 190 anybody else does. The route's own docstring makes the same 191 distinction — conflating the two would put a scarlet letter on 192 an organiser's test account. 193 194 ⚠️ Idempotent: setting it to the value it already holds returns 195 the row unchanged rather than re-stamping it. 196 """ 197 resp = self._http.request( 198 "PATCH", 199 f"/admin/users/{user_id}/standings", 200 json={"excluded": excluded}, 201 ) 202 _raise_for_status(resp) 203 return AdminUserInfo.model_validate(resp.json()) 204 205 def trace_token(self, token: str) -> TokenTraceResponse: 206 """Attribute a leaked bearer token to its owner and its lineage. 207 208 Requires **super-admin**. The sibling of 209 :meth:`RecordsResource.trace_answer`, on the other kind of 210 secret that ends up in a paste bin: ``team_tokens`` stores only 211 a SHA-256 hash, so an operator holding a ``pf_…`` from a log has 212 nothing to grep for and no way to learn whose it is. 213 214 ``chain`` is the whole **rotation lineage**, oldest first — 215 ``rotate`` mints a replacement row linked by ``rotated_from`` 216 rather than re-secreting the old one, so a secret found in a 217 six-month-old log resolves to a revoked row whose successor is 218 the credential still authenticating requests. ``live`` marks the 219 one worth revoking; a token that was never rotated yields a 220 chain of one. 221 222 ⚠️ A string this deployment never minted comes back 223 ``found=False`` at 200 rather than 404 — "not ours" is an answer 224 somebody asked for, and a 404 reads as a wrong URL. 225 226 ⚠️ Nothing derived from the secret comes back, not even its 227 hash: that digest is unsalted, so it *is* the stored verifier. 228 229 The token travels in the request body, never a query string, so 230 a live credential does not land in an access log on the way. 231 """ 232 resp = self._http.request("POST", "/admin/token-trace", json={"token": token}) 233 _raise_for_status(resp) 234 return TokenTraceResponse.model_validate(resp.json()) 235 236 def revoke_token(self, token_id: str) -> AdminTokenRevokeResponse: 237 """End a token this super-admin does not own — the revoke half of 238 :meth:`trace_token`. 239 240 Requires **super-admin**. Owner-revoke (``DELETE 241 /auth/tokens/{id}``) refuses a token whose owner is not the 242 caller; this is the operator door for a leaked ``pf_…`` — pass 243 the ``token_id`` that ``trace_token`` marked *live*. Especially 244 for a synthetic **agent** entrant, whose owner is an ``agent:…`` 245 account nobody can log in as, this is the only sanctioned path. 246 247 ⚠️ Idempotent: re-revoking an already-revoked row returns 248 ``already_revoked=True`` at 200, not an error. A ``token_id`` 249 that resolves to nothing is a 404 (unlike ``trace_token``'s 250 200 miss — here the id came from a trace, so a miss is a typo). 251 """ 252 resp = self._http.request("POST", f"/admin/tokens/{token_id}/revoke") 253 _raise_for_status(resp) 254 return AdminTokenRevokeResponse.model_validate(resp.json()) 255 256 def set_role(self, user_id: str, role: str) -> AdminUserInfo: 257 """Promote / demote between ``"user"`` and ``"admin"``. 258 259 Requires **super-admin**. ``super_admin`` role is env-anchored and 260 cannot be changed via API; trying returns 403. 261 """ 262 resp = self._http.request( 263 "PATCH", 264 f"/admin/users/{user_id}/role", 265 json={"role": role}, 266 ) 267 _raise_for_status(resp) 268 return AdminUserInfo.model_validate(resp.json()) 269 270 def list_bans(self, *, active_only: bool = False) -> builtins.list[AccountBanInfo]: 271 """Platform-wide account bans, newest first (#2068). Lifted and 272 lapsed rows stay listed unless ``active_only``.""" 273 resp = self._http.request( 274 "GET", 275 "/admin/account-bans", 276 params={"active_only": "true" if active_only else "false"}, 277 ) 278 _raise_for_status(resp) 279 return [AccountBanInfo.model_validate(row) for row in resp.json()] 280 281 def ban(self, user_id: str, *, reason: str, expires_at: str | None = None) -> AccountBanInfo: 282 """Ban an account from the whole platform: sign-in, every bearer and 283 every open event stream stop working. 284 285 ``reason`` (>= 10 characters) is shown to the account and is what 286 an emailed appeal is argued from; ``expires_at`` (an ISO instant 287 with a timezone) lets it lapse by itself. Idempotent. An ordinary 288 admin cannot ban another admin; nobody bans a super-admin or 289 themselves. 290 """ 291 resp = self._http.request( 292 "POST", 293 f"/admin/users/{user_id}/ban", 294 json={"reason": reason, "expires_at": expires_at}, 295 ) 296 _raise_for_status(resp) 297 return AccountBanInfo.model_validate(resp.json()) 298 299 def lift_ban(self, user_id: str) -> AccountBanInfo: 300 """Lift an account's ban early. Its bearers work again.""" 301 resp = self._http.request("DELETE", f"/admin/users/{user_id}/ban") 302 _raise_for_status(resp) 303 return AccountBanInfo.model_validate(resp.json()) 304 305 def sign_out( 306 self, user_id: str, *, reason: str, reset_password: bool = False 307 ) -> AdminSignOutResult: 308 """Sign an account out everywhere without banning it (#2773). 309 310 Every session and token is revoked and every open stream and shell 311 closed; the account works again the moment its owner signs in. 312 ``reset_password`` also removes the password (refused with 313 ``sign_out_would_lock_out`` when that leaves no way in). ``reason`` 314 (>= 10 characters) is mailed to the person. 315 """ 316 resp = self._http.request( 317 "POST", 318 f"/admin/users/{user_id}/sign-out", 319 json={"reason": reason, "reset_password": reset_password}, 320 ) 321 _raise_for_status(resp) 322 return AdminSignOutResult.model_validate(resp.json()) 323 324 def unlink_identity( 325 self, user_id: str, identity_id: str, *, reason: str, sign_out: bool = True 326 ) -> AdminSignInRemovalResult: 327 """Unlink one of an account's sign-in identities (#2777). 328 329 Refused with ``unlink_would_lock_out`` when it is the account's last 330 way in. ``reason`` (>= 10 characters) is mailed to the person; 331 ``sign_out`` also revokes every session and token. 332 """ 333 resp = self._http.request( 334 "POST", 335 f"/admin/users/{user_id}/identities/{identity_id}/unlink", 336 json={"reason": reason, "sign_out": sign_out}, 337 ) 338 _raise_for_status(resp) 339 return AdminSignInRemovalResult.model_validate(resp.json()) 340 341 def remove_email( 342 self, user_id: str, email: str, *, reason: str, sign_out: bool = True 343 ) -> AdminSignInRemovalResult: 344 """Remove a non-primary address from an account (#2777). 345 346 Never the primary (``cannot_remove_primary``) and never the last way 347 in (``unlink_would_lock_out``). ``reason`` is mailed to the person. 348 """ 349 resp = self._http.request( 350 "POST", 351 f"/admin/users/{user_id}/emails/{quote(email, safe='')}/remove", 352 json={"reason": reason, "sign_out": sign_out}, 353 ) 354 _raise_for_status(resp) 355 return AdminSignInRemovalResult.model_validate(resp.json()) 356 357 # -- administrators' private notes (#2776) ------------------------------- 358 359 def notes(self, user_id: str) -> AdminUserNotes: 360 """Every administrator note on the account, oldest first.""" 361 resp = self._http.request("GET", f"/admin/users/{user_id}/notes") 362 _raise_for_status(resp) 363 return AdminUserNotes.model_validate(resp.json()) 364 365 def add_note(self, user_id: str, body: str) -> AdminUserNote: 366 """Write a private note on the account. Never shown to it.""" 367 resp = self._http.request("POST", f"/admin/users/{user_id}/notes", json={"body": body}) 368 _raise_for_status(resp) 369 return AdminUserNote.model_validate(resp.json()) 370 371 def edit_note(self, user_id: str, note_id: str, body: str) -> AdminUserNote: 372 """Edit a note (its author only); the words it replaces are kept.""" 373 resp = self._http.request( 374 "PATCH", f"/admin/users/{user_id}/notes/{note_id}", json={"body": body} 375 ) 376 _raise_for_status(resp) 377 return AdminUserNote.model_validate(resp.json()) 378 379 def delete_note(self, user_id: str, note_id: str) -> None: 380 """Delete a note and its earlier wordings (its author or a super-admin).""" 381 resp = self._http.request("DELETE", f"/admin/users/{user_id}/notes/{note_id}") 382 _raise_for_status(resp) 383 384 def note_history(self, user_id: str, note_id: str) -> AdminUserNoteHistory: 385 """A note with every wording its edits replaced, oldest first.""" 386 resp = self._http.request("GET", f"/admin/users/{user_id}/notes/{note_id}/history") 387 _raise_for_status(resp) 388 return AdminUserNoteHistory.model_validate(resp.json()) 389 390 def reset_profile_field( 391 self, 392 user_id: str, 393 field: Literal["display_name", "avatar", "bio", "links"], 394 *, 395 reason: str, 396 lock_days: int = 0, 397 ) -> AdminProfileResetResult: 398 """Reset one field of an account's public profile, without a ban (#2775). 399 400 ``display_name`` becomes a neutral ``Player <id>``; ``avatar``, 401 ``bio`` and ``links`` are cleared. ``lock_days`` keeps the person 402 from setting it again for that long. ``reason`` (>= 10 characters) 403 is mailed to the person. 404 """ 405 resp = self._http.request( 406 "POST", 407 f"/admin/users/{user_id}/profile-reset", 408 json={"field": field, "reason": reason, "lock_days": lock_days}, 409 ) 410 _raise_for_status(resp) 411 return AdminProfileResetResult.model_validate(resp.json()) 412 413 def restrict( 414 self, 415 user_id: str, 416 kinds: builtins.list[RestrictionKind], 417 *, 418 reason: str, 419 days: int = 0, 420 ) -> builtins.list[AccountRestrictionInfo]: 421 """Take capabilities away from an account, short of a ban (#2774). 422 423 Each of ``kinds`` is stored as its own restriction with this reason 424 and expiry; ``days`` lapses them by the clock (``0`` = until lifted); 425 ``reason`` (>= 10 characters) is what the person reads at every 426 refusal and in the mail. Returns the restrictions now in force. 427 """ 428 resp = self._http.request( 429 "POST", 430 f"/admin/users/{user_id}/restrictions", 431 json={"kinds": list(kinds), "reason": reason, "days": days}, 432 ) 433 _raise_for_status(resp) 434 return [AccountRestrictionInfo.model_validate(r) for r in resp.json()] 435 436 def lift_restriction( 437 self, user_id: str, kind: RestrictionKind 438 ) -> builtins.list[AccountRestrictionInfo]: 439 """Lift one restriction before its clock does (#2774).""" 440 resp = self._http.request("DELETE", f"/admin/users/{user_id}/restrictions/{kind}") 441 _raise_for_status(resp) 442 return [AccountRestrictionInfo.model_validate(r) for r in resp.json()]
Admin-or-better user listing, lookup and promotion.
40 def list( 41 self, 42 offset: int = 0, 43 limit: int = 50, 44 *, 45 q: str = "", 46 role: str = "", 47 kind: str = "", 48 verified: str = "", 49 institution: str = "", 50 joined: str = "", 51 domain: str = "", 52 email_category: str = "", 53 method: str = "", 54 school: str = "", 55 school_role: str = "", 56 school_region: str = "", 57 school_tier: str = "", 58 intent: str = "", 59 active: str = "", 60 competitions: str = "", 61 country: str = "", 62 sort: str = "", 63 dir: str = "", 64 ) -> builtins.list[AdminUserInfo]: 65 """Users known to the platform, with role + promotion audit. 66 67 ``q`` (matches display name / email / id), ``role``, ``kind`` 68 (``human`` / ``agent`` / ``service``), ``verified`` 69 (``verified`` / ``unverified``), ``sort`` (``name`` / ``email`` / 70 ``created`` / ``last_active`` / ``solves`` / ``attempts``) and 71 ``dir`` (``asc`` / ``desc``) are applied **server-side**, before 72 the page is sliced. 73 74 ⚠️ ``kind`` and ``verified`` are separate axes from ``role``, not 75 refinements of it: every synthetic agent entrant is role 76 ``user``, and whether an address was proved says nothing about 77 privilege. A caller narrowing with ``role`` alone cannot reach 78 either question. 79 80 ``joined`` … ``country`` are the keys :meth:`stats` hands back on 81 each bar (#2772); an unknown value narrows to nobody. 82 """ 83 params: dict[str, str | int] = {"offset": offset, "limit": limit} 84 params.update( 85 { 86 k: v 87 for k, v in ( 88 ("q", q), 89 ("role", role), 90 ("kind", kind), 91 ("verified", verified), 92 ("institution", institution), 93 ("joined", joined), 94 ("domain", domain), 95 ("email_category", email_category), 96 ("method", method), 97 ("school", school), 98 ("school_role", school_role), 99 ("school_region", school_region), 100 ("school_tier", school_tier), 101 ("intent", intent), 102 ("active", active), 103 ("competitions", competitions), 104 ("country", country), 105 ("sort", sort), 106 ("dir", dir), 107 ) 108 if v 109 } 110 ) 111 resp = self._http.request("GET", "/admin/users", params=params) 112 _raise_for_status(resp) 113 return [AdminUserInfo.model_validate(u) for u in resp.json()["items"]]
Users known to the platform, with role + promotion audit.
q (matches display name / email / id), role, kind
(human / agent / service), verified
(verified / unverified), sort (name / email /
created / last_active / solves / attempts) and
dir (asc / desc) are applied server-side, before
the page is sliced.
⚠️ kind and verified are separate axes from role, not
refinements of it: every synthetic agent entrant is role
user, and whether an address was proved says nothing about
privilege. A caller narrowing with role alone cannot reach
either question.
joined … country are the keys stats() hands back on
each bar (#2772); an unknown value narrows to nobody.
115 def stats(self, *, kind: StatsKind = "human", window: StatsWindow = "all") -> AdminUserStats: 116 """Aggregate statistics over the platform's accounts (#2772). 117 118 Counts only: a domain, school or country held by fewer than five 119 accounts is folded into its ``*_other`` total on the server. 120 """ 121 resp = self._http.request( 122 "GET", "/admin/users/stats", params={"kind": kind, "window": window} 123 ) 124 _raise_for_status(resp) 125 return AdminUserStats.model_validate(resp.json())
Aggregate statistics over the platform's accounts (#2772).
Counts only: a domain, school or country held by fewer than five
accounts is folded into its *_other total on the server.
127 def resend_verification(self, user_id: str) -> None: 128 """Mail this account's own address a fresh verification code. 129 130 ⚠️ **Writes nothing.** The account still has to receive the code 131 and exchange it, so a bounce leaves it exactly as unproved as it 132 was — which is why this is a separate call from 133 :meth:`verify_email` rather than a flag on it. 134 """ 135 resp = self._http.request("POST", f"/admin/users/{user_id}/resend-verification") 136 _raise_for_status(resp)
Mail this account's own address a fresh verification code.
⚠️ Writes nothing. The account still has to receive the code
and exchange it, so a bounce leaves it exactly as unproved as it
was — which is why this is a separate call from
verify_email() rather than a flag on it.
138 def verify_email(self, user_id: str) -> AdminUserInfo: 139 """Vouch for an address the deployment cannot reach at all. 140 141 ⚠️ **This writes a permanent claim in somebody else's name**, and 142 there is no call that undoes it: the stamp fills and never 143 overwrites. The row records which operator vouched, so a vouch 144 stays legible as a vouch rather than passing for evidence. 145 """ 146 resp = self._http.request("POST", f"/admin/users/{user_id}/verify-email") 147 _raise_for_status(resp) 148 return AdminUserInfo.model_validate(resp.json())
Vouch for an address the deployment cannot reach at all.
⚠️ This writes a permanent claim in somebody else's name, and there is no call that undoes it: the stamp fills and never overwrites. The row records which operator vouched, so a vouch stays legible as a vouch rather than passing for evidence.
150 def get(self, user_id: str) -> AdminUserInfo: 151 """Admin-or-better: single-user lookup with the audit fields 152 (role, promoted_by, promoted_at, current team summary). Skips 153 the list scan :meth:`list` would otherwise force.""" 154 resp = self._http.request("GET", f"/admin/users/{user_id}") 155 _raise_for_status(resp) 156 return AdminUserInfo.model_validate(resp.json())
Admin-or-better: single-user lookup with the audit fields
(role, promoted_by, promoted_at, current team summary). Skips
the list scan list() would otherwise force.
158 def detail(self, user_id: str) -> AdminUserDetail: 159 """Everything the admin user page draws but its timeline and its 160 audited 风控 reads (#2772): tiles, addresses, tokens, sittings, 161 authored challenges, risk cases and ban history.""" 162 resp = self._http.request("GET", f"/admin/users/{user_id}/detail") 163 _raise_for_status(resp) 164 return AdminUserDetail.model_validate(resp.json())
Everything the admin user page draws but its timeline and its audited 风控 reads (#2772): tiles, addresses, tokens, sittings, authored challenges, risk cases and ban history.
166 def timeline(self, user_id: str) -> UserTimeline: 167 """One account's history, newest first, from the records the 168 platform keeps (#2772). ``truncated`` says older audit rows exist.""" 169 resp = self._http.request("GET", f"/admin/users/{user_id}/timeline") 170 _raise_for_status(resp) 171 return UserTimeline.model_validate(resp.json())
One account's history, newest first, from the records the
platform keeps (#2772). truncated says older audit rows exist.
181 def set_standings_excluded(self, user_id: str, excluded: bool) -> AdminUserInfo: 182 """Take an account off the public standings, or put it back. 183 184 Requires **super-admin**. For the deployment's own staff and test 185 accounts, which otherwise sit on the leaderboard beside the 186 people the event is for. 187 188 ⚠️ **Not a disqualification.** The account keeps playing, keeps 189 its solves and still sees its own rank; what changes is whether 190 anybody else does. The route's own docstring makes the same 191 distinction — conflating the two would put a scarlet letter on 192 an organiser's test account. 193 194 ⚠️ Idempotent: setting it to the value it already holds returns 195 the row unchanged rather than re-stamping it. 196 """ 197 resp = self._http.request( 198 "PATCH", 199 f"/admin/users/{user_id}/standings", 200 json={"excluded": excluded}, 201 ) 202 _raise_for_status(resp) 203 return AdminUserInfo.model_validate(resp.json())
Take an account off the public standings, or put it back.
Requires super-admin. For the deployment's own staff and test accounts, which otherwise sit on the leaderboard beside the people the event is for.
⚠️ Not a disqualification. The account keeps playing, keeps its solves and still sees its own rank; what changes is whether anybody else does. The route's own docstring makes the same distinction — conflating the two would put a scarlet letter on an organiser's test account.
⚠️ Idempotent: setting it to the value it already holds returns the row unchanged rather than re-stamping it.
205 def trace_token(self, token: str) -> TokenTraceResponse: 206 """Attribute a leaked bearer token to its owner and its lineage. 207 208 Requires **super-admin**. The sibling of 209 :meth:`RecordsResource.trace_answer`, on the other kind of 210 secret that ends up in a paste bin: ``team_tokens`` stores only 211 a SHA-256 hash, so an operator holding a ``pf_…`` from a log has 212 nothing to grep for and no way to learn whose it is. 213 214 ``chain`` is the whole **rotation lineage**, oldest first — 215 ``rotate`` mints a replacement row linked by ``rotated_from`` 216 rather than re-secreting the old one, so a secret found in a 217 six-month-old log resolves to a revoked row whose successor is 218 the credential still authenticating requests. ``live`` marks the 219 one worth revoking; a token that was never rotated yields a 220 chain of one. 221 222 ⚠️ A string this deployment never minted comes back 223 ``found=False`` at 200 rather than 404 — "not ours" is an answer 224 somebody asked for, and a 404 reads as a wrong URL. 225 226 ⚠️ Nothing derived from the secret comes back, not even its 227 hash: that digest is unsalted, so it *is* the stored verifier. 228 229 The token travels in the request body, never a query string, so 230 a live credential does not land in an access log on the way. 231 """ 232 resp = self._http.request("POST", "/admin/token-trace", json={"token": token}) 233 _raise_for_status(resp) 234 return TokenTraceResponse.model_validate(resp.json())
Attribute a leaked bearer token to its owner and its lineage.
Requires super-admin. The sibling of
RecordsResource.trace_answer(), on the other kind of
secret that ends up in a paste bin: team_tokens stores only
a SHA-256 hash, so an operator holding a pf_… from a log has
nothing to grep for and no way to learn whose it is.
chain is the whole rotation lineage, oldest first —
rotate mints a replacement row linked by rotated_from
rather than re-secreting the old one, so a secret found in a
six-month-old log resolves to a revoked row whose successor is
the credential still authenticating requests. live marks the
one worth revoking; a token that was never rotated yields a
chain of one.
⚠️ A string this deployment never minted comes back
found=False at 200 rather than 404 — "not ours" is an answer
somebody asked for, and a 404 reads as a wrong URL.
⚠️ Nothing derived from the secret comes back, not even its hash: that digest is unsalted, so it is the stored verifier.
The token travels in the request body, never a query string, so a live credential does not land in an access log on the way.
236 def revoke_token(self, token_id: str) -> AdminTokenRevokeResponse: 237 """End a token this super-admin does not own — the revoke half of 238 :meth:`trace_token`. 239 240 Requires **super-admin**. Owner-revoke (``DELETE 241 /auth/tokens/{id}``) refuses a token whose owner is not the 242 caller; this is the operator door for a leaked ``pf_…`` — pass 243 the ``token_id`` that ``trace_token`` marked *live*. Especially 244 for a synthetic **agent** entrant, whose owner is an ``agent:…`` 245 account nobody can log in as, this is the only sanctioned path. 246 247 ⚠️ Idempotent: re-revoking an already-revoked row returns 248 ``already_revoked=True`` at 200, not an error. A ``token_id`` 249 that resolves to nothing is a 404 (unlike ``trace_token``'s 250 200 miss — here the id came from a trace, so a miss is a typo). 251 """ 252 resp = self._http.request("POST", f"/admin/tokens/{token_id}/revoke") 253 _raise_for_status(resp) 254 return AdminTokenRevokeResponse.model_validate(resp.json())
End a token this super-admin does not own — the revoke half of
trace_token().
Requires super-admin. Owner-revoke (DELETE
/auth/tokens/{id}) refuses a token whose owner is not the
caller; this is the operator door for a leaked pf_… — pass
the token_id that trace_token marked live. Especially
for a synthetic agent entrant, whose owner is an agent:…
account nobody can log in as, this is the only sanctioned path.
⚠️ Idempotent: re-revoking an already-revoked row returns
already_revoked=True at 200, not an error. A token_id
that resolves to nothing is a 404 (unlike trace_token's
200 miss — here the id came from a trace, so a miss is a typo).
256 def set_role(self, user_id: str, role: str) -> AdminUserInfo: 257 """Promote / demote between ``"user"`` and ``"admin"``. 258 259 Requires **super-admin**. ``super_admin`` role is env-anchored and 260 cannot be changed via API; trying returns 403. 261 """ 262 resp = self._http.request( 263 "PATCH", 264 f"/admin/users/{user_id}/role", 265 json={"role": role}, 266 ) 267 _raise_for_status(resp) 268 return AdminUserInfo.model_validate(resp.json())
Promote / demote between "user" and "admin".
Requires super-admin. super_admin role is env-anchored and
cannot be changed via API; trying returns 403.
270 def list_bans(self, *, active_only: bool = False) -> builtins.list[AccountBanInfo]: 271 """Platform-wide account bans, newest first (#2068). Lifted and 272 lapsed rows stay listed unless ``active_only``.""" 273 resp = self._http.request( 274 "GET", 275 "/admin/account-bans", 276 params={"active_only": "true" if active_only else "false"}, 277 ) 278 _raise_for_status(resp) 279 return [AccountBanInfo.model_validate(row) for row in resp.json()]
Platform-wide account bans, newest first (#2068). Lifted and
lapsed rows stay listed unless active_only.
281 def ban(self, user_id: str, *, reason: str, expires_at: str | None = None) -> AccountBanInfo: 282 """Ban an account from the whole platform: sign-in, every bearer and 283 every open event stream stop working. 284 285 ``reason`` (>= 10 characters) is shown to the account and is what 286 an emailed appeal is argued from; ``expires_at`` (an ISO instant 287 with a timezone) lets it lapse by itself. Idempotent. An ordinary 288 admin cannot ban another admin; nobody bans a super-admin or 289 themselves. 290 """ 291 resp = self._http.request( 292 "POST", 293 f"/admin/users/{user_id}/ban", 294 json={"reason": reason, "expires_at": expires_at}, 295 ) 296 _raise_for_status(resp) 297 return AccountBanInfo.model_validate(resp.json())
Ban an account from the whole platform: sign-in, every bearer and every open event stream stop working.
reason (>= 10 characters) is shown to the account and is what
an emailed appeal is argued from; expires_at (an ISO instant
with a timezone) lets it lapse by itself. Idempotent. An ordinary
admin cannot ban another admin; nobody bans a super-admin or
themselves.
299 def lift_ban(self, user_id: str) -> AccountBanInfo: 300 """Lift an account's ban early. Its bearers work again.""" 301 resp = self._http.request("DELETE", f"/admin/users/{user_id}/ban") 302 _raise_for_status(resp) 303 return AccountBanInfo.model_validate(resp.json())
Lift an account's ban early. Its bearers work again.
305 def sign_out( 306 self, user_id: str, *, reason: str, reset_password: bool = False 307 ) -> AdminSignOutResult: 308 """Sign an account out everywhere without banning it (#2773). 309 310 Every session and token is revoked and every open stream and shell 311 closed; the account works again the moment its owner signs in. 312 ``reset_password`` also removes the password (refused with 313 ``sign_out_would_lock_out`` when that leaves no way in). ``reason`` 314 (>= 10 characters) is mailed to the person. 315 """ 316 resp = self._http.request( 317 "POST", 318 f"/admin/users/{user_id}/sign-out", 319 json={"reason": reason, "reset_password": reset_password}, 320 ) 321 _raise_for_status(resp) 322 return AdminSignOutResult.model_validate(resp.json())
Sign an account out everywhere without banning it (#2773).
Every session and token is revoked and every open stream and shell
closed; the account works again the moment its owner signs in.
reset_password also removes the password (refused with
sign_out_would_lock_out when that leaves no way in). reason
(>= 10 characters) is mailed to the person.
324 def unlink_identity( 325 self, user_id: str, identity_id: str, *, reason: str, sign_out: bool = True 326 ) -> AdminSignInRemovalResult: 327 """Unlink one of an account's sign-in identities (#2777). 328 329 Refused with ``unlink_would_lock_out`` when it is the account's last 330 way in. ``reason`` (>= 10 characters) is mailed to the person; 331 ``sign_out`` also revokes every session and token. 332 """ 333 resp = self._http.request( 334 "POST", 335 f"/admin/users/{user_id}/identities/{identity_id}/unlink", 336 json={"reason": reason, "sign_out": sign_out}, 337 ) 338 _raise_for_status(resp) 339 return AdminSignInRemovalResult.model_validate(resp.json())
Unlink one of an account's sign-in identities (#2777).
Refused with unlink_would_lock_out when it is the account's last
way in. reason (>= 10 characters) is mailed to the person;
sign_out also revokes every session and token.
341 def remove_email( 342 self, user_id: str, email: str, *, reason: str, sign_out: bool = True 343 ) -> AdminSignInRemovalResult: 344 """Remove a non-primary address from an account (#2777). 345 346 Never the primary (``cannot_remove_primary``) and never the last way 347 in (``unlink_would_lock_out``). ``reason`` is mailed to the person. 348 """ 349 resp = self._http.request( 350 "POST", 351 f"/admin/users/{user_id}/emails/{quote(email, safe='')}/remove", 352 json={"reason": reason, "sign_out": sign_out}, 353 ) 354 _raise_for_status(resp) 355 return AdminSignInRemovalResult.model_validate(resp.json())
Remove a non-primary address from an account (#2777).
Never the primary (cannot_remove_primary) and never the last way
in (unlink_would_lock_out). reason is mailed to the person.
359 def notes(self, user_id: str) -> AdminUserNotes: 360 """Every administrator note on the account, oldest first.""" 361 resp = self._http.request("GET", f"/admin/users/{user_id}/notes") 362 _raise_for_status(resp) 363 return AdminUserNotes.model_validate(resp.json())
Every administrator note on the account, oldest first.
365 def add_note(self, user_id: str, body: str) -> AdminUserNote: 366 """Write a private note on the account. Never shown to it.""" 367 resp = self._http.request("POST", f"/admin/users/{user_id}/notes", json={"body": body}) 368 _raise_for_status(resp) 369 return AdminUserNote.model_validate(resp.json())
Write a private note on the account. Never shown to it.
371 def edit_note(self, user_id: str, note_id: str, body: str) -> AdminUserNote: 372 """Edit a note (its author only); the words it replaces are kept.""" 373 resp = self._http.request( 374 "PATCH", f"/admin/users/{user_id}/notes/{note_id}", json={"body": body} 375 ) 376 _raise_for_status(resp) 377 return AdminUserNote.model_validate(resp.json())
Edit a note (its author only); the words it replaces are kept.
379 def delete_note(self, user_id: str, note_id: str) -> None: 380 """Delete a note and its earlier wordings (its author or a super-admin).""" 381 resp = self._http.request("DELETE", f"/admin/users/{user_id}/notes/{note_id}") 382 _raise_for_status(resp)
Delete a note and its earlier wordings (its author or a super-admin).
384 def note_history(self, user_id: str, note_id: str) -> AdminUserNoteHistory: 385 """A note with every wording its edits replaced, oldest first.""" 386 resp = self._http.request("GET", f"/admin/users/{user_id}/notes/{note_id}/history") 387 _raise_for_status(resp) 388 return AdminUserNoteHistory.model_validate(resp.json())
A note with every wording its edits replaced, oldest first.
390 def reset_profile_field( 391 self, 392 user_id: str, 393 field: Literal["display_name", "avatar", "bio", "links"], 394 *, 395 reason: str, 396 lock_days: int = 0, 397 ) -> AdminProfileResetResult: 398 """Reset one field of an account's public profile, without a ban (#2775). 399 400 ``display_name`` becomes a neutral ``Player <id>``; ``avatar``, 401 ``bio`` and ``links`` are cleared. ``lock_days`` keeps the person 402 from setting it again for that long. ``reason`` (>= 10 characters) 403 is mailed to the person. 404 """ 405 resp = self._http.request( 406 "POST", 407 f"/admin/users/{user_id}/profile-reset", 408 json={"field": field, "reason": reason, "lock_days": lock_days}, 409 ) 410 _raise_for_status(resp) 411 return AdminProfileResetResult.model_validate(resp.json())
Reset one field of an account's public profile, without a ban (#2775).
display_name becomes a neutral Player <id>; avatar,
bio and links are cleared. lock_days keeps the person
from setting it again for that long. reason (>= 10 characters)
is mailed to the person.
413 def restrict( 414 self, 415 user_id: str, 416 kinds: builtins.list[RestrictionKind], 417 *, 418 reason: str, 419 days: int = 0, 420 ) -> builtins.list[AccountRestrictionInfo]: 421 """Take capabilities away from an account, short of a ban (#2774). 422 423 Each of ``kinds`` is stored as its own restriction with this reason 424 and expiry; ``days`` lapses them by the clock (``0`` = until lifted); 425 ``reason`` (>= 10 characters) is what the person reads at every 426 refusal and in the mail. Returns the restrictions now in force. 427 """ 428 resp = self._http.request( 429 "POST", 430 f"/admin/users/{user_id}/restrictions", 431 json={"kinds": list(kinds), "reason": reason, "days": days}, 432 ) 433 _raise_for_status(resp) 434 return [AccountRestrictionInfo.model_validate(r) for r in resp.json()]
Take capabilities away from an account, short of a ban (#2774).
Each of kinds is stored as its own restriction with this reason
and expiry; days lapses them by the clock (0 = until lifted);
reason (>= 10 characters) is what the person reads at every
refusal and in the mail. Returns the restrictions now in force.
436 def lift_restriction( 437 self, user_id: str, kind: RestrictionKind 438 ) -> builtins.list[AccountRestrictionInfo]: 439 """Lift one restriction before its clock does (#2774).""" 440 resp = self._http.request("DELETE", f"/admin/users/{user_id}/restrictions/{kind}") 441 _raise_for_status(resp) 442 return [AccountRestrictionInfo.model_validate(r) for r in resp.json()]
Lift one restriction before its clock does (#2774).