feat(cloud-auth): enforce public scopes

This commit is contained in:
2026-07-12 17:56:14 +08:00
parent b916b394c7
commit 71f0a892e3
4 changed files with 113 additions and 17 deletions
@@ -28,7 +28,7 @@
## 4. Authentication And Authorization ## 4. Authentication And Authorization
- [x] 4.1 Extend authenticated principals with scopes and implement constant-time configured bearer-token verification without logging credentials. - [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.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.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. - [ ] 4.5 Add authentication tests covering invalid tokens, missing scopes, host impersonation, plugin administration, and secret redaction.
+10
View File
@@ -6,11 +6,21 @@ from hmac import compare_digest
from typing import Protocol, runtime_checkable 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) @dataclass(frozen=True)
class Principal: class Principal:
id: str = "anonymous" id: str = "anonymous"
scopes: frozenset[str] = field(default_factory=frozenset) 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 @runtime_checkable
class AuthProvider(Protocol): class AuthProvider(Protocol):
+23 -8
View File
@@ -12,7 +12,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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 ( from cloud.sdk.models import (
DeviceResponse, DeviceResponse,
ErrorResponse, ErrorResponse,
@@ -43,12 +52,18 @@ def create_cloud_router(
auth = auth_provider or NullAuthProvider() auth = auth_provider or NullAuthProvider()
router = APIRouter(prefix=version_prefix, tags=["cloud-platform"]) 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) principal = auth.authenticate(request)
if principal is None: if principal is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="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 return principal
@@ -61,7 +76,7 @@ def create_cloud_router(
payload: TaskSubmissionRequest, payload: TaskSubmissionRequest,
request: Request, request: Request,
) -> TaskSubmissionResponse: ) -> TaskSubmissionResponse:
_authorize(request) _authorize(request, TASKS_SUBMIT_SCOPE)
task_constraints = _build_constraints(payload.constraints) task_constraints = _build_constraints(payload.constraints)
try: try:
task_id = scheduler.submit( task_id = scheduler.submit(
@@ -78,7 +93,7 @@ def create_cloud_router(
@router.get("/tasks/{task_id}", response_model=TaskStatusResponse) @router.get("/tasks/{task_id}", response_model=TaskStatusResponse)
def get_task_status(task_id: str, request: Request) -> 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) task = scheduler.store.get_task(task_id)
if task is None: if task is None:
raise HTTPException( raise HTTPException(
@@ -96,7 +111,7 @@ def create_cloud_router(
@router.get("/devices", response_model=list[DeviceResponse]) @router.get("/devices", response_model=list[DeviceResponse])
def list_devices(request: Request) -> list[DeviceResponse]: def list_devices(request: Request) -> list[DeviceResponse]:
_authorize(request) _authorize(request, POOL_READ_SCOPE)
return [ return [
DeviceResponse( DeviceResponse(
device_id=d.device_id, device_id=d.device_id,
@@ -110,7 +125,7 @@ def create_cloud_router(
@router.get("/hosts", response_model=list[HostResponse]) @router.get("/hosts", response_model=list[HostResponse])
def list_hosts(request: Request) -> list[HostResponse]: def list_hosts(request: Request) -> list[HostResponse]:
_authorize(request) _authorize(request, POOL_READ_SCOPE)
return [ return [
HostResponse( HostResponse(
host_id=h.host_id, host_id=h.host_id,
@@ -122,7 +137,7 @@ def create_cloud_router(
@router.get("/plugins", response_model=list[PluginResponse]) @router.get("/plugins", response_model=list[PluginResponse])
def list_plugins(request: Request) -> list[PluginResponse]: def list_plugins(request: Request) -> list[PluginResponse]:
_authorize(request) _authorize(request, PLUGINS_READ_SCOPE)
return [ return [
PluginResponse( PluginResponse(
name=manifest.name, name=manifest.name,
@@ -147,7 +162,7 @@ def create_cloud_router(
payload: PluginRegistrationRequest, payload: PluginRegistrationRequest,
request: Request, request: Request,
) -> PluginResponse: ) -> PluginResponse:
_authorize(request) _authorize(request, PLUGINS_ADMIN_SCOPE)
from cloud.plugins import ( from cloud.plugins import (
DriverRegistryUnavailableError, DriverRegistryUnavailableError,
DuplicatePluginError, DuplicatePluginError,
+79 -8
View File
@@ -5,15 +5,15 @@ from __future__ import annotations
import pytest import pytest
from cloud.config import CloudConfig from cloud.config import CloudConfig
from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider
from cloud.plugins import PluginRegistry from cloud.plugins import PluginRegistry
from cloud.pool import DevicePool from cloud.pool import DevicePool
from cloud.sdk.api import ( from cloud.sdk.api import (
AuthProvider, AuthProvider,
NullAuthProvider,
Principal, Principal,
create_cloud_router, create_cloud_router,
) )
from cloud.scheduler import TaskConstraints, TaskScheduler from cloud.scheduler import TaskScheduler
from cloud.store import CloudStore from cloud.store import CloudStore
from core.models import Device 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/devices").status_code == 401
assert client.get("/v1/hosts").status_code == 401 assert client.get("/v1/hosts").status_code == 401
assert client.get("/v1/plugins").status_code == 401 assert client.get("/v1/plugins").status_code == 401
assert client.post("/v1/plugins", json={ assert (
"name": "x", client.post(
"version": "1", "/v1/plugins",
"entry_point_kind": "tool", json={
"target": "cloud.store:CloudStore", "name": "x",
}).status_code == 401 "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: 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. # And the device should match if we run assign() manually via the scheduler.
status = client.get(f"/v1/tasks/{task_id}").json() status = client.get(f"/v1/tasks/{task_id}").json()
assert status["status"] == "queued" 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}