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/__init__.py +144 -0
- memyauth/_client.py +169 -0
- memyauth/_config.py +407 -0
- memyauth/_discovery.py +172 -0
- memyauth/_errors.py +270 -0
- memyauth/_iam.py +298 -0
- memyauth/_idtoken.py +165 -0
- memyauth/_oidc.py +237 -0
- memyauth/_pkce.py +61 -0
- memyauth/_roles.py +109 -0
- memyauth/_session.py +149 -0
- memyauth/_state.py +139 -0
- memyauth/_sync/__init__.py +3 -0
- memyauth/_sync/_client.py +171 -0
- memyauth/_sync/_discovery.py +174 -0
- memyauth/_sync/_iam.py +300 -0
- memyauth/_sync/_idtoken.py +167 -0
- memyauth/_sync/_oidc.py +239 -0
- memyauth/_sync/_transport.py +223 -0
- memyauth/_transport.py +221 -0
- memyauth/_types.py +309 -0
- memyauth/py.typed +0 -0
- memyauth/sync/__init__.py +147 -0
- memydev_auth_sdk-0.1.0.dist-info/METADATA +86 -0
- memydev_auth_sdk-0.1.0.dist-info/RECORD +26 -0
- memydev_auth_sdk-0.1.0.dist-info/WHEEL +4 -0
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()
|