Skip to content

Commit 04a89ad

Browse files
fix(server): route evaluator and observability auth through framework
1 parent 84d85bb commit 04a89ad

8 files changed

Lines changed: 170 additions & 50 deletions

File tree

docs/auth.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ policies.update
1717
agents.read
1818
agents.create
1919
agents.update
20+
evaluators.read
21+
observability.read
22+
observability.write
2023
control_bindings.read
2124
control_bindings.write
2225
runtime.token_exchange

server/src/agent_control_server/auth_framework/core.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ class Operation(StrEnum):
5555
AGENTS_READ = "agents.read"
5656
AGENTS_CREATE = "agents.create"
5757
AGENTS_UPDATE = "agents.update"
58+
EVALUATORS_READ = "evaluators.read"
59+
OBSERVABILITY_READ = "observability.read"
60+
OBSERVABILITY_WRITE = "observability.write"
5861
RUNTIME_USE = "runtime.use"
5962

6063

@@ -109,8 +112,7 @@ async def authorize(
109112
request: Request,
110113
operation: Operation,
111114
context: dict[str, Any] | None = None,
112-
) -> Principal:
113-
...
115+
) -> Principal: ...
114116

115117

116118
_default_authorizer: RequestAuthorizer | None = None

server/src/agent_control_server/auth_framework/providers/header.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ class AccessLevel(Enum):
4848
Operation.AGENTS_READ: AccessLevel.AUTHENTICATED,
4949
Operation.AGENTS_CREATE: AccessLevel.AUTHENTICATED,
5050
Operation.AGENTS_UPDATE: AccessLevel.ADMIN,
51+
Operation.EVALUATORS_READ: AccessLevel.AUTHENTICATED,
52+
Operation.OBSERVABILITY_READ: AccessLevel.AUTHENTICATED,
53+
Operation.OBSERVABILITY_WRITE: AccessLevel.AUTHENTICATED,
5154
Operation.RUNTIME_TOKEN_EXCHANGE: AccessLevel.AUTHENTICATED,
5255
Operation.RUNTIME_USE: AccessLevel.AUTHENTICATED,
5356
}

server/src/agent_control_server/endpoints/evaluators.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
from typing import Any
44

55
from agent_control_engine import list_evaluators
6-
from fastapi import APIRouter
6+
from fastapi import APIRouter, Depends
77
from pydantic import BaseModel, Field
88

9+
from ..auth_framework import Operation, require_operation
10+
911
router = APIRouter(prefix="/evaluators", tags=["evaluators"])
1012

1113

@@ -25,6 +27,7 @@ class EvaluatorInfo(BaseModel):
2527
response_model=dict[str, EvaluatorInfo],
2628
summary="List available evaluators",
2729
response_description="Dictionary of evaluator name to evaluator info",
30+
dependencies=[Depends(require_operation(Operation.EVALUATORS_READ))],
2831
)
2932
async def get_evaluators() -> dict[str, EvaluatorInfo]:
3033
"""List all available evaluators.

server/src/agent_control_server/endpoints/observability.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
2. Event queries (POST /events/query) - Query raw events by trace_id, etc.
66
3. Stats (GET /stats) - Aggregated statistics for dashboards
77
8-
All endpoints require API key authentication.
8+
All endpoints declare operation-based auth dependencies.
99
1010
Dependencies are stored on app.state during server lifespan (see main.py):
1111
- app.state.event_ingestor: EventIngestor
@@ -27,7 +27,7 @@
2727
)
2828
from fastapi import APIRouter, Depends, Request
2929

30-
from ..auth import require_api_key
30+
from ..auth_framework import Operation, require_operation
3131
from ..observability.ingest.base import EventIngestor
3232
from ..observability.store.base import (
3333
EventStore,
@@ -42,7 +42,6 @@
4242
router = APIRouter(
4343
prefix="/observability",
4444
tags=["observability"],
45-
dependencies=[Depends(require_api_key)],
4645
)
4746

4847

@@ -72,7 +71,12 @@ def get_event_store(request: Request) -> EventStore:
7271
# =============================================================================
7372

7473

75-
@router.post("/events", status_code=202, response_model=BatchEventsResponse)
74+
@router.post(
75+
"/events",
76+
status_code=202,
77+
response_model=BatchEventsResponse,
78+
dependencies=[Depends(require_operation(Operation.OBSERVABILITY_WRITE))],
79+
)
7680
async def ingest_events(
7781
request: BatchEventsRequest,
7882
ingestor: EventIngestor = Depends(get_event_ingestor),
@@ -121,7 +125,11 @@ async def ingest_events(
121125
# =============================================================================
122126

123127

124-
@router.post("/events/query", response_model=EventQueryResponse)
128+
@router.post(
129+
"/events/query",
130+
response_model=EventQueryResponse,
131+
dependencies=[Depends(require_operation(Operation.OBSERVABILITY_READ))],
132+
)
125133
async def query_events(
126134
request: EventQueryRequest,
127135
store: EventStore = Depends(get_event_store),
@@ -158,7 +166,11 @@ async def query_events(
158166
# =============================================================================
159167

160168

161-
@router.get("/stats", response_model=StatsResponse)
169+
@router.get(
170+
"/stats",
171+
response_model=StatsResponse,
172+
dependencies=[Depends(require_operation(Operation.OBSERVABILITY_READ))],
173+
)
162174
async def get_stats(
163175
agent_name: str,
164176
time_range: TimeRange = "5m",
@@ -207,7 +219,11 @@ async def get_stats(
207219
)
208220

209221

210-
@router.get("/stats/controls/{control_id}", response_model=ControlStatsResponse)
222+
@router.get(
223+
"/stats/controls/{control_id}",
224+
response_model=ControlStatsResponse,
225+
dependencies=[Depends(require_operation(Operation.OBSERVABILITY_READ))],
226+
)
211227
async def get_control_stats(
212228
control_id: int,
213229
agent_name: str,
@@ -266,7 +282,10 @@ async def get_control_stats(
266282
# =============================================================================
267283

268284

269-
@router.get("/status")
285+
@router.get(
286+
"/status",
287+
dependencies=[Depends(require_operation(Operation.OBSERVABILITY_READ))],
288+
)
270289
async def get_status(request: Request) -> dict:
271290
"""
272291
Get observability system status.

server/src/agent_control_server/main.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from starlette_exporter import PrometheusMiddleware, handle_metrics
1818

1919
from . import __version__ as server_version
20-
from .auth import get_api_key_from_header, require_api_key
20+
from .auth import get_api_key_from_header
2121
from .config import observability_settings, settings
2222
from .db import AsyncSessionLocal
2323
from .endpoints.agents import router as agent_router
@@ -314,17 +314,16 @@ async def attach_version_header(request, call_next): # type: ignore[no-untyped-
314314
dependencies=[Depends(get_api_key_from_header)],
315315
)
316316

317-
# Evaluator discovery still uses the local credential dependency.
318317
app.include_router(
319318
evaluator_router,
320319
prefix=api_v1_prefix,
321-
dependencies=[Depends(require_api_key)],
320+
dependencies=[Depends(get_api_key_from_header)],
322321
)
323322

324-
# Observability routes (already has auth dependency in router)
325323
app.include_router(
326324
observability_router,
327325
prefix=api_v1_prefix,
326+
dependencies=[Depends(get_api_key_from_header)],
328327
)
329328

330329
# System routes (config, login, logout) - no auth required

server/tests/test_auth.py

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
11
"""Tests for API key authentication."""
22

33
import uuid
4+
from typing import Any
45

56
import pytest
7+
from fastapi import Request
68
from fastapi.testclient import TestClient
79

810
from agent_control_server import __version__ as server_version
11+
from agent_control_server.auth_framework import Operation, Principal, set_authorizer
912
from agent_control_server.config import auth_settings
1013

1114
from .utils import VALID_CONTROL_PAYLOAD
1215

1316

17+
class _RecordingAuthorizer:
18+
"""Test authorizer that records the operation requested by a route."""
19+
20+
def __init__(self) -> None:
21+
self.calls: list[tuple[Operation, dict[str, Any] | None]] = []
22+
23+
async def authorize(
24+
self,
25+
request: Request,
26+
operation: Operation,
27+
context: dict[str, Any] | None = None,
28+
) -> Principal:
29+
del request
30+
self.calls.append((operation, context))
31+
return Principal(namespace_key="default")
32+
33+
1434
class TestHealthEndpoint:
1535
"""Health endpoint should always be accessible without authentication."""
1636

@@ -40,9 +60,7 @@ class TestProtectedEndpoints:
4060
def test_missing_api_key_returns_401(self, unauthenticated_client: TestClient) -> None:
4161
"""Given no API key, when requesting protected endpoint, then returns 401."""
4262
# When:
43-
response = unauthenticated_client.get(
44-
"/api/v1/agents/00000000-0000-0000-0000-000000000000"
45-
)
63+
response = unauthenticated_client.get("/api/v1/agents/00000000-0000-0000-0000-000000000000")
4664

4765
# Then:
4866
assert response.status_code == 401
@@ -111,6 +129,20 @@ def test_missing_key_returns_401_on_evaluators(
111129
# Then:
112130
assert response.status_code == 401
113131

132+
def test_evaluators_use_auth_framework_provider(self, app: object) -> None:
133+
"""Given a custom authorizer, when listing evaluators, then route uses it."""
134+
# Given:
135+
authorizer = _RecordingAuthorizer()
136+
set_authorizer(authorizer)
137+
client = TestClient(app, raise_server_exceptions=True)
138+
139+
# When:
140+
response = client.get("/api/v1/evaluators")
141+
142+
# Then:
143+
assert response.status_code == 200
144+
assert authorizer.calls == [(Operation.EVALUATORS_READ, None)]
145+
114146

115147
class TestAuthDisabled:
116148
"""When auth is disabled, all requests should succeed."""
@@ -120,21 +152,15 @@ def disable_auth(self, monkeypatch: pytest.MonkeyPatch) -> None:
120152
"""Disable auth for tests in this class."""
121153
monkeypatch.setattr(auth_settings, "api_key_enabled", False)
122154

123-
def test_no_key_allowed_when_disabled(
124-
self, unauthenticated_client: TestClient
125-
) -> None:
155+
def test_no_key_allowed_when_disabled(self, unauthenticated_client: TestClient) -> None:
126156
"""Given auth disabled, when requesting without API key, then request succeeds."""
127157
# When:
128-
response = unauthenticated_client.get(
129-
"/api/v1/agents/00000000-0000-0000-0000-000000000000"
130-
)
158+
response = unauthenticated_client.get("/api/v1/agents/00000000-0000-0000-0000-000000000000")
131159

132160
# Then: (404 for non-existent resource, but NOT 401)
133161
assert response.status_code == 404
134162

135-
def test_evaluators_accessible_when_disabled(
136-
self, unauthenticated_client: TestClient
137-
) -> None:
163+
def test_evaluators_accessible_when_disabled(self, unauthenticated_client: TestClient) -> None:
138164
"""Given auth disabled, when listing evaluators without API key, then returns 200."""
139165
# When:
140166
response = unauthenticated_client.get("/api/v1/evaluators")
@@ -264,9 +290,7 @@ def test_admin_key_allowed_on_representative_mutations(self, admin_client: TestC
264290
init_response = admin_client.post("/api/v1/agents/initAgent", json=init_payload)
265291
assert init_response.status_code == 200
266292

267-
set_policy_response = admin_client.post(
268-
f"/api/v1/agents/{agent_name}/policy/{policy_id}"
269-
)
293+
set_policy_response = admin_client.post(f"/api/v1/agents/{agent_name}/policy/{policy_id}")
270294
assert set_policy_response.status_code == 200
271295

272296

@@ -344,9 +368,7 @@ def setup_no_keys(self, monkeypatch: pytest.MonkeyPatch) -> None:
344368
def test_misconfigured_returns_500(self, unauthenticated_client: TestClient) -> None:
345369
"""Given auth enabled but no keys configured, when requesting, then returns 500."""
346370
# When:
347-
response = unauthenticated_client.get(
348-
"/api/v1/agents/00000000-0000-0000-0000-000000000000"
349-
)
371+
response = unauthenticated_client.get("/api/v1/agents/00000000-0000-0000-0000-000000000000")
350372

351373
# Then:
352374
assert response.status_code == 500
@@ -360,6 +382,7 @@ class TestOptionalApiKey:
360382

361383
def _make_optional_app(self) -> TestClient:
362384
from fastapi import Depends, FastAPI
385+
363386
from agent_control_server.auth import optional_api_key
364387

365388
app = FastAPI()
@@ -374,7 +397,9 @@ def maybe_auth(client=Depends(optional_api_key)) -> dict[str, object]:
374397

375398
return TestClient(app)
376399

377-
def test_optional_api_key_auth_disabled_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
400+
def test_optional_api_key_auth_disabled_returns_none(
401+
self, monkeypatch: pytest.MonkeyPatch
402+
) -> None:
378403
# Given: auth disabled
379404
monkeypatch.setattr(auth_settings, "api_key_enabled", False)
380405

@@ -386,7 +411,9 @@ def test_optional_api_key_auth_disabled_returns_none(self, monkeypatch: pytest.M
386411
assert response.status_code == 200
387412
assert response.json()["auth"] is False
388413

389-
def test_optional_api_key_missing_header_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
414+
def test_optional_api_key_missing_header_returns_none(
415+
self, monkeypatch: pytest.MonkeyPatch
416+
) -> None:
390417
# Given: auth enabled with configured keys
391418
monkeypatch.setattr(auth_settings, "api_key_enabled", True)
392419
monkeypatch.setattr(auth_settings, "api_keys", "user-key")
@@ -402,7 +429,9 @@ def test_optional_api_key_missing_header_returns_none(self, monkeypatch: pytest.
402429
assert response.status_code == 200
403430
assert response.json()["auth"] is False
404431

405-
def test_optional_api_key_invalid_header_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
432+
def test_optional_api_key_invalid_header_returns_none(
433+
self, monkeypatch: pytest.MonkeyPatch
434+
) -> None:
406435
# Given: auth enabled with configured keys
407436
monkeypatch.setattr(auth_settings, "api_key_enabled", True)
408437
monkeypatch.setattr(auth_settings, "api_keys", "user-key")
@@ -418,7 +447,9 @@ def test_optional_api_key_invalid_header_returns_none(self, monkeypatch: pytest.
418447
assert response.status_code == 200
419448
assert response.json()["auth"] is False
420449

421-
def test_optional_api_key_admin_header_sets_admin(self, monkeypatch: pytest.MonkeyPatch) -> None:
450+
def test_optional_api_key_admin_header_sets_admin(
451+
self, monkeypatch: pytest.MonkeyPatch
452+
) -> None:
422453
# Given: auth enabled with admin key
423454
monkeypatch.setattr(auth_settings, "api_key_enabled", True)
424455
monkeypatch.setattr(auth_settings, "api_keys", "user-key")
@@ -449,6 +480,7 @@ def test_require_admin_key_rejects_non_admin(
449480

450481
# When: requiring admin key on an endpoint
451482
from fastapi import Depends, FastAPI
483+
452484
from agent_control_server.auth import require_admin_key
453485

454486
local_app = FastAPI()
@@ -483,6 +515,7 @@ def test_authenticated_client_key_id_masks_short_key(self) -> None:
483515
def test_get_api_key_from_header_extracts_value(self) -> None:
484516
# Given: a route that returns raw API key header
485517
from fastapi import Depends, FastAPI
518+
486519
from agent_control_server.auth import get_api_key_from_header
487520

488521
app = FastAPI()
@@ -503,6 +536,7 @@ def raw_key(key: str | None = Depends(get_api_key_from_header)) -> dict[str, str
503536
def test_get_api_key_from_header_allows_missing(self) -> None:
504537
# Given: a route that returns raw API key header
505538
from fastapi import Depends, FastAPI
539+
506540
from agent_control_server.auth import get_api_key_from_header
507541

508542
app = FastAPI()

0 commit comments

Comments
 (0)