2026-09-28 11:44:50 +02:00
|
|
|
"""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)
|
2026-09-28 12:01:42 +02:00
|
|
|
# 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)
|
2026-09-28 11:44:50 +02:00
|
|
|
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")
|
2026-09-28 12:01:42 +02:00
|
|
|
return Decision(envelope["effect"] == "allow", envelope["id"], provenance["policy_version"],
|
|
|
|
|
caller, go_json(envelope).decode())
|
2026-09-28 11:44:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|