diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ede906c..3bd28c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,10 +94,22 @@ jobs: tags: agent-control-plane:ci - name: Smoke packaged service run: | - docker run --detach --name agent-control-plane-ci --publish 8000:8000 agent-control-plane:ci + if docker run --rm agent-control-plane:ci; then + echo "service started without explicit authentication or development mode" + exit 1 + fi + ACP_SMOKE_ADMIN_TOKEN="$(openssl rand -hex 32)" + ACP_SMOKE_READER_TOKEN="$(openssl rand -hex 32)" + ACP_SMOKE_ADMIN_HASH="$(printf '%s' "$ACP_SMOKE_ADMIN_TOKEN" | sha256sum | cut -d ' ' -f1)" + ACP_SMOKE_READER_HASH="$(printf '%s' "$ACP_SMOKE_READER_TOKEN" | sha256sum | cut -d ' ' -f1)" + ACP_AUTH_CONFIG="$(printf '{"principals":[{"subject":"ci@example.test","token_sha256":"%s","permissions":["*"]},{"subject":"reader@example.test","token_sha256":"%s","permissions":["agents:read"]}]}' "$ACP_SMOKE_ADMIN_HASH" "$ACP_SMOKE_READER_HASH")" + export ACP_SMOKE_ADMIN_TOKEN ACP_SMOKE_READER_TOKEN + docker run --detach --name agent-control-plane-ci --publish 8000:8000 \ + --env "ACP_AUTH_CONFIG=$ACP_AUTH_CONFIG" agent-control-plane:ci trap 'docker logs agent-control-plane-ci; docker rm --force agent-control-plane-ci' EXIT python - <<'PY' import json + import os import time import urllib.error import urllib.request @@ -114,16 +126,29 @@ jobs: raise time.sleep(0.5) - def call(path, method="GET", payload=None): + admin_token = os.environ["ACP_SMOKE_ADMIN_TOKEN"] + + def call(path, method="GET", payload=None, token=admin_token): + headers = {"Content-Type": "application/json"} + if token is not None: + headers["Authorization"] = f"Bearer {token}" request = urllib.request.Request( f"http://127.0.0.1:8000{path}", data=None if payload is None else json.dumps(payload).encode(), - headers={"Content-Type": "application/json"}, + headers=headers, method=method, ) with urllib.request.urlopen(request, timeout=2) as response: return json.load(response) + def expect_http_error(status, path, method="GET", payload=None, token=admin_token): + try: + call(path, method, payload, token) + except urllib.error.HTTPError as error: + assert error.code == status + return json.load(error) + raise AssertionError(f"expected HTTP {status}") + specification = { "agent_id": "ci-smoke-agent", "version": "1.0.0", @@ -131,11 +156,29 @@ jobs: "description": "Validates the packaged service contract.", "entrypoint": "https://agents.example.test/ci-smoke", } + assert expect_http_error(401, "/v1/agents", token=None)["detail"]["code"] == "authentication_failed" + assert expect_http_error(401, "/v1/agents", token="invalid")["detail"]["code"] == "authentication_failed" + reader_token = os.environ["ACP_SMOKE_READER_TOKEN"] + registration_payload = {"spec": specification, "actor": "ci@example.test"} + assert expect_http_error( + 403, + "/v1/agents", + "POST", + registration_payload, + reader_token, + )["detail"]["code"] == "permission_denied" + mismatched_payload = {"spec": specification, "actor": "impersonated@example.test"} + assert expect_http_error( + 403, + "/v1/agents", + "POST", + mismatched_payload, + )["detail"]["code"] == "actor_mismatch" assert call("/v1/agent-specs/validate", "POST", specification)["valid"] is True registered = call( "/v1/agents", "POST", - {"spec": specification, "actor": "ci@example.test"}, + registration_payload, ) assert registered["revision"] == 1 activated = call( @@ -157,7 +200,7 @@ jobs: "agent_id": "ci-smoke-agent", "action": "deployment.promote", "risk": "high", - "actor": "ci-smoke-agent", + "actor": "ci@example.test", "reason": "Exercise the packaged governance loop.", }, ) @@ -166,7 +209,7 @@ jobs: "POST", { "decision": "approve", - "actor": "ci-reviewer@example.test", + "actor": "ci@example.test", "reason": "Container smoke evidence passed.", }, ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e652ed..947a205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,5 @@ for public contracts once they are declared stable. - Human approval queue with single-decision enforcement and append-only audit events. - PostgreSQL system of record with transactional audit writes, Alembic migrations, readiness checks, and database-level audit mutation protection. +- Bearer-token authentication adapter with scoped permissions, fail-closed durable startup, and + authenticated actor binding for audit-producing writes. diff --git a/Makefile b/Makefile index 8226cbd..96ab800 100644 --- a/Makefile +++ b/Makefile @@ -35,4 +35,4 @@ migrate: python -m alembic upgrade head run: - python -m agent_control_plane + ACP_ALLOW_INSECURE_DEV=true python -m agent_control_plane diff --git a/README.md b/README.md index bede0c2..f9f1922 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ make check make run ``` -Without `ACP_DATABASE_URL`, the service uses its process-local in-memory adapter. The API is -then available at `http://127.0.0.1:8000`. Important endpoints: +`make run` explicitly enables an unauthenticated, process-local in-memory adapter for +development. The API is then available at `http://127.0.0.1:8000`. Important endpoints: - `GET /health/live` - `GET /health/ready` @@ -55,15 +55,34 @@ Persistent local execution starts PostgreSQL, runs migrations, and then starts t docker compose up --build ``` -For an externally managed PostgreSQL database, set a `postgresql+psycopg://` URL and migrate -before starting the service: +The Compose profile is protected by the development-only bearer token +`local-dev-control-plane-token`. Send it as `Authorization: Bearer ` when calling a +`/v1` endpoint. Health endpoints remain public. + +For an externally managed PostgreSQL database, configure principals, set a +`postgresql+psycopg://` URL, and migrate before starting the service: ```bash +export ACP_AUTH_CONFIG='{"principals":[{"subject":"operator@example.test","token_sha256":"","permissions":["*"]}]}' export ACP_DATABASE_URL='postgresql+psycopg://user:password@host/database' make migrate make run ``` +Generate a fingerprint without placing the raw token in shell history: + +```bash +python -c 'import getpass, hashlib; token = getpass.getpass("Bearer token: "); print(hashlib.sha256(token.encode()).hexdigest())' +``` + +`ACP_AUTH_CONFIG` stores token fingerprints, subjects, and permissions, never raw bearer +tokens. Generate each token with at least 256 bits of entropy, retain the raw value in the +calling system's secret manager, and send it only over TLS. Available permissions are +`agents:read`, `agents:write`, `approvals:read`, `approvals:request`, `approvals:decide`, and +`audit:read`; `*` is intended only for tightly controlled administrators. This static-token +adapter is the bootstrap authentication mechanism. A future OIDC adapter can replace it +without changing route authorization policy. + ## Delivery policy Every change merged to `main` goes through a pull request, review, and required fast checks. @@ -82,7 +101,9 @@ their audit events commit atomically; a database trigger rejects audit updates, truncation. Readiness fails when the configured database is unavailable or not migrated. The in-memory adapter remains available for development and evaluation only. Authenticated -actor identity, request idempotency, backup automation, and durable workflows remain planned. +subjects and scoped permissions protect durable deployments, and actor-bearing writes reject +identity mismatches. Request idempotency, OIDC, backup automation, and durable workflows remain +planned. ## License diff --git a/SECURITY.md b/SECURITY.md index 7f7f4f8..134256b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,6 +11,14 @@ changes require the high-risk review path described in `docs/QUALITY_GATES.md`. Never place API keys, customer traces, prompts, memories, production data, or credentials in the repository or test fixtures. -The credentials in `compose.yaml` are fixed development-only values. Production deployments -must inject a separate database URL through secret management, restrict the application role, -encrypt connections, and run backup/restore exercises before storing customer data. +The PostgreSQL credentials in `compose.yaml` and bearer token documented for local Compose are +fixed development-only values. Production deployments must inject a separate database URL +through secret management, restrict the application role, encrypt connections, and run +backup/restore exercises before storing customer data. + +Durable deployments fail startup unless `ACP_AUTH_CONFIG` defines authenticated principals. +The configuration contains only SHA-256 fingerprints of high-entropy bearer tokens; raw tokens +must remain in the caller's secret manager, must be sent only over TLS, and must never appear in +logs. Grant explicit permissions and avoid the `*` administrator permission for routine agent +or reviewer identities. The static-token adapter is an initial bootstrap mechanism, not a +replacement for centrally managed identity, short-lived credentials, or token rotation. diff --git a/compose.yaml b/compose.yaml index b27dd01..fbb5125 100644 --- a/compose.yaml +++ b/compose.yaml @@ -27,6 +27,8 @@ services: control-plane: build: . environment: + ACP_AUTH_CONFIG: >- + {"principals":[{"subject":"local-admin","token_sha256":"8fa4550a0cd4c25171ff8010ccd3fa30dd9430e9df0d8b1314b20dfdd4ca729d","permissions":["*"]}]} ACP_DATABASE_URL: postgresql+psycopg://control_plane:control_plane@postgres:5432/control_plane ports: - "${ACP_HTTP_PORT:-8000}:8000" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 754819b..fc82e3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,6 +15,7 @@ Existing Agent v Control Plane API |- AgentSpec validation + |- authenticated principals and scoped permissions |- agent lifecycle and optimistic revision checks |- human approval queue and append-only audit events |- trace and replay (planned) @@ -35,8 +36,15 @@ a second conflict check. A database trigger blocks audit mutation and removal. E returned newest first. Alembic owns schema versioning. Deployments run migrations as a separate step before the API; -readiness stays unavailable when the schema is missing. Authentication, backup policy, and -retention enforcement are required before production use. +readiness stays unavailable when the schema is missing. + +Authentication is an owned adapter boundary. The initial adapter maps opaque bearer-token +fingerprints to subjects and permissions. Protected writes bind the request actor to the +authenticated subject before state reaches the store, so audit identity cannot be selected by +an untrusted request body. Durable mode fails closed without authentication; the unauthenticated +in-memory mode requires an explicit development switch. OIDC remains a future adapter rather +than a route-level dependency. Backup policy and retention enforcement are still required +before production use. ## Adapter policy diff --git a/docs/QUALITY_GATES.md b/docs/QUALITY_GATES.md index 39a9e2b..097a414 100644 --- a/docs/QUALITY_GATES.md +++ b/docs/QUALITY_GATES.md @@ -33,6 +33,10 @@ load, recovery, or security tests. Database changes additionally require upgrade, integration, downgrade, and re-upgrade evidence against the supported PostgreSQL version. Migration rehearsal uses disposable data only. +Authentication changes additionally require packaged-service evidence for missing, invalid, +under-scoped, mismatched-actor, and valid credentials. Authentication remains a high-risk +change even when the public request schema is unchanged. + ## Dependency maintenance Dependency pull requests must identify a compatibility, security, or reproducibility benefit. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ea93937..50ffa38 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -7,6 +7,7 @@ Roadmap items advance only when tied to a validated user problem and an acceptan - [x] Versioned AgentSpec and event contracts. - [x] API health, readiness, and failure conventions. - [x] Pull request governance and automated quality gates. +- [x] Authenticated principals, scoped permissions, and audit actor binding. ## Reliability gateway @@ -23,6 +24,7 @@ Roadmap items advance only when tied to a validated user problem and an acceptan - Durable workflow adapter for cross-day tasks. - Idempotency, retry, compensation, and dead-letter handling. - Backup, restore, tenant isolation, and disaster exercises. +- OIDC identity and automated credential rotation. ## Governed evolution diff --git a/src/agent_control_plane/__main__.py b/src/agent_control_plane/__main__.py index c4895cc..8deac8d 100644 --- a/src/agent_control_plane/__main__.py +++ b/src/agent_control_plane/__main__.py @@ -4,7 +4,12 @@ def main() -> None: - uvicorn.run("agent_control_plane.api:app", host="0.0.0.0", port=8000) + uvicorn.run( + "agent_control_plane.api:create_app_from_environment", + host="0.0.0.0", + port=8000, + factory=True, + ) if __name__ == "__main__": # pragma: no cover diff --git a/src/agent_control_plane/api.py b/src/agent_control_plane/api.py index 6a2908c..cf3d50a 100644 --- a/src/agent_control_plane/api.py +++ b/src/agent_control_plane/api.py @@ -1,14 +1,22 @@ """HTTP surface for the control-plane contract.""" -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from typing import Annotated, NoReturn from uuid import UUID -from fastapi import FastAPI, HTTPException, Query, status +from fastapi import Depends, FastAPI, HTTPException, Query, Security, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from agent_control_plane import __version__ -from agent_control_plane.bootstrap import create_store_from_environment +from agent_control_plane.auth import ( + AuthenticatedPrincipal, + AuthenticationError, + Authenticator, + DisabledAuthenticator, + Permission, +) +from agent_control_plane.bootstrap import ControlPlaneRuntime, create_runtime_from_environment from agent_control_plane.models import ( AgentRecord, AgentRegistrationRequest, @@ -37,17 +45,88 @@ ) SERVICE_NAME = "agent-control-plane" +bearer_scheme = HTTPBearer(auto_error=False) +PermissionDependency = Callable[ + [HTTPAuthorizationCredentials | None], AuthenticatedPrincipal | None +] -def _raise_http_error(status_code: int, code: str, error: Exception) -> NoReturn: +def _raise_http_error( + status_code: int, + code: str, + error: Exception, + *, + headers: dict[str, str] | None = None, +) -> NoReturn: raise HTTPException( status_code=status_code, detail={"code": code, "message": str(error)}, + headers=headers, ) from error -def create_app(store: ControlPlaneStore | None = None) -> FastAPI: +def _permission_dependency( + authenticator: Authenticator, + permission: Permission, +) -> PermissionDependency: + def require_permission( + credentials: Annotated[ + HTTPAuthorizationCredentials | None, + Security(bearer_scheme), + ] = None, + ) -> AuthenticatedPrincipal | None: + if not authenticator.enabled: + return None + try: + principal = authenticator.authenticate( + credentials.credentials if credentials is not None else None + ) + except AuthenticationError as error: + _raise_http_error( + status.HTTP_401_UNAUTHORIZED, + "authentication_failed", + error, + headers={"WWW-Authenticate": "Bearer"}, + ) + if principal is None: + _raise_http_error( + status.HTTP_401_UNAUTHORIZED, + "authentication_failed", + AuthenticationError("bearer authentication failed"), + headers={"WWW-Authenticate": "Bearer"}, + ) + if not principal.allows(permission): + _raise_http_error( + status.HTTP_403_FORBIDDEN, + "permission_denied", + PermissionError(f"permission '{permission}' is required"), + ) + return principal + + return require_permission + + +def _validate_actor(principal: AuthenticatedPrincipal | None, claimed_actor: str) -> None: + if principal is not None and principal.subject != claimed_actor: + _raise_http_error( + status.HTTP_403_FORBIDDEN, + "actor_mismatch", + PermissionError("request actor must match the authenticated subject"), + ) + + +def create_app( + store: ControlPlaneStore | None = None, + authenticator: Authenticator | None = None, +) -> FastAPI: control_plane = store if store is not None else InMemoryControlPlaneStore() + auth = authenticator if authenticator is not None else DisabledAuthenticator() + agents_read = _permission_dependency(auth, Permission.AGENTS_READ) + agents_write = _permission_dependency(auth, Permission.AGENTS_WRITE) + approvals_read = _permission_dependency(auth, Permission.APPROVALS_READ) + approvals_request = _permission_dependency(auth, Permission.APPROVALS_REQUEST) + approvals_decide = _permission_dependency(auth, Permission.APPROVALS_DECIDE) + audit_read = _permission_dependency(auth, Permission.AUDIT_READ) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -80,7 +159,10 @@ def readiness() -> HealthResponse: response_model=AgentSpecValidationResponse, tags=["agent-specs"], ) - async def validate_agent_spec(spec: AgentSpec) -> AgentSpecValidationResponse: + async def validate_agent_spec( + spec: AgentSpec, + _principal: Annotated[AuthenticatedPrincipal | None, Depends(agents_read)], + ) -> AgentSpecValidationResponse: return AgentSpecValidationResponse( valid=True, agent_id=spec.agent_id, @@ -93,18 +175,27 @@ async def validate_agent_spec(spec: AgentSpec) -> AgentSpecValidationResponse: status_code=status.HTTP_201_CREATED, tags=["agents"], ) - def register_agent(request: AgentRegistrationRequest) -> AgentRecord: + def register_agent( + request: AgentRegistrationRequest, + principal: Annotated[AuthenticatedPrincipal | None, Depends(agents_write)], + ) -> AgentRecord: + _validate_actor(principal, request.actor) try: return control_plane.register_agent(request) except AgentAlreadyExistsError as error: _raise_http_error(status.HTTP_409_CONFLICT, "agent_already_exists", error) @application.get("/v1/agents", response_model=list[AgentRecord], tags=["agents"]) - def list_agents() -> tuple[AgentRecord, ...]: + def list_agents( + _principal: Annotated[AuthenticatedPrincipal | None, Depends(agents_read)], + ) -> tuple[AgentRecord, ...]: return control_plane.list_agents() @application.get("/v1/agents/{agent_id}", response_model=AgentRecord, tags=["agents"]) - def get_agent(agent_id: str) -> AgentRecord: + def get_agent( + agent_id: str, + _principal: Annotated[AuthenticatedPrincipal | None, Depends(agents_read)], + ) -> AgentRecord: try: return control_plane.get_agent(agent_id) except AgentNotFoundError as error: @@ -115,7 +206,12 @@ def get_agent(agent_id: str) -> AgentRecord: response_model=AgentRecord, tags=["agents"], ) - def update_agent_status(agent_id: str, update: AgentStatusUpdate) -> AgentRecord: + def update_agent_status( + agent_id: str, + update: AgentStatusUpdate, + principal: Annotated[AuthenticatedPrincipal | None, Depends(agents_write)], + ) -> AgentRecord: + _validate_actor(principal, update.actor) try: return control_plane.update_agent_status(agent_id, update) except AgentNotFoundError as error: @@ -131,7 +227,11 @@ def update_agent_status(agent_id: str, update: AgentStatusUpdate) -> AgentRecord status_code=status.HTTP_201_CREATED, tags=["approvals"], ) - def create_approval(request: ApprovalRequestCreate) -> ApprovalRecord: + def create_approval( + request: ApprovalRequestCreate, + principal: Annotated[AuthenticatedPrincipal | None, Depends(approvals_request)], + ) -> ApprovalRecord: + _validate_actor(principal, request.actor) try: return control_plane.create_approval(request) except AgentNotFoundError as error: @@ -142,7 +242,10 @@ def create_approval(request: ApprovalRequestCreate) -> ApprovalRecord: @application.get( "/v1/approvals/{request_id}", response_model=ApprovalRecord, tags=["approvals"] ) - def get_approval(request_id: UUID) -> ApprovalRecord: + def get_approval( + request_id: UUID, + _principal: Annotated[AuthenticatedPrincipal | None, Depends(approvals_read)], + ) -> ApprovalRecord: try: return control_plane.get_approval(request_id) except ApprovalNotFoundError as error: @@ -150,6 +253,7 @@ def get_approval(request_id: UUID) -> ApprovalRecord: @application.get("/v1/approvals", response_model=ApprovalQueueResponse, tags=["approvals"]) def list_approvals( + _principal: Annotated[AuthenticatedPrincipal | None, Depends(approvals_read)], approval_status: Annotated[ApprovalStatus | None, Query(alias="status")] = None, agent_id: str | None = None, ) -> ApprovalQueueResponse: @@ -161,7 +265,12 @@ def list_approvals( response_model=ApprovalRecord, tags=["approvals"], ) - def decide_approval(request_id: UUID, decision: ApprovalDecisionRequest) -> ApprovalRecord: + def decide_approval( + request_id: UUID, + decision: ApprovalDecisionRequest, + principal: Annotated[AuthenticatedPrincipal | None, Depends(approvals_decide)], + ) -> ApprovalRecord: + _validate_actor(principal, decision.actor) try: return control_plane.decide_approval(request_id, decision) except ApprovalNotFoundError as error: @@ -171,6 +280,7 @@ def decide_approval(request_id: UUID, decision: ApprovalDecisionRequest) -> Appr @application.get("/v1/audit-events", response_model=AuditEventPage, tags=["audit"]) def list_audit_events( + _principal: Annotated[AuthenticatedPrincipal | None, Depends(audit_read)], agent_id: str | None = None, limit: Annotated[int, Query(ge=1, le=500)] = 100, ) -> AuditEventPage: @@ -180,4 +290,6 @@ def list_audit_events( return application -app = create_app(create_store_from_environment()) +def create_app_from_environment() -> FastAPI: + runtime: ControlPlaneRuntime = create_runtime_from_environment() + return create_app(runtime.store, runtime.authenticator) diff --git a/src/agent_control_plane/auth.py b/src/agent_control_plane/auth.py new file mode 100644 index 0000000..a950321 --- /dev/null +++ b/src/agent_control_plane/auth.py @@ -0,0 +1,101 @@ +"""Framework-neutral authentication and permission policy adapters.""" + +from dataclasses import dataclass +from enum import StrEnum +from hashlib import sha256 +from typing import Protocol, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from agent_control_plane.models import ActorId + + +class Permission(StrEnum): + ALL = "*" + AGENTS_READ = "agents:read" + AGENTS_WRITE = "agents:write" + APPROVALS_READ = "approvals:read" + APPROVALS_REQUEST = "approvals:request" + APPROVALS_DECIDE = "approvals:decide" + AUDIT_READ = "audit:read" + + +class TokenPrincipalConfig(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + subject: ActorId + token_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + permissions: frozenset[Permission] = Field(min_length=1) + + +class AuthenticationConfig(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + principals: tuple[TokenPrincipalConfig, ...] = Field(min_length=1) + + @model_validator(mode="after") + def token_fingerprints_must_be_unique(self) -> Self: + fingerprints = [principal.token_sha256 for principal in self.principals] + if len(fingerprints) != len(set(fingerprints)): + raise ValueError("token fingerprints must be unique") + return self + + +@dataclass(frozen=True) +class AuthenticatedPrincipal: + subject: str + permissions: frozenset[Permission] + + def allows(self, permission: Permission) -> bool: + return Permission.ALL in self.permissions or permission in self.permissions + + +class AuthenticationError(Exception): + """Raised when bearer credentials cannot be authenticated.""" + + +class Authenticator(Protocol): + @property + def enabled(self) -> bool: ... + + def authenticate(self, token: str | None) -> AuthenticatedPrincipal | None: ... + + +class DisabledAuthenticator: + """Explicit development adapter that performs no authentication.""" + + @property + def enabled(self) -> bool: + return False + + def authenticate(self, token: str | None) -> None: + return None + + +class StaticBearerAuthenticator: + """Authenticate opaque bearer tokens using configured SHA-256 fingerprints.""" + + def __init__(self, config: AuthenticationConfig) -> None: + self._principals = { + item.token_sha256: AuthenticatedPrincipal( + subject=item.subject, + permissions=item.permissions, + ) + for item in config.principals + } + + @property + def enabled(self) -> bool: + return True + + def authenticate(self, token: str | None) -> AuthenticatedPrincipal: + if token is None: + raise AuthenticationError("bearer authentication failed") + principal = self._principals.get(hash_bearer_token(token)) + if principal is None: + raise AuthenticationError("bearer authentication failed") + return principal + + +def hash_bearer_token(token: str) -> str: + return sha256(token.encode("utf-8")).hexdigest() diff --git a/src/agent_control_plane/bootstrap.py b/src/agent_control_plane/bootstrap.py index b3402b7..af750d5 100644 --- a/src/agent_control_plane/bootstrap.py +++ b/src/agent_control_plane/bootstrap.py @@ -2,11 +2,28 @@ import os from collections.abc import Mapping +from dataclasses import dataclass +from pydantic import ValidationError + +from agent_control_plane.auth import ( + AuthenticationConfig, + Authenticator, + DisabledAuthenticator, + StaticBearerAuthenticator, +) from agent_control_plane.postgres_store import PostgresControlPlaneStore from agent_control_plane.store import ControlPlaneStore, InMemoryControlPlaneStore DATABASE_URL_ENV = "ACP_DATABASE_URL" +AUTH_CONFIG_ENV = "ACP_AUTH_CONFIG" +ALLOW_INSECURE_DEV_ENV = "ACP_ALLOW_INSECURE_DEV" + + +@dataclass(frozen=True) +class ControlPlaneRuntime: + store: ControlPlaneStore + authenticator: Authenticator def create_store_from_environment( @@ -19,3 +36,32 @@ def create_store_from_environment( if not database_url.startswith(("postgresql://", "postgresql+psycopg://")): raise ValueError(f"{DATABASE_URL_ENV} must be a PostgreSQL URL") return PostgresControlPlaneStore(database_url) + + +def create_authenticator_from_environment( + environment: Mapping[str, str] | None = None, +) -> Authenticator: + values = os.environ if environment is None else environment + raw_config = values.get(AUTH_CONFIG_ENV) + if not raw_config: + return DisabledAuthenticator() + try: + config = AuthenticationConfig.model_validate_json(raw_config) + except ValidationError as error: + raise ValueError(f"{AUTH_CONFIG_ENV} is invalid") from error + return StaticBearerAuthenticator(config) + + +def create_runtime_from_environment( + environment: Mapping[str, str] | None = None, +) -> ControlPlaneRuntime: + values = os.environ if environment is None else environment + authenticator = create_authenticator_from_environment(values) + if values.get(DATABASE_URL_ENV) and not authenticator.enabled: + raise ValueError(f"{AUTH_CONFIG_ENV} is required when {DATABASE_URL_ENV} is configured") + if not authenticator.enabled and values.get(ALLOW_INSECURE_DEV_ENV) != "true": + raise ValueError(f"{AUTH_CONFIG_ENV} is required unless {ALLOW_INSECURE_DEV_ENV}=true") + return ControlPlaneRuntime( + store=create_store_from_environment(values), + authenticator=authenticator, + ) diff --git a/tests/smoke/test_api_smoke.py b/tests/smoke/test_api_smoke.py index 5cca05d..1c458bc 100644 --- a/tests/smoke/test_api_smoke.py +++ b/tests/smoke/test_api_smoke.py @@ -1,9 +1,17 @@ +import secrets from collections.abc import AsyncIterator import httpx import pytest from agent_control_plane.api import create_app +from agent_control_plane.auth import ( + AuthenticationConfig, + Permission, + StaticBearerAuthenticator, + TokenPrincipalConfig, + hash_bearer_token, +) from agent_control_plane.store import InMemoryControlPlaneStore @@ -48,6 +56,89 @@ def is_ready(self) -> bool: assert ready_response.json()["detail"]["code"] == "store_unavailable" +@pytest.mark.smoke +@pytest.mark.anyio +async def test_protected_api_enforces_credentials_permissions_and_actor_binding() -> None: + reader_token = secrets.token_urlsafe(32) + operator_token = secrets.token_urlsafe(32) + authenticator = StaticBearerAuthenticator( + AuthenticationConfig( + principals=( + TokenPrincipalConfig( + subject="reader@example.test", + token_sha256=hash_bearer_token(reader_token), + permissions=frozenset({Permission.AGENTS_READ}), + ), + TokenPrincipalConfig( + subject="operator@example.test", + token_sha256=hash_bearer_token(operator_token), + permissions=frozenset({Permission.ALL}), + ), + ) + ) + ) + transport = httpx.ASGITransport(app=create_app(authenticator=authenticator)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: + assert (await test_client.get("/health/live")).status_code == 200 + + missing = await test_client.get("/v1/agents") + invalid = await test_client.get( + "/v1/agents", + headers={"Authorization": f"Bearer {secrets.token_urlsafe(32)}"}, + ) + wrong_scheme = await test_client.get( + "/v1/agents", + headers={"Authorization": "Basic ignored"}, + ) + assert missing.status_code == invalid.status_code == wrong_scheme.status_code == 401 + assert missing.json() == invalid.json() == wrong_scheme.json() + for response in (missing, invalid, wrong_scheme): + assert response.headers["www-authenticate"] == "Bearer" + + insufficient = await test_client.post( + "/v1/agents", + json=registration_payload(), + headers={"Authorization": f"Bearer {reader_token}"}, + ) + assert insufficient.status_code == 403 + assert insufficient.json()["detail"]["code"] == "permission_denied" + + mismatch_payload = registration_payload() + mismatch_payload["actor"] = "impersonated@example.test" + mismatch = await test_client.post( + "/v1/agents", + json=mismatch_payload, + headers={"Authorization": f"Bearer {operator_token}"}, + ) + assert mismatch.status_code == 403 + assert mismatch.json()["detail"]["code"] == "actor_mismatch" + + registered = await test_client.post( + "/v1/agents", + json=registration_payload(), + headers={"Authorization": f"Bearer {operator_token}"}, + ) + assert registered.status_code == 201 + + audit = await test_client.get( + "/v1/audit-events", + headers={"Authorization": f"Bearer {operator_token}"}, + ) + assert audit.json()["items"][0]["actor"] == "operator@example.test" + + +@pytest.mark.smoke +def test_every_versioned_operation_declares_bearer_authentication() -> None: + schema = create_app().openapi() + + for path, operations in schema["paths"].items(): + for operation in operations.values(): + if path.startswith("/v1"): + assert {"HTTPBearer": []} in operation["security"] + else: + assert "security" not in operation + + @pytest.mark.smoke @pytest.mark.anyio async def test_agent_spec_contract_is_reachable(client: httpx.AsyncClient) -> None: diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..01683e9 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,81 @@ +import secrets + +import pytest +from pydantic import ValidationError + +from agent_control_plane.auth import ( + AuthenticatedPrincipal, + AuthenticationConfig, + AuthenticationError, + DisabledAuthenticator, + Permission, + StaticBearerAuthenticator, + TokenPrincipalConfig, + hash_bearer_token, +) + + +def config_for(token: str, *permissions: Permission) -> AuthenticationConfig: + return AuthenticationConfig( + principals=( + TokenPrincipalConfig( + subject="operator@example.test", + token_sha256=hash_bearer_token(token), + permissions=frozenset(permissions), + ), + ) + ) + + +def test_disabled_authenticator_is_explicitly_inactive() -> None: + authenticator = DisabledAuthenticator() + + assert authenticator.enabled is False + assert authenticator.authenticate(None) is None + + +def test_static_authenticator_resolves_a_token_fingerprint() -> None: + token = secrets.token_urlsafe(32) + authenticator = StaticBearerAuthenticator(config_for(token, Permission.AGENTS_READ)) + + assert authenticator.enabled is True + assert authenticator.authenticate(token) == AuthenticatedPrincipal( + subject="operator@example.test", + permissions=frozenset({Permission.AGENTS_READ}), + ) + + +def test_missing_and_invalid_tokens_have_the_same_failure() -> None: + valid_token = secrets.token_urlsafe(32) + authenticator = StaticBearerAuthenticator(config_for(valid_token, Permission.AGENTS_READ)) + + for token in (None, secrets.token_urlsafe(32)): + with pytest.raises(AuthenticationError, match="bearer authentication failed"): + authenticator.authenticate(token) + + +def test_permissions_are_explicit_with_an_admin_wildcard() -> None: + reader = AuthenticatedPrincipal( + subject="reader@example.test", + permissions=frozenset({Permission.AGENTS_READ}), + ) + admin = AuthenticatedPrincipal( + subject="admin@example.test", + permissions=frozenset({Permission.ALL}), + ) + + assert reader.allows(Permission.AGENTS_READ) is True + assert reader.allows(Permission.AGENTS_WRITE) is False + assert admin.allows(Permission.APPROVALS_DECIDE) is True + + +def test_authentication_config_rejects_duplicate_token_fingerprints() -> None: + token_hash = hash_bearer_token(secrets.token_urlsafe(32)) + principal = TokenPrincipalConfig( + subject="operator@example.test", + token_sha256=token_hash, + permissions=frozenset({Permission.ALL}), + ) + + with pytest.raises(ValidationError, match="token fingerprints must be unique"): + AuthenticationConfig(principals=(principal, principal)) diff --git a/tests/unit/test_bootstrap.py b/tests/unit/test_bootstrap.py index c931578..5c4814a 100644 --- a/tests/unit/test_bootstrap.py +++ b/tests/unit/test_bootstrap.py @@ -1,6 +1,14 @@ +import json +import secrets + import pytest -from agent_control_plane.bootstrap import create_store_from_environment +from agent_control_plane.auth import Permission, StaticBearerAuthenticator, hash_bearer_token +from agent_control_plane.bootstrap import ( + create_authenticator_from_environment, + create_runtime_from_environment, + create_store_from_environment, +) from agent_control_plane.postgres_store import PostgresControlPlaneStore from agent_control_plane.store import InMemoryControlPlaneStore @@ -23,3 +31,59 @@ def test_bootstrap_builds_postgres_store_for_a_database_url() -> None: def test_bootstrap_rejects_non_postgres_urls() -> None: with pytest.raises(ValueError, match="must be a PostgreSQL URL"): create_store_from_environment({"ACP_DATABASE_URL": "sqlite:///control-plane.db"}) + + +def authentication_environment(token: str) -> dict[str, str]: + return { + "ACP_AUTH_CONFIG": json.dumps( + { + "principals": [ + { + "subject": "operator@example.test", + "token_sha256": hash_bearer_token(token), + "permissions": [Permission.ALL], + } + ] + } + ) + } + + +def test_bootstrap_builds_static_authentication_from_json() -> None: + token = secrets.token_urlsafe(32) + + authenticator = create_authenticator_from_environment(authentication_environment(token)) + + assert isinstance(authenticator, StaticBearerAuthenticator) + assert authenticator.authenticate(token).subject == "operator@example.test" + + +def test_bootstrap_rejects_invalid_authentication_json() -> None: + with pytest.raises(ValueError, match="ACP_AUTH_CONFIG is invalid"): + create_authenticator_from_environment({"ACP_AUTH_CONFIG": "not-json"}) + + +def test_durable_runtime_requires_authentication() -> None: + database_environment = { + "ACP_DATABASE_URL": "postgresql+psycopg://user:password@localhost/database" + } + + with pytest.raises(ValueError, match="ACP_AUTH_CONFIG is required"): + create_runtime_from_environment(database_environment) + + token = secrets.token_urlsafe(32) + runtime = create_runtime_from_environment( + database_environment | authentication_environment(token) + ) + assert runtime.authenticator.enabled is True + runtime.store.close() + + +def test_runtime_requires_an_explicit_insecure_development_mode() -> None: + with pytest.raises(ValueError, match="ACP_ALLOW_INSECURE_DEV=true"): + create_runtime_from_environment({}) + + runtime = create_runtime_from_environment({"ACP_ALLOW_INSECURE_DEV": "true"}) + assert isinstance(runtime.store, InMemoryControlPlaneStore) + assert runtime.authenticator.enabled is False + runtime.store.close() diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ad349a6..070d72f 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -60,15 +60,16 @@ def test_agent_spec_rejects_empty_capabilities() -> None: def test_command_entrypoint_runs_the_api(monkeypatch: pytest.MonkeyPatch) -> None: invocation: dict[str, object] = {} - def fake_run(app: str, *, host: str, port: int) -> None: - invocation.update(app=app, host=host, port=port) + def fake_run(app: str, *, host: str, port: int, factory: bool) -> None: + invocation.update(app=app, host=host, port=port, factory=factory) monkeypatch.setattr(entrypoint.uvicorn, "run", fake_run) entrypoint.main() assert invocation == { - "app": "agent_control_plane.api:app", + "app": "agent_control_plane.api:create_app_from_environment", "host": "0.0.0.0", "port": 8000, + "factory": True, }