diff --git a/openspec/changes/cloud-control-plane-integration/tasks.md b/openspec/changes/cloud-control-plane-integration/tasks.md index 522dcdd..81cfa84 100644 --- a/openspec/changes/cloud-control-plane-integration/tasks.md +++ b/openspec/changes/cloud-control-plane-integration/tasks.md @@ -28,7 +28,7 @@ ## 4. Authentication And Authorization - [x] 4.1 Extend authenticated principals with scopes and implement constant-time configured bearer-token verification without logging credentials. -- [ ] 4.2 Add public scopes for task submission/read, pool read, plugin read, and plugin administration and enforce them on every `/v1` route. +- [x] 4.2 Add public scopes for task submission/read, pool read, plugin read, and plugin administration and enforce them on every `/v1` route. - [ ] 4.3 Add host principals bound to one `host_id` and reject cross-host heartbeat, claim, renewal, or result operations. - [ ] 4.4 Make missing production credentials a startup/readiness failure and permit anonymous mode only through the explicit non-production override. - [ ] 4.5 Add authentication tests covering invalid tokens, missing scopes, host impersonation, plugin administration, and secret redaction. diff --git a/packages/cloud-platform/cloud/auth.py b/packages/cloud-platform/cloud/auth.py index 41e714e..2522775 100644 --- a/packages/cloud-platform/cloud/auth.py +++ b/packages/cloud-platform/cloud/auth.py @@ -6,11 +6,21 @@ from hmac import compare_digest from typing import Protocol, runtime_checkable +TASKS_SUBMIT_SCOPE = "tasks:submit" +TASKS_READ_SCOPE = "tasks:read" +POOL_READ_SCOPE = "pool:read" +PLUGINS_READ_SCOPE = "plugins:read" +PLUGINS_ADMIN_SCOPE = "plugins:admin" + + @dataclass(frozen=True) class Principal: id: str = "anonymous" scopes: frozenset[str] = field(default_factory=frozenset) + def has_scope(self, scope: str) -> bool: + return "*" in self.scopes or scope in self.scopes + @runtime_checkable class AuthProvider(Protocol): diff --git a/packages/cloud-platform/cloud/sdk/api.py b/packages/cloud-platform/cloud/sdk/api.py index 93a4b56..6cd43a3 100644 --- a/packages/cloud-platform/cloud/sdk/api.py +++ b/packages/cloud-platform/cloud/sdk/api.py @@ -12,7 +12,16 @@ from __future__ import annotations from typing import TYPE_CHECKING -from cloud.auth import AuthProvider, NullAuthProvider, Principal +from cloud.auth import ( + PLUGINS_ADMIN_SCOPE, + PLUGINS_READ_SCOPE, + POOL_READ_SCOPE, + TASKS_READ_SCOPE, + TASKS_SUBMIT_SCOPE, + AuthProvider, + NullAuthProvider, + Principal, +) from cloud.sdk.models import ( DeviceResponse, ErrorResponse, @@ -43,12 +52,18 @@ def create_cloud_router( auth = auth_provider or NullAuthProvider() router = APIRouter(prefix=version_prefix, tags=["cloud-platform"]) - def _authorize(request: Request) -> Principal: + def _authorize(request: Request, required_scope: str) -> Principal: principal = auth.authenticate(request) if principal is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not principal.has_scope(required_scope): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"missing required scope: {required_scope}", ) return principal @@ -61,7 +76,7 @@ def create_cloud_router( payload: TaskSubmissionRequest, request: Request, ) -> TaskSubmissionResponse: - _authorize(request) + _authorize(request, TASKS_SUBMIT_SCOPE) task_constraints = _build_constraints(payload.constraints) try: task_id = scheduler.submit( @@ -78,7 +93,7 @@ def create_cloud_router( @router.get("/tasks/{task_id}", response_model=TaskStatusResponse) def get_task_status(task_id: str, request: Request) -> TaskStatusResponse: - _authorize(request) + _authorize(request, TASKS_READ_SCOPE) task = scheduler.store.get_task(task_id) if task is None: raise HTTPException( @@ -96,7 +111,7 @@ def create_cloud_router( @router.get("/devices", response_model=list[DeviceResponse]) def list_devices(request: Request) -> list[DeviceResponse]: - _authorize(request) + _authorize(request, POOL_READ_SCOPE) return [ DeviceResponse( device_id=d.device_id, @@ -110,7 +125,7 @@ def create_cloud_router( @router.get("/hosts", response_model=list[HostResponse]) def list_hosts(request: Request) -> list[HostResponse]: - _authorize(request) + _authorize(request, POOL_READ_SCOPE) return [ HostResponse( host_id=h.host_id, @@ -122,7 +137,7 @@ def create_cloud_router( @router.get("/plugins", response_model=list[PluginResponse]) def list_plugins(request: Request) -> list[PluginResponse]: - _authorize(request) + _authorize(request, PLUGINS_READ_SCOPE) return [ PluginResponse( name=manifest.name, @@ -147,7 +162,7 @@ def create_cloud_router( payload: PluginRegistrationRequest, request: Request, ) -> PluginResponse: - _authorize(request) + _authorize(request, PLUGINS_ADMIN_SCOPE) from cloud.plugins import ( DriverRegistryUnavailableError, DuplicatePluginError, diff --git a/tests/test_cloud_sdk_api.py b/tests/test_cloud_sdk_api.py index 794f48b..d8d9995 100644 --- a/tests/test_cloud_sdk_api.py +++ b/tests/test_cloud_sdk_api.py @@ -5,15 +5,15 @@ from __future__ import annotations import pytest from cloud.config import CloudConfig +from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider from cloud.plugins import PluginRegistry from cloud.pool import DevicePool from cloud.sdk.api import ( AuthProvider, - NullAuthProvider, Principal, create_cloud_router, ) -from cloud.scheduler import TaskConstraints, TaskScheduler +from cloud.scheduler import TaskScheduler from cloud.store import CloudStore from core.models import Device @@ -177,12 +177,18 @@ def test_rejecting_auth_provider_blocks_every_route(tmp_path) -> None: assert client.get("/v1/devices").status_code == 401 assert client.get("/v1/hosts").status_code == 401 assert client.get("/v1/plugins").status_code == 401 - assert client.post("/v1/plugins", json={ - "name": "x", - "version": "1", - "entry_point_kind": "tool", - "target": "cloud.store:CloudStore", - }).status_code == 401 + assert ( + client.post( + "/v1/plugins", + json={ + "name": "x", + "version": "1", + "entry_point_kind": "tool", + "target": "cloud.store:CloudStore", + }, + ).status_code + == 401 + ) def test_default_null_auth_provider_is_used_when_omitted(tmp_path) -> None: @@ -216,3 +222,68 @@ def test_submit_with_constraints(tmp_path) -> None: # And the device should match if we run assign() manually via the scheduler. status = client.get(f"/v1/tasks/{task_id}").json() assert status["status"] == "queued" + + +@pytest.mark.parametrize( + ("method", "path", "payload", "required_scope"), + [ + ("post", "/v1/tasks", {"goal": "x"}, "tasks:submit"), + ("get", "/v1/tasks/missing", None, "tasks:read"), + ("get", "/v1/devices", None, "pool:read"), + ("get", "/v1/hosts", None, "pool:read"), + ("get", "/v1/plugins", None, "plugins:read"), + ( + "post", + "/v1/plugins", + { + "name": "scope-test", + "version": "1.0.0", + "entry_point_kind": "tool", + "target": "cloud.store:CloudStore", + }, + "plugins:admin", + ), + ], +) +def test_every_public_route_enforces_its_scope( + tmp_path, + method: str, + path: str, + payload: dict[str, object] | None, + required_scope: str, +) -> None: + provider = ConfiguredBearerAuthProvider( + [ + BearerCredential( + principal_id="integrator", + token="scoped-token", + scopes=frozenset({required_scope}), + ), + BearerCredential( + principal_id="under-scoped", + token="wrong-token", + scopes=frozenset(), + ), + ] + ) + app, _, _, _ = _build_app(tmp_path, auth_provider=provider) + client = _client_for(app) + + unauthorized = client.request(method, path, json=payload) + forbidden = client.request( + method, + path, + json=payload, + headers={"Authorization": "Bearer wrong-token"}, + ) + authorized = client.request( + method, + path, + json=payload, + headers={"Authorization": "Bearer scoped-token"}, + ) + + assert unauthorized.status_code == 401 + assert unauthorized.headers["www-authenticate"] == "Bearer" + assert forbidden.status_code == 403 + assert authorized.status_code not in {401, 403}