enterprise_identity.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. """Fail-closed enterprise identity policy, mapping, lifecycle and emergency access."""
  2. from __future__ import annotations
  3. import hashlib
  4. import ipaddress
  5. import json
  6. import os
  7. import re
  8. from collections.abc import Callable, Mapping
  9. from dataclasses import dataclass, field
  10. from datetime import UTC, datetime, timedelta
  11. from typing import Any
  12. from urllib.parse import urlsplit
  13. from app.core.common.identifiers import new_governance_uid
  14. class IdentityPolicyError(ValueError):
  15. pass
  16. class IdentityUpstreamError(IdentityPolicyError):
  17. """Safe boundary error for unavailable or malformed enterprise IdP responses."""
  18. pass
  19. _SENSITIVE_KEYS = {"code", "token", "id_token", "access_token", "refresh_token", "verifier", "code_verifier",
  20. "secret", "client_secret", "claims", "cookie", "password", "assertion", "authorization_url"}
  21. def _utc(value: datetime) -> datetime:
  22. if value.tzinfo is None or value.utcoffset() is None:
  23. raise IdentityPolicyError("timestamp must be timezone-aware")
  24. return value.astimezone(UTC)
  25. def _safe_https_url(value: str, *, redirect: bool = False) -> None:
  26. parsed = urlsplit(value)
  27. if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
  28. raise IdentityPolicyError("identity endpoints require absolute HTTPS URLs")
  29. if parsed.fragment or (redirect and parsed.query):
  30. raise IdentityPolicyError("redirect and identity URLs may not contain query/fragment")
  31. host = parsed.hostname.lower()
  32. if host == "localhost" or host.endswith(".localhost"):
  33. raise IdentityPolicyError("localhost identity endpoints are forbidden")
  34. try:
  35. address = ipaddress.ip_address(host)
  36. except ValueError:
  37. return
  38. if not address.is_global:
  39. raise IdentityPolicyError("private or reserved identity endpoint is forbidden")
  40. @dataclass(frozen=True)
  41. class IdpConfig:
  42. provider_uid: str
  43. version: int
  44. issuer: str
  45. client_id: str
  46. secret_ref: str = field(repr=False)
  47. authorization_endpoint: str
  48. token_endpoint: str
  49. jwks_uri: str
  50. redirect_uris: tuple[str, ...]
  51. post_login_redirect_uris: tuple[str, ...]
  52. algorithms: tuple[str, ...]
  53. mapping_version: str
  54. status: str = "draft"
  55. def validate(self) -> IdpConfig:
  56. if self.version < 1 or not self.provider_uid or not self.client_id or not self.mapping_version:
  57. raise IdentityPolicyError("incomplete IdP configuration")
  58. for endpoint in (self.issuer, self.authorization_endpoint, self.token_endpoint, self.jwks_uri):
  59. _safe_https_url(endpoint)
  60. if not self.redirect_uris:
  61. raise IdentityPolicyError("redirect allowlist is required")
  62. for uri in self.redirect_uris:
  63. _safe_https_url(uri, redirect=True)
  64. if not self.post_login_redirect_uris:
  65. raise IdentityPolicyError("post-login redirect allowlist is required")
  66. for uri in self.post_login_redirect_uris:
  67. _safe_https_url(uri, redirect=True)
  68. if not re.fullmatch(r"env:DATAOPS_OIDC_[A-Z0-9_]+", self.secret_ref):
  69. raise IdentityPolicyError("client secret must use a dedicated DATAOPS_OIDC environment reference")
  70. if not self.algorithms or any(alg not in {"RS256", "RS384", "RS512", "ES256", "ES384"} for alg in self.algorithms):
  71. raise IdentityPolicyError("only explicitly configured asymmetric algorithms are allowed")
  72. return self
  73. def resolve_secret(self) -> str:
  74. self.validate()
  75. value = os.environ.get(self.secret_ref[4:])
  76. if not value:
  77. raise IdentityPolicyError("configured secret reference is unavailable")
  78. return value
  79. def public_dict(self) -> dict[str, Any]:
  80. return {"provider_uid": self.provider_uid, "version": self.version, "issuer": self.issuer,
  81. "client_id": self.client_id, "authorization_endpoint": self.authorization_endpoint,
  82. "redirect_uris": list(self.redirect_uris), "algorithms": list(self.algorithms),
  83. "post_login_redirect_uris": list(self.post_login_redirect_uris),
  84. "mapping_version": self.mapping_version, "status": self.status,
  85. "secret_ref": "env:DATAOPS_OIDC_***"}
  86. def sanitize_audit_detail(value: Any) -> Any:
  87. if isinstance(value, Mapping):
  88. safe: dict[str, Any] = {}
  89. for key, item in value.items():
  90. lowered = str(key).lower()
  91. if lowered in _SENSITIVE_KEYS or any(part in lowered for part in ("secret", "token", "verifier")):
  92. continue
  93. if lowered.endswith("claims"):
  94. continue
  95. safe[str(key)] = sanitize_audit_detail(item)
  96. return safe
  97. if isinstance(value, (list, tuple)):
  98. return [sanitize_audit_detail(item) for item in value]
  99. return value
  100. @dataclass(frozen=True)
  101. class MappedIdentity:
  102. subject: str
  103. username: str
  104. display_name: str
  105. department: str
  106. groups: tuple[str, ...]
  107. roles: tuple[str, ...]
  108. business_domain_uids: tuple[str, ...]
  109. object_types: tuple[str, ...]
  110. environments: tuple[str, ...]
  111. data_scopes: tuple[str, ...]
  112. mapping_version: str
  113. claims_digest: str
  114. evidence: Mapping[str, Any] = field(repr=False)
  115. class ClaimMapper:
  116. REQUIRED = ("sub", "preferred_username", "name", "department", "groups")
  117. RULE_FIELDS = {"roles", "business_domain_uids", "object_types", "environments", "data_scopes"}
  118. def __init__(self, *, version: str, group_rules: Mapping[str, Mapping[str, Any]]) -> None:
  119. if not isinstance(version, str) or not version.strip():
  120. raise IdentityPolicyError("mapping version is required")
  121. if not isinstance(group_rules, Mapping) or not group_rules:
  122. raise IdentityPolicyError("at least one claims group rule is required")
  123. normalized: dict[str, dict[str, tuple[str, ...]]] = {}
  124. for group, raw_rule in group_rules.items():
  125. if not isinstance(group, str) or not group.strip() or not isinstance(raw_rule, Mapping):
  126. raise IdentityPolicyError("claims group rules must be named objects")
  127. if set(raw_rule) - self.RULE_FIELDS:
  128. raise IdentityPolicyError("claims group rule contains unsupported fields")
  129. rule: dict[str, tuple[str, ...]] = {}
  130. for field_name in self.RULE_FIELDS:
  131. values = raw_rule.get(field_name, ())
  132. if not isinstance(values, (list, tuple)) or isinstance(values, (str, bytes)):
  133. raise IdentityPolicyError("claims mapping scope fields must be string arrays")
  134. if any(not isinstance(value, str) or not value.strip() for value in values):
  135. raise IdentityPolicyError("claims mapping scope values must be non-empty strings")
  136. rule[field_name] = tuple(sorted(set(values)))
  137. if not rule["roles"] or not set(rule["roles"]).issubset({"admin", "editor", "viewer"}):
  138. raise IdentityPolicyError("mapping contains an unsupported or empty platform role")
  139. normalized[group.strip()] = rule
  140. self.version = version.strip()
  141. self.group_rules = normalized
  142. def map(self, claims: Mapping[str, Any]) -> MappedIdentity:
  143. if not isinstance(claims, Mapping):
  144. raise IdentityPolicyError("identity claims must be an object")
  145. limits = {"sub": 500, "preferred_username": 64, "name": 300, "department": 300}
  146. if any(not isinstance(claims.get(key), str) or not claims[key].strip()
  147. or len(claims[key].strip()) > maximum for key, maximum in limits.items()):
  148. raise IdentityPolicyError("required identity claim is missing")
  149. raw_groups = claims.get("groups")
  150. if (not isinstance(raw_groups, (list, tuple)) or isinstance(raw_groups, (str, bytes)) or not raw_groups
  151. or any(not isinstance(item, str) or not item.strip() or len(item.strip()) > 300 for item in raw_groups)):
  152. raise IdentityPolicyError("identity groups claim must be a non-empty string array")
  153. groups = tuple(sorted({item.strip() for item in raw_groups}))
  154. matched = [self.group_rules[group] for group in groups if group in self.group_rules]
  155. if not matched:
  156. raise IdentityPolicyError("claims do not match an approved mapping rule")
  157. def collect(key: str) -> tuple[str, ...]:
  158. return tuple(sorted({str(value) for rule in matched for value in rule.get(key, ())}))
  159. roles = collect("roles")
  160. if not roles or not set(roles).issubset({"admin", "editor", "viewer"}):
  161. raise IdentityPolicyError("mapping resolves to no approved platform role")
  162. canonical = json.dumps(claims, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
  163. digest = hashlib.sha256(canonical.encode()).hexdigest()
  164. evidence = {"mapping_version": self.version, "claims_digest": digest,
  165. "matched_group_digests": [hashlib.sha256(group.encode()).hexdigest() for group in groups if group in self.group_rules]}
  166. return MappedIdentity(claims["sub"].strip(), claims["preferred_username"].strip(), claims["name"].strip(),
  167. claims["department"].strip(), groups, roles, collect("business_domain_uids"),
  168. collect("object_types"), collect("environments"), collect("data_scopes"),
  169. self.version, digest, evidence)
  170. class DirectorySynchronizer:
  171. EVENTS = {"JOINER", "MOVER", "LEAVER", "DISABLE", "RESTORE"}
  172. ORGANIZATION_EVENTS = {"DEPARTMENT", "GROUP"}
  173. ORGANIZATION_ACTIONS = {"UPSERT", "DISABLE", "RESTORE"}
  174. def __init__(self, repository: Any, sessions: Any, *, clock: Callable[[], datetime] | None = None) -> None:
  175. self.repository = repository
  176. self.sessions = sessions
  177. self.clock = clock or (lambda: datetime.now(UTC))
  178. def apply(self, *, provider_uid: str, source: str, source_event_id: str, cursor: str,
  179. cursor_sequence: int, event_type: str, attributes: Mapping[str, Any],
  180. subject: str | None = None) -> dict[str, Any]:
  181. event_type = event_type.upper()
  182. if event_type not in self.EVENTS | self.ORGANIZATION_EVENTS or not all((provider_uid, source, source_event_id, cursor)):
  183. raise IdentityPolicyError("invalid directory delta event")
  184. if event_type in self.EVENTS and not subject:
  185. raise IdentityPolicyError("person lifecycle event requires enterprise subject")
  186. if not isinstance(cursor_sequence, int) or cursor_sequence < 1:
  187. raise IdentityPolicyError("directory cursor sequence must be a positive integer")
  188. digest = hashlib.sha256(json.dumps({"cursor": cursor, "event_type": event_type, "subject": subject,
  189. "attributes": attributes}, sort_keys=True, default=str).encode()).hexdigest()
  190. event = {"provider_uid": provider_uid, "source": source, "source_event_id": source_event_id,
  191. "cursor": cursor, "cursor_sequence": cursor_sequence, "event_type": event_type,
  192. "subject": subject, "payload_digest": digest, "processed_at": _utc(self.clock()).isoformat()}
  193. def mutate_organization() -> dict[str, Any]:
  194. node_type = event_type.lower()
  195. external_id = str(attributes.get("external_id") or "").strip()
  196. action = str(attributes.get("lifecycle_action") or attributes.get("action") or "UPSERT").upper()
  197. if not external_id or action not in self.ORGANIZATION_ACTIONS:
  198. raise IdentityPolicyError("invalid organization node delta")
  199. existing = self.repository.get_organization_node(provider_uid, node_type, external_id, for_update=True)
  200. if action in {"DISABLE", "RESTORE"} and not existing:
  201. raise IdentityPolicyError("organization node lifecycle target does not exist")
  202. display_name = str(attributes.get("display_name") or (existing or {}).get("display_name") or "").strip()
  203. if not display_name:
  204. raise IdentityPolicyError("organization node display name is required")
  205. node = {
  206. "uid": (existing or {}).get("uid") or new_governance_uid(),
  207. "provider_uid": provider_uid,
  208. "external_id": external_id,
  209. "node_type": node_type,
  210. "parent_external_id": attributes.get("parent_external_id"),
  211. "display_name": display_name,
  212. "status": "disabled" if action == "DISABLE" else "active",
  213. "attributes": dict(attributes.get("attributes", {})),
  214. "updated_at": _utc(self.clock()),
  215. }
  216. self.repository.put_organization_node(node, commit=False)
  217. return {"node_uid": node["uid"], "node_type": node_type, "external_id": external_id,
  218. "status": node["status"], "lifecycle_action": action, "idempotent": False}
  219. def mutate_identity() -> dict[str, Any]:
  220. identity = self.repository.get_identity(provider_uid, str(subject), for_update=True)
  221. if not identity and event_type != "JOINER":
  222. raise IdentityPolicyError("person lifecycle target does not exist")
  223. identity = identity or {"user_uid": new_governance_uid(), "token_version": 0}
  224. old_roles = tuple(identity.get("roles", ()))
  225. roles = sorted(set(attributes.get("roles", ())))
  226. if not set(roles).issubset({"admin", "editor", "viewer"}):
  227. raise IdentityPolicyError("directory event contains an unsupported platform role")
  228. username = attributes.get("username") or identity.get("username")
  229. if not username:
  230. raise IdentityPolicyError("person lifecycle username is required")
  231. identity.update(subject=subject, username=username,
  232. department=attributes.get("department", identity.get("department", "")),
  233. display_name=attributes.get("display_name") or identity.get("display_name") or username,
  234. provider_uid=provider_uid, groups=sorted(attributes.get("groups", identity.get("groups", ()))),
  235. roles=roles, mapping_version=attributes.get("mapping_version") or identity.get("mapping_version"),
  236. claims_digest=attributes.get("claims_digest") or identity.get("claims_digest"),
  237. authorization_scope=dict(attributes.get("authorization_scope", identity.get("authorization_scope", {}))),
  238. status="disabled" if event_type in {"LEAVER", "DISABLE"} else "active",
  239. token_version=int(identity.get("token_version", 0)) + 1,
  240. updated_at=_utc(self.clock()).isoformat())
  241. # RESTORE deliberately recalculates from current input and never restores old roles.
  242. self.repository.put_identity(provider_uid, str(subject), identity, commit=False)
  243. if event_type in {"MOVER", "LEAVER", "DISABLE", "RESTORE"} or old_roles != tuple(identity["roles"]):
  244. self.repository.revoke_subject_sessions(provider_uid, str(subject),
  245. reason=f"directory_{event_type.lower()}", commit=False)
  246. return {"user_uid": identity["user_uid"], "status": identity["status"],
  247. "token_version": identity["token_version"], "idempotent": False}
  248. mutation = mutate_organization if event_type in self.ORGANIZATION_EVENTS else mutate_identity
  249. return self.repository.apply_directory_event_atomic(event, mutation)
  250. class EmergencyAccess:
  251. MAX_DURATION = timedelta(hours=2)
  252. def __init__(self, repository: Any, *, clock: Callable[[], datetime] | None = None) -> None:
  253. self.repository = repository
  254. self.clock = clock or (lambda: datetime.now(UTC))
  255. def request(self, *, requester_uid: str, account_uid: str, reason: str, expires_at: datetime,
  256. account_is_local_active_admin: bool) -> dict[str, Any]:
  257. now = _utc(self.clock())
  258. expiry = _utc(expires_at)
  259. if (requester_uid == account_uid or not reason.strip() or not now < expiry
  260. or expiry - now > self.MAX_DURATION or not account_is_local_active_admin):
  261. raise IdentityPolicyError("invalid emergency access request")
  262. record = {"uid": new_governance_uid(), "requester_uid": requester_uid, "account_uid": account_uid,
  263. "reason": reason.strip(), "status": "pending", "approver_uids": [],
  264. "requested_at": now, "expires_at": expiry, "reviewed_at": None}
  265. self.repository.put_emergency(record)
  266. return record
  267. def approve(self, request_uid: str, *, approver_uid: str) -> dict[str, Any]:
  268. record = self.repository.get_emergency(request_uid)
  269. if not record or record["status"] != "pending" or _utc(self.clock()) >= record["expires_at"]:
  270. raise IdentityPolicyError("emergency request is not approvable")
  271. forbidden = {record["requester_uid"], record["account_uid"], *record["approver_uids"]}
  272. if approver_uid in forbidden:
  273. raise IdentityPolicyError("emergency access requires two distinct non-self approvers")
  274. record["approver_uids"].append(approver_uid)
  275. self.repository.put_emergency(record)
  276. return record
  277. def activate(self, request_uid: str) -> dict[str, Any]:
  278. record = self.repository.get_emergency(request_uid)
  279. if not record or record["status"] != "pending" or len(set(record["approver_uids"])) != 2 or _utc(self.clock()) >= record["expires_at"]:
  280. raise IdentityPolicyError("two approvals in the active time window are required")
  281. record.update(status="active", activated_at=_utc(self.clock()), alert_required=True)
  282. self.repository.put_emergency(record)
  283. return record
  284. def review(self, request_uid: str, *, reviewer_uid: str, outcome: str) -> dict[str, Any]:
  285. record = self.repository.get_emergency(request_uid)
  286. if record and record["status"] == "active" and _utc(self.clock()) >= record["expires_at"]:
  287. record["status"] = "expired"
  288. self.repository.put_emergency(record)
  289. if not record or record["status"] not in {"closed", "expired"} or reviewer_uid == record["account_uid"]:
  290. raise IdentityPolicyError("invalid emergency review")
  291. record.update(status="reviewed", reviewed_at=_utc(self.clock()), reviewer_uid=reviewer_uid, review_outcome=outcome)
  292. self.repository.put_emergency(record)
  293. return record
  294. def close(self, request_uid: str, *, actor_uid: str) -> dict[str, Any]:
  295. record = self.repository.get_emergency(request_uid)
  296. if record and record["status"] == "active" and _utc(self.clock()) >= record["expires_at"]:
  297. record["status"] = "expired"
  298. self.repository.put_emergency(record)
  299. raise IdentityPolicyError("emergency access already expired")
  300. if not record or record["status"] != "active" or actor_uid == record["account_uid"]:
  301. raise IdentityPolicyError("invalid emergency access closure")
  302. record.update(status="closed", closed_at=_utc(self.clock()), closed_by_uid=actor_uid)
  303. self.repository.put_emergency(record)
  304. return record