hub-core/hub_core/security/policy.py
tegwick 3e386147fd
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s
feat: add fail-closed Hub access profile foundation
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
2026-09-28 11:44:50 +02:00

177 lines
8.3 KiB
Python

"""Authenticated flex-auth client; verification precedes every allow/deny."""
from __future__ import annotations
import base64
import hashlib
import json
from datetime import datetime, 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)
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")
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")
return Decision(envelope["effect"] == "allow", envelope["id"], provenance["policy_version"], caller)
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