feat(cloud-auth): enforce public scopes
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user