This commit is contained in:
@@ -22,6 +22,7 @@ from cloud.auth import (
|
||||
NullAuthProvider,
|
||||
Principal,
|
||||
)
|
||||
from cloud.governance import TaskSubmissionPolicyError, enforce_user_submission_policy
|
||||
from cloud.sdk.models import (
|
||||
DeviceResponse,
|
||||
ErrorResponse,
|
||||
@@ -94,8 +95,21 @@ def create_cloud_router(
|
||||
payload: TaskSubmissionRequest,
|
||||
request: Request,
|
||||
) -> TaskSubmissionResponse:
|
||||
_authorize(request, TASKS_SUBMIT_SCOPE)
|
||||
task_constraints = _build_constraints(payload.constraints)
|
||||
principal = _authorize(request, TASKS_SUBMIT_SCOPE)
|
||||
try:
|
||||
task_constraints = _build_constraints(payload.constraints)
|
||||
_validate_task_target(pool, task_constraints)
|
||||
_enforce_user_policy(principal, scheduler.store, task_constraints)
|
||||
except TaskSubmissionPolicyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
try:
|
||||
task_id = scheduler.submit(
|
||||
goal=payload.goal,
|
||||
@@ -128,6 +142,8 @@ def create_cloud_router(
|
||||
attempt_count=task.attempt_count,
|
||||
lease_expires_at=task.lease_expires_at,
|
||||
failure_reason=task.failure_reason,
|
||||
target_host_id=task.constraints.target_host_id,
|
||||
target_device_id=task.constraints.target_device_id,
|
||||
)
|
||||
|
||||
@router.get("/tasks", response_model=TaskListResponse)
|
||||
@@ -158,6 +174,8 @@ def create_cloud_router(
|
||||
assigned_host_id=task.assigned_host_id,
|
||||
attempt_count=task.attempt_count,
|
||||
failure_reason=task.failure_reason,
|
||||
target_host_id=task.constraints.target_host_id,
|
||||
target_device_id=task.constraints.target_device_id,
|
||||
created_at=task.created_at,
|
||||
)
|
||||
for task in tasks
|
||||
@@ -303,4 +321,35 @@ def _build_constraints(model):
|
||||
return TaskConstraints(
|
||||
driver_type=model.driver_type,
|
||||
capability_tags=list(model.capability_tags),
|
||||
target_host_id=model.target_host_id,
|
||||
target_device_id=model.target_device_id,
|
||||
)
|
||||
|
||||
|
||||
def _validate_task_target(pool, constraints) -> None:
|
||||
if constraints.target_device_id and not constraints.target_host_id:
|
||||
raise ValueError("target_device_id requires target_host_id")
|
||||
if constraints.target_host_id is None:
|
||||
return
|
||||
if not any(host.host_id == constraints.target_host_id for host in pool.list_hosts()):
|
||||
raise ValueError(f"target host {constraints.target_host_id!r} is not known")
|
||||
if constraints.target_device_id is not None and not any(
|
||||
device.host_id == constraints.target_host_id
|
||||
and device.device_id == constraints.target_device_id
|
||||
for device in pool.list_devices()
|
||||
):
|
||||
raise ValueError(
|
||||
f"target device {constraints.target_device_id!r} is not owned by "
|
||||
f"host {constraints.target_host_id!r}"
|
||||
)
|
||||
|
||||
|
||||
def _enforce_user_policy(principal, store, constraints) -> None:
|
||||
if not principal.id.startswith("user:"):
|
||||
return
|
||||
policy = store.get_user_submission_policy(principal.id.removeprefix("user:"))
|
||||
enforce_user_submission_policy(
|
||||
policy,
|
||||
target_host_id=constraints.target_host_id,
|
||||
target_device_id=constraints.target_device_id,
|
||||
)
|
||||
|
||||
@@ -72,15 +72,24 @@ class CloudClient:
|
||||
workflow_definition_id: str | None = None,
|
||||
driver_type: str | None = None,
|
||||
capability_tags: list[str] | None = None,
|
||||
target_host_id: str | None = None,
|
||||
target_device_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"goal": goal,
|
||||
"workflow_definition_id": workflow_definition_id,
|
||||
}
|
||||
if driver_type is not None or capability_tags is not None:
|
||||
if (
|
||||
driver_type is not None
|
||||
or capability_tags is not None
|
||||
or target_host_id is not None
|
||||
or target_device_id is not None
|
||||
):
|
||||
payload["constraints"] = {
|
||||
"driver_type": driver_type,
|
||||
"capability_tags": list(capability_tags or []),
|
||||
"target_host_id": target_host_id,
|
||||
"target_device_id": target_device_id,
|
||||
}
|
||||
resp = self._request("POST", "/tasks", json=payload)
|
||||
return resp.json()
|
||||
@@ -209,6 +218,28 @@ class CloudClient:
|
||||
def revoke_user_sessions(self, user_id: str) -> None:
|
||||
self._request("DELETE", f"/users/{user_id}/sessions")
|
||||
|
||||
# ------------------------------------------------------------- governance
|
||||
|
||||
def get_user_submission_policy(self, user_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/users/{user_id}/submission-policy").json()
|
||||
|
||||
def update_user_submission_policy(
|
||||
self, user_id: str, **policy: Any
|
||||
) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"PUT", f"/users/{user_id}/submission-policy", json=policy
|
||||
).json()
|
||||
|
||||
def get_host_governance_policy(self, host_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/hosts/{host_id}/governance-policy").json()
|
||||
|
||||
def update_host_governance_policy(
|
||||
self, host_id: str, **policy: Any
|
||||
) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"PUT", f"/hosts/{host_id}/governance-policy", json=policy
|
||||
).json()
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from cloud.auth import AuthProvider, GOVERNANCE_ADMIN_SCOPE, GOVERNANCE_READ_SCOPE
|
||||
from cloud.observability import current_correlation_id
|
||||
from cloud.sdk.models import (
|
||||
HostGovernancePolicyRequest,
|
||||
HostGovernancePolicyResponse,
|
||||
UserSubmissionPolicyRequest,
|
||||
UserSubmissionPolicyResponse,
|
||||
)
|
||||
from cloud.user_auth import AuthAuditEvent
|
||||
from core.models import utc_now
|
||||
|
||||
|
||||
def create_governance_router(*, repository, auth_provider: AuthProvider) -> APIRouter:
|
||||
router = APIRouter(prefix="/v1", tags=["cloud-governance"])
|
||||
|
||||
def authorize(request: Request, required_scope: str):
|
||||
principal = auth_provider.authenticate(request)
|
||||
if principal is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="unauthorized",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if principal.must_change_password or not principal.has_scope(required_scope):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"missing required scope: {required_scope}",
|
||||
)
|
||||
return principal
|
||||
|
||||
@router.get(
|
||||
"/users/{user_id}/submission-policy",
|
||||
response_model=UserSubmissionPolicyResponse,
|
||||
)
|
||||
def get_user_policy(user_id: str, request: Request) -> UserSubmissionPolicyResponse:
|
||||
authorize(request, GOVERNANCE_READ_SCOPE)
|
||||
policy = repository.get_user_submission_policy(user_id)
|
||||
if policy is None:
|
||||
raise HTTPException(status_code=404, detail="submission policy not found")
|
||||
return UserSubmissionPolicyResponse(
|
||||
user_id=policy.user_id,
|
||||
revision=policy.revision,
|
||||
submission_enabled=policy.submission_enabled,
|
||||
allowed_host_ids=list(policy.allowed_host_ids)
|
||||
if policy.allowed_host_ids is not None
|
||||
else None,
|
||||
allowed_device_targets=[
|
||||
{"host_id": host_id, "device_id": device_id}
|
||||
for host_id, device_id in policy.allowed_device_targets or ()
|
||||
]
|
||||
if policy.allowed_device_targets is not None
|
||||
else None,
|
||||
updated_at=policy.updated_at,
|
||||
)
|
||||
|
||||
@router.put(
|
||||
"/users/{user_id}/submission-policy",
|
||||
response_model=UserSubmissionPolicyResponse,
|
||||
)
|
||||
def put_user_policy(
|
||||
user_id: str,
|
||||
payload: UserSubmissionPolicyRequest,
|
||||
request: Request,
|
||||
) -> UserSubmissionPolicyResponse:
|
||||
principal = authorize(request, GOVERNANCE_ADMIN_SCOPE)
|
||||
try:
|
||||
policy = repository.upsert_user_submission_policy(
|
||||
user_id=user_id,
|
||||
submission_enabled=payload.submission_enabled,
|
||||
allowed_host_ids=_unique_strings(payload.allowed_host_ids),
|
||||
allowed_device_targets=(
|
||||
tuple((item.host_id, item.device_id) for item in payload.allowed_device_targets)
|
||||
if payload.allowed_device_targets is not None
|
||||
else None
|
||||
),
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="user not found") from exc
|
||||
_audit(repository, principal.id, user_id, "user_submission_policy_update")
|
||||
return UserSubmissionPolicyResponse(
|
||||
user_id=policy.user_id,
|
||||
revision=policy.revision,
|
||||
submission_enabled=policy.submission_enabled,
|
||||
allowed_host_ids=list(policy.allowed_host_ids)
|
||||
if policy.allowed_host_ids is not None
|
||||
else None,
|
||||
allowed_device_targets=[
|
||||
{"host_id": host_id, "device_id": device_id}
|
||||
for host_id, device_id in policy.allowed_device_targets or ()
|
||||
]
|
||||
if policy.allowed_device_targets is not None
|
||||
else None,
|
||||
updated_at=policy.updated_at,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/hosts/{host_id}/governance-policy",
|
||||
response_model=HostGovernancePolicyResponse,
|
||||
)
|
||||
def get_host_policy(host_id: str, request: Request) -> HostGovernancePolicyResponse:
|
||||
authorize(request, GOVERNANCE_READ_SCOPE)
|
||||
policy = repository.get_host_governance_policy(host_id)
|
||||
if policy is None:
|
||||
raise HTTPException(status_code=404, detail="Host policy not found")
|
||||
return _host_response(policy)
|
||||
|
||||
@router.put(
|
||||
"/hosts/{host_id}/governance-policy",
|
||||
response_model=HostGovernancePolicyResponse,
|
||||
)
|
||||
def put_host_policy(
|
||||
host_id: str,
|
||||
payload: HostGovernancePolicyRequest,
|
||||
request: Request,
|
||||
) -> HostGovernancePolicyResponse:
|
||||
principal = authorize(request, GOVERNANCE_ADMIN_SCOPE)
|
||||
try:
|
||||
policy = repository.upsert_host_governance_policy(
|
||||
host_id=host_id,
|
||||
self_submission_enabled=payload.self_submission_enabled,
|
||||
max_active_tasks=payload.max_active_tasks,
|
||||
daily_token_budget=payload.daily_token_budget,
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="Host not found") from exc
|
||||
_audit(repository, principal.id, host_id, "host_governance_policy_update")
|
||||
return _host_response(policy)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _unique_strings(values: list[str] | None) -> tuple[str, ...] | None:
|
||||
if values is None:
|
||||
return None
|
||||
return tuple(dict.fromkeys(value for value in values if value))
|
||||
|
||||
|
||||
def _host_response(policy) -> HostGovernancePolicyResponse:
|
||||
return HostGovernancePolicyResponse(
|
||||
host_id=policy.host_id,
|
||||
revision=policy.revision,
|
||||
self_submission_enabled=policy.self_submission_enabled,
|
||||
max_active_tasks=policy.max_active_tasks,
|
||||
daily_token_budget=policy.daily_token_budget,
|
||||
updated_at=policy.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _audit(repository, actor_id: str, target: str, action: str) -> None:
|
||||
repository.record_auth_audit(
|
||||
AuthAuditEvent(
|
||||
id=uuid4().hex,
|
||||
occurred_at=utc_now(),
|
||||
actor_principal_id=actor_id,
|
||||
target_user_id=target if action.startswith("user_") else None,
|
||||
action=action,
|
||||
outcome="success",
|
||||
correlation_id=current_correlation_id(),
|
||||
metadata={"target": target},
|
||||
)
|
||||
)
|
||||
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field
|
||||
class TaskConstraintsModel(BaseModel):
|
||||
driver_type: str | None = None
|
||||
capability_tags: list[str] = Field(default_factory=list)
|
||||
target_host_id: str | None = Field(default=None, min_length=1)
|
||||
target_device_id: str | None = Field(default=None, min_length=1)
|
||||
|
||||
|
||||
class TaskSubmissionRequest(BaseModel):
|
||||
@@ -33,6 +35,8 @@ class TaskStatusResponse(BaseModel):
|
||||
attempt_count: int = 0
|
||||
lease_expires_at: datetime | None = None
|
||||
failure_reason: str | None = None
|
||||
target_host_id: str | None = None
|
||||
target_device_id: str | None = None
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
@@ -44,6 +48,8 @@ class TaskListItem(BaseModel):
|
||||
assigned_host_id: str | None = None
|
||||
attempt_count: int = 0
|
||||
failure_reason: str | None = None
|
||||
target_host_id: str | None = None
|
||||
target_device_id: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -145,3 +151,32 @@ class PasswordResetRequest(BaseModel):
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
|
||||
|
||||
class DeviceTargetModel(BaseModel):
|
||||
host_id: str = Field(min_length=1)
|
||||
device_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class UserSubmissionPolicyRequest(BaseModel):
|
||||
submission_enabled: bool = True
|
||||
allowed_host_ids: list[str] | None = None
|
||||
allowed_device_targets: list[DeviceTargetModel] | None = None
|
||||
|
||||
|
||||
class UserSubmissionPolicyResponse(UserSubmissionPolicyRequest):
|
||||
user_id: str
|
||||
revision: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HostGovernancePolicyRequest(BaseModel):
|
||||
self_submission_enabled: bool = True
|
||||
max_active_tasks: int | None = Field(default=None, ge=1)
|
||||
daily_token_budget: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class HostGovernancePolicyResponse(HostGovernancePolicyRequest):
|
||||
host_id: str
|
||||
revision: int
|
||||
updated_at: datetime
|
||||
|
||||
Reference in New Issue
Block a user