ctfy.sdk.admin_resources.risk
client.admin.risk -- the risk-control queue (#2068).
Detection reports; a person decides. cases / case read the
organiser's queue; sightings is the one door to an account's sealed IP and
browser-fingerprint record and is platform administrators only.
Acting on a case is the existing, separately authorised verbs:
client.admin.teams (disqualify, ban, void solves) and
client.admin.users.ban (platform account ban).
1"""``client.admin.risk`` -- the risk-control queue (#2068). 2 3Detection **reports**; a person decides. ``cases`` / ``case`` read the 4organiser's queue; ``sightings`` is the one door to an account's sealed IP and 5browser-fingerprint record and is **platform administrators only**. 6 7Acting on a case is the existing, separately authorised verbs: 8``client.admin.teams`` (disqualify, ban, void solves) and 9``client.admin.users.ban`` (platform account ban). 10""" 11 12from __future__ import annotations 13 14from ctfy.sdk._helpers import _raise_for_status 15from ctfy.sdk.base import BaseHttpClient 16from ctfy.server.models import ( 17 RiskActionRow, 18 RiskAppealList, 19 RiskAppealView, 20 RiskCaseDetail, 21 RiskCaseList, 22 RiskSettings, 23 SightingsResponse, 24) 25 26 27class AdminRiskResource: 28 def __init__(self, http: BaseHttpClient) -> None: 29 self._http = http 30 31 def cases( 32 self, competition_id: str, *, status: str = "", offset: int = 0, limit: int = 50 33 ) -> RiskCaseList: 34 """The event's risk cases, strongest first, with the count of every 35 status. ``status`` is ``open`` / ``dismissed`` / ``actioned``.""" 36 params: dict[str, str | int] = {"offset": offset, "limit": limit} 37 if status: 38 params["status"] = status 39 resp = self._http.request( 40 "GET", f"/admin/competitions/{competition_id}/risk/cases", params=params 41 ) 42 _raise_for_status(resp) 43 return RiskCaseList.model_validate(resp.json()) 44 45 def case(self, competition_id: str, case_id: str) -> RiskCaseDetail: 46 """One case and every clue it rests on.""" 47 resp = self._http.request( 48 "GET", f"/admin/competitions/{competition_id}/risk/cases/{case_id}" 49 ) 50 _raise_for_status(resp) 51 return RiskCaseDetail.model_validate(resp.json()) 52 53 def sightings(self, user_id: str) -> SightingsResponse: 54 """An account's IPs and browser fingerprints, opened. **Platform 55 administrators only**; every read is an audit row. An IP is a lead, 56 never evidence.""" 57 resp = self._http.request("GET", f"/admin/users/{user_id}/sightings") 58 _raise_for_status(resp) 59 return SightingsResponse.model_validate(resp.json()) 60 61 def settings(self, competition_id: str) -> RiskSettings: 62 """Whether the risk-control scan looks at this event (default on).""" 63 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/risk/settings") 64 _raise_for_status(resp) 65 return RiskSettings.model_validate(resp.json()) 66 67 def set_enabled(self, competition_id: str, enabled: bool) -> RiskSettings: 68 """Switch detection on or off for one event. Off stops new clues and 69 cases; what was already found stays readable and actionable.""" 70 resp = self._http.request( 71 "PUT", 72 f"/admin/competitions/{competition_id}/risk/settings", 73 json={"enabled": enabled}, 74 ) 75 _raise_for_status(resp) 76 return RiskSettings.model_validate(resp.json()) 77 78 def dismiss(self, competition_id: str, case_id: str, *, note: str = "") -> RiskCaseDetail: 79 """Dismiss an open case: the organisers looked and there is nothing to 80 do. A dismissed case re-opens by itself only on genuinely new evidence.""" 81 resp = self._http.request( 82 "POST", 83 f"/admin/competitions/{competition_id}/risk/cases/{case_id}/dismiss", 84 json={"note": note}, 85 ) 86 _raise_for_status(resp) 87 return RiskCaseDetail.model_validate(resp.json()) 88 89 def reopen(self, competition_id: str, case_id: str) -> RiskCaseDetail: 90 """Put a dismissed or actioned case back in the queue.""" 91 resp = self._http.request( 92 "POST", f"/admin/competitions/{competition_id}/risk/cases/{case_id}/reopen" 93 ) 94 _raise_for_status(resp) 95 return RiskCaseDetail.model_validate(resp.json()) 96 97 def actions( 98 self, competition_id: str, *, team_id: str = "", case_id: str = "" 99 ) -> list[RiskActionRow]: 100 """What the organisers did about teams here, newest first.""" 101 params: dict[str, str] = {} 102 if team_id: 103 params["team_id"] = team_id 104 if case_id: 105 params["case_id"] = case_id 106 resp = self._http.request( 107 "GET", f"/admin/competitions/{competition_id}/risk/actions", params=params 108 ) 109 _raise_for_status(resp) 110 return [RiskActionRow.model_validate(row) for row in resp.json()] 111 112 def appeals(self, competition_id: str, *, status: str = "pending") -> RiskAppealList: 113 """The appeals queue: ``pending`` (an answer is owed), ``replied`` or 114 ``all``. The words are the team's, in plain text.""" 115 resp = self._http.request( 116 "GET", 117 f"/admin/competitions/{competition_id}/risk/appeals", 118 params={"status": status}, 119 ) 120 _raise_for_status(resp) 121 return RiskAppealList.model_validate(resp.json()) 122 123 def reply(self, competition_id: str, appeal_id: str, text: str) -> RiskAppealView: 124 """Reply to an appeal; kept with the action, in plain text.""" 125 resp = self._http.request( 126 "POST", 127 f"/admin/competitions/{competition_id}/risk/appeals/{appeal_id}/reply", 128 json={"text": text}, 129 ) 130 _raise_for_status(resp) 131 return RiskAppealView.model_validate(resp.json())
28class AdminRiskResource: 29 def __init__(self, http: BaseHttpClient) -> None: 30 self._http = http 31 32 def cases( 33 self, competition_id: str, *, status: str = "", offset: int = 0, limit: int = 50 34 ) -> RiskCaseList: 35 """The event's risk cases, strongest first, with the count of every 36 status. ``status`` is ``open`` / ``dismissed`` / ``actioned``.""" 37 params: dict[str, str | int] = {"offset": offset, "limit": limit} 38 if status: 39 params["status"] = status 40 resp = self._http.request( 41 "GET", f"/admin/competitions/{competition_id}/risk/cases", params=params 42 ) 43 _raise_for_status(resp) 44 return RiskCaseList.model_validate(resp.json()) 45 46 def case(self, competition_id: str, case_id: str) -> RiskCaseDetail: 47 """One case and every clue it rests on.""" 48 resp = self._http.request( 49 "GET", f"/admin/competitions/{competition_id}/risk/cases/{case_id}" 50 ) 51 _raise_for_status(resp) 52 return RiskCaseDetail.model_validate(resp.json()) 53 54 def sightings(self, user_id: str) -> SightingsResponse: 55 """An account's IPs and browser fingerprints, opened. **Platform 56 administrators only**; every read is an audit row. An IP is a lead, 57 never evidence.""" 58 resp = self._http.request("GET", f"/admin/users/{user_id}/sightings") 59 _raise_for_status(resp) 60 return SightingsResponse.model_validate(resp.json()) 61 62 def settings(self, competition_id: str) -> RiskSettings: 63 """Whether the risk-control scan looks at this event (default on).""" 64 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/risk/settings") 65 _raise_for_status(resp) 66 return RiskSettings.model_validate(resp.json()) 67 68 def set_enabled(self, competition_id: str, enabled: bool) -> RiskSettings: 69 """Switch detection on or off for one event. Off stops new clues and 70 cases; what was already found stays readable and actionable.""" 71 resp = self._http.request( 72 "PUT", 73 f"/admin/competitions/{competition_id}/risk/settings", 74 json={"enabled": enabled}, 75 ) 76 _raise_for_status(resp) 77 return RiskSettings.model_validate(resp.json()) 78 79 def dismiss(self, competition_id: str, case_id: str, *, note: str = "") -> RiskCaseDetail: 80 """Dismiss an open case: the organisers looked and there is nothing to 81 do. A dismissed case re-opens by itself only on genuinely new evidence.""" 82 resp = self._http.request( 83 "POST", 84 f"/admin/competitions/{competition_id}/risk/cases/{case_id}/dismiss", 85 json={"note": note}, 86 ) 87 _raise_for_status(resp) 88 return RiskCaseDetail.model_validate(resp.json()) 89 90 def reopen(self, competition_id: str, case_id: str) -> RiskCaseDetail: 91 """Put a dismissed or actioned case back in the queue.""" 92 resp = self._http.request( 93 "POST", f"/admin/competitions/{competition_id}/risk/cases/{case_id}/reopen" 94 ) 95 _raise_for_status(resp) 96 return RiskCaseDetail.model_validate(resp.json()) 97 98 def actions( 99 self, competition_id: str, *, team_id: str = "", case_id: str = "" 100 ) -> list[RiskActionRow]: 101 """What the organisers did about teams here, newest first.""" 102 params: dict[str, str] = {} 103 if team_id: 104 params["team_id"] = team_id 105 if case_id: 106 params["case_id"] = case_id 107 resp = self._http.request( 108 "GET", f"/admin/competitions/{competition_id}/risk/actions", params=params 109 ) 110 _raise_for_status(resp) 111 return [RiskActionRow.model_validate(row) for row in resp.json()] 112 113 def appeals(self, competition_id: str, *, status: str = "pending") -> RiskAppealList: 114 """The appeals queue: ``pending`` (an answer is owed), ``replied`` or 115 ``all``. The words are the team's, in plain text.""" 116 resp = self._http.request( 117 "GET", 118 f"/admin/competitions/{competition_id}/risk/appeals", 119 params={"status": status}, 120 ) 121 _raise_for_status(resp) 122 return RiskAppealList.model_validate(resp.json()) 123 124 def reply(self, competition_id: str, appeal_id: str, text: str) -> RiskAppealView: 125 """Reply to an appeal; kept with the action, in plain text.""" 126 resp = self._http.request( 127 "POST", 128 f"/admin/competitions/{competition_id}/risk/appeals/{appeal_id}/reply", 129 json={"text": text}, 130 ) 131 _raise_for_status(resp) 132 return RiskAppealView.model_validate(resp.json())
32 def cases( 33 self, competition_id: str, *, status: str = "", offset: int = 0, limit: int = 50 34 ) -> RiskCaseList: 35 """The event's risk cases, strongest first, with the count of every 36 status. ``status`` is ``open`` / ``dismissed`` / ``actioned``.""" 37 params: dict[str, str | int] = {"offset": offset, "limit": limit} 38 if status: 39 params["status"] = status 40 resp = self._http.request( 41 "GET", f"/admin/competitions/{competition_id}/risk/cases", params=params 42 ) 43 _raise_for_status(resp) 44 return RiskCaseList.model_validate(resp.json())
The event's risk cases, strongest first, with the count of every
status. status is open / dismissed / actioned.
46 def case(self, competition_id: str, case_id: str) -> RiskCaseDetail: 47 """One case and every clue it rests on.""" 48 resp = self._http.request( 49 "GET", f"/admin/competitions/{competition_id}/risk/cases/{case_id}" 50 ) 51 _raise_for_status(resp) 52 return RiskCaseDetail.model_validate(resp.json())
One case and every clue it rests on.
54 def sightings(self, user_id: str) -> SightingsResponse: 55 """An account's IPs and browser fingerprints, opened. **Platform 56 administrators only**; every read is an audit row. An IP is a lead, 57 never evidence.""" 58 resp = self._http.request("GET", f"/admin/users/{user_id}/sightings") 59 _raise_for_status(resp) 60 return SightingsResponse.model_validate(resp.json())
An account's IPs and browser fingerprints, opened. Platform administrators only; every read is an audit row. An IP is a lead, never evidence.
62 def settings(self, competition_id: str) -> RiskSettings: 63 """Whether the risk-control scan looks at this event (default on).""" 64 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/risk/settings") 65 _raise_for_status(resp) 66 return RiskSettings.model_validate(resp.json())
Whether the risk-control scan looks at this event (default on).
68 def set_enabled(self, competition_id: str, enabled: bool) -> RiskSettings: 69 """Switch detection on or off for one event. Off stops new clues and 70 cases; what was already found stays readable and actionable.""" 71 resp = self._http.request( 72 "PUT", 73 f"/admin/competitions/{competition_id}/risk/settings", 74 json={"enabled": enabled}, 75 ) 76 _raise_for_status(resp) 77 return RiskSettings.model_validate(resp.json())
Switch detection on or off for one event. Off stops new clues and cases; what was already found stays readable and actionable.
79 def dismiss(self, competition_id: str, case_id: str, *, note: str = "") -> RiskCaseDetail: 80 """Dismiss an open case: the organisers looked and there is nothing to 81 do. A dismissed case re-opens by itself only on genuinely new evidence.""" 82 resp = self._http.request( 83 "POST", 84 f"/admin/competitions/{competition_id}/risk/cases/{case_id}/dismiss", 85 json={"note": note}, 86 ) 87 _raise_for_status(resp) 88 return RiskCaseDetail.model_validate(resp.json())
Dismiss an open case: the organisers looked and there is nothing to do. A dismissed case re-opens by itself only on genuinely new evidence.
90 def reopen(self, competition_id: str, case_id: str) -> RiskCaseDetail: 91 """Put a dismissed or actioned case back in the queue.""" 92 resp = self._http.request( 93 "POST", f"/admin/competitions/{competition_id}/risk/cases/{case_id}/reopen" 94 ) 95 _raise_for_status(resp) 96 return RiskCaseDetail.model_validate(resp.json())
Put a dismissed or actioned case back in the queue.
98 def actions( 99 self, competition_id: str, *, team_id: str = "", case_id: str = "" 100 ) -> list[RiskActionRow]: 101 """What the organisers did about teams here, newest first.""" 102 params: dict[str, str] = {} 103 if team_id: 104 params["team_id"] = team_id 105 if case_id: 106 params["case_id"] = case_id 107 resp = self._http.request( 108 "GET", f"/admin/competitions/{competition_id}/risk/actions", params=params 109 ) 110 _raise_for_status(resp) 111 return [RiskActionRow.model_validate(row) for row in resp.json()]
What the organisers did about teams here, newest first.
113 def appeals(self, competition_id: str, *, status: str = "pending") -> RiskAppealList: 114 """The appeals queue: ``pending`` (an answer is owed), ``replied`` or 115 ``all``. The words are the team's, in plain text.""" 116 resp = self._http.request( 117 "GET", 118 f"/admin/competitions/{competition_id}/risk/appeals", 119 params={"status": status}, 120 ) 121 _raise_for_status(resp) 122 return RiskAppealList.model_validate(resp.json())
The appeals queue: pending (an answer is owed), replied or
all. The words are the team's, in plain text.
124 def reply(self, competition_id: str, appeal_id: str, text: str) -> RiskAppealView: 125 """Reply to an appeal; kept with the action, in plain text.""" 126 resp = self._http.request( 127 "POST", 128 f"/admin/competitions/{competition_id}/risk/appeals/{appeal_id}/reply", 129 json={"text": text}, 130 ) 131 _raise_for_status(resp) 132 return RiskAppealView.model_validate(resp.json())
Reply to an appeal; kept with the action, in plain text.