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 6 7from ctfy.sdk._helpers import _raise_for_status 8from ctfy.sdk.base import BaseHttpClient 9from ctfy.server.models import ( 10 AdminTokenRevokeResponse, 11 AdminUserInfo, 12 TokenTraceResponse, 13) 14 15 16class AdminUsersResource: 17 """Admin-or-better user listing, lookup and promotion.""" 18 19 def __init__(self, http: BaseHttpClient) -> None: 20 self._http = http 21 22 def list( 23 self, 24 offset: int = 0, 25 limit: int = 50, 26 *, 27 q: str = "", 28 role: str = "", 29 kind: str = "", 30 verified: str = "", 31 sort: str = "", 32 dir: str = "", 33 ) -> builtins.list[AdminUserInfo]: 34 """Users known to the platform, with role + promotion audit. 35 36 ``q`` (matches display name / email / id), ``role``, ``kind`` 37 (``human`` / ``agent`` / ``service``), ``verified`` 38 (``verified`` / ``unverified``), ``sort`` (``name`` / ``email`` / 39 ``created`` / ``last_active`` / ``solves`` / ``attempts``) and 40 ``dir`` (``asc`` / ``desc``) are applied **server-side**, before 41 the page is sliced. 42 43 ⚠️ ``kind`` and ``verified`` are separate axes from ``role``, not 44 refinements of it: every synthetic agent entrant is role 45 ``user``, and whether an address was proved says nothing about 46 privilege. A caller narrowing with ``role`` alone cannot reach 47 either question. 48 """ 49 params: dict[str, str | int] = {"offset": offset, "limit": limit} 50 params.update( 51 { 52 k: v 53 for k, v in ( 54 ("q", q), 55 ("role", role), 56 ("kind", kind), 57 ("verified", verified), 58 ("sort", sort), 59 ("dir", dir), 60 ) 61 if v 62 } 63 ) 64 resp = self._http.request("GET", "/admin/users", params=params) 65 _raise_for_status(resp) 66 return [AdminUserInfo.model_validate(u) for u in resp.json()["items"]] 67 68 def resend_verification(self, user_id: str) -> None: 69 """Mail this account's own address a fresh verification code. 70 71 ⚠️ **Writes nothing.** The account still has to receive the code 72 and exchange it, so a bounce leaves it exactly as unproved as it 73 was — which is why this is a separate call from 74 :meth:`verify_email` rather than a flag on it. 75 """ 76 resp = self._http.request("POST", f"/admin/users/{user_id}/resend-verification") 77 _raise_for_status(resp) 78 79 def verify_email(self, user_id: str) -> AdminUserInfo: 80 """Vouch for an address the deployment cannot reach at all. 81 82 ⚠️ **This writes a permanent claim in somebody else's name**, and 83 there is no call that undoes it: the stamp fills and never 84 overwrites. The row records which operator vouched, so a vouch 85 stays legible as a vouch rather than passing for evidence. 86 """ 87 resp = self._http.request("POST", f"/admin/users/{user_id}/verify-email") 88 _raise_for_status(resp) 89 return AdminUserInfo.model_validate(resp.json()) 90 91 def get(self, user_id: str) -> AdminUserInfo: 92 """Admin-or-better: single-user lookup with the audit fields 93 (role, promoted_by, promoted_at, current team summary). Skips 94 the list scan :meth:`list` would otherwise force.""" 95 resp = self._http.request("GET", f"/admin/users/{user_id}") 96 _raise_for_status(resp) 97 return AdminUserInfo.model_validate(resp.json()) 98 99 def set_standings_excluded(self, user_id: str, excluded: bool) -> AdminUserInfo: 100 """Take an account off the public standings, or put it back. 101 102 Requires **super-admin**. For the deployment's own staff and test 103 accounts, which otherwise sit on the leaderboard beside the 104 people the event is for. 105 106 ⚠️ **Not a disqualification.** The account keeps playing, keeps 107 its solves and still sees its own rank; what changes is whether 108 anybody else does. The route's own docstring makes the same 109 distinction — conflating the two would put a scarlet letter on 110 an organiser's test account. 111 112 ⚠️ Idempotent: setting it to the value it already holds returns 113 the row unchanged rather than re-stamping it. 114 """ 115 resp = self._http.request( 116 "PATCH", 117 f"/admin/users/{user_id}/standings", 118 json={"excluded": excluded}, 119 ) 120 _raise_for_status(resp) 121 return AdminUserInfo.model_validate(resp.json()) 122 123 def trace_token(self, token: str) -> TokenTraceResponse: 124 """Attribute a leaked bearer token to its owner and its lineage. 125 126 Requires **super-admin**. The sibling of 127 :meth:`RecordsResource.trace_answer`, on the other kind of 128 secret that ends up in a paste bin: ``team_tokens`` stores only 129 a SHA-256 hash, so an operator holding a ``pf_…`` from a log has 130 nothing to grep for and no way to learn whose it is. 131 132 ``chain`` is the whole **rotation lineage**, oldest first — 133 ``rotate`` mints a replacement row linked by ``rotated_from`` 134 rather than re-secreting the old one, so a secret found in a 135 six-month-old log resolves to a revoked row whose successor is 136 the credential still authenticating requests. ``live`` marks the 137 one worth revoking; a token that was never rotated yields a 138 chain of one. 139 140 ⚠️ A string this deployment never minted comes back 141 ``found=False`` at 200 rather than 404 — "not ours" is an answer 142 somebody asked for, and a 404 reads as a wrong URL. 143 144 ⚠️ Nothing derived from the secret comes back, not even its 145 hash: that digest is unsalted, so it *is* the stored verifier. 146 147 The token travels in the request body, never a query string, so 148 a live credential does not land in an access log on the way. 149 """ 150 resp = self._http.request("POST", "/admin/token-trace", json={"token": token}) 151 _raise_for_status(resp) 152 return TokenTraceResponse.model_validate(resp.json()) 153 154 def revoke_token(self, token_id: str) -> AdminTokenRevokeResponse: 155 """End a token this super-admin does not own — the revoke half of 156 :meth:`trace_token`. 157 158 Requires **super-admin**. Owner-revoke (``DELETE 159 /auth/tokens/{id}``) refuses a token whose owner is not the 160 caller; this is the operator door for a leaked ``pf_…`` — pass 161 the ``token_id`` that ``trace_token`` marked *live*. Especially 162 for a synthetic **agent** entrant, whose owner is an ``agent:…`` 163 account nobody can log in as, this is the only sanctioned path. 164 165 ⚠️ Idempotent: re-revoking an already-revoked row returns 166 ``already_revoked=True`` at 200, not an error. A ``token_id`` 167 that resolves to nothing is a 404 (unlike ``trace_token``'s 168 200 miss — here the id came from a trace, so a miss is a typo). 169 """ 170 resp = self._http.request("POST", f"/admin/tokens/{token_id}/revoke") 171 _raise_for_status(resp) 172 return AdminTokenRevokeResponse.model_validate(resp.json()) 173 174 def set_role(self, user_id: str, role: str) -> AdminUserInfo: 175 """Promote / demote between ``"user"`` and ``"admin"``. 176 177 Requires **super-admin**. ``super_admin`` role is env-anchored and 178 cannot be changed via API; trying returns 403. 179 """ 180 resp = self._http.request( 181 "PATCH", 182 f"/admin/users/{user_id}/role", 183 json={"role": role}, 184 ) 185 _raise_for_status(resp) 186 return AdminUserInfo.model_validate(resp.json())
17class AdminUsersResource: 18 """Admin-or-better user listing, lookup and promotion.""" 19 20 def __init__(self, http: BaseHttpClient) -> None: 21 self._http = http 22 23 def list( 24 self, 25 offset: int = 0, 26 limit: int = 50, 27 *, 28 q: str = "", 29 role: str = "", 30 kind: str = "", 31 verified: str = "", 32 sort: str = "", 33 dir: str = "", 34 ) -> builtins.list[AdminUserInfo]: 35 """Users known to the platform, with role + promotion audit. 36 37 ``q`` (matches display name / email / id), ``role``, ``kind`` 38 (``human`` / ``agent`` / ``service``), ``verified`` 39 (``verified`` / ``unverified``), ``sort`` (``name`` / ``email`` / 40 ``created`` / ``last_active`` / ``solves`` / ``attempts``) and 41 ``dir`` (``asc`` / ``desc``) are applied **server-side**, before 42 the page is sliced. 43 44 ⚠️ ``kind`` and ``verified`` are separate axes from ``role``, not 45 refinements of it: every synthetic agent entrant is role 46 ``user``, and whether an address was proved says nothing about 47 privilege. A caller narrowing with ``role`` alone cannot reach 48 either question. 49 """ 50 params: dict[str, str | int] = {"offset": offset, "limit": limit} 51 params.update( 52 { 53 k: v 54 for k, v in ( 55 ("q", q), 56 ("role", role), 57 ("kind", kind), 58 ("verified", verified), 59 ("sort", sort), 60 ("dir", dir), 61 ) 62 if v 63 } 64 ) 65 resp = self._http.request("GET", "/admin/users", params=params) 66 _raise_for_status(resp) 67 return [AdminUserInfo.model_validate(u) for u in resp.json()["items"]] 68 69 def resend_verification(self, user_id: str) -> None: 70 """Mail this account's own address a fresh verification code. 71 72 ⚠️ **Writes nothing.** The account still has to receive the code 73 and exchange it, so a bounce leaves it exactly as unproved as it 74 was — which is why this is a separate call from 75 :meth:`verify_email` rather than a flag on it. 76 """ 77 resp = self._http.request("POST", f"/admin/users/{user_id}/resend-verification") 78 _raise_for_status(resp) 79 80 def verify_email(self, user_id: str) -> AdminUserInfo: 81 """Vouch for an address the deployment cannot reach at all. 82 83 ⚠️ **This writes a permanent claim in somebody else's name**, and 84 there is no call that undoes it: the stamp fills and never 85 overwrites. The row records which operator vouched, so a vouch 86 stays legible as a vouch rather than passing for evidence. 87 """ 88 resp = self._http.request("POST", f"/admin/users/{user_id}/verify-email") 89 _raise_for_status(resp) 90 return AdminUserInfo.model_validate(resp.json()) 91 92 def get(self, user_id: str) -> AdminUserInfo: 93 """Admin-or-better: single-user lookup with the audit fields 94 (role, promoted_by, promoted_at, current team summary). Skips 95 the list scan :meth:`list` would otherwise force.""" 96 resp = self._http.request("GET", f"/admin/users/{user_id}") 97 _raise_for_status(resp) 98 return AdminUserInfo.model_validate(resp.json()) 99 100 def set_standings_excluded(self, user_id: str, excluded: bool) -> AdminUserInfo: 101 """Take an account off the public standings, or put it back. 102 103 Requires **super-admin**. For the deployment's own staff and test 104 accounts, which otherwise sit on the leaderboard beside the 105 people the event is for. 106 107 ⚠️ **Not a disqualification.** The account keeps playing, keeps 108 its solves and still sees its own rank; what changes is whether 109 anybody else does. The route's own docstring makes the same 110 distinction — conflating the two would put a scarlet letter on 111 an organiser's test account. 112 113 ⚠️ Idempotent: setting it to the value it already holds returns 114 the row unchanged rather than re-stamping it. 115 """ 116 resp = self._http.request( 117 "PATCH", 118 f"/admin/users/{user_id}/standings", 119 json={"excluded": excluded}, 120 ) 121 _raise_for_status(resp) 122 return AdminUserInfo.model_validate(resp.json()) 123 124 def trace_token(self, token: str) -> TokenTraceResponse: 125 """Attribute a leaked bearer token to its owner and its lineage. 126 127 Requires **super-admin**. The sibling of 128 :meth:`RecordsResource.trace_answer`, on the other kind of 129 secret that ends up in a paste bin: ``team_tokens`` stores only 130 a SHA-256 hash, so an operator holding a ``pf_…`` from a log has 131 nothing to grep for and no way to learn whose it is. 132 133 ``chain`` is the whole **rotation lineage**, oldest first — 134 ``rotate`` mints a replacement row linked by ``rotated_from`` 135 rather than re-secreting the old one, so a secret found in a 136 six-month-old log resolves to a revoked row whose successor is 137 the credential still authenticating requests. ``live`` marks the 138 one worth revoking; a token that was never rotated yields a 139 chain of one. 140 141 ⚠️ A string this deployment never minted comes back 142 ``found=False`` at 200 rather than 404 — "not ours" is an answer 143 somebody asked for, and a 404 reads as a wrong URL. 144 145 ⚠️ Nothing derived from the secret comes back, not even its 146 hash: that digest is unsalted, so it *is* the stored verifier. 147 148 The token travels in the request body, never a query string, so 149 a live credential does not land in an access log on the way. 150 """ 151 resp = self._http.request("POST", "/admin/token-trace", json={"token": token}) 152 _raise_for_status(resp) 153 return TokenTraceResponse.model_validate(resp.json()) 154 155 def revoke_token(self, token_id: str) -> AdminTokenRevokeResponse: 156 """End a token this super-admin does not own — the revoke half of 157 :meth:`trace_token`. 158 159 Requires **super-admin**. Owner-revoke (``DELETE 160 /auth/tokens/{id}``) refuses a token whose owner is not the 161 caller; this is the operator door for a leaked ``pf_…`` — pass 162 the ``token_id`` that ``trace_token`` marked *live*. Especially 163 for a synthetic **agent** entrant, whose owner is an ``agent:…`` 164 account nobody can log in as, this is the only sanctioned path. 165 166 ⚠️ Idempotent: re-revoking an already-revoked row returns 167 ``already_revoked=True`` at 200, not an error. A ``token_id`` 168 that resolves to nothing is a 404 (unlike ``trace_token``'s 169 200 miss — here the id came from a trace, so a miss is a typo). 170 """ 171 resp = self._http.request("POST", f"/admin/tokens/{token_id}/revoke") 172 _raise_for_status(resp) 173 return AdminTokenRevokeResponse.model_validate(resp.json()) 174 175 def set_role(self, user_id: str, role: str) -> AdminUserInfo: 176 """Promote / demote between ``"user"`` and ``"admin"``. 177 178 Requires **super-admin**. ``super_admin`` role is env-anchored and 179 cannot be changed via API; trying returns 403. 180 """ 181 resp = self._http.request( 182 "PATCH", 183 f"/admin/users/{user_id}/role", 184 json={"role": role}, 185 ) 186 _raise_for_status(resp) 187 return AdminUserInfo.model_validate(resp.json())
Admin-or-better user listing, lookup and promotion.
23 def list( 24 self, 25 offset: int = 0, 26 limit: int = 50, 27 *, 28 q: str = "", 29 role: str = "", 30 kind: str = "", 31 verified: str = "", 32 sort: str = "", 33 dir: str = "", 34 ) -> builtins.list[AdminUserInfo]: 35 """Users known to the platform, with role + promotion audit. 36 37 ``q`` (matches display name / email / id), ``role``, ``kind`` 38 (``human`` / ``agent`` / ``service``), ``verified`` 39 (``verified`` / ``unverified``), ``sort`` (``name`` / ``email`` / 40 ``created`` / ``last_active`` / ``solves`` / ``attempts``) and 41 ``dir`` (``asc`` / ``desc``) are applied **server-side**, before 42 the page is sliced. 43 44 ⚠️ ``kind`` and ``verified`` are separate axes from ``role``, not 45 refinements of it: every synthetic agent entrant is role 46 ``user``, and whether an address was proved says nothing about 47 privilege. A caller narrowing with ``role`` alone cannot reach 48 either question. 49 """ 50 params: dict[str, str | int] = {"offset": offset, "limit": limit} 51 params.update( 52 { 53 k: v 54 for k, v in ( 55 ("q", q), 56 ("role", role), 57 ("kind", kind), 58 ("verified", verified), 59 ("sort", sort), 60 ("dir", dir), 61 ) 62 if v 63 } 64 ) 65 resp = self._http.request("GET", "/admin/users", params=params) 66 _raise_for_status(resp) 67 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.
69 def resend_verification(self, user_id: str) -> None: 70 """Mail this account's own address a fresh verification code. 71 72 ⚠️ **Writes nothing.** The account still has to receive the code 73 and exchange it, so a bounce leaves it exactly as unproved as it 74 was — which is why this is a separate call from 75 :meth:`verify_email` rather than a flag on it. 76 """ 77 resp = self._http.request("POST", f"/admin/users/{user_id}/resend-verification") 78 _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.
80 def verify_email(self, user_id: str) -> AdminUserInfo: 81 """Vouch for an address the deployment cannot reach at all. 82 83 ⚠️ **This writes a permanent claim in somebody else's name**, and 84 there is no call that undoes it: the stamp fills and never 85 overwrites. The row records which operator vouched, so a vouch 86 stays legible as a vouch rather than passing for evidence. 87 """ 88 resp = self._http.request("POST", f"/admin/users/{user_id}/verify-email") 89 _raise_for_status(resp) 90 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.
92 def get(self, user_id: str) -> AdminUserInfo: 93 """Admin-or-better: single-user lookup with the audit fields 94 (role, promoted_by, promoted_at, current team summary). Skips 95 the list scan :meth:`list` would otherwise force.""" 96 resp = self._http.request("GET", f"/admin/users/{user_id}") 97 _raise_for_status(resp) 98 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.
100 def set_standings_excluded(self, user_id: str, excluded: bool) -> AdminUserInfo: 101 """Take an account off the public standings, or put it back. 102 103 Requires **super-admin**. For the deployment's own staff and test 104 accounts, which otherwise sit on the leaderboard beside the 105 people the event is for. 106 107 ⚠️ **Not a disqualification.** The account keeps playing, keeps 108 its solves and still sees its own rank; what changes is whether 109 anybody else does. The route's own docstring makes the same 110 distinction — conflating the two would put a scarlet letter on 111 an organiser's test account. 112 113 ⚠️ Idempotent: setting it to the value it already holds returns 114 the row unchanged rather than re-stamping it. 115 """ 116 resp = self._http.request( 117 "PATCH", 118 f"/admin/users/{user_id}/standings", 119 json={"excluded": excluded}, 120 ) 121 _raise_for_status(resp) 122 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.
124 def trace_token(self, token: str) -> TokenTraceResponse: 125 """Attribute a leaked bearer token to its owner and its lineage. 126 127 Requires **super-admin**. The sibling of 128 :meth:`RecordsResource.trace_answer`, on the other kind of 129 secret that ends up in a paste bin: ``team_tokens`` stores only 130 a SHA-256 hash, so an operator holding a ``pf_…`` from a log has 131 nothing to grep for and no way to learn whose it is. 132 133 ``chain`` is the whole **rotation lineage**, oldest first — 134 ``rotate`` mints a replacement row linked by ``rotated_from`` 135 rather than re-secreting the old one, so a secret found in a 136 six-month-old log resolves to a revoked row whose successor is 137 the credential still authenticating requests. ``live`` marks the 138 one worth revoking; a token that was never rotated yields a 139 chain of one. 140 141 ⚠️ A string this deployment never minted comes back 142 ``found=False`` at 200 rather than 404 — "not ours" is an answer 143 somebody asked for, and a 404 reads as a wrong URL. 144 145 ⚠️ Nothing derived from the secret comes back, not even its 146 hash: that digest is unsalted, so it *is* the stored verifier. 147 148 The token travels in the request body, never a query string, so 149 a live credential does not land in an access log on the way. 150 """ 151 resp = self._http.request("POST", "/admin/token-trace", json={"token": token}) 152 _raise_for_status(resp) 153 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.
155 def revoke_token(self, token_id: str) -> AdminTokenRevokeResponse: 156 """End a token this super-admin does not own — the revoke half of 157 :meth:`trace_token`. 158 159 Requires **super-admin**. Owner-revoke (``DELETE 160 /auth/tokens/{id}``) refuses a token whose owner is not the 161 caller; this is the operator door for a leaked ``pf_…`` — pass 162 the ``token_id`` that ``trace_token`` marked *live*. Especially 163 for a synthetic **agent** entrant, whose owner is an ``agent:…`` 164 account nobody can log in as, this is the only sanctioned path. 165 166 ⚠️ Idempotent: re-revoking an already-revoked row returns 167 ``already_revoked=True`` at 200, not an error. A ``token_id`` 168 that resolves to nothing is a 404 (unlike ``trace_token``'s 169 200 miss — here the id came from a trace, so a miss is a typo). 170 """ 171 resp = self._http.request("POST", f"/admin/tokens/{token_id}/revoke") 172 _raise_for_status(resp) 173 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).
175 def set_role(self, user_id: str, role: str) -> AdminUserInfo: 176 """Promote / demote between ``"user"`` and ``"admin"``. 177 178 Requires **super-admin**. ``super_admin`` role is env-anchored and 179 cannot be changed via API; trying returns 403. 180 """ 181 resp = self._http.request( 182 "PATCH", 183 f"/admin/users/{user_id}/role", 184 json={"role": role}, 185 ) 186 _raise_for_status(resp) 187 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.