simulo 0.26.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simulo/__init__.py +433 -0
- simulo/_client/__init__.py +6 -0
- simulo/_client/_entrypoint.py +313 -0
- simulo/_client/_mounts.py +25 -0
- simulo/_client/_runner.py +186 -0
- simulo/_client/_secure_downloads.py +1181 -0
- simulo/_client/app.py +1308 -0
- simulo/_client/asset.py +331 -0
- simulo/_client/asset_api.py +517 -0
- simulo/_client/asset_package.py +1103 -0
- simulo/_client/asset_pins.py +187 -0
- simulo/_client/builtin_aliases.py +107 -0
- simulo/_client/bundle.py +254 -0
- simulo/_client/cancel_api.py +104 -0
- simulo/_client/cli.py +9063 -0
- simulo/_client/config.py +186 -0
- simulo/_client/credentials.py +210 -0
- simulo/_client/discovery.py +214 -0
- simulo/_client/export_api.py +212 -0
- simulo/_client/export_bundle.py +296 -0
- simulo/_client/facades.py +581 -0
- simulo/_client/http.py +414 -0
- simulo/_client/identity_api.py +117 -0
- simulo/_client/install_samples.py +267 -0
- simulo/_client/jobs_api.py +224 -0
- simulo/_client/learning.py +393 -0
- simulo/_client/login.py +319 -0
- simulo/_client/mode.py +29 -0
- simulo/_client/outputs.py +116 -0
- simulo/_client/packaging.py +445 -0
- simulo/_client/preflight_api.py +186 -0
- simulo/_client/preflight_render.py +200 -0
- simulo/_client/registry.py +98 -0
- simulo/_client/runtime.py +185 -0
- simulo/_client/runtime_display.py +90 -0
- simulo/_client/seed_ref.py +76 -0
- simulo/_client/stub.py +41 -0
- simulo/_client/submit_api.py +1057 -0
- simulo/_client/templates/__init__.py +21 -0
- simulo/_client/templates/inference/app.py.tmpl +316 -0
- simulo/_client/templates/inference/simuloignore.tmpl +30 -0
- simulo/_client/templates/scenario/app.py.tmpl +93 -0
- simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
- simulo/_client/templates/training/app.py.tmpl +235 -0
- simulo/_client/templates/training/simuloignore.tmpl +29 -0
- simulo/_client/view_fragment.py +21 -0
- simulo/_client/view_session_api.py +122 -0
- simulo/_client/volume.py +71 -0
- simulo/callbacks.py +274 -0
- simulo/py.typed +0 -0
- simulo-0.26.0.dist-info/METADATA +130 -0
- simulo-0.26.0.dist-info/RECORD +55 -0
- simulo-0.26.0.dist-info/WHEEL +5 -0
- simulo-0.26.0.dist-info/entry_points.txt +2 -0
- simulo-0.26.0.dist-info/top_level.txt +1 -0
simulo/_client/http.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Shared HTTP request/auth logic for the cloud submit surface (stdlib ``urllib`` only).
|
|
2
|
+
|
|
3
|
+
Factored out of ``jobs_api.py`` so ``jobs_api.py``, ``submit_api.py``, and
|
|
4
|
+
``login.py`` share exactly one request path, one structured-error parser, and
|
|
5
|
+
one bearer-attachment policy — a security-sensitive decision that must not be
|
|
6
|
+
reimplemented three times.
|
|
7
|
+
|
|
8
|
+
**https-when-token guard.** A bearer token must never be sent to a plain-http
|
|
9
|
+
URL unless that URL's host is the local loopback (developing against
|
|
10
|
+
``simulo-backend serve`` on ``127.0.0.1`` is the one legitimate plaintext
|
|
11
|
+
case). Sending a token to a non-loopback ``http://`` URL is refused with a
|
|
12
|
+
clear error — it almost always means ``SIMULO_API_URL`` is misconfigured
|
|
13
|
+
(missing ``https://``), and silently sending the token anyway would leak it to
|
|
14
|
+
anyone on the network path.
|
|
15
|
+
|
|
16
|
+
**Foreign-host rule.** A caller resolving a presigned (or control-plane-served)
|
|
17
|
+
``download_url``/``upload_url`` from a JSON response body must attach the API
|
|
18
|
+
bearer only when that URL's host equals the API host — see
|
|
19
|
+
``simulo.interfaces.platform.submit`` module docstring ("Worker URL rule").
|
|
20
|
+
Presigned S3 URLs carry their own auth in their query string; sending the API
|
|
21
|
+
bearer to a third-party host is both unnecessary and a credential-leakage risk.
|
|
22
|
+
:func:`may_attach_bearer` implements this rule for any URL against a resolved
|
|
23
|
+
API base URL.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ipaddress
|
|
29
|
+
import json
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
from typing import Any, Callable, Optional
|
|
34
|
+
|
|
35
|
+
#: Every outbound call has an explicit timeout (NFR — no open-ended waits).
|
|
36
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
37
|
+
|
|
38
|
+
#: Loopback hostnames exempt from the https-when-token guard. ``localhost``
|
|
39
|
+
#: is not an IP literal so it needs an explicit exact-match entry;
|
|
40
|
+
#: ``127.0.0.1``/``::1`` are listed too for defense-in-depth clarity even
|
|
41
|
+
#: though :func:`is_loopback_host` also recognizes them (and the rest of
|
|
42
|
+
#: ``127.0.0.0/8``) via ``ipaddress.ip_address(...).is_loopback``.
|
|
43
|
+
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HttpError(RuntimeError):
|
|
47
|
+
"""Base error for HTTP communication problems against the Simulo platform."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HttpUnavailable(HttpError):
|
|
51
|
+
"""The endpoint cannot be reached at all (nothing listening / DNS failure)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HttpHTTPError(HttpError):
|
|
55
|
+
"""A structured non-2xx response, parsed from the standard error envelope.
|
|
56
|
+
|
|
57
|
+
``candidates`` (optional): some 409 responses carry a machine-readable
|
|
58
|
+
``candidates`` list as a SIBLING of the ``error`` envelope — the ids an
|
|
59
|
+
ambiguous resource-id prefix matched (``job_ref_ambiguous``,
|
|
60
|
+
explicit-run-intent wave; the server's human ``message`` lists them too,
|
|
61
|
+
so callers that only print ``message`` still show them). ``None`` when
|
|
62
|
+
the response carried no such field — the normal case.
|
|
63
|
+
|
|
64
|
+
``retry_after`` (optional): the parsed ``Retry-After`` header (seconds) that
|
|
65
|
+
the control plane sends on a 429 ``rate_limit_exceeded`` response (NFR:
|
|
66
|
+
"Never return 429 without ``Retry-After``"). ``None`` when the header was
|
|
67
|
+
absent or not an integer number of seconds. The CLI's observe loops sleep
|
|
68
|
+
this value (falling back to their own poll interval) and continue rather
|
|
69
|
+
than dying on a rate limit — see ``cli.py``'s 429 branches.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
status: int,
|
|
75
|
+
code: str,
|
|
76
|
+
message: str,
|
|
77
|
+
*,
|
|
78
|
+
candidates: Optional[list[str]] = None,
|
|
79
|
+
retry_after: Optional[int] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
super().__init__(f"{code}: {message}")
|
|
82
|
+
self.status = status
|
|
83
|
+
self.code = code
|
|
84
|
+
self.message = message
|
|
85
|
+
self.candidates = candidates
|
|
86
|
+
self.retry_after = retry_after
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InsecureBearerError(HttpError):
|
|
90
|
+
"""Refused to attach a bearer token to a plain-http, non-loopback URL."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_loopback_host(hostname: Optional[str]) -> bool:
|
|
94
|
+
"""``True`` for ``localhost`` / ``::1`` / any genuine loopback IP literal — the
|
|
95
|
+
local dev loopback.
|
|
96
|
+
|
|
97
|
+
Public (not ``_``-prefixed): shared by the bearer-attachment guard below
|
|
98
|
+
AND by any caller that needs the same "is this the local dev stand-in"
|
|
99
|
+
judgment for a DIFFERENT scheme pair (e.g. ``simulo view``'s https-or-
|
|
100
|
+
loopback-http / wss-or-loopback-ws checks on a server-supplied URL before
|
|
101
|
+
handing it to ``webbrowser.open`` — see ``cli._validate_mint_response_urls``).
|
|
102
|
+
This is also the function the console's ``isLoopbackHost`` port
|
|
103
|
+
(``simulo-ui/apps/simulo-console/src/api/jobs.ts``) names as its
|
|
104
|
+
"authority" — any behavioral drift there is a bug in the console, not a
|
|
105
|
+
reinterpretation, so the rule stated here is the canonical one.
|
|
106
|
+
|
|
107
|
+
**The rule.** ``hostname`` is loopback when it is EXACTLY ``localhost``
|
|
108
|
+
or ``::1`` (a case-SENSITIVE exact-string match against this function's
|
|
109
|
+
own input — ``localhost.evil.com`` and the trailing-dot ``localhost.``
|
|
110
|
+
are NOT loopback regardless of case. This does NOT mean an uppercase
|
|
111
|
+
URL is rejected end to end: BOTH real call sites pass this function
|
|
112
|
+
``urlsplit(url).hostname``, which ``urlsplit`` already lowercases, so
|
|
113
|
+
``http://LOCALHOST/`` — and ``http://LOCALHOST/`` piped through either
|
|
114
|
+
``require_https_or_loopback_for_bearer`` or
|
|
115
|
+
``cli._validate_mint_response_urls`` — is accepted; only a DIRECT call
|
|
116
|
+
to this function with a raw, still-uppercase string like
|
|
117
|
+
``is_loopback_host("LOCALHOST")`` sees the case sensitivity), OR when
|
|
118
|
+
it parses as a
|
|
119
|
+
genuine IPv4/IPv6 literal whose address is loopback — i.e. the full
|
|
120
|
+
``127.0.0.0/8`` range (``127.0.0.1``, ``127.5.5.5``,
|
|
121
|
+
``127.255.255.255``, ...), not just ``127.0.0.1`` itself, plus the
|
|
122
|
+
IPv4-mapped IPv6 alias ``::ffff:127.0.0.1`` (a genuine loopback address
|
|
123
|
+
per RFC 4291, not an attacker-controllable string) — but ONLY the
|
|
124
|
+
IPv4-MAPPED form (``::ffff:a.b.c.d``, ``IPv6Address.ipv4_mapped`` set).
|
|
125
|
+
The deprecated IPv4-COMPATIBLE form (``::127.0.0.1``), a 6to4 address
|
|
126
|
+
embedding a loopback octet (``2002:7f00:1::``), and a NAT64
|
|
127
|
+
well-known-prefix address embedding one (``64:ff9b::127.0.0.1``) are
|
|
128
|
+
all REJECTED — each parses to a distinct IPv6 literal with
|
|
129
|
+
``ipv4_mapped is None`` (verified directly), so they fall to
|
|
130
|
+
``addr.is_loopback`` on the address AS WRITTEN, which is ``False`` for
|
|
131
|
+
all three; they are not silently folded into the mapped case. A
|
|
132
|
+
hostname is deliberately NOT loopback merely for STARTING WITH the
|
|
133
|
+
string
|
|
134
|
+
``"127."`` — ``127.evil.com``, ``127.0.0.1.evil.com``, and
|
|
135
|
+
``127.0.0.1.attacker.tld`` are ordinary attacker-registrable public
|
|
136
|
+
domains and are rejected — a naive ``hostname.startswith("127.")``
|
|
137
|
+
check would have wrongly accepted all three, which is exactly the
|
|
138
|
+
trap this function exists to avoid. The trailing-dot form
|
|
139
|
+
``127.0.0.1.`` is ALSO now rejected (``ipaddress.ip_address`` refuses
|
|
140
|
+
it) — a narrowing versus the old prefix check, which used to accept
|
|
141
|
+
it; fail-closed, so not a security regression, but worth stating since
|
|
142
|
+
it is a behavior change. An IPv6 zone id (``::1%eth0``,
|
|
143
|
+
``::1%25eth0``) is still accepted — ``ipaddress`` parses the zone and
|
|
144
|
+
the address portion is still exactly ``::1``; security-neutral (a zone
|
|
145
|
+
id narrows which local interface a scope-local address binds to, it
|
|
146
|
+
cannot be used to reach a remote host), documented here so it reads as
|
|
147
|
+
a deliberate decision rather than an oversight.
|
|
148
|
+
|
|
149
|
+
**Why this does not just call ``ipaddress.IPv6Address.is_loopback``
|
|
150
|
+
directly.** That property only special-cases an IPv4-mapped address
|
|
151
|
+
(delegating to the mapped ``IPv4Address``'s own ``is_loopback``) on
|
|
152
|
+
Python >= 3.11.9 / >= 3.12.4 (the CVE-2024-4032 backport) — on any
|
|
153
|
+
OLDER patch release within this package's own ``>=3.11`` floor (e.g.
|
|
154
|
+
3.11.0-3.11.8, 3.12.0-3.12.3 — verified directly against a 3.12.3
|
|
155
|
+
interpreter), it falls back to a bare ``self._ip == 1`` check and
|
|
156
|
+
returns ``False`` for ``::ffff:127.0.0.1``. Relying on that delegation
|
|
157
|
+
would make this function's behavior depend on which patch release
|
|
158
|
+
happens to be installed — the exact same ``127.0.0.1`` URL would be
|
|
159
|
+
accepted on one machine and refused on another. This function reads
|
|
160
|
+
``IPv6Address.ipv4_mapped`` (a plain attribute, unaffected by that CVE
|
|
161
|
+
fix, correct on every supported release) itself and checks
|
|
162
|
+
``is_loopback`` on the UNWRAPPED address, so the result is identical
|
|
163
|
+
on every Python version this package supports.
|
|
164
|
+
|
|
165
|
+
**Deliberately REJECTED, not accidentally:** alternate/shorthand IPv4
|
|
166
|
+
encodings — ``127.1`` (BSD ``inet_aton`` shorthand), ``2130706433``
|
|
167
|
+
(decimal 32-bit form), ``0x7f000001`` (hex form), and ``0177.0.0.1``
|
|
168
|
+
(leading-octal first octet) — all raise ``ValueError`` out of
|
|
169
|
+
``ipaddress.ip_address`` and so are treated as NOT loopback here,
|
|
170
|
+
fail-closed. (The console's JS port's OWN ``isLoopbackHost`` function
|
|
171
|
+
rejects these same four strings too — ``isLoopbackHost('127.1')`` is
|
|
172
|
+
``False`` there as well. What differs is the console's URL-LEVEL
|
|
173
|
+
check: the browser's ``URL()`` parser canonicalizes a ``viewerUrl``/
|
|
174
|
+
``gatewayUrl`` string like ``http://127.1/session`` to
|
|
175
|
+
``http://127.0.0.1/session`` *before* ``isLoopbackHost`` ever sees the
|
|
176
|
+
hostname, so the console's end-to-end URL validation accepts those
|
|
177
|
+
URLs even though its own ``isLoopbackHost`` would reject the bare
|
|
178
|
+
string ``"127.1"``. Python's ``urllib.parse.urlsplit()`` performs no
|
|
179
|
+
such canonicalization — the hostname reaching THIS function is still
|
|
180
|
+
the literal, unresolved string — so accepting these here would mean
|
|
181
|
+
reimplementing that canonicalization ourselves for a dev-only
|
|
182
|
+
affordance that real dev servers never report their bind address as.)
|
|
183
|
+
Out-of-range octets (``127.256.0.1``) and ambiguous leading-zero
|
|
184
|
+
octets (``127.001.0.1`` — the same octal-vs-decimal ambiguity
|
|
185
|
+
CVE-2021-29921 hardened ``ipaddress`` against) are rejected the same
|
|
186
|
+
way.
|
|
187
|
+
|
|
188
|
+
A THIRD, independent copy of this "is this the local dev loopback"
|
|
189
|
+
judgment lives in ``simulo-backend``'s parked CLI —
|
|
190
|
+
``sdk/src/simulo/cli/api/client.py``'s module-level ``_LOOPBACK_HOSTS``
|
|
191
|
+
frozenset, tested inline inside ``SimuloClient._assert_token_transport_safe``
|
|
192
|
+
(no separate function). Not vulnerable to the bug this function's
|
|
193
|
+
docstring describes above (it was always exact-set membership, no
|
|
194
|
+
prefix test), but it does not recognize the full ``127.0.0.0/8``
|
|
195
|
+
range or IPv4-mapped IPv6 the way this function now does, so it is
|
|
196
|
+
STRICTER, not just independently correct. Not unified with this
|
|
197
|
+
module here (`simulo-backend` is a separate, GPU-only package that
|
|
198
|
+
does not depend on the ``simulo`` thin client) — flagged so a future
|
|
199
|
+
reader of either copy knows the other exists.
|
|
200
|
+
"""
|
|
201
|
+
if hostname is None or not isinstance(hostname, str):
|
|
202
|
+
return False
|
|
203
|
+
if hostname in _LOOPBACK_HOSTS:
|
|
204
|
+
return True
|
|
205
|
+
try:
|
|
206
|
+
addr = ipaddress.ip_address(hostname)
|
|
207
|
+
except ValueError:
|
|
208
|
+
return False
|
|
209
|
+
# Read `ipv4_mapped` ourselves rather than trusting `addr.is_loopback`
|
|
210
|
+
# to do it — see the docstring's CVE-2024-4032 note above.
|
|
211
|
+
mapped = getattr(addr, "ipv4_mapped", None)
|
|
212
|
+
return (mapped or addr).is_loopback
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def require_https_or_loopback_for_bearer(url: str) -> None:
|
|
216
|
+
"""Raise :class:`InsecureBearerError` if *url* would carry a bearer over plaintext.
|
|
217
|
+
|
|
218
|
+
Call this immediately before attaching an ``Authorization: Bearer`` header to
|
|
219
|
+
any request. ``https://`` is always fine; ``http://`` is fine only to the
|
|
220
|
+
local loopback (127.0.0.1 / localhost / ::1) — the local dev stand-in.
|
|
221
|
+
"""
|
|
222
|
+
parsed = urllib.parse.urlsplit(url)
|
|
223
|
+
if parsed.scheme == "https":
|
|
224
|
+
return
|
|
225
|
+
if parsed.scheme == "http" and is_loopback_host(parsed.hostname):
|
|
226
|
+
return
|
|
227
|
+
raise InsecureBearerError(
|
|
228
|
+
f"Refusing to send an Authorization bearer token to {url!r}: only https:// URLs "
|
|
229
|
+
"(or plain http:// to the local loopback) may carry a bearer token. Check "
|
|
230
|
+
"SIMULO_API_URL / SIMULO_ENV for a missing 'https://'."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def may_attach_bearer(url: str, *, api_base_url: str) -> bool:
|
|
235
|
+
"""``True`` when *url*'s host matches *api_base_url*'s host.
|
|
236
|
+
|
|
237
|
+
Implements the "worker URL rule" from ``simulo.interfaces.platform.submit``
|
|
238
|
+
for client-side downloads too: a presigned/foreign-host URL returned inside
|
|
239
|
+
a JSON response (e.g. a recording's ``download_url``) must never receive the
|
|
240
|
+
API bearer — only requests to the API host itself may carry it.
|
|
241
|
+
"""
|
|
242
|
+
return urllib.parse.urlsplit(url).netloc == urllib.parse.urlsplit(api_base_url).netloc
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
#: Ceiling on a parsed ``Retry-After`` (seconds) — mirrors the control plane's
|
|
246
|
+
#: own rate-limit windows (all well under an hour). Security clamp (LOW,
|
|
247
|
+
#: PR #398 review): without this, an untrusted/misbehaving server value flows
|
|
248
|
+
#: straight into ``time.sleep`` in the CLI's observe loops, which has TWO
|
|
249
|
+
#: failure modes — an indefinite-looking wedge on a huge-but-sleepable value,
|
|
250
|
+
#: and an *uncaught* ``OverflowError`` (``time.sleep`` rejects a duration that
|
|
251
|
+
#: doesn't fit its underlying timer) on a value beyond roughly 1e9 seconds,
|
|
252
|
+
#: crashing the CLI with a traceback instead of backing off. Clamping here —
|
|
253
|
+
#: the single parse site — closes both: every caller only ever sees a value
|
|
254
|
+
#: in ``[0, _RETRY_AFTER_MAX_S]``.
|
|
255
|
+
_RETRY_AFTER_MAX_S = 3600
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_retry_after(headers: Any) -> Optional[int]:
|
|
259
|
+
"""Parse a ``Retry-After`` response header (seconds) tolerantly, clamped.
|
|
260
|
+
|
|
261
|
+
``headers`` is the error response's ``http.client.HTTPMessage`` (a
|
|
262
|
+
case-insensitive ``email.message.Message``); ``.get`` returns ``None`` when
|
|
263
|
+
the header is absent. The control plane always sends ``Retry-After`` as an
|
|
264
|
+
integer number of seconds on a 429. Per RFC 7231 the header MAY instead be
|
|
265
|
+
an HTTP-date — the platform never emits that form, so a non-integer (or
|
|
266
|
+
negative) value simply yields ``None`` and the caller falls back to its own
|
|
267
|
+
poll interval rather than failing the whole error parse. A value larger
|
|
268
|
+
than ``_RETRY_AFTER_MAX_S`` is clamped down to it, never rejected to
|
|
269
|
+
``None`` — a large-but-honest backoff request should still be honored
|
|
270
|
+
(bounded), not silently downgraded to the caller's (likely much shorter)
|
|
271
|
+
default poll interval.
|
|
272
|
+
"""
|
|
273
|
+
if headers is None:
|
|
274
|
+
return None
|
|
275
|
+
raw = headers.get("Retry-After")
|
|
276
|
+
if raw is None:
|
|
277
|
+
return None
|
|
278
|
+
try:
|
|
279
|
+
seconds = int(str(raw).strip())
|
|
280
|
+
except (TypeError, ValueError):
|
|
281
|
+
return None
|
|
282
|
+
if seconds < 0:
|
|
283
|
+
return None
|
|
284
|
+
return min(seconds, _RETRY_AFTER_MAX_S)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _structured_http_error(exc: urllib.error.HTTPError) -> HttpHTTPError:
|
|
288
|
+
"""Parse the standard ``{"error": {code, message, ...}}`` envelope, tolerantly."""
|
|
289
|
+
code, message = "http_error", f"HTTP {exc.code}"
|
|
290
|
+
candidates: Optional[list[str]] = None
|
|
291
|
+
# Read the `Retry-After` header (429 rate-limit backoff) BEFORE consuming
|
|
292
|
+
# the body — headers are always present even when the body is empty/non-JSON.
|
|
293
|
+
retry_after = _parse_retry_after(getattr(exc, "headers", None))
|
|
294
|
+
try:
|
|
295
|
+
body = json.loads(exc.read().decode("utf-8"))
|
|
296
|
+
if isinstance(body, dict) and isinstance(body.get("error"), dict):
|
|
297
|
+
error = body["error"]
|
|
298
|
+
code = str(error.get("code", code))
|
|
299
|
+
message = str(error.get("message", message))
|
|
300
|
+
# `candidates` rides as a SIBLING of the error envelope (see
|
|
301
|
+
# HttpHTTPError docstring) — parsed just as tolerantly: anything
|
|
302
|
+
# that isn't a list stays None rather than failing the parse.
|
|
303
|
+
raw_candidates = body.get("candidates")
|
|
304
|
+
if isinstance(raw_candidates, list):
|
|
305
|
+
candidates = [str(candidate) for candidate in raw_candidates]
|
|
306
|
+
except (ValueError, OSError):
|
|
307
|
+
pass # non-JSON error body — keep the generic code/message
|
|
308
|
+
return HttpHTTPError(exc.code, code, message, candidates=candidates, retry_after=retry_after)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _redact_query(url: str) -> str:
|
|
312
|
+
"""Strip *url*'s query string (security LOW, PR-7 trio review).
|
|
313
|
+
|
|
314
|
+
A presigned S3 PUT/GET URL's query string carries the SigV4 signature
|
|
315
|
+
and credential scope — printing it whole into an error message (which
|
|
316
|
+
can land in a terminal, a CI log, or a bug report) would leak a live,
|
|
317
|
+
unexpired credential for the duration of the presign's TTL. The host and
|
|
318
|
+
path stay (useful for diagnosis: which endpoint failed); only the query
|
|
319
|
+
is dropped. Applied narrowly at the one error-text call site
|
|
320
|
+
(:func:`_unavailable_message`) — additive, no behavior change for a
|
|
321
|
+
caller that never sees an unreachable-host error.
|
|
322
|
+
"""
|
|
323
|
+
parsed = urllib.parse.urlsplit(url)
|
|
324
|
+
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", parsed.fragment))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _unavailable_message(url: str, unavailable_hint: str, reason: object) -> str:
|
|
328
|
+
hint = f"\n{unavailable_hint}" if unavailable_hint else ""
|
|
329
|
+
return f"Cannot reach the Simulo platform at {_redact_query(url)} ({reason}).{hint}"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def request_bytes(
|
|
333
|
+
method: str,
|
|
334
|
+
url: str,
|
|
335
|
+
*,
|
|
336
|
+
token: Optional[str] = None,
|
|
337
|
+
api_base_url: Optional[str] = None,
|
|
338
|
+
body: Optional[bytes] = None,
|
|
339
|
+
content_type: Optional[str] = None,
|
|
340
|
+
extra_headers: Optional[dict[str, str]] = None,
|
|
341
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
342
|
+
unavailable_hint: str = "",
|
|
343
|
+
on_response_headers: Optional[Callable[[Any], None]] = None,
|
|
344
|
+
) -> bytes:
|
|
345
|
+
"""Issue one HTTP request; return the raw response body bytes.
|
|
346
|
+
|
|
347
|
+
When *token* is given, it is attached as ``Authorization: Bearer`` UNLESS
|
|
348
|
+
*api_base_url* is given and *url* resolves to a different host (the
|
|
349
|
+
foreign-host rule) — in which case the header is silently omitted (the
|
|
350
|
+
normal, expected case for a presigned download URL). When the header WOULD
|
|
351
|
+
be attached, :func:`require_https_or_loopback_for_bearer` is enforced first.
|
|
352
|
+
|
|
353
|
+
``on_response_headers`` (optional) is called with the SUCCESS response's
|
|
354
|
+
header mapping (``http.client.HTTPMessage``) before the body is read —
|
|
355
|
+
the additive seam the asset multipart-upload client uses to capture a
|
|
356
|
+
presigned part PUT's ``ETag`` header (S3's convention) without a second
|
|
357
|
+
request path. Never called on an error response.
|
|
358
|
+
"""
|
|
359
|
+
headers: dict[str, str] = dict(extra_headers or {})
|
|
360
|
+
if token is not None and (api_base_url is None or may_attach_bearer(url, api_base_url=api_base_url)):
|
|
361
|
+
require_https_or_loopback_for_bearer(url)
|
|
362
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
363
|
+
if content_type is not None:
|
|
364
|
+
headers["Content-Type"] = content_type
|
|
365
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
366
|
+
try:
|
|
367
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
368
|
+
if on_response_headers is not None:
|
|
369
|
+
on_response_headers(response.headers)
|
|
370
|
+
result: bytes = response.read()
|
|
371
|
+
return result
|
|
372
|
+
except urllib.error.HTTPError as exc:
|
|
373
|
+
raise _structured_http_error(exc) from exc
|
|
374
|
+
except urllib.error.URLError as exc:
|
|
375
|
+
raise HttpUnavailable(_unavailable_message(url, unavailable_hint, exc.reason)) from exc
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def request_json(
|
|
379
|
+
method: str,
|
|
380
|
+
url: str,
|
|
381
|
+
*,
|
|
382
|
+
token: Optional[str] = None,
|
|
383
|
+
api_base_url: Optional[str] = None,
|
|
384
|
+
json_body: Optional[Any] = None,
|
|
385
|
+
extra_headers: Optional[dict[str, str]] = None,
|
|
386
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
387
|
+
unavailable_hint: str = "",
|
|
388
|
+
) -> Any:
|
|
389
|
+
"""Issue one JSON-in/JSON-out request; return the parsed response body."""
|
|
390
|
+
headers = {"Accept": "application/json", **(extra_headers or {})}
|
|
391
|
+
body: Optional[bytes] = None
|
|
392
|
+
content_type: Optional[str] = None
|
|
393
|
+
if json_body is not None:
|
|
394
|
+
body = json.dumps(json_body).encode("utf-8")
|
|
395
|
+
content_type = "application/json"
|
|
396
|
+
raw = request_bytes(
|
|
397
|
+
method,
|
|
398
|
+
url,
|
|
399
|
+
token=token,
|
|
400
|
+
api_base_url=api_base_url,
|
|
401
|
+
body=body,
|
|
402
|
+
content_type=content_type,
|
|
403
|
+
extra_headers=headers,
|
|
404
|
+
timeout=timeout,
|
|
405
|
+
unavailable_hint=unavailable_hint,
|
|
406
|
+
)
|
|
407
|
+
if not raw:
|
|
408
|
+
return None
|
|
409
|
+
return json.loads(raw.decode("utf-8"))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def quote_path_segment(segment: str) -> str:
|
|
413
|
+
"""Percent-encode one URL path segment (rejects ``/`` so traversal cannot survive)."""
|
|
414
|
+
return urllib.parse.quote(segment, safe="")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Authenticated identity lookup for the thin CLI.
|
|
2
|
+
|
|
3
|
+
Organization names and slugs are live server-owned profile data. They do not
|
|
4
|
+
belong in the credential file, whose UUID fields remain the authorization
|
|
5
|
+
contract. This client therefore reads ``GET /users/me`` through the shared
|
|
6
|
+
stdlib HTTP boundary without persisting any response field.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from simulo._client import http
|
|
16
|
+
|
|
17
|
+
_REQUEST_TIMEOUT_S = 10.0
|
|
18
|
+
_UNAVAILABLE_HINT = "Cannot load your current identity — check your network connection and try again."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdentityApiError(http.HttpError):
|
|
22
|
+
"""A malformed or unavailable current-user response."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _require_canonical_uuid(value: Any, *, context: str) -> str:
|
|
26
|
+
if not isinstance(value, str):
|
|
27
|
+
raise IdentityApiError(f"Malformed {context} (organization ID is not a string).")
|
|
28
|
+
try:
|
|
29
|
+
canonical = str(UUID(value))
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise IdentityApiError(f"Malformed {context} (organization ID is not a UUID).") from exc
|
|
32
|
+
if canonical != value:
|
|
33
|
+
raise IdentityApiError(f"Malformed {context} (organization ID is not canonical).")
|
|
34
|
+
return canonical
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IdentityApiClient:
|
|
38
|
+
"""Client for ``GET /users/me``."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, base_url: str, *, token: Optional[str] = None) -> None:
|
|
41
|
+
if not base_url.startswith(("http://", "https://")):
|
|
42
|
+
raise IdentityApiError(f"API base URL must be an http(s) URL, got {base_url!r}.")
|
|
43
|
+
self._base_url = base_url.rstrip("/")
|
|
44
|
+
self._token = token
|
|
45
|
+
|
|
46
|
+
def get_me(self) -> dict[str, Any]:
|
|
47
|
+
try:
|
|
48
|
+
payload = http.request_json(
|
|
49
|
+
"GET",
|
|
50
|
+
self._base_url + "/users/me",
|
|
51
|
+
token=self._token,
|
|
52
|
+
api_base_url=self._base_url,
|
|
53
|
+
timeout=_REQUEST_TIMEOUT_S,
|
|
54
|
+
unavailable_hint=_UNAVAILABLE_HINT,
|
|
55
|
+
)
|
|
56
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
57
|
+
raise IdentityApiError("Malformed current-user response (expected UTF-8 JSON).") from exc
|
|
58
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("email"), str):
|
|
59
|
+
raise IdentityApiError("Malformed current-user response (missing email).")
|
|
60
|
+
active = payload.get("active_organization")
|
|
61
|
+
if active is not None:
|
|
62
|
+
if not isinstance(active, dict):
|
|
63
|
+
raise IdentityApiError("Malformed current-user response (active organization is not an object).")
|
|
64
|
+
missing = [
|
|
65
|
+
field
|
|
66
|
+
for field in ("organization_id", "name", "slug")
|
|
67
|
+
if not isinstance(active.get(field), str) or not active[field]
|
|
68
|
+
]
|
|
69
|
+
if missing:
|
|
70
|
+
raise IdentityApiError(
|
|
71
|
+
f"Malformed current-user response (active organization missing field(s) {missing})."
|
|
72
|
+
)
|
|
73
|
+
_require_canonical_uuid(active["organization_id"], context="current-user response")
|
|
74
|
+
memberships = payload.get("memberships")
|
|
75
|
+
if not isinstance(memberships, list):
|
|
76
|
+
raise IdentityApiError("Malformed current-user response (memberships is not an array).")
|
|
77
|
+
for membership in memberships:
|
|
78
|
+
if not isinstance(membership, dict):
|
|
79
|
+
raise IdentityApiError("Malformed current-user response (membership is not an object).")
|
|
80
|
+
missing = [
|
|
81
|
+
field
|
|
82
|
+
for field in ("organization_id", "name", "slug", "role", "status")
|
|
83
|
+
if not isinstance(membership.get(field), str) or not membership[field]
|
|
84
|
+
]
|
|
85
|
+
if missing:
|
|
86
|
+
raise IdentityApiError(f"Malformed current-user response (membership missing field(s) {missing}).")
|
|
87
|
+
_require_canonical_uuid(membership["organization_id"], context="current-user response")
|
|
88
|
+
return payload
|
|
89
|
+
|
|
90
|
+
def switch_organization(self, organization_id: str) -> dict[str, Any]:
|
|
91
|
+
"""Switch to an already-resolved active membership UUID."""
|
|
92
|
+
organization_id = _require_canonical_uuid(organization_id, context="organization switch request")
|
|
93
|
+
try:
|
|
94
|
+
payload = http.request_json(
|
|
95
|
+
"POST",
|
|
96
|
+
self._base_url + f"/orgs/{http.quote_path_segment(organization_id)}/switch",
|
|
97
|
+
token=self._token,
|
|
98
|
+
api_base_url=self._base_url,
|
|
99
|
+
timeout=_REQUEST_TIMEOUT_S,
|
|
100
|
+
unavailable_hint=_UNAVAILABLE_HINT,
|
|
101
|
+
)
|
|
102
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
103
|
+
raise IdentityApiError("Malformed organization-switch response (expected UTF-8 JSON).") from exc
|
|
104
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("active_organization"), dict):
|
|
105
|
+
raise IdentityApiError("Malformed organization-switch response (missing active organization).")
|
|
106
|
+
active = payload["active_organization"]
|
|
107
|
+
missing = [
|
|
108
|
+
field
|
|
109
|
+
for field in ("organization_id", "name", "slug", "role")
|
|
110
|
+
if not isinstance(active.get(field), str) or not active[field]
|
|
111
|
+
]
|
|
112
|
+
if missing:
|
|
113
|
+
raise IdentityApiError(f"Malformed organization-switch response (missing field(s) {missing}).")
|
|
114
|
+
_require_canonical_uuid(active["organization_id"], context="organization-switch response")
|
|
115
|
+
if str(active["organization_id"]) != organization_id:
|
|
116
|
+
raise IdentityApiError("Malformed organization-switch response (organization does not match request).")
|
|
117
|
+
return dict(active)
|