feat: add fail-closed Hub access profile foundation
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
df39fd5f43
commit
3e386147fd
35 changed files with 2009 additions and 195 deletions
177
hub_core/security/policy.py
Normal file
177
hub_core/security/policy.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue