"""Authenticated flex-auth client; verification precedes every allow/deny.""" from __future__ import annotations import base64 import hashlib import json from datetime import datetime, timedelta, timezone from pathlib import Path import httpx from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from hub_core.security.boundary import Authorization, Decision from hub_core.security.identity import AccessFailure, require_https def _object(pairs): result = {} for key, value in pairs: if key in result: raise ValueError("duplicate JSON key") result[key] = value return result def parse_json(raw: bytes | str): def reject(_): raise ValueError("non-integer numbers are outside this profile") return json.loads(raw, object_pairs_hook=_object, parse_float=reject, parse_constant=reject) def go_json(value) -> bytes: """Preserve Go struct/wire order and escape HTML as encoding/json does. This deliberately does not sort struct fields. Maps sent in CheckRequest are sorted separately. Unsupported floating point input fails closed. """ encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) for char, escaped in (("<", "\\u003c"), (">", "\\u003e"), ("&", "\\u0026"), ("\u2028", "\\u2028"), ("\u2029", "\\u2029")): encoded = encoded.replace(char, escaped) return encoded.encode() def _sorted_maps(value): if isinstance(value, dict): return {k: _sorted_maps(value[k]) for k in sorted(value)} if isinstance(value, list): return [_sorted_maps(x) for x in value] return value def submitted_digest(request: dict) -> str: # requestDigestMaterial, SubjectRef and ResourceRef are Go structs, whose # declaration order (unlike maps) participates in the current wire contract. material = {} if request.get("tenant"): material["tenant"] = request["tenant"] for field, order in (("subject", ("id", "type", "tenant", "attributes")), ("resource", ("id", "type", "system", "tenant", "attributes"))): if field == "resource": material["action"] = request["action"] material[field] = {k: _sorted_maps(request[field][k]) for k in order if request[field].get(k)} if request.get("context"): material["context"] = _sorted_maps(request["context"]) return "sha256:" + hashlib.sha256(go_json(material)).hexdigest() def verify_signature(envelope: dict, keys: dict) -> None: signature = envelope["signature"] if signature["mode"] != "signed" or signature["alg"] != "ed25519": raise ValueError("signed Ed25519 decision required") candidates = [key for key in keys["keys"] if key["kid"] == signature["kid"]] if len(candidates) != 1 or candidates[0]["alg"] != "ed25519": raise ValueError("untrusted signing key") def decode(value): return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True) key = Ed25519PublicKey.from_public_bytes(decode(candidates[0]["public_key"])) key.verify(decode(signature["value"]), go_json({ k: v for k, v in envelope.items() if k != "signature" })) def _time(value: str) -> datetime: result = datetime.fromisoformat(value.replace("Z", "+00:00")) if result.tzinfo is None: raise ValueError("timezone required") return result def verify_decision(envelope: dict, *, request: dict, keys: dict, caller: str, now: datetime | None = None) -> Decision: verify_signature(envelope, keys) # The exact signed artifact is retained for independent verification. Do # not hide secret-shaped fields in its serialized audit representation. def check_fields(value): if isinstance(value, dict): for key, item in value.items(): if any(fragment in key.lower() for fragment in ( "password", "secret", "token", "credential", "private_key", )): raise ValueError("sensitive decision field is outside the audit profile") check_fields(item) elif isinstance(value, list): for item in value: check_fields(item) check_fields(envelope) now = now or datetime.now(timezone.utc) if (envelope["contract_version"] != "flex-auth.decision-record.v1" or envelope["request_id"] != request["id"] or not envelope["id"]): raise ValueError("invalid decision contract or correlation") binding = envelope["binding"] if binding["submitted_request_digest"] != submitted_digest(request): raise ValueError("submitted request mismatch") if binding["action"] != request["action"] or binding.get("tenant") != request["tenant"]: raise ValueError("action or tenant mismatch") for field, fields in (("subject", ("id", "type", "tenant")), ("resource", ("id", "type", "system", "tenant"))): for key in fields: if binding[field].get(key) != request[field].get(key): raise ValueError("evaluated identity or resource mismatch") if envelope[field] != binding[field]: raise ValueError("inconsistent binding") if binding.get("context", {}) != request.get("context", {}): raise ValueError("context mismatch") provenance = envelope["provenance"] caller_record = provenance["caller"] if (caller_record["mode"] != "enforce" or caller_record["principal"] != caller or caller_record["audience"] != "flex-auth" or _time(caller_record["not_after"]) <= now): raise ValueError("untrusted workload caller") if not provenance["policy_version"] or not provenance["policy_package_digest"]: raise ValueError("missing policy provenance") age = (now - _time(provenance["decision_time"])).total_seconds() if not 0 <= age < 30: raise ValueError("stale decision") if envelope.get("obligations"): # No obligation is silently treated as satisfied. Owner-specific # approval/redaction/audit handlers require a later profile revision. raise ValueError("unsupported decision obligations") if envelope["effect"] not in {"allow", "deny"}: raise ValueError("unsupported effect") deadline = min(_time(caller_record["not_after"]), _time(provenance["decision_time"]) + timedelta(seconds=30)) if envelope["effect"] == "allow": lifetime = envelope["lifetime"] if (lifetime["kind"] != "ttl" or not _time(lifetime["not_before"]) <= now < _time(lifetime["expires_at"])): raise ValueError("invalid decision lifetime") deadline = min(deadline, _time(lifetime["expires_at"])) return Decision(envelope["effect"] == "allow", envelope["id"], provenance["policy_version"], deadline.timestamp(), caller, go_json(envelope).decode()) class FlexPolicy: def __init__(self, *, base_url: str, client: httpx.AsyncClient, caller_token_file: Path, trusted_keys_file: Path, caller: str): require_https(base_url) if not caller.startswith("system:serviceaccount:"): raise ValueError("explicit admitted workload caller required") self.base_url, self.client = base_url.rstrip("/"), client self.caller_token_file, self.trusted_keys_file = caller_token_file, trusted_keys_file self.caller = caller async def evaluate(self, request: Authorization) -> Decision: actor, facts = request.actor, request.facts check = { "id": request.correlation_id, "tenant": facts.target_tenant, "subject": {"id": actor.subject, "type": actor.principal_type, "tenant": actor.tenant, "attributes": {"issuer": actor.issuer, "assurance": actor.assurance}}, "action": request.action, "resource": {"id": request.resource, "type": "hub-route", "system": "hub-core", "tenant": facts.target_tenant}, "context": {"http_request_digest": request.request_digest, "facts_evidence": facts.evidence_id, "root_entitled": facts.root_entitled}, } try: # Reread projected credentials and owner-delivered public trust at # every check. No remote key response can bootstrap its own trust. token = self.caller_token_file.read_text().strip() if not token or any(c.isspace() for c in token): raise ValueError("invalid caller credential") keys = parse_json(self.trusted_keys_file.read_bytes()) response = await self.client.post(self.base_url + "/v1/check", json=check, headers={"Authorization": f"Bearer {token}"}, timeout=3, follow_redirects=False) response.raise_for_status() return verify_decision(parse_json(response.content), request=check, keys=keys, caller=self.caller) except Exception as exc: # Neither response body nor credentials appear in the public error. raise AccessFailure(503, "policy_unavailable_or_untrusted") from exc