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
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue