63 lines
2.7 KiB
Python
63 lines
2.7 KiB
Python
|
|
"""Explicit runtime composition: owner facts remain an injected trust adapter."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import os
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from hub_core.security.audit import AuditCoreSink
|
||
|
|
from hub_core.security.boundary import AccessController, FactSource
|
||
|
|
from hub_core.security.identity import OIDCVerifier, require_https
|
||
|
|
from hub_core.security.policy import FlexPolicy
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class SecuritySettings:
|
||
|
|
issuer: str
|
||
|
|
audience: str
|
||
|
|
root_subject: str
|
||
|
|
policy_url: str
|
||
|
|
policy_caller: str
|
||
|
|
policy_token_file: Path
|
||
|
|
policy_keys_file: Path
|
||
|
|
audit_url: str
|
||
|
|
audit_token_file: Path
|
||
|
|
|
||
|
|
def __post_init__(self):
|
||
|
|
for url in (self.issuer, self.policy_url, self.audit_url):
|
||
|
|
require_https(url)
|
||
|
|
if not self.audience or not self.root_subject:
|
||
|
|
raise ValueError("explicit audience and immutable root subject required")
|
||
|
|
if not self.policy_caller.startswith("system:serviceaccount:"):
|
||
|
|
raise ValueError("explicit policy workload principal required")
|
||
|
|
for path in (self.policy_token_file, self.policy_keys_file, self.audit_token_file):
|
||
|
|
if not isinstance(path, Path) or not path.is_absolute():
|
||
|
|
raise ValueError("absolute credential/trust paths required")
|
||
|
|
if self.policy_token_file == self.audit_token_file:
|
||
|
|
raise ValueError("policy and audit require separate credentials")
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_env(cls) -> SecuritySettings | None:
|
||
|
|
fields = tuple(cls.__dataclass_fields__)
|
||
|
|
values = {field: os.getenv("HUB_CORE_SECURITY_" + field.upper(), "") for field in fields}
|
||
|
|
if not any(values.values()):
|
||
|
|
return None
|
||
|
|
missing = [field for field, value in values.items() if not value]
|
||
|
|
if missing:
|
||
|
|
raise ValueError("incomplete Hub security configuration: " + ", ".join(missing))
|
||
|
|
return cls(**{field: Path(value) if field.endswith("_file") else value
|
||
|
|
for field, value in values.items()})
|
||
|
|
|
||
|
|
def compose(self, *, facts: FactSource, client: httpx.AsyncClient) -> AccessController:
|
||
|
|
return AccessController(
|
||
|
|
identity=OIDCVerifier(issuer=self.issuer, audience=self.audience, client=client),
|
||
|
|
facts=facts,
|
||
|
|
policy=FlexPolicy(base_url=self.policy_url, client=client,
|
||
|
|
caller_token_file=self.policy_token_file,
|
||
|
|
trusted_keys_file=self.policy_keys_file, caller=self.policy_caller),
|
||
|
|
audit=AuditCoreSink(base_url=self.audit_url, token_file=self.audit_token_file, client=client),
|
||
|
|
root_issuer=self.issuer, root_subject=self.root_subject,
|
||
|
|
)
|