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/_idtoken.py ADDED
@@ -0,0 +1,165 @@
1
+ """
2
+ @fileoverview Capability C0 — ID-token trust strategies: local JWKS verification, or an explicit
3
+ no-verification mode for TLS-token-endpoint trust.
4
+ @module memyauth._idtoken
5
+ @description [async -> _sync/_idtoken.py twin via scripts/gen_sync.py]. BOTH trust strategies are
6
+ first-class and BOTH are in live production use (§C.10.1): `local-jwks` (memyui, roboson,
7
+ memyvet) and `tls-token-endpoint` (memybase). `mode` has no default at this layer — the caller
8
+ MUST pick one, mirroring `_config.py`'s `id_token_validation` (§C.16). This is the module the
9
+ mandatory RS256 negative test (R1) exercises: a pin that cannot verify ANY token this provider
10
+ issues must fail LOUDLY, and `UnsupportedAlgorithmError` must be raised on the `alg` header
11
+ BEFORE any key lookup — a verifier that skipped the algorithm check and just tried every key
12
+ would accept a genuinely-signed RS256 token, which is exactly the twice-shipped defect (D4)
13
+ this class exists to make impossible. Exp/nbf are checked against the INJECTED `Clock`, not
14
+ PyJWT's internal wall-clock read, so the conformance harness's `fixedClock` can pin determinism.
15
+ @dependencies pyjwt[crypto]
16
+ @relatedFiles _discovery.py (source of `jwks_uri` — always the internal/fetch origin, never
17
+ rewritten, since JWKS is a server-side endpoint per §C.1 step 6), _config.py
18
+ (`allowed_algorithms`/`id_token_validation`), _errors.py, tests/test_idtoken.py
19
+ @created 2026-09-06
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Optional
24
+
25
+ import httpx
26
+ import jwt
27
+
28
+ # ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
29
+ # _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
30
+ from memyauth._errors import ConfigError, IdTokenError, UnsupportedAlgorithmError
31
+ from memyauth._types import Clock, IdpTokenClaims, IdTokenValidationMode, SystemClock
32
+
33
+ from ._discovery import DiscoveryService
34
+ from ._transport import Transport, read_json_or_none
35
+
36
+ __all__ = ["IdTokenVerifier"]
37
+
38
+ _DEFAULT_LOCAL_JWKS_ALGORITHMS = ("ES384",)
39
+ _DEFAULT_CLOCK_TOLERANCE_SECONDS = 60
40
+
41
+
42
+ class IdTokenVerifier:
43
+ def __init__(
44
+ self,
45
+ *,
46
+ mode: IdTokenValidationMode,
47
+ allowed_algorithms: Optional[list[str]] = None,
48
+ discovery: DiscoveryService,
49
+ clock_tolerance_seconds: int = _DEFAULT_CLOCK_TOLERANCE_SECONDS,
50
+ transport: Optional[httpx.BaseTransport] = None,
51
+ clock: Optional[Clock] = None,
52
+ ) -> None:
53
+ if mode not in ("local-jwks", "tls-token-endpoint"):
54
+ raise ConfigError("idTokenValidation must be local-jwks or tls-token-endpoint")
55
+
56
+ resolved_algorithms = allowed_algorithms
57
+ if resolved_algorithms is None:
58
+ resolved_algorithms = list(_DEFAULT_LOCAL_JWKS_ALGORITHMS) if mode == "local-jwks" else []
59
+
60
+ if mode == "tls-token-endpoint" and resolved_algorithms:
61
+ raise ConfigError("allowedAlgorithms must be empty when idTokenValidation is tls-token-endpoint")
62
+ if mode == "local-jwks" and not resolved_algorithms:
63
+ raise ConfigError("allowedAlgorithms must not be empty when idTokenValidation is local-jwks")
64
+
65
+ self.mode: IdTokenValidationMode = mode
66
+ self._allowed_algorithms = set(resolved_algorithms)
67
+ self._discovery = discovery
68
+ self._clock_tolerance_seconds = clock_tolerance_seconds
69
+ self._clock: Clock = clock or SystemClock()
70
+ self._transport = Transport(transport=transport)
71
+ self._jwks_cache: dict[str, dict[str, Any]] = {}
72
+
73
+ async def _refresh_jwks(self) -> None:
74
+ doc = await self._discovery.discover()
75
+ resp = await self._transport.send("GET", doc["jwks_uri"])
76
+ if not (200 <= resp.status_code < 300):
77
+ raise IdTokenError(f"failed to fetch JWKS: {resp.status_code}")
78
+ body = read_json_or_none(resp)
79
+ keys = (body or {}).get("keys", []) if isinstance(body, dict) else []
80
+ self._jwks_cache = {k["kid"]: k for k in keys if isinstance(k, dict) and "kid" in k}
81
+
82
+ async def _get_key_for_kid(self, kid: str) -> dict[str, Any]:
83
+ if kid not in self._jwks_cache:
84
+ await self._refresh_jwks() # the single forced refresh on an unknown kid
85
+ if kid not in self._jwks_cache:
86
+ raise IdTokenError(f"no matching JWKS key for kid={kid!r}")
87
+ return self._jwks_cache[kid]
88
+
89
+ async def verify(
90
+ self,
91
+ id_token: str,
92
+ *,
93
+ issuer: str,
94
+ audience: str,
95
+ nonce: Optional[str] = None,
96
+ max_age_seconds: Optional[int] = None,
97
+ ) -> IdpTokenClaims:
98
+ if self.mode == "tls-token-endpoint":
99
+ raise ConfigError(
100
+ "tls-token-endpoint mode does not verify ID tokens; identity is derived from the "
101
+ "token-endpoint TLS channel per OIDC Core 3.1.3.7 item 6 — use decode_unverified() "
102
+ "plus /oidc/me or /iam/me"
103
+ )
104
+
105
+ try:
106
+ header = jwt.get_unverified_header(id_token)
107
+ except jwt.PyJWTError as exc:
108
+ raise IdTokenError(f"malformed ID token: {exc}") from exc
109
+
110
+ alg = header.get("alg")
111
+ # THE R1 check: reject on `alg` BEFORE any key lookup — a verifier that tried every key
112
+ # regardless of alg would accept a genuinely-signed RS256 token under an ES384-only pin.
113
+ if alg not in self._allowed_algorithms:
114
+ raise UnsupportedAlgorithmError(
115
+ f"ID token alg {alg!r} is not in the allowed algorithms {sorted(self._allowed_algorithms)!r}"
116
+ )
117
+
118
+ kid = header.get("kid")
119
+ if not kid:
120
+ raise IdTokenError("ID token is missing a kid header")
121
+ jwk = await self._get_key_for_kid(kid)
122
+ key = jwt.PyJWK(jwk).key
123
+
124
+ try:
125
+ claims = jwt.decode(
126
+ id_token,
127
+ key,
128
+ algorithms=[alg],
129
+ audience=audience,
130
+ issuer=issuer,
131
+ options={"verify_exp": False, "verify_nbf": False, "require": ["exp", "iat"]},
132
+ )
133
+ except jwt.PyJWTError as exc:
134
+ raise IdTokenError(f"ID token verification failed: {exc}") from exc
135
+
136
+ # exp/nbf checked against the INJECTED clock, not PyJWT's wall-clock read (determinism).
137
+ now_seconds = self._clock.now() / 1000
138
+ tolerance = self._clock_tolerance_seconds
139
+ exp = claims.get("exp")
140
+ if not isinstance(exp, (int, float)) or now_seconds > exp + tolerance:
141
+ raise IdTokenError("ID token has expired")
142
+ nbf = claims.get("nbf")
143
+ if isinstance(nbf, (int, float)) and now_seconds < nbf - tolerance:
144
+ raise IdTokenError("ID token is not yet valid")
145
+
146
+ if nonce is not None and claims.get("nonce") != nonce:
147
+ raise IdTokenError("ID token nonce mismatch")
148
+
149
+ if max_age_seconds is not None:
150
+ auth_time = claims.get("auth_time")
151
+ if not isinstance(auth_time, (int, float)) or (now_seconds - auth_time) > max_age_seconds:
152
+ raise IdTokenError("ID token auth_time exceeds max_age_seconds")
153
+
154
+ return claims # type: ignore[return-value]
155
+
156
+ def decode_unverified(self, id_token: str) -> IdpTokenClaims:
157
+ """Decode without verifying. Available in BOTH modes; the ONLY path in
158
+ `tls-token-endpoint` mode."""
159
+ try:
160
+ return jwt.decode(id_token, options={"verify_signature": False}) # type: ignore[return-value]
161
+ except jwt.PyJWTError as exc:
162
+ raise IdTokenError(f"malformed ID token: {exc}") from exc
163
+
164
+ async def close(self) -> None:
165
+ await self._transport.close()
memyauth/_oidc.py ADDED
@@ -0,0 +1,237 @@
1
+ """
2
+ @fileoverview Capabilities 2, 3, 4, 9 — authorization URL, token exchange/refresh, userinfo, logout.
3
+ @module memyauth._oidc
4
+ @description [async -> _sync/_oidc.py twin via scripts/gen_sync.py]. Ported from
5
+ `memybase/src/auth/oidc.ts:14-20,155-159,178-225`. Every URL this service builds comes from the
6
+ DISCOVERY DOCUMENT, never from a guessed path convention (§C.2, D7 — memyui's guessed
7
+ `{issuer}/auth`). Token exchange and refresh are POSTs and are therefore NEVER retried by
8
+ `Transport` (§0 C13 — an authorization code is single-use); their errors are bespoke
9
+ (`TokenExchangeError` / `RefreshRejectedError`) rather than the generic `decode_error` mapping,
10
+ because the token endpoint's failure body is an OAuth `{error, error_description}` shape, not
11
+ the IAM surface's `{code,message,detail}` envelope.
12
+ @dependencies base64, urllib.parse (stdlib); ._transport, ._pkce, ._discovery
13
+ @relatedFiles _discovery.py (this service's sole source of every OIDC URL), _pkce.py (PKCE +
14
+ random helpers), _config.py (ResolvedConfig), _errors.py, tests/test_oidc.py
15
+ @created 2026-09-06
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ from typing import Optional
21
+ from urllib.parse import urlencode
22
+
23
+ import httpx
24
+
25
+ # ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
26
+ # _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
27
+ from memyauth._config import ResolvedConfig
28
+ from memyauth._errors import ConfigError, ProtocolError, RefreshRejectedError, TokenExchangeError, UserInfoError
29
+ from memyauth._pkce import generate_code_challenge, generate_code_verifier, random_nonce, random_state
30
+ from memyauth._types import AuthorizationRequest, OidcTokenResponse, RandomSource, UserInfo
31
+
32
+ from ._discovery import DiscoveryService
33
+ from ._transport import Transport, read_json_or_none, truncated_text
34
+
35
+ __all__ = ["OidcService"]
36
+
37
+ _AUTH_STANDARD_KEYS = frozenset(
38
+ {"response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "state", "nonce", "prompt"}
39
+ )
40
+ _LOGOUT_STANDARD_KEYS = frozenset({"post_logout_redirect_uri", "id_token_hint", "client_id", "state"})
41
+
42
+
43
+ class OidcService:
44
+ def __init__(
45
+ self,
46
+ *,
47
+ config: ResolvedConfig,
48
+ discovery: DiscoveryService,
49
+ transport: Optional[httpx.BaseTransport] = None,
50
+ random: Optional[RandomSource] = None,
51
+ ) -> None:
52
+ self._config = config
53
+ self._discovery = discovery
54
+ self._random: RandomSource = random or config["random"]
55
+ self._transport = Transport(
56
+ timeout_ms=config.get("discovery_timeout_ms", 10000),
57
+ max_retries=config["retry"]["max_retries"],
58
+ base_delay_ms=config["retry"]["base_delay_ms"],
59
+ retryable_statuses=config["retry"]["retryable_statuses"],
60
+ transport=transport,
61
+ )
62
+
63
+ async def build_authorization_url(
64
+ self,
65
+ *,
66
+ redirect_uri: Optional[str] = None,
67
+ scopes: Optional[list[str]] = None,
68
+ state: Optional[str] = None,
69
+ nonce: Optional[str] = None,
70
+ code_verifier: Optional[str] = None,
71
+ prompt: Optional[str] = None,
72
+ return_to: Optional[str] = None,
73
+ extra_params: Optional[dict[str, str]] = None,
74
+ ) -> AuthorizationRequest:
75
+ # Validated BEFORE any network call (sdk-design.md §C.2) — mirrors js/src/oidc.ts:143,
76
+ # where `assertNoReservedCollision` runs ahead of `discovery.discover()`. A misconfigured
77
+ # caller must fail deterministically without spending a discovery round trip or generating
78
+ # provider traffic.
79
+ _assert_no_extra_param_collision(extra_params, _AUTH_STANDARD_KEYS)
80
+ doc = await self._discovery.discover()
81
+
82
+ effective_redirect_uri = redirect_uri or self._config["redirect_uri"]
83
+ if not effective_redirect_uri:
84
+ raise ConfigError("redirectUri or publicBaseUrl must be configured")
85
+
86
+ cv = code_verifier or generate_code_verifier(self._random)
87
+ challenge = generate_code_challenge(cv)
88
+ effective_state = state or random_state(self._random)
89
+ effective_nonce = nonce or random_nonce(self._random)
90
+
91
+ base_scopes = scopes if scopes is not None else self._config["scopes"]
92
+ scopes_supported = doc.get("scopes_supported")
93
+ if isinstance(scopes_supported, list) and scopes_supported:
94
+ negotiated = [s for s in base_scopes if s == "openid" or s in scopes_supported]
95
+ else:
96
+ negotiated = list(base_scopes)
97
+ scope = " ".join(negotiated)
98
+
99
+ params: dict[str, str] = {
100
+ "response_type": "code",
101
+ "client_id": self._config["client_id"],
102
+ "redirect_uri": effective_redirect_uri,
103
+ "code_challenge": challenge,
104
+ "code_challenge_method": "S256",
105
+ "scope": scope,
106
+ "state": effective_state,
107
+ "nonce": effective_nonce,
108
+ "prompt": prompt if prompt is not None else "consent",
109
+ }
110
+ if extra_params:
111
+ params.update(extra_params)
112
+
113
+ url = f"{doc['authorization_endpoint']}?{urlencode(params)}"
114
+ return {
115
+ "url": url,
116
+ "code_verifier": cv,
117
+ "code_challenge": challenge,
118
+ "state": effective_state,
119
+ "nonce": effective_nonce,
120
+ "redirect_uri": effective_redirect_uri,
121
+ "scope": scope,
122
+ }
123
+
124
+ def _credential_form_fields(self) -> dict[str, str]:
125
+ if self._config["token_endpoint_auth_method"] == "client_secret_post":
126
+ return {"client_id": self._config["client_id"], "client_secret": self._config["client_secret"]}
127
+ return {}
128
+
129
+ def _credential_headers(self) -> dict[str, str]:
130
+ if self._config["token_endpoint_auth_method"] == "client_secret_basic":
131
+ raw = f"{self._config['client_id']}:{self._config['client_secret']}".encode("utf-8")
132
+ return {"Authorization": f"Basic {base64.b64encode(raw).decode('ascii')}"}
133
+ return {}
134
+
135
+ async def _post_token(self, body: dict[str, str]) -> httpx.Response:
136
+ doc = await self._discovery.discover()
137
+ form = {**body, **self._credential_form_fields()}
138
+ headers = self._credential_headers()
139
+ return await self._transport.send("POST", doc["token_endpoint"], form=form, headers=headers)
140
+
141
+ @staticmethod
142
+ def _parse_token_response(resp: httpx.Response) -> OidcTokenResponse:
143
+ body = read_json_or_none(resp)
144
+ if not isinstance(body, dict) or "token_type" not in body or "expires_in" not in body:
145
+ raise ProtocolError("OIDC token response is missing token_type or expires_in")
146
+ return body # type: ignore[return-value]
147
+
148
+ async def exchange_code(
149
+ self, *, code: str, code_verifier: str, redirect_uri: Optional[str] = None
150
+ ) -> OidcTokenResponse:
151
+ body = {
152
+ "grant_type": "authorization_code",
153
+ "code": code,
154
+ "redirect_uri": redirect_uri or self._config["redirect_uri"],
155
+ "code_verifier": code_verifier,
156
+ }
157
+ resp = await self._post_token(body)
158
+ if not (200 <= resp.status_code < 300):
159
+ raise TokenExchangeError(
160
+ f"OIDC token exchange failed: {resp.status_code}", detail=truncated_text(resp)
161
+ )
162
+ return self._parse_token_response(resp)
163
+
164
+ async def refresh_tokens(self, refresh_token: str) -> OidcTokenResponse:
165
+ body = {"grant_type": "refresh_token", "refresh_token": refresh_token}
166
+ resp = await self._post_token(body)
167
+ if not (200 <= resp.status_code < 300):
168
+ raise RefreshRejectedError(
169
+ f"OIDC token refresh rejected: {resp.status_code}", detail=truncated_text(resp)
170
+ )
171
+ return self._parse_token_response(resp)
172
+
173
+ async def fetch_user_info(self, access_token: str) -> UserInfo:
174
+ doc = await self._discovery.discover()
175
+ resp = await self._transport.send(
176
+ "GET",
177
+ doc["userinfo_endpoint"],
178
+ headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
179
+ )
180
+ if not (200 <= resp.status_code < 300):
181
+ raise UserInfoError(f"OIDC userinfo failed: {resp.status_code}", detail=truncated_text(resp))
182
+ body = read_json_or_none(resp)
183
+ if not isinstance(body, dict) or not isinstance(body.get("sub"), str) or not body["sub"]:
184
+ raise ProtocolError("OIDC userinfo response is missing sub")
185
+ return body # type: ignore[return-value]
186
+
187
+ async def build_logout_url(
188
+ self,
189
+ *,
190
+ post_logout_redirect_uri: Optional[str] = None,
191
+ id_token_hint: Optional[str] = None,
192
+ client_id: Optional[str] = None,
193
+ state: Optional[str] = None,
194
+ extra_params: Optional[dict[str, str]] = None,
195
+ ) -> str:
196
+ doc = await self._discovery.discover()
197
+ endpoint = doc.get("end_session_endpoint") or f"{doc['issuer'].rstrip('/')}/session/end"
198
+
199
+ # NOTE: unlike build_authorization_url, client_id is NEVER silently defaulted from config
200
+ # here — only an EXPLICITLY passed value is emitted. Memybase's own `buildLogoutUrl()`
201
+ # (the estate's shipped behavior, oidc.ts:222-225) takes no arguments and returns the bare
202
+ # endpoint with no query string at all; the frozen invariant "no arguments and no
203
+ # configured postLogoutRedirectUri -> bare endpoint, no ?" (conformance case
204
+ # `logout-url-bare-endpoint`) is only satisfiable if every param, client_id included, is
205
+ # caller-opt-in.
206
+ params: dict[str, str] = {}
207
+ effective_plr = post_logout_redirect_uri or self._config.get("post_logout_redirect_uri")
208
+ if effective_plr:
209
+ params["post_logout_redirect_uri"] = effective_plr
210
+ if id_token_hint:
211
+ params["id_token_hint"] = id_token_hint
212
+ if client_id:
213
+ params["client_id"] = client_id
214
+ if state:
215
+ params["state"] = state
216
+ _merge_extra_params(params, extra_params, _LOGOUT_STANDARD_KEYS)
217
+
218
+ if not params:
219
+ return endpoint
220
+ return f"{endpoint}?{urlencode(params)}"
221
+
222
+ async def close(self) -> None:
223
+ await self._transport.close()
224
+
225
+
226
+ def _assert_no_extra_param_collision(extra: Optional[dict[str, str]], standard_keys: frozenset) -> None:
227
+ if not extra:
228
+ return
229
+ for key in extra:
230
+ if key in standard_keys:
231
+ raise ConfigError(f"extraParams cannot override a standard parameter: {key}")
232
+
233
+
234
+ def _merge_extra_params(params: dict[str, str], extra: Optional[dict[str, str]], standard_keys: frozenset) -> None:
235
+ _assert_no_extra_param_collision(extra, standard_keys)
236
+ if extra:
237
+ params.update(extra)
memyauth/_pkce.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ @fileoverview PKCE (RFC 7636) code-verifier/challenge generation and random state/nonce helpers.
3
+ @module memyauth._pkce
4
+ @description Pure, synchronous, zero I/O — ported verbatim from
5
+ `memybase/src/auth/oidc.ts:14-20`. PKCE is mandatory and S256-only in this estate (the
6
+ descriptor schema makes anything else structurally invalid — §C.2). Every random value is drawn
7
+ through the injected `RandomSource` seam so the conformance harness can seed determinism
8
+ (`fixedRandom` replaying `case.seed.randomHex`); the default seam uses `secrets.token_bytes`,
9
+ Python's CSPRNG.
10
+ @dependencies hashlib, base64, secrets (stdlib only)
11
+ @relatedFiles _oidc.py (build_authorization_url), _types.py (RandomSource protocol)
12
+ @created 2026-09-06
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import secrets
19
+
20
+ from ._types import RandomSource
21
+
22
+ __all__ = [
23
+ "SystemRandomSource",
24
+ "base64url_encode",
25
+ "generate_code_verifier",
26
+ "generate_code_challenge",
27
+ "random_state",
28
+ "random_nonce",
29
+ ]
30
+
31
+
32
+ class SystemRandomSource:
33
+ """Default `RandomSource`: Python's CSPRNG (`secrets.token_bytes`, itself `os.urandom`)."""
34
+
35
+ def bytes(self, n: int) -> bytes:
36
+ return secrets.token_bytes(n)
37
+
38
+
39
+ def base64url_encode(data: bytes) -> str:
40
+ """Base64url, NO padding — RFC 7636 §4.1 / RFC 4648 §5, matching the JS SDK's encoder."""
41
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
42
+
43
+
44
+ def generate_code_verifier(random: RandomSource) -> str:
45
+ """43-128 char verifier. 32 random bytes -> 43-char base64url string (RFC 7636 §4.1)."""
46
+ return base64url_encode(random.bytes(32))
47
+
48
+
49
+ def generate_code_challenge(code_verifier: str) -> str:
50
+ """S256 challenge: base64url(sha256(ascii(code_verifier))). The only method this estate's
51
+ descriptor schema permits (`pkce.methods ⊆ ['S256']`)."""
52
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
53
+ return base64url_encode(digest)
54
+
55
+
56
+ def random_state(random: RandomSource) -> str:
57
+ return base64url_encode(random.bytes(32))
58
+
59
+
60
+ def random_nonce(random: RandomSource) -> str:
61
+ return base64url_encode(random.bytes(32))
memyauth/_roles.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ @fileoverview IdP role -> consumer role mapping, and the sole global-admin vocabulary check.
3
+ @module memyauth._roles
4
+ @description Pure — no I/O, no async, no sync twin (§C.6, shared module in both surfaces). Ported
5
+ verbatim from `memybase/src/iam/iam-client.ts:61-72,82-89`. This is a CONSUMER concept: memyauth
6
+ itself has no role-mapping table. `GLOBAL_ADMIN_IDP_ROLES = ['admin']` is the single most
7
+ contested fact in the whole design (§C.6.1) — roboson independently invented `'global-admin'`,
8
+ a string that appears NOWHERE in the provider (verified by a zero-hit grep across the whole
9
+ memyauth tree), and its admin gate has matched nobody since. `is_global_admin` MUST test the
10
+ RAW idp roles, never the mapped output, and `'global-admin'` MUST NOT be a default alias — a
11
+ test that accepted both would be a weaker test than the one this module ships with.
12
+ @dependencies typing (stdlib only)
13
+ @relatedFiles _iam.py (constructs a RoleMapper and calls map_all/is_global_admin during
14
+ projection), _errors.py (ConfigError), tests/test_roles.py
15
+ @created 2026-09-06
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Mapping, Optional, Sequence
20
+
21
+ from ._errors import ConfigError
22
+
23
+ __all__ = [
24
+ "DEFAULT_ROLE_MAP",
25
+ "CONSUMER_ROLES",
26
+ "DEFAULT_CONSUMER_ROLE",
27
+ "GLOBAL_ADMIN_IDP_ROLES",
28
+ "RoleMapper",
29
+ ]
30
+
31
+ # Ported verbatim from memybase/src/iam/iam-client.ts:61-72 (includes the `datamanager` alias).
32
+ DEFAULT_ROLE_MAP: dict[str, str] = {
33
+ "admin": "owner",
34
+ "owner": "owner",
35
+ "member": "member",
36
+ "developer": "developer",
37
+ "data-manager": "data-manager",
38
+ "datamanager": "data-manager",
39
+ "viewer": "viewer",
40
+ }
41
+
42
+ CONSUMER_ROLES: tuple[str, ...] = ("owner", "member", "developer", "data-manager", "viewer")
43
+ DEFAULT_CONSUMER_ROLE = "member"
44
+
45
+ # §C.6.1 RESOLVED: 'admin' is the canonical global-admin IdP role. 'global-admin' (roboson's
46
+ # invented string) is deliberately ABSENT and must never be added as a default alias.
47
+ GLOBAL_ADMIN_IDP_ROLES: tuple[str, ...] = ("admin",)
48
+
49
+
50
+ def _normalize(raw: str) -> str:
51
+ return raw.strip().lower()
52
+
53
+
54
+ class RoleMapper:
55
+ def __init__(
56
+ self,
57
+ *,
58
+ role_map: Optional[Mapping[str, str]] = None,
59
+ role_overrides: Optional[Mapping[str, str]] = None,
60
+ default_role: str = DEFAULT_CONSUMER_ROLE,
61
+ global_admin_roles: Sequence[str] = GLOBAL_ADMIN_IDP_ROLES,
62
+ passthrough_unknown: bool = False,
63
+ ) -> None:
64
+ base = role_map if role_map is not None else DEFAULT_ROLE_MAP
65
+ self._validate_map_values("roleMap", base)
66
+ merged: dict[str, str] = {_normalize(k): v for k, v in base.items()}
67
+ if role_overrides:
68
+ self._validate_map_values("roleOverrides", role_overrides)
69
+ merged.update({_normalize(k): v for k, v in role_overrides.items()})
70
+ self._map = merged
71
+
72
+ if not default_role or not default_role.strip():
73
+ raise ConfigError("defaultRole must be a non-empty string")
74
+ self._default_role = default_role
75
+
76
+ admins = list(global_admin_roles)
77
+ if not admins or any(not a or not a.strip() for a in admins):
78
+ raise ConfigError("globalAdminRoles must be a non-empty array of role names")
79
+ self._global_admin_roles = {_normalize(a) for a in admins}
80
+
81
+ self._passthrough_unknown = passthrough_unknown
82
+
83
+ @staticmethod
84
+ def _validate_map_values(label: str, mapping: Mapping[str, str]) -> None:
85
+ for value in mapping.values():
86
+ if not isinstance(value, str) or not value.strip():
87
+ raise ConfigError(f"{label} values must be non-empty strings")
88
+
89
+ def map(self, idp_role: str) -> Optional[str]:
90
+ normalized = _normalize(idp_role)
91
+ if normalized in self._map:
92
+ return self._map[normalized]
93
+ return normalized if self._passthrough_unknown else None
94
+
95
+ def map_all(self, idp_roles: Sequence[str]) -> list[str]:
96
+ result: list[str] = []
97
+ seen: set[str] = set()
98
+ for raw in idp_roles:
99
+ mapped = self.map(raw)
100
+ if mapped is None:
101
+ continue
102
+ if mapped not in seen:
103
+ seen.add(mapped)
104
+ result.append(mapped)
105
+ return result if result else [self._default_role]
106
+
107
+ def is_global_admin(self, idp_roles: Sequence[str]) -> bool:
108
+ """Tests the RAW idp roles (never the mapped output) against `global_admin_roles`."""
109
+ return any(_normalize(raw) in self._global_admin_roles for raw in idp_roles)