memydev-auth-sdk 0.1.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.
memyauth/_errors.py ADDED
@@ -0,0 +1,270 @@
1
+ """
2
+ @fileoverview SDK error hierarchy — typed exceptions decoded from the MemyAuth wire format.
3
+ @module memyauth._errors
4
+ @description Mirrors the JS SDK's error taxonomy class-for-class, `code`-for-`code`,
5
+ `status`-for-`status` (design §H) so cross-language conformance can assert on `.code`/`.status`
6
+ rather than reimplementing per-language logic. `decode_error` is the single decoder: it maps a
7
+ wire `{code, message, detail?}` envelope (openapi.yaml Error schema) to the matching class, and
8
+ falls back to an HTTP-status-keyed default when the wire `code` is unrecognized. `ConfigError`
9
+ deliberately extends `MemyAuthError` (status 0) rather than a bare `ValueError` — unlike
10
+ memybase's `ReservedFieldError` split, a config error still carries `code`/`status` and belongs
11
+ in the one taxonomy (§H "Deliberate divergence from memybase").
12
+ @dependencies typing (stdlib only)
13
+ @relatedFiles _transport.py (raises these from non-2xx responses), _config.py (raises ConfigError
14
+ and UnsupportedAlgorithmPinError), _idtoken.py (raises UnsupportedAlgorithmError), tests/test_errors.py
15
+ @created 2026-09-06
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Optional
20
+
21
+ __all__ = [
22
+ "MemyAuthError",
23
+ "ConfigError",
24
+ "UnsupportedAlgorithmPinError",
25
+ "NetworkError",
26
+ "TimeoutError",
27
+ "ProtocolError",
28
+ "BadRequestError",
29
+ "UnauthorizedError",
30
+ "ForbiddenError",
31
+ "NotFoundError",
32
+ "GoneError",
33
+ "ServiceUnavailableError",
34
+ "DiscoveryError",
35
+ "IssuerMismatchError",
36
+ "DiscoveryEscapeError",
37
+ "TokenExchangeError",
38
+ "RefreshRejectedError",
39
+ "UserInfoError",
40
+ "IdTokenError",
41
+ "UnsupportedAlgorithmError",
42
+ "SessionTokenError",
43
+ "StateCookieError",
44
+ "StateMismatchError",
45
+ "ERROR_CLASS_MAP",
46
+ "decode_error",
47
+ ]
48
+
49
+
50
+ class MemyAuthError(Exception):
51
+ """Base for every MemyAuth SDK error. `status == 0` means "no wire status" (pre-wire guard,
52
+ network failure, or a client-side construction error) — the same signal memybase uses, so a
53
+ caller can branch on `status` without an extra isinstance check."""
54
+
55
+ code: str = "memyauth_error"
56
+ status: int = 0
57
+
58
+ def __init__(
59
+ self,
60
+ message: str = "",
61
+ *,
62
+ code: Optional[str] = None,
63
+ status: Optional[int] = None,
64
+ detail: Any = None,
65
+ body: Any = None,
66
+ request_id: Optional[str] = None,
67
+ retry_after: Optional[float] = None,
68
+ ) -> None:
69
+ if code is not None:
70
+ self.code = code
71
+ if status is not None:
72
+ self.status = status
73
+ self.detail = detail
74
+ self.body = body
75
+ self.request_id = request_id
76
+ self.retry_after = retry_after
77
+ super().__init__(message or self.code)
78
+
79
+
80
+ class ConfigError(MemyAuthError):
81
+ """A supplied configuration value is missing, malformed, or self-contradictory. Raised at
82
+ `resolve_config()` time (or at lazy service-getter time — see §F) — never after a wire call."""
83
+
84
+ code = "config_error"
85
+ status = 0
86
+
87
+
88
+ class UnsupportedAlgorithmPinError(ConfigError):
89
+ """`allowedAlgorithms` cannot possibly verify a token this provider issues (§C.10.2). This is
90
+ the loud-failure mechanism that makes the twice-shipped RS256-vs-ES384 defect (roboson,
91
+ memyvet) structurally unrepeatable — see `_config.py::_check_algorithm_pin`."""
92
+
93
+ code = "unverifiable_algorithm_pin"
94
+ status = 0
95
+
96
+
97
+ class NetworkError(MemyAuthError):
98
+ code = "network_error"
99
+ status = 0
100
+
101
+
102
+ class TimeoutError(MemyAuthError): # noqa: A001 - intentional shadow, matches the JS class name
103
+ code = "timeout"
104
+ status = 0
105
+
106
+
107
+ class ProtocolError(MemyAuthError):
108
+ """The provider responded, but the body did not conform to the shape this SDK requires
109
+ (non-JSON 2xx, a malformed `/iam/me`, a missing required discovery key, ...)."""
110
+
111
+ code = "protocol_error"
112
+ status = 0
113
+
114
+
115
+ class BadRequestError(MemyAuthError):
116
+ code = "bad_request"
117
+ status = 400
118
+
119
+
120
+ class UnauthorizedError(MemyAuthError):
121
+ code = "unauthorized"
122
+ status = 401
123
+
124
+
125
+ class ForbiddenError(MemyAuthError):
126
+ code = "forbidden"
127
+ status = 403
128
+
129
+
130
+ class NotFoundError(MemyAuthError):
131
+ code = "not_found"
132
+ status = 404
133
+
134
+
135
+ class GoneError(MemyAuthError):
136
+ code = "gone"
137
+ status = 410
138
+
139
+
140
+ class ServiceUnavailableError(MemyAuthError):
141
+ code = "service_unavailable"
142
+ status = 503
143
+
144
+
145
+ class DiscoveryError(MemyAuthError):
146
+ code = "discovery_failed"
147
+ status = 0
148
+
149
+
150
+ class IssuerMismatchError(DiscoveryError):
151
+ code = "issuer_mismatch"
152
+ status = 0
153
+
154
+
155
+ class DiscoveryEscapeError(DiscoveryError):
156
+ """A server-side discovery endpoint (token/userinfo/jwks) resolved to an origin outside the
157
+ internal fetch origin — see the dual-origin rule in §C.1 step 6."""
158
+
159
+ code = "discovery_escape"
160
+ status = 0
161
+
162
+
163
+ class TokenExchangeError(MemyAuthError):
164
+ code = "token_exchange_failed"
165
+ status = 0
166
+
167
+
168
+ class RefreshRejectedError(MemyAuthError):
169
+ """A rejected refresh means the session is over — modeled as 401 semantics even though the
170
+ wire status may differ, matching memybase's `UnauthorizedError`-on-refresh-rejection behavior."""
171
+
172
+ code = "refresh_rejected"
173
+ status = 401
174
+
175
+
176
+ class UserInfoError(MemyAuthError):
177
+ code = "userinfo_failed"
178
+ status = 0
179
+
180
+
181
+ class IdTokenError(MemyAuthError):
182
+ code = "id_token_invalid"
183
+ status = 401
184
+
185
+
186
+ class UnsupportedAlgorithmError(IdTokenError):
187
+ """A token's `alg` header is outside the configured `allowed_algorithms` allow-list — raised
188
+ BEFORE any key lookup. This is what the mandatory RS256 negative test (R1) asserts on."""
189
+
190
+ code = "unsupported_algorithm"
191
+ status = 401
192
+
193
+
194
+ class SessionTokenError(MemyAuthError):
195
+ """Any SDK-issued (HS256) session-token verification failure. Never spans the provider
196
+ (ES384) token family — see the six-way separation documented in `_session.py`."""
197
+
198
+ code = "session_token_invalid"
199
+ status = 401
200
+
201
+
202
+ class StateCookieError(MemyAuthError):
203
+ code = "state_cookie_invalid"
204
+ status = 401
205
+
206
+
207
+ class StateMismatchError(StateCookieError):
208
+ """The callback `state` query parameter did not match the signed cookie — the actual CSRF
209
+ check (`verify()` alone only proves the cookie is ours, not that it matches this callback)."""
210
+
211
+ code = "state_mismatch"
212
+ status = 401
213
+
214
+
215
+ # Wire code -> class, from contract.json's errorMap (§B.3, §H).
216
+ ERROR_CLASS_MAP: dict[str, type[MemyAuthError]] = {
217
+ "bad_request": BadRequestError,
218
+ "unauthorized": UnauthorizedError,
219
+ "forbidden": ForbiddenError,
220
+ "not_found": NotFoundError,
221
+ "gone": GoneError,
222
+ "service_unavailable": ServiceUnavailableError,
223
+ "internal_error": MemyAuthError, # explicit, generic — stated the same way in both languages
224
+ }
225
+
226
+ # Unknown wire code -> fall back on HTTP status (§H).
227
+ _BY_STATUS: dict[int, type[MemyAuthError]] = {
228
+ 400: BadRequestError,
229
+ 401: UnauthorizedError,
230
+ 403: ForbiddenError,
231
+ 404: NotFoundError,
232
+ 410: GoneError,
233
+ 503: ServiceUnavailableError,
234
+ }
235
+
236
+
237
+ def decode_error(status: int, body: Any = None, request_id: Optional[str] = None) -> MemyAuthError:
238
+ """Build the right `MemyAuthError` for a non-2xx response.
239
+
240
+ The envelope is `{code, message, detail?}` (openapi.yaml Error schema — `code` and `message`
241
+ are required). A non-conforming body still yields a typed error: the class comes from the
242
+ status fallback and the raw payload is preserved on `.body` for debugging, but the SDK never
243
+ raises a bare, untyped exception for a wire failure.
244
+ """
245
+ code: Optional[str] = None
246
+ message: Optional[str] = None
247
+ detail: Any = None
248
+
249
+ if isinstance(body, dict):
250
+ raw_code = body.get("code")
251
+ raw_message = body.get("message")
252
+ if isinstance(raw_code, str):
253
+ code = raw_code
254
+ if isinstance(raw_message, str):
255
+ message = raw_message
256
+ detail = body.get("detail")
257
+
258
+ cls = ERROR_CLASS_MAP.get(code) if code else None
259
+ if cls is None:
260
+ cls = _BY_STATUS.get(status, MemyAuthError)
261
+
262
+ eff_message = message or f"HTTP {status}"
263
+ err = cls(eff_message, status=status, detail=detail, body=body, request_id=request_id)
264
+ # The original wire code is always carried verbatim, even when the class came from the
265
+ # status fallback (an unrecognized code must never be silently rewritten to the class's own
266
+ # default code) — §H: "The original wire code is always carried verbatim, never reconstructed
267
+ # from the chosen class."
268
+ if code:
269
+ err.code = code
270
+ return err
memyauth/_iam.py ADDED
@@ -0,0 +1,298 @@
1
+ """
2
+ @fileoverview Capability 5 — IAM identity resolver: caller-credential resolution, permissive
3
+ response validation, role projection, tenant-key redaction.
4
+ @module memyauth._iam
5
+ @description [async -> _sync/_iam.py twin via scripts/gen_sync.py]. This module is the direct
6
+ fix for the estate's single worst confirmed defect (D2): roboson's `/iam/me` model is
7
+ `extra="forbid"` and omits `organizations`, a field memyauth's IAM returns UNCONDITIONALLY, so
8
+ a real login there can 503. `assert_iam_user_context()` is the enforcement point of the
9
+ "permissive about unknown fields, strict about known ones" policy (§C.5.1) — it is a
10
+ hand-written checker, NOT a Pydantic `BaseModel`, deliberately: `ConfigDict(extra="forbid")` is
11
+ one keyword away from re-creating D2, and Pydantic is not a dependency of this SDK (zero deps
12
+ beyond httpx + PyJWT). The caller-credential resolver mirrors
13
+ `memybase/src/iam/iam-client.ts:112-126` with the header names corrected to the real ones
14
+ (`X-App-Id`/`X-App-Secret` never existed) and enforces mutual exclusion structurally: the
15
+ header builder can only ever produce ONE marker header per request (R6).
16
+ @dependencies ._transport, ._roles
17
+ @relatedFiles _roles.py (RoleMapper — mapAll/isGlobalAdmin), _config.py (ResolvedConfig, the
18
+ caller-credential fields), _errors.py, _types.py (UserContext/IamSession/IamOrgEntry),
19
+ tests/test_iam.py
20
+ @created 2026-09-06
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Optional
25
+
26
+ import httpx
27
+
28
+ # ABSOLUTE imports for the shared/pure modules; RELATIVE for ._transport, which DOES get a
29
+ # _sync/ twin and must bind to ITS sibling twin (see _transport.py's header for why).
30
+ from memyauth._config import ResolvedConfig
31
+ from memyauth._errors import ConfigError, ProtocolError, UnauthorizedError
32
+ from memyauth._roles import RoleMapper
33
+ from memyauth._types import IamCallerMode, IamOrgEntry, IamSession, IamTenant, IamUserContext
34
+
35
+ from ._transport import Transport
36
+
37
+ __all__ = ["IamService", "assert_iam_user_context"]
38
+
39
+ _KNOWN_TOP_LEVEL = frozenset({"user", "tenant", "organizations", "permissions"})
40
+ _KNOWN_USER_FIELDS = frozenset(
41
+ {
42
+ "sub", "username", "primary_email", "primary_phone", "name", "avatar", "custom_data",
43
+ "identities", "is_suspended", "has_password", "last_sign_in_at", "created_at",
44
+ "updated_at", "roles", "scopes", "organization_roles",
45
+ }
46
+ )
47
+ _KNOWN_TENANT_FIELDS = frozenset({"id", "code", "idp_users_sub", "key", "created_at", "updated_at"})
48
+ _KNOWN_ORG_ENTRY_FIELDS = frozenset({"id", "name", "roles", "tenant"})
49
+
50
+
51
+ def assert_iam_user_context(body: Any, *, strict_unknown_fields: bool = False) -> IamUserContext:
52
+ """The "permissive about unknown fields, strict about known ones" enforcement point (§C.5.1).
53
+
54
+ Unknown keys, at ANY level, are ALWAYS tolerated and preserved (the returned value IS `body`,
55
+ unmodified) — there is no allow-list construct that could reject a field the provider adds
56
+ later. `strict_unknown_fields=True` (opt-in canary, never the default) additionally checks for
57
+ unknown keys and raises if any are found; the default path never even computes that check.
58
+ """
59
+ if not isinstance(body, dict):
60
+ raise ProtocolError("Malformed IAM /iam/me response")
61
+
62
+ user = body.get("user")
63
+ if not isinstance(user, dict) or not isinstance(user.get("sub"), str) or not user["sub"]:
64
+ raise ProtocolError("Malformed IAM /iam/me response")
65
+ if "roles" in user and not isinstance(user["roles"], list):
66
+ raise ProtocolError("Malformed IAM /iam/me response")
67
+ if "scopes" in user and not isinstance(user["scopes"], list):
68
+ raise ProtocolError("Malformed IAM /iam/me response")
69
+
70
+ tenant = body.get("tenant")
71
+ if not isinstance(tenant, dict) or not isinstance(tenant.get("id"), str) or not tenant["id"]:
72
+ raise ProtocolError("Malformed IAM /iam/me response")
73
+
74
+ organizations = body.get("organizations")
75
+ if organizations is not None:
76
+ if not isinstance(organizations, dict):
77
+ raise ProtocolError("Malformed IAM /iam/me response")
78
+ for key in ("own", "as_member"):
79
+ if key in organizations and not isinstance(organizations[key], list):
80
+ raise ProtocolError("Malformed IAM /iam/me response")
81
+
82
+ permissions = body.get("permissions")
83
+ if permissions is not None and not isinstance(permissions, list):
84
+ raise ProtocolError("Malformed IAM /iam/me response")
85
+
86
+ if strict_unknown_fields:
87
+ _assert_no_unknown_fields(body, user, tenant, organizations)
88
+
89
+ return body # type: ignore[return-value]
90
+
91
+
92
+ def _assert_no_unknown_fields(
93
+ body: dict[str, Any], user: dict[str, Any], tenant: dict[str, Any], organizations: Any
94
+ ) -> None:
95
+ unknown = set(body) - _KNOWN_TOP_LEVEL
96
+ if unknown:
97
+ raise ProtocolError(f"IAM /iam/me response has unknown top-level field(s): {sorted(unknown)}")
98
+ unknown = set(user) - _KNOWN_USER_FIELDS
99
+ if unknown:
100
+ raise ProtocolError(f"IAM /iam/me response has unknown user field(s): {sorted(unknown)}")
101
+ unknown = set(tenant) - _KNOWN_TENANT_FIELDS
102
+ if unknown:
103
+ raise ProtocolError(f"IAM /iam/me response has unknown tenant field(s): {sorted(unknown)}")
104
+ if isinstance(organizations, dict):
105
+ for entries in (organizations.get("own") or [], organizations.get("as_member") or []):
106
+ for entry in entries:
107
+ if isinstance(entry, dict):
108
+ unknown = set(entry) - _KNOWN_ORG_ENTRY_FIELDS
109
+ if unknown:
110
+ raise ProtocolError(
111
+ f"IAM /iam/me response has unknown organization field(s): {sorted(unknown)}"
112
+ )
113
+
114
+
115
+ def _project_org_entry(entry: dict[str, Any], *, strip_key: bool) -> IamOrgEntry:
116
+ raw_tenant = entry.get("tenant")
117
+ projected_tenant = None
118
+ if isinstance(raw_tenant, dict):
119
+ projected_tenant = {
120
+ "id": raw_tenant.get("id"),
121
+ "code": raw_tenant.get("code"),
122
+ "idp_users_sub": raw_tenant.get("idp_users_sub"),
123
+ }
124
+ key = raw_tenant.get("key")
125
+ # strip_key=True (as_member) => the key is NEVER copied, even when the wire sent one —
126
+ # the C.5.1 / tenantKeyRedaction enforcement that must hold regardless of provider intent.
127
+ if not strip_key and isinstance(key, str) and key:
128
+ projected_tenant["key"] = key
129
+ return {
130
+ "id": entry.get("id"),
131
+ "name": entry.get("name"),
132
+ "roles": list(entry.get("roles") or []),
133
+ "tenant": projected_tenant,
134
+ }
135
+
136
+
137
+ def project_session(body: IamUserContext, role_mapper: RoleMapper) -> IamSession:
138
+ """Ported from `iam-client.ts:75-110`."""
139
+ user = body["user"] # type: ignore[index]
140
+ tenant = body["tenant"] # type: ignore[index]
141
+ organizations = body.get("organizations") or {} # type: ignore[union-attr]
142
+ own_raw = organizations.get("own") or []
143
+ as_member_raw = organizations.get("as_member") or []
144
+ idp_roles = list(user.get("roles") or [])
145
+ scopes = list(user.get("scopes") or [])
146
+ permissions = list(body.get("permissions") or []) # type: ignore[union-attr]
147
+
148
+ email_verified = user.get("email_verified")
149
+ session: IamSession = {
150
+ "user_id": user["sub"],
151
+ "idp_subject": user["sub"],
152
+ "email": user.get("primary_email") or "",
153
+ "email_verified": email_verified if isinstance(email_verified, bool) else None,
154
+ "tenant_id": tenant["id"],
155
+ "tenant_code": tenant.get("code") or "",
156
+ "idp_roles": idp_roles,
157
+ "roles": role_mapper.map_all(idp_roles),
158
+ "scopes": scopes,
159
+ "permissions": permissions,
160
+ "is_global_admin": role_mapper.is_global_admin(idp_roles),
161
+ "organizations": {
162
+ "own": [_project_org_entry(o, strip_key=False) for o in own_raw],
163
+ "as_member": [_project_org_entry(o, strip_key=True) for o in as_member_raw],
164
+ },
165
+ }
166
+ display_name = user.get("name")
167
+ if display_name:
168
+ session["display_name"] = display_name
169
+ tenant_key = tenant.get("key")
170
+ if isinstance(tenant_key, str) and tenant_key:
171
+ session["tenant_key"] = tenant_key
172
+ return session
173
+
174
+
175
+ def resolve_caller_headers(config: ResolvedConfig, override_mode: Optional[IamCallerMode]) -> tuple[dict[str, str], str]:
176
+ """Returns EXACTLY ONE marker header (or none, for m2m) + the caller kind (§C.5, R6)."""
177
+ mode = override_mode or config["iam_caller_mode"]
178
+
179
+ def appiam() -> tuple[dict[str, str], str]:
180
+ if config.get("iam_app_id") and config.get("iam_app_secret"):
181
+ return (
182
+ {"X-AppIam-Id": config["iam_app_id"], "Authorization": f"Bearer {config['iam_app_secret']}"},
183
+ "appiam",
184
+ )
185
+ raise ConfigError("IAM caller credentials are not configured")
186
+
187
+ def appidp() -> tuple[dict[str, str], str]:
188
+ if config.get("client_id") and config.get("client_secret"):
189
+ return (
190
+ {"X-AppIdp-Id": config["client_id"], "Authorization": f"Bearer {config['client_secret']}"},
191
+ "appidp",
192
+ )
193
+ raise ConfigError("IAM caller credentials are not configured")
194
+
195
+ def m2m() -> tuple[dict[str, str], str]:
196
+ if config.get("iam_m2m_token"):
197
+ return {"Authorization": f"Bearer {config['iam_m2m_token']}"}, "m2m"
198
+ raise ConfigError("IAM caller credentials are not configured")
199
+
200
+ if mode == "appiam":
201
+ return appiam()
202
+ if mode == "appidp":
203
+ return appidp()
204
+ if mode == "m2m":
205
+ return m2m()
206
+
207
+ # auto: precedence appiam -> appidp -> m2m (memybase's order, §C.5).
208
+ if config.get("iam_app_id") and config.get("iam_app_secret"):
209
+ return appiam()
210
+ if config.get("client_id") and config.get("client_secret"):
211
+ return appidp()
212
+ if config.get("iam_m2m_token"):
213
+ return m2m()
214
+ raise ConfigError("IAM caller credentials are not configured")
215
+
216
+
217
+ class IamService:
218
+ def __init__(
219
+ self,
220
+ *,
221
+ config: ResolvedConfig,
222
+ transport: Optional[httpx.BaseTransport] = None,
223
+ ) -> None:
224
+ self._config = config
225
+ self._transport = Transport(
226
+ timeout_ms=config["iam_timeout_ms"],
227
+ max_retries=config["retry"]["max_retries"],
228
+ base_delay_ms=config["retry"]["base_delay_ms"],
229
+ retryable_statuses=config["retry"]["retryable_statuses"],
230
+ transport=transport,
231
+ )
232
+ self._role_mapper = RoleMapper(
233
+ role_map=config["role_map"],
234
+ role_overrides=config["role_overrides"],
235
+ default_role=config["default_role"],
236
+ global_admin_roles=config["global_admin_roles"],
237
+ )
238
+
239
+ async def me_raw(
240
+ self,
241
+ user_token: str,
242
+ *,
243
+ caller_mode: Optional[IamCallerMode] = None,
244
+ timeout_ms: Optional[int] = None,
245
+ headers: Optional[dict[str, str]] = None,
246
+ ) -> IamUserContext:
247
+ if not user_token:
248
+ raise UnauthorizedError("Missing user token")
249
+ auth_headers, _kind = resolve_caller_headers(self._config, caller_mode)
250
+ req_headers = {**auth_headers, "X-User-Token": user_token, "Accept": "application/json", **(headers or {})}
251
+ url = f"{self._config['iam_base_url']}/iam/me"
252
+ body = await self._transport.request_json(
253
+ "GET", url, headers=req_headers, timeout_ms=timeout_ms if timeout_ms is not None else self._config["iam_timeout_ms"]
254
+ )
255
+ return assert_iam_user_context(body, strict_unknown_fields=self._config["strict_unknown_fields"])
256
+
257
+ async def me(
258
+ self,
259
+ user_token: str,
260
+ *,
261
+ caller_mode: Optional[IamCallerMode] = None,
262
+ timeout_ms: Optional[int] = None,
263
+ headers: Optional[dict[str, str]] = None,
264
+ ) -> IamSession:
265
+ body = await self.me_raw(user_token, caller_mode=caller_mode, timeout_ms=timeout_ms, headers=headers)
266
+ return project_session(body, self._role_mapper)
267
+
268
+ async def tenant_by_key(
269
+ self,
270
+ tenant_key: str,
271
+ *,
272
+ caller_mode: Optional[IamCallerMode] = None,
273
+ timeout_ms: Optional[int] = None,
274
+ headers: Optional[dict[str, str]] = None,
275
+ ) -> IamTenant:
276
+ if not tenant_key:
277
+ raise UnauthorizedError("Missing tenant key")
278
+ auth_headers, _kind = resolve_caller_headers(self._config, caller_mode)
279
+ req_headers = {**auth_headers, "X-Tenant-Key": tenant_key, "Accept": "application/json", **(headers or {})}
280
+ url = f"{self._config['iam_base_url']}/iam/tenants/me"
281
+ body = await self._transport.request_json(
282
+ "GET", url, headers=req_headers, timeout_ms=timeout_ms if timeout_ms is not None else self._config["iam_timeout_ms"]
283
+ )
284
+ if not isinstance(body, dict) or not isinstance(body.get("id"), str) or not body["id"]:
285
+ raise ProtocolError("Malformed IAM /iam/tenants/me response")
286
+ return body # type: ignore[return-value]
287
+
288
+ async def health(self) -> bool:
289
+ """Never throws."""
290
+ try:
291
+ url = f"{self._config['iam_base_url']}/iam/health"
292
+ resp = await self._transport.send("GET", url)
293
+ return 200 <= resp.status_code < 300
294
+ except Exception: # noqa: BLE001 - a health probe must never raise
295
+ return False
296
+
297
+ async def close(self) -> None:
298
+ await self._transport.close()