diff --git a/openspec/changes/cloud-control-plane-integration/tasks.md b/openspec/changes/cloud-control-plane-integration/tasks.md index ebf55b5..3b3e06e 100644 --- a/openspec/changes/cloud-control-plane-integration/tasks.md +++ b/openspec/changes/cloud-control-plane-integration/tasks.md @@ -31,7 +31,7 @@ - [x] 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.3 Add host principals bound to one `host_id` and reject cross-host heartbeat, claim, renewal, or result operations. - [x] 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. +- [x] 4.5 Add authentication tests covering invalid tokens, missing scopes, host impersonation, plugin administration, and secret redaction. ## 5. Host Agent Internal API diff --git a/tests/test_cloud_auth.py b/tests/test_cloud_auth.py index aeaf07c..56c1dc2 100644 --- a/tests/test_cloud_auth.py +++ b/tests/test_cloud_auth.py @@ -147,3 +147,18 @@ def test_public_principal_cannot_act_as_host() -> None: assert principal is not None with pytest.raises(HostPrincipalRequiredError): principal.require_host("host-a") + + +def test_authentication_failure_does_not_log_bearer_secret(caplog) -> None: + provider = ConfiguredBearerAuthProvider( + [BearerCredential(principal_id="integrator", token="valid-secret")] + ) + + assert ( + provider.authenticate( + _Request(headers={"authorization": "Bearer invalid-secret"}) + ) + is None + ) + assert "invalid-secret" not in caplog.text + assert "valid-secret" not in caplog.text diff --git a/tests/test_cloud_sdk_api.py b/tests/test_cloud_sdk_api.py index d8d9995..d35b46d 100644 --- a/tests/test_cloud_sdk_api.py +++ b/tests/test_cloud_sdk_api.py @@ -287,3 +287,56 @@ def test_every_public_route_enforces_its_scope( assert unauthorized.headers["www-authenticate"] == "Bearer" assert forbidden.status_code == 403 assert authorized.status_code not in {401, 403} + + +def test_plugin_admin_scope_is_checked_before_registration( + tmp_path, + monkeypatch, +) -> None: + provider = ConfiguredBearerAuthProvider( + [ + BearerCredential( + principal_id="plugin-reader", + token="reader-token", + scopes=frozenset({"plugins:read"}), + ) + ] + ) + app, _, _, plugin_registry = _build_app(tmp_path, auth_provider=provider) + registration_called = False + + def fail_if_called(_manifest) -> None: + nonlocal registration_called + registration_called = True + raise AssertionError("plugin registration must not run before authorization") + + monkeypatch.setattr(plugin_registry, "register", fail_if_called) + response = _client_for(app).post( + "/v1/plugins", + headers={"Authorization": "Bearer reader-token"}, + json={ + "name": "forbidden-plugin", + "version": "1.0.0", + "entry_point_kind": "driver", + "target": "secret.module:builder", + }, + ) + + assert response.status_code == 403 + assert registration_called is False + assert "secret.module" not in response.text + + +def test_invalid_token_is_rejected_by_public_router(tmp_path) -> None: + provider = ConfiguredBearerAuthProvider( + [BearerCredential(principal_id="integrator", token="valid-token")] + ) + app, _, _, _ = _build_app(tmp_path, auth_provider=provider) + + response = _client_for(app).get( + "/v1/devices", + headers={"Authorization": "Bearer invalid-token"}, + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer"