test_phase3_wp02_enterprise_identity.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. from __future__ import annotations
  2. import base64
  3. import hashlib
  4. from datetime import UTC, datetime, timedelta
  5. import jwt
  6. import pytest
  7. from cryptography.hazmat.primitives.asymmetric import rsa
  8. NOW = datetime(2026, 8, 2, 8, 0, tzinfo=UTC)
  9. def _b64int(value: int) -> str:
  10. size = (value.bit_length() + 7) // 8
  11. return base64.urlsafe_b64encode(value.to_bytes(size, "big")).rstrip(b"=").decode()
  12. def _key_material():
  13. private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  14. numbers = private.public_key().public_numbers()
  15. jwk = {"kty": "RSA", "kid": "test-key", "alg": "RS256", "use": "sig", "n": _b64int(numbers.n), "e": _b64int(numbers.e)}
  16. return private, jwk
  17. def test_idp_config_rejects_unsafe_urls_redirects_and_secret_values(monkeypatch):
  18. from app.core.system.enterprise_identity import IdentityPolicyError, IdpConfig
  19. valid = {
  20. "provider_uid": "idp-1", "version": 1, "issuer": "https://login.example.com/tenant",
  21. "client_id": "dataops", "secret_ref": "env:DATAOPS_OIDC_CLIENT_SECRET",
  22. "authorization_endpoint": "https://login.example.com/oauth2/authorize",
  23. "token_endpoint": "https://login.example.com/oauth2/token",
  24. "jwks_uri": "https://login.example.com/.well-known/jwks.json",
  25. "redirect_uris": ("https://dataops.example.com/api/system/identity/callback",),
  26. "post_login_redirect_uris": ("https://dataops.example.com/login/callback",),
  27. "algorithms": ("RS256",), "mapping_version": "mapping-v1",
  28. }
  29. config = IdpConfig(**valid)
  30. config.validate()
  31. monkeypatch.setenv("DATAOPS_OIDC_CLIENT_SECRET", "not-returned")
  32. assert config.resolve_secret() == "not-returned"
  33. assert "not-returned" not in repr(config)
  34. for update in (
  35. {"issuer": "http://login.example.com"},
  36. {"issuer": "https://127.0.0.1"},
  37. {"issuer": "https://localhost"},
  38. {"secret_ref": "plain-secret"},
  39. {"secret_ref": "env:AWS_SECRET_ACCESS_KEY"},
  40. {"secret_ref": "env:OIDC_TEST_SECRET"},
  41. {"algorithms": ("none",)},
  42. {"redirect_uris": ("https://dataops.example.com/callback#token",)},
  43. {"post_login_redirect_uris": ("javascript:alert(1)",)},
  44. {"post_login_redirect_uris": ("https://localhost/login/callback",)},
  45. ):
  46. payload = {**valid, **update}
  47. with pytest.raises(IdentityPolicyError):
  48. IdpConfig(**payload).validate()
  49. def test_oidc_pkce_state_nonce_signature_and_replay_are_fail_closed():
  50. from app.core.system.enterprise_identity import IdentityPolicyError, IdpConfig
  51. from app.core.system.identity_repository import MemoryIdentityRepository
  52. from app.core.system.oidc import OidcClient
  53. config = IdpConfig(
  54. provider_uid="idp-1", version=1, issuer="https://login.example.com/tenant",
  55. client_id="dataops", secret_ref="env:DATAOPS_OIDC_TEST_SECRET",
  56. authorization_endpoint="https://login.example.com/authorize",
  57. token_endpoint="https://login.example.com/token",
  58. jwks_uri="https://login.example.com/jwks", redirect_uris=("https://dataops.example.com/callback",),
  59. post_login_redirect_uris=("https://dataops.example.com/login/callback",),
  60. algorithms=("RS256",), mapping_version="mapping-v1",
  61. )
  62. private, jwk = _key_material()
  63. repo = MemoryIdentityRepository()
  64. client = OidcClient(repo, clock=lambda: NOW)
  65. started = client.begin(config, "https://dataops.example.com/callback")
  66. assert started.code_challenge_method == "S256"
  67. assert started.code_challenge == base64.urlsafe_b64encode(
  68. hashlib.sha256(started.code_verifier.encode()).digest()
  69. ).rstrip(b"=").decode()
  70. claims = {"iss": config.issuer, "aud": config.client_id, "sub": "employee-1", "nonce": started.nonce,
  71. "iat": int(NOW.timestamp()), "exp": int((NOW + timedelta(minutes=5)).timestamp()),
  72. "preferred_username": "alice", "name": "Alice", "department": "ops", "groups": ["stewards"]}
  73. token = jwt.encode(claims, private, algorithm="RS256", headers={"kid": "test-key"})
  74. verified = client.verify_callback(config, state=started.state, redirect_uri="https://dataops.example.com/callback",
  75. code_verifier=started.code_verifier, id_token=token, jwks={"keys": [jwk]})
  76. assert verified["sub"] == "employee-1"
  77. with pytest.raises(IdentityPolicyError, match="state"):
  78. client.verify_callback(config, state=started.state, redirect_uri="https://dataops.example.com/callback",
  79. code_verifier=started.code_verifier, id_token=token, jwks={"keys": [jwk]})
  80. forged = jwt.encode({**claims, "nonce": "wrong"}, "symmetric", algorithm="HS256", headers={"kid": "test-key"})
  81. second = client.begin(config, "https://dataops.example.com/callback")
  82. with pytest.raises(IdentityPolicyError):
  83. client.verify_callback(config, state=second.state, redirect_uri="https://dataops.example.com/callback",
  84. code_verifier=second.code_verifier, id_token=forged, jwks={"keys": [jwk]})
  85. def test_oidc_transport_rejects_private_dns_redirects_oversize_and_bad_json():
  86. from app.core.system.enterprise_identity import (
  87. IdentityPolicyError,
  88. IdentityUpstreamError,
  89. IdpConfig,
  90. )
  91. from app.core.system.oidc import OidcTransport
  92. config = IdpConfig(
  93. provider_uid="idp-1", version=1, issuer="https://login.example.com/tenant",
  94. client_id="dataops", secret_ref="env:DATAOPS_OIDC_TEST_SECRET",
  95. authorization_endpoint="https://login.example.com/authorize",
  96. token_endpoint="https://login.example.com/token", jwks_uri="https://login.example.com/jwks",
  97. redirect_uris=("https://dataops.example.com/callback",),
  98. post_login_redirect_uris=("https://dataops.example.com/login/callback",),
  99. algorithms=("RS256",), mapping_version="mapping-v1",
  100. )
  101. class Response:
  102. def __init__(self, status, body, content_type="application/json"):
  103. self.status_code = status
  104. self.body = body
  105. self.headers = {"Content-Type": content_type, "Content-Length": str(len(body))}
  106. def iter_content(self, chunk_size):
  107. del chunk_size
  108. yield self.body
  109. def close(self):
  110. return None
  111. class Http:
  112. def __init__(self, response):
  113. self.response = response
  114. def get(self, url, **kwargs):
  115. del url, kwargs
  116. return self.response
  117. def public_dns(*args, **kwargs):
  118. del args, kwargs
  119. return [(2, 1, 6, "", ("93.184.216.34", 443))]
  120. def private_dns(*args, **kwargs):
  121. del args, kwargs
  122. return [(2, 1, 6, "", ("10.0.0.8", 443))]
  123. with pytest.raises(IdentityPolicyError, match="non-public"):
  124. OidcTransport(http=Http(Response(200, b'{"keys":[]}')), resolver=private_dns).fetch_jwks(config)
  125. with pytest.raises(IdentityUpstreamError, match="redirect"):
  126. OidcTransport(http=Http(Response(302, b"{}")), resolver=public_dns).fetch_jwks(config)
  127. with pytest.raises(IdentityUpstreamError, match="too large"):
  128. OidcTransport(http=Http(Response(200, b"x" * 33)), resolver=public_dns,
  129. max_response_bytes=32).fetch_jwks(config)
  130. with pytest.raises(IdentityUpstreamError, match="invalid JSON"):
  131. OidcTransport(http=Http(Response(200, b"not-json")), resolver=public_dns).fetch_jwks(config)
  132. with pytest.raises(IdentityUpstreamError, match="must be JSON"):
  133. OidcTransport(http=Http(Response(200, b"{}", "text/plain")), resolver=public_dns).fetch_jwks(config)
  134. def test_oidc_production_transport_pins_validated_ip_and_preserves_tls_hostname():
  135. from app.core.system.enterprise_identity import IdpConfig
  136. from app.core.system.oidc import OidcTransport
  137. config = IdpConfig(
  138. provider_uid="idp-1", version=1, issuer="https://login.example.com/tenant",
  139. client_id="dataops", secret_ref="env:DATAOPS_OIDC_TEST_SECRET",
  140. authorization_endpoint="https://login.example.com/authorize",
  141. token_endpoint="https://login.example.com/token", jwks_uri="https://login.example.com/jwks?tenant=one",
  142. redirect_uris=("https://dataops.example.com/callback",),
  143. post_login_redirect_uris=("https://dataops.example.com/login/callback",),
  144. algorithms=("RS256",), mapping_version="mapping-v1",
  145. )
  146. observed = {"resolver_calls": 0, "pool": [], "request": []}
  147. def resolver(host, port, **kwargs):
  148. observed["resolver_calls"] += 1
  149. assert host == "login.example.com" and port == 443
  150. assert kwargs["type"]
  151. return [(2, 1, 6, "", ("93.184.216.34", 443))]
  152. class Response:
  153. status = 200
  154. headers = {"Content-Type": "application/json", "Content-Length": "11"}
  155. def stream(self, **kwargs):
  156. assert kwargs == {"amt": 64 * 1024, "decode_content": True}
  157. yield b'{"keys":[]}'
  158. def release_conn(self):
  159. return None
  160. class Pool:
  161. def __init__(self, **kwargs):
  162. observed["pool"].append(kwargs)
  163. def urlopen(self, method, target, **kwargs):
  164. observed["request"].append((method, target, kwargs))
  165. return Response()
  166. def close(self):
  167. return None
  168. payload = OidcTransport(resolver=resolver, pool_factory=Pool).fetch_jwks(config)
  169. assert payload == {"keys": []}
  170. assert observed["resolver_calls"] == 1
  171. pool = observed["pool"][0]
  172. assert pool["host"] == "93.184.216.34"
  173. assert pool["server_hostname"] == "login.example.com"
  174. assert pool["assert_hostname"] == "login.example.com"
  175. assert pool["cert_reqs"] == "CERT_REQUIRED" and pool["ca_certs"]
  176. method, target, request_options = observed["request"][0]
  177. assert method == "GET" and target == "/jwks?tenant=one"
  178. assert request_options["headers"]["Host"] == "login.example.com"
  179. assert request_options["redirect"] is False and request_options["retries"] is False
  180. def test_claim_mapping_is_deterministic_versioned_and_default_deny():
  181. from app.core.system.enterprise_identity import ClaimMapper, IdentityPolicyError
  182. mapper = ClaimMapper(
  183. version="mapping-v7",
  184. group_rules={"stewards": {"roles": ["editor"], "business_domain_uids": ["domain-b", "domain-a"],
  185. "object_types": ["dataset"], "environments": ["prod"],
  186. "data_scopes": ["plant-1"]}},
  187. )
  188. claims = {"sub": "s-1", "preferred_username": "alice", "name": "Alice", "department": "ops",
  189. "groups": ["stewards"]}
  190. result = mapper.map(claims)
  191. assert result.roles == ("editor",)
  192. assert result.business_domain_uids == ("domain-a", "domain-b")
  193. assert result.mapping_version == "mapping-v7"
  194. assert len(result.claims_digest) == 64
  195. assert "Alice" not in repr(result.evidence)
  196. with pytest.raises(IdentityPolicyError):
  197. mapper.map({**claims, "groups": ["unknown"]})
  198. for malformed_claims in (
  199. {**claims, "groups": "stewards"},
  200. {**claims, "groups": ["stewards", 1]},
  201. {**claims, "groups": []},
  202. {**claims, "sub": {"value": "s-1"}},
  203. {**claims, "preferred_username": ["alice"]},
  204. {**claims, "name": ""},
  205. {**claims, "department": "x" * 301},
  206. ):
  207. with pytest.raises(IdentityPolicyError):
  208. mapper.map(malformed_claims)
  209. with pytest.raises(IdentityPolicyError):
  210. ClaimMapper(version="x", group_rules={"bad": {"roles": ["superuser"]}})
  211. for version, rules in (
  212. ("", {"group": {"roles": ["viewer"]}}),
  213. ("v1", {}),
  214. ("v1", {"group": "viewer"}),
  215. ("v1", {"group": {"roles": "viewer"}}),
  216. ("v1", {"group": {"roles": ["viewer"], "unknown_scope": ["x"]}}),
  217. ("v1", {"group": {"roles": ["viewer"], "data_scopes": [1]}}),
  218. ):
  219. with pytest.raises(IdentityPolicyError):
  220. ClaimMapper(version=version, group_rules=rules)
  221. def test_directory_events_are_idempotent_conflict_safe_and_leaver_revokes():
  222. from app.core.system.enterprise_identity import (
  223. DirectorySynchronizer,
  224. IdentityPolicyError,
  225. )
  226. from app.core.system.identity_repository import MemoryIdentityRepository
  227. from app.core.system.identity_sessions import SessionManager
  228. repo = MemoryIdentityRepository()
  229. sessions = SessionManager(repo, secret="a-long-test-secret", clock=lambda: NOW)
  230. sync = DirectorySynchronizer(repo, sessions, clock=lambda: NOW)
  231. first = sync.apply(provider_uid="idp-a", source="scim", source_event_id="e1", cursor="10", cursor_sequence=10, event_type="JOINER",
  232. subject="s1", attributes={"username": "alice", "roles": ["viewer"]})
  233. sessions.create(provider_uid="idp-a", subject="s1", user_uid=first["user_uid"], roles=["viewer"], identity_source="oidc")
  234. sessions.create(provider_uid="idp-b", subject="s1", user_uid="other", roles=["viewer"], identity_source="oidc")
  235. assert sync.apply(provider_uid="idp-a", source="scim", source_event_id="e1", cursor="10", cursor_sequence=10, event_type="JOINER",
  236. subject="s1", attributes={"username": "alice", "roles": ["viewer"]})["idempotent"] is True
  237. with pytest.raises(IdentityPolicyError):
  238. sync.apply(provider_uid="idp-a", source="scim", source_event_id="e1", cursor="10", cursor_sequence=10, event_type="MOVER",
  239. subject="s1", attributes={"username": "alice", "roles": ["editor"]})
  240. with pytest.raises(IdentityPolicyError, match="monotonic"):
  241. sync.apply(provider_uid="idp-a", source="scim", source_event_id="old", cursor="9", cursor_sequence=9,
  242. event_type="MOVER", subject="s1", attributes={"username": "alice", "roles": ["viewer"]})
  243. assert len(repo.directory_conflicts) == 2
  244. sync.apply(provider_uid="idp-a", source="scim", source_event_id="e2", cursor="11", cursor_sequence=11, event_type="LEAVER",
  245. subject="s1", attributes={"username": "alice", "roles": []})
  246. assert all(item["status"] == "revoked" for item in repo.list_sessions("idp-a", "s1"))
  247. assert repo.list_sessions("idp-b", "s1")[0]["status"] == "active"
  248. def test_directory_organization_nodes_are_provider_scoped_and_atomic(monkeypatch):
  249. from app.core.system.enterprise_identity import (
  250. DirectorySynchronizer,
  251. IdentityPolicyError,
  252. )
  253. from app.core.system.identity_repository import MemoryIdentityRepository
  254. from app.core.system.identity_sessions import SessionManager
  255. repo = MemoryIdentityRepository()
  256. sync = DirectorySynchronizer(repo, SessionManager(repo, secret="a-long-test-secret"), clock=lambda: NOW)
  257. first = sync.apply(provider_uid="idp-a", source="directory", source_event_id="d1", cursor="1",
  258. cursor_sequence=1, event_type="DEPARTMENT",
  259. attributes={"external_id": "ops", "display_name": "Operations", "action": "UPSERT"})
  260. sync.apply(provider_uid="idp-a", source="directory", source_event_id="d2", cursor="2",
  261. cursor_sequence=2, event_type="DEPARTMENT",
  262. attributes={"external_id": "ops", "action": "DISABLE"})
  263. restored = sync.apply(provider_uid="idp-a", source="directory", source_event_id="d3", cursor="3",
  264. cursor_sequence=3, event_type="DEPARTMENT",
  265. attributes={"external_id": "ops", "display_name": "Operations", "action": "RESTORE"})
  266. assert first["node_uid"] == restored["node_uid"]
  267. assert repo.get_organization_node("idp-a", "department", "ops")["status"] == "active"
  268. sync.apply(provider_uid="idp-b", source="directory", source_event_id="d1", cursor="1",
  269. cursor_sequence=1, event_type="DEPARTMENT",
  270. attributes={"external_id": "ops", "display_name": "Other Operations", "action": "UPSERT"})
  271. assert repo.get_organization_node("idp-b", "department", "ops")["display_name"] == "Other Operations"
  272. with pytest.raises(IdentityPolicyError, match="idempotency"):
  273. sync.apply(provider_uid="idp-a", source="directory", source_event_id="d3", cursor="3",
  274. cursor_sequence=3, event_type="DEPARTMENT",
  275. attributes={"external_id": "ops", "display_name": "Changed", "action": "RESTORE"})
  276. original = repo.put_directory_event
  277. monkeypatch.setattr(repo, "put_directory_event", lambda event: (_ for _ in ()).throw(RuntimeError("event failure")))
  278. with pytest.raises(RuntimeError, match="event failure"):
  279. sync.apply(provider_uid="idp-a", source="rollback", source_event_id="g1", cursor="1",
  280. cursor_sequence=1, event_type="GROUP",
  281. attributes={"external_id": "stewards", "display_name": "Stewards", "action": "UPSERT"})
  282. monkeypatch.setattr(repo, "put_directory_event", original)
  283. assert repo.get_organization_node("idp-a", "group", "stewards") is None
  284. assert repo.get_directory_checkpoint("idp-a", "rollback") is None
  285. def test_refresh_rotation_reuse_concurrency_risk_and_token_version():
  286. from app.core.system.enterprise_identity import IdentityPolicyError
  287. from app.core.system.identity_repository import MemoryIdentityRepository
  288. from app.core.system.identity_sessions import SessionManager
  289. repo = MemoryIdentityRepository()
  290. manager = SessionManager(repo, secret="a-long-test-secret", clock=lambda: NOW, max_sessions=2)
  291. one = manager.create(provider_uid="idp-1", subject="s1", user_uid="u1", roles=["viewer"], identity_source="oidc")
  292. rotated = manager.refresh(one.refresh_token)
  293. with pytest.raises(IdentityPolicyError, match="reuse"):
  294. manager.refresh(one.refresh_token)
  295. assert repo.get_session(rotated.session_uid)["status"] == "revoked"
  296. manager.create(provider_uid="idp-1", subject="s1", user_uid="u1", roles=["viewer"], identity_source="oidc")
  297. latest = manager.create(provider_uid="idp-1", subject="s1", user_uid="u1", roles=["editor"], identity_source="oidc")
  298. manager.create(provider_uid="idp-1", subject="s1", user_uid="u1", roles=["viewer"], identity_source="oidc")
  299. assert len([x for x in repo.list_sessions("idp-1", "s1") if x["status"] == "active"]) <= 2
  300. manager.risk_revoke(latest.session_uid, reason="impossible_travel")
  301. assert repo.get_session(latest.session_uid)["risk_reason"] == "impossible_travel"
  302. def test_emergency_access_requires_two_distinct_non_self_approvers_and_window():
  303. from app.core.system.enterprise_identity import EmergencyAccess, IdentityPolicyError
  304. from app.core.system.identity_repository import MemoryIdentityRepository
  305. from app.core.system.identity_sessions import SessionManager
  306. repo = MemoryIdentityRepository()
  307. emergency = EmergencyAccess(repo, clock=lambda: NOW)
  308. with pytest.raises(IdentityPolicyError):
  309. emergency.request(requester_uid="requester", account_uid="breakglass", reason="IdP outage",
  310. expires_at=NOW + timedelta(minutes=30), account_is_local_active_admin=False)
  311. req = emergency.request(requester_uid="requester", account_uid="breakglass", reason="IdP outage",
  312. expires_at=NOW + timedelta(minutes=30), account_is_local_active_admin=True)
  313. for approver in ("requester", "breakglass"):
  314. with pytest.raises(IdentityPolicyError):
  315. emergency.approve(req["uid"], approver_uid=approver)
  316. emergency.approve(req["uid"], approver_uid="admin-a")
  317. with pytest.raises(IdentityPolicyError):
  318. emergency.activate(req["uid"])
  319. emergency.approve(req["uid"], approver_uid="admin-b")
  320. active = emergency.activate(req["uid"])
  321. assert active["status"] == "active"
  322. manager = SessionManager(repo, secret="a-long-test-secret", clock=lambda: NOW)
  323. credentials = manager.create(
  324. subject="breakglass", user_uid="breakglass", roles=["admin"], identity_source="emergency",
  325. emergency_request_uid=req["uid"], emergency_expires_at=req["expires_at"])
  326. rotated = manager.refresh(credentials.refresh_token)
  327. assert repo.get_session(rotated.session_uid)["expires_at"] == req["expires_at"]
  328. emergency.close(req["uid"], actor_uid="admin-a")
  329. with pytest.raises(IdentityPolicyError, match="window"):
  330. manager.refresh(rotated.refresh_token)
  331. reviewed = emergency.review(req["uid"], reviewer_uid="admin-b", outcome="approved")
  332. assert reviewed["status"] == "reviewed"
  333. def test_identity_audit_sanitizes_sensitive_fields_recursively():
  334. from app.core.system.enterprise_identity import sanitize_audit_detail
  335. safe = sanitize_audit_detail({"code": "secret", "token": "secret", "claims": {"sub": "s1"},
  336. "cookie": "secret", "password": "secret", "assertion": "secret",
  337. "ok": True, "nested": {"verifier": "secret", "event": "login"}})
  338. serialized = repr(safe)
  339. assert "secret" not in serialized and "s1" not in serialized
  340. assert safe["ok"] is True and safe["nested"]["event"] == "login"
  341. def test_public_identity_policy_is_method_exact_and_logout_is_viewer_safe():
  342. from app.core.system.permissions import (
  343. IDENTITY_READ,
  344. MANAGE_USERS,
  345. PUBLIC,
  346. permission_for_request,
  347. )
  348. assert permission_for_request("/api/system/identity/providers", "GET") == (PUBLIC,)
  349. assert permission_for_request("/api/system/identity/providers", "POST") != (PUBLIC,)
  350. assert permission_for_request("/api/system/identity/callback", "POST") != (PUBLIC,)
  351. assert permission_for_request("/api/system/auth/login", "GET") == (MANAGE_USERS,)
  352. assert permission_for_request("/api/system/identity/logout", "POST") == (IDENTITY_READ,)
  353. assert permission_for_request("/api/system/identity/idp-versions", "GET") == ("identity:manage",)
  354. assert permission_for_request("/api/system/identity/sessions", "GET") == ("identity:operate",)
  355. assert permission_for_request("/api/system/identity/audit", "GET") == ("identity:manage",)
  356. def test_identity_management_input_helpers_reject_malformed_values():
  357. from app.api.system.enterprise_identity import _positive_int, _timestamp, _uuid
  358. from app.core.system.enterprise_identity import IdentityPolicyError
  359. for value in (None, "not-a-uuid", 123):
  360. with pytest.raises(IdentityPolicyError):
  361. _uuid(value, "provider_uid")
  362. for value in (None, "bad", 0, -1, True, 501):
  363. with pytest.raises(IdentityPolicyError):
  364. _positive_int(value, "limit", maximum=500)
  365. for value in (None, "2026-08-02", "not-a-date"):
  366. with pytest.raises(IdentityPolicyError):
  367. _timestamp(value, "expires_at")
  368. def test_identity_management_api_returns_safe_4xx_and_upstream_502(monkeypatch):
  369. from app import create_app
  370. from app.api.system import enterprise_identity as identity_api
  371. from app.core.system.enterprise_identity import IdentityUpstreamError
  372. from app.core.system.oidc import OidcTransport
  373. monkeypatch.setattr(
  374. "app.core.system.auth.load_identity_from_token",
  375. lambda token, secret: {"id": "01900000-0000-7000-8000-000000000001", "username": "admin",
  376. "roles": ["admin"]} if token == "admin" else None,
  377. )
  378. monkeypatch.setattr(identity_api, "_audit", lambda *args, **kwargs: None)
  379. app = create_app()
  380. app.config.update(TESTING=True)
  381. client = app.test_client()
  382. headers = {"Authorization": "Bearer admin"}
  383. malformed = (
  384. client.post("/api/system/identity/idp-versions", json={"provider_uid": "bad"}, headers=headers),
  385. client.get("/api/system/identity/sessions?provider_uid=bad", headers=headers),
  386. client.get("/api/system/identity/audit?limit=not-an-int", headers=headers),
  387. client.post("/api/system/identity/directory/delta", json={"provider_uid": "bad"}, headers=headers),
  388. client.post("/api/system/identity/emergency/requests", json={"account_uid": "bad"}, headers=headers),
  389. )
  390. assert all(response.status_code == 400 for response in malformed)
  391. assert all("Traceback" not in response.get_data(as_text=True) for response in malformed)
  392. provider_uid = "01900000-0000-7000-8000-000000000099"
  393. monkeypatch.setattr(identity_api, "_load_config", lambda *args, **kwargs: {
  394. "provider_uid": provider_uid, "version": 1, "status": "draft",
  395. "issuer": "https://login.example.com/tenant", "client_id": "dataops",
  396. "secret_ref": "env:DATAOPS_OIDC_TEST_SECRET",
  397. "authorization_endpoint": "https://login.example.com/authorize",
  398. "token_endpoint": "https://login.example.com/token", "jwks_uri": "https://login.example.com/jwks",
  399. "redirect_uris": ["https://dataops.example.com/callback"], "algorithms": ["RS256"],
  400. "mapping_version": "v1", "config": {
  401. "post_login_redirect_uris": ["https://dataops.example.com/login/callback"],
  402. "group_rules": {"stewards": {"roles": ["viewer"]}},
  403. },
  404. })
  405. monkeypatch.setattr(OidcTransport, "fetch_jwks", lambda self, config: (_ for _ in ()).throw(
  406. IdentityUpstreamError("internal network detail must not leak")))
  407. response = client.post(f"/api/system/identity/idp-versions/{provider_uid}/1/connectivity",
  408. json={}, headers=headers)
  409. assert response.status_code == 502
  410. assert "internal network detail" not in response.get_data(as_text=True)