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()]
class AdminUsersResource:
 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.

AdminUsersResource(http: ctfy.sdk.base.BaseHttpClient)
37    def __init__(self, http: BaseHttpClient) -> None:
38        self._http = http
def list( self, offset: int = 0, limit: int = 50, *, q: str = '', role: str = '', kind: str = '', verified: str = '', institution: str = '', joined: str = '', domain: str = '', email_category: str = '', method: str = '', school: str = '', school_role: str = '', school_region: str = '', school_tier: str = '', intent: str = '', active: str = '', competitions: str = '', country: str = '', sort: str = '', dir: str = '') -> list[ctfy.server.models.AdminUserInfo]:
 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.

def stats( self, *, kind: Literal['human', 'agent', 'all'] = 'human', window: Literal['30d', '90d', '1y', 'all'] = 'all') -> ctfy.core.user_stats.AdminUserStats:
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.

def resend_verification(self, user_id: str) -> None:
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.

def verify_email(self, user_id: str) -> ctfy.server.models.AdminUserInfo:
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.

def get(self, user_id: str) -> ctfy.server.models.AdminUserInfo:
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.

def detail( self, user_id: str) -> ctfy.server.models.admin_user_detail.AdminUserDetail:
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.

def timeline(self, user_id: str) -> ctfy.core.user_timeline.UserTimeline:
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.

def related_accounts(self, user_id: str) -> ctfy.core.related_accounts.RelatedAccounts:
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())

Other accounts seen from one of this account's IPs or browsers: counts of shared values, never the values. Every read is an audit row (account_sightings_read). An IP is a lead, never evidence.

def set_standings_excluded( self, user_id: str, excluded: bool) -> ctfy.server.models.AdminUserInfo:
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.

def trace_token(self, token: str) -> ctfy.server.models.TokenTraceResponse:
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.

def revoke_token(self, token_id: str) -> ctfy.server.models.AdminTokenRevokeResponse:
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).

def set_role(self, user_id: str, role: str) -> ctfy.server.models.AdminUserInfo:
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.

def list_bans( self, *, active_only: bool = False) -> list[ctfy.server.models.AccountBanInfo]:
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.

def ban( self, user_id: str, *, reason: str, expires_at: str | None = None) -> ctfy.server.models.AccountBanInfo:
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.

def lift_ban(self, user_id: str) -> ctfy.server.models.AccountBanInfo:
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.

def sign_out( self, user_id: str, *, reason: str, reset_password: bool = False) -> ctfy.server.models.AdminSignOutResult:
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.

def remove_email( self, user_id: str, email: str, *, reason: str, sign_out: bool = True) -> ctfy.server.models.AdminSignInRemovalResult:
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.

def notes( self, user_id: str) -> ctfy.server.models.admin_user_detail.AdminUserNotes:
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.

def add_note( self, user_id: str, body: str) -> ctfy.server.models.admin_user_detail.AdminUserNote:
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.

def edit_note( self, user_id: str, note_id: str, body: str) -> ctfy.server.models.admin_user_detail.AdminUserNote:
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.

def delete_note(self, user_id: str, note_id: str) -> None:
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).

def note_history( self, user_id: str, note_id: str) -> ctfy.server.models.admin_user_detail.AdminUserNoteHistory:
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.

def reset_profile_field( self, user_id: str, field: Literal['display_name', 'avatar', 'bio', 'links'], *, reason: str, lock_days: int = 0) -> ctfy.server.models.AdminProfileResetResult:
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.

def restrict( self, user_id: str, kinds: list[typing.Literal['submit', 'hints', 'checker', 'defence', 'author', 'register', 'read_only']], *, reason: str, days: int = 0) -> list[ctfy.server.models.AccountRestrictionInfo]:
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.

def lift_restriction( self, user_id: str, kind: Literal['submit', 'hints', 'checker', 'defence', 'author', 'register', 'read_only']) -> list[ctfy.server.models.AccountRestrictionInfo]:
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).