feat: add fail-closed Hub access profile foundation
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 11:44:50 +02:00
parent df39fd5f43
commit 3e386147fd
35 changed files with 2009 additions and 195 deletions

View file

@ -27,6 +27,7 @@ from hub_core.runtime.workload_projection import (
WorkloadProjectionService,
)
from hub_core.runtime.workload_projection_routes import create_workload_projection_router
from hub_core.security.boundary import AccessBoundary, AccessController
def create_app(
@ -35,6 +36,7 @@ def create_app(
port_store: PortStore | None = None,
repo_projection_client: RepoProjectionClient | None = None,
workload_projection_client: WorkloadProjectionClient | None = None,
access_controller: AccessController | None = None,
) -> FastAPI:
resolved_settings = settings or RuntimeSettings.from_env()
resolved_store = port_store or _create_store(resolved_settings)
@ -108,6 +110,9 @@ def create_app(
app.state.contract_validator = ContractValidator()
app.state.repository_navigation = repository_navigation
app.state.workload_projection = workload_projection
app.state.access_controller = access_controller
if resolved_settings.enforce_access:
app.add_middleware(AccessBoundary, host=app, controller=access_controller)
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
async def healthz() -> HealthResponse:
@ -123,6 +128,8 @@ def create_app(
**await repository_navigation.readiness_checks(),
**await workload_projection.readiness_checks(),
}
if resolved_settings.enforce_access:
dependency_checks["access_profile"] = "ok" if access_controller else "unavailable"
ready = resolved_settings.is_ready(resolved_store.backend_name) and all(
value in {"ok", "not_applicable"} for value in dependency_checks.values()
)

View file

@ -110,7 +110,9 @@ def _run_api(host: str, port: int) -> None:
def _run_mcp(host: str, port: int, transport: str, api_base: str) -> None:
server = HubCoreMCPServer(name="hub-core", api_base=api_base)
server = HubCoreMCPServer(name="hub-core", api_base=api_base,
require_credentials=RuntimeSettings.from_env().enforce_access,
trailing_slash=False)
server.mcp.run(transport=transport, host=host, port=port)

View file

@ -548,6 +548,10 @@ async def _protected(
_enabled(request, group)
if write and group not in request.app.state.settings.v2_write_groups:
raise HTTPException(status_code=503, detail="compatibility group is read-only")
if request.app.state.settings.enforce_access:
if getattr(request.state, "hub_access", None) is None:
raise HTTPException(status_code=503, detail="access boundary unavailable")
return
if not authorization or not authorization.startswith("Bearer "):
raise _unauthorized("Missing bearer token")
token = authorization.removeprefix("Bearer ").strip()

View file

@ -38,9 +38,21 @@ class RuntimeSettings:
legacy_health: bool = False
statehub_inbox_reads: bool = False
statehub_inbox_agent: str = "state-hub"
access_mode: str = "auto"
@property
def enforce_access(self) -> bool:
return self.access_mode == "enforce" or (
self.access_mode == "auto" and self.environment not in {"development", "test"}
)
def __post_init__(self) -> None:
if self.statehub_inbox_reads and (self.backend != "postgresql" or not self.api_token):
if self.access_mode not in {"auto", "enforce", "development"}:
raise ValueError("unsupported access mode")
if self.access_mode == "development" and self.environment not in {"development", "test"}:
raise ValueError("development access is forbidden outside development/test")
if self.statehub_inbox_reads and (self.backend != "postgresql" or
(not self.enforce_access and not self.api_token)):
raise ValueError("State Hub inbox reads require PostgreSQL and operator token")
if self.repo_manager_timeout_seconds <= 0:
raise ValueError("Repo Manager timeout must be positive")
@ -87,6 +99,7 @@ class RuntimeSettings:
legacy_health=_env_bool("HUB_CORE_LEGACY_HEALTH", False),
statehub_inbox_reads=_env_bool("HUB_CORE_STATEHUB_INBOX_READS", False),
statehub_inbox_agent=os.getenv("HUB_CORE_STATEHUB_INBOX_AGENT", "state-hub"),
access_mode=os.getenv("HUB_CORE_ACCESS_MODE", "auto"),
)
def readiness_checks(self, store_backend: str) -> dict[str, str]:
@ -99,7 +112,8 @@ class RuntimeSettings:
"operator",
}
authorization_ready = (
not protected_groups
self.enforce_access
or not protected_groups
or bool(self.api_token)
or store_backend == "postgresql"
)

View file

@ -99,7 +99,10 @@ def create_inbox_projection_router() -> APIRouter:
settings = request.app.state.settings
token = settings.api_token
supplied = (authorization or "").removeprefix("Bearer ")
if not token or not (authorization or "").startswith("Bearer ") or not hmac.compare_digest(supplied, token):
if settings.enforce_access:
if getattr(request.state, "hub_access", None) is None:
raise HTTPException(503, "access boundary unavailable")
elif not token or not (authorization or "").startswith("Bearer ") or not hmac.compare_digest(supplied, token):
raise HTTPException(401, "inbox pilot requires operator bearer authentication",
headers={"WWW-Authenticate": "Bearer"})
if to_agent != settings.statehub_inbox_agent:

View file

@ -25,6 +25,21 @@ def get_contract_validator(request: Request) -> ContractValidator:
return request.app.state.contract_validator
def _attribute_event(body: EventCommand, request: Request) -> EventCommand:
context = getattr(request.state, "hub_access", None)
if context is None:
return body
# Reserved server provenance overrides any payload assertion. Domain
# subject_refs remain business data and are never authentication evidence.
return body.model_copy(update={"payload": {**body.payload, "_hub_access": {
"issuer": context.actor.issuer, "subject": context.actor.subject,
"principal_type": context.actor.principal_type,
"actor_tenant": context.actor.tenant,
"target_tenant": context.facts.target_tenant,
"correlation_id": context.correlation_id,
}}})
def create_ports_router() -> APIRouter:
router = APIRouter(prefix="/ports")
@ -112,6 +127,7 @@ def create_ports_router() -> APIRouter:
)
async def append_progress(
body: EventCommand,
request: Request,
store: PortStore = Depends(get_port_store),
validator: ContractValidator = Depends(get_contract_validator),
) -> PortAccepted:
@ -119,7 +135,7 @@ def create_ports_router() -> APIRouter:
validator.validate_event_family(body.event_type, "progress")
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return await store.append_progress(body)
return await store.append_progress(_attribute_event(body, request))
@router.post(
"/events/interaction",
@ -130,6 +146,7 @@ def create_ports_router() -> APIRouter:
)
async def append_interaction(
body: EventCommand,
request: Request,
store: PortStore = Depends(get_port_store),
validator: ContractValidator = Depends(get_contract_validator),
) -> PortAccepted:
@ -137,7 +154,7 @@ def create_ports_router() -> APIRouter:
validator.validate_event_family(body.event_type, "interaction")
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return await store.append_interaction(body)
return await store.append_interaction(_attribute_event(body, request))
@router.get(
"/projections/{projection_id}",