Skip to content

Commit 071a63a

Browse files
fix(server): harden auth and namespace edges
1 parent 3214382 commit 071a63a

23 files changed

Lines changed: 671 additions & 80 deletions

docs/auth.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ Management auth is selected by `AGENT_CONTROL_AUTH_MODE`.
5151
| Mode | Meaning |
5252
| --- | --- |
5353
| `none` | No credentials required. Intended for local development only. |
54-
| `api_key` | Validate caller credentials locally with `AGENT_CONTROL_API_KEYS`. This is the default. `header` is accepted as a backwards-compatible alias. |
54+
| `api_key` | Validate caller credentials locally with `AGENT_CONTROL_API_KEYS` and/or `AGENT_CONTROL_ADMIN_API_KEYS`. Requires `AGENT_CONTROL_API_KEY_ENABLED=true`. `header` is accepted as a backwards-compatible alias. |
5555
| `http_upstream` | POST each management authorization decision to `AGENT_CONTROL_AUTH_UPSTREAM_URL`. |
5656

57+
When `AGENT_CONTROL_AUTH_MODE` is unset, startup selects `api_key` if local API-key validation is enabled and `none` otherwise.
58+
5759
Runtime auth is selected by `AGENT_CONTROL_RUNTIME_AUTH_MODE`.
5860

5961
| Mode | Meaning |
@@ -68,7 +70,7 @@ Common combinations:
6870
| Management | Runtime | Use case |
6971
| --- | --- | --- |
7072
| `api_key` | unset | Existing standalone deployments. |
71-
| `api_key` | `jwt` | Local management keys with short-lived target-bound runtime tokens. |
73+
| `api_key` | `jwt` | Local management keys with short-lived target-bound runtime tokens. This does not perform per-target authorization; any valid local API key can exchange for any target in the local namespace. |
7274
| `http_upstream` | `jwt` | External identity or authorization service for management, local token verify for high-volume runtime calls. |
7375
| `none` | `none` | Single-process local development. Do not use in production. |
7476

@@ -125,6 +127,7 @@ Status handling:
125127
| `429` | `503` with a rate-limit detail and `Retry-After` hint when present. |
126128
| Other statuses or upstream network errors | Fail closed with `503`. |
127129
| Malformed `200` principal response | Fail closed with `502`. |
130+
| `200` target grant that conflicts with request context | Fail closed with `403`. |
128131

129132
## Runtime JWT Claims
130133

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""namespace observability events
2+
3+
Revision ID: b6f4c2d8e9a1
4+
Revises: a7f3b1e0d9c5
5+
Create Date: 2026-05-14 12:00:00.000000
6+
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "b6f4c2d8e9a1"
16+
down_revision = "a7f3b1e0d9c5"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade() -> None:
22+
op.add_column(
23+
"control_execution_events",
24+
sa.Column(
25+
"namespace_key",
26+
sa.String(length=255),
27+
server_default=sa.text("'default'"),
28+
nullable=False,
29+
),
30+
)
31+
op.create_index(
32+
"ix_events_namespace_agent_time",
33+
"control_execution_events",
34+
["namespace_key", "agent_name", sa.literal_column("timestamp DESC")],
35+
unique=False,
36+
)
37+
38+
39+
def downgrade() -> None:
40+
op.drop_index(
41+
"ix_events_namespace_agent_time",
42+
table_name="control_execution_events",
43+
)
44+
op.drop_column("control_execution_events", "namespace_key")

server/src/agent_control_server/auth_framework/config.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import os
2929
from dataclasses import dataclass
3030

31+
from ..config import auth_settings
3132
from ..logging_utils import get_logger
3233
from .core import Operation, RequestAuthorizer, clear_authorizers, set_authorizer
3334
from .providers import (
@@ -88,8 +89,10 @@ def configure_auth_from_env() -> None:
8889
Default flow:
8990
9091
- ``AGENT_CONTROL_AUTH_MODE=none``: :class:`NoAuthProvider`.
91-
- ``AGENT_CONTROL_AUTH_MODE=api_key`` (default): :class:`HeaderAuthProvider`.
92-
``header`` remains accepted as a backwards-compatible alias.
92+
- ``AGENT_CONTROL_AUTH_MODE=api_key``: :class:`HeaderAuthProvider`.
93+
``header`` remains accepted as a backwards-compatible alias. When the mode
94+
is unset, startup selects ``api_key`` only if local API-key validation is
95+
enabled; otherwise it selects ``none``.
9396
- ``AGENT_CONTROL_AUTH_MODE=http_upstream``: :class:`HttpUpstreamAuthProvider`
9497
pointed at ``AGENT_CONTROL_AUTH_UPSTREAM_URL``.
9598
@@ -190,11 +193,17 @@ def set_runtime_auth_config(config: RuntimeAuthConfig | None) -> None:
190193

191194

192195
def _build_default_provider() -> RequestAuthorizer:
193-
mode = os.environ.get(_MODE_ENV, "api_key").strip().lower()
196+
raw_mode = os.environ.get(_MODE_ENV)
197+
mode = (
198+
raw_mode
199+
if raw_mode is not None
200+
else ("api_key" if auth_settings.api_key_enabled else "none")
201+
).strip().lower()
194202
if mode in {"none", "no_auth"}:
195203
_logger.info("Default auth provider: none")
196204
return NoAuthProvider()
197205
if mode in {"api_key", "header"}:
206+
_validate_local_api_key_mode()
198207
_logger.info("Default auth provider: api_key (local credentials)")
199208
return HeaderAuthProvider()
200209
if mode == "http_upstream":
@@ -223,6 +232,20 @@ def _build_default_provider() -> RequestAuthorizer:
223232
)
224233

225234

235+
def _validate_local_api_key_mode() -> None:
236+
"""Fail startup when local API-key mode has no local key validator."""
237+
if not auth_settings.api_key_enabled:
238+
raise RuntimeError(
239+
f"{_MODE_ENV}=api_key requires AGENT_CONTROL_API_KEY_ENABLED=true. "
240+
f"Use {_MODE_ENV}=none for deployments without credential enforcement."
241+
)
242+
if not auth_settings.get_api_keys() and not auth_settings.get_admin_api_keys():
243+
raise RuntimeError(
244+
f"{_MODE_ENV}=api_key requires AGENT_CONTROL_API_KEYS or "
245+
"AGENT_CONTROL_ADMIN_API_KEYS to be configured."
246+
)
247+
248+
226249
def _parse_extra_forward_headers(raw: str | None) -> tuple[str, ...]:
227250
"""Parse a comma-separated header list into a deduplicated tuple.
228251

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

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,18 @@ class HttpUpstreamConfig:
147147
dropped. Names duplicating the default set or each other (after
148148
case-folding) are deduplicated."""
149149

150+
def __post_init__(self) -> None:
151+
if self.service_token is None:
152+
return
153+
forwarded = {
154+
name.lower()
155+
for name in (*_DEFAULT_FORWARDED_HEADERS, *self.extra_forward_headers)
156+
}
157+
if self.service_token_header.lower() in forwarded:
158+
raise ValueError(
159+
"service_token_header must not match a forwarded caller credential header"
160+
)
161+
150162

151163
class HttpUpstreamAuthProvider(RequestAuthorizer):
152164
"""Delegates authorization to an upstream HTTP service."""
@@ -197,7 +209,7 @@ async def authorize(
197209
hint="Retry the request; if the failure persists, contact the operator.",
198210
) from exc
199211

200-
return self._handle_response(response, operation)
212+
return self._handle_response(response, operation, context)
201213

202214
def _forward_headers(self, request: Request) -> dict[str, str]:
203215
headers: dict[str, str] = {}
@@ -215,11 +227,16 @@ def _forward_headers(self, request: Request) -> dict[str, str]:
215227
return headers
216228

217229
def _handle_response(
218-
self, response: httpx.Response, operation: Operation
230+
self,
231+
response: httpx.Response,
232+
operation: Operation,
233+
context: dict[str, Any] | None,
219234
) -> Principal:
220235
status = response.status_code
221236
if status == 200:
222-
return self._parse_principal(response)
237+
principal = self._parse_principal(response)
238+
_ensure_target_context_matches_grant(context, principal)
239+
return principal
223240
if status == 401:
224241
raise AuthenticationError(
225242
error_code=ErrorCode.AUTH_INVALID_KEY,
@@ -309,3 +326,27 @@ def _parse_principal(self, response: httpx.Response) -> Principal:
309326
scopes=grant.scopes,
310327
grant_expires_at=grant.expires_at,
311328
)
329+
330+
331+
def _ensure_target_context_matches_grant(
332+
context: dict[str, Any] | None,
333+
principal: Principal,
334+
) -> None:
335+
"""Reject target-bound grants that do not match the requested target."""
336+
if principal.target_type is None and principal.target_id is None:
337+
return
338+
if context is None:
339+
return
340+
341+
expected_type = context.get("target_type")
342+
expected_id = context.get("target_id")
343+
if not isinstance(expected_type, str) or not isinstance(expected_id, str):
344+
return
345+
if principal.target_type == expected_type and principal.target_id == expected_id:
346+
return
347+
348+
raise ForbiddenError(
349+
error_code=ErrorCode.AUTH_INSUFFICIENT_PRIVILEGES,
350+
detail="Authorization grant target does not match the requested target.",
351+
hint="Retry with credentials authorized for the requested target.",
352+
)

server/src/agent_control_server/auth_framework/runtime_token.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ def mint_runtime_token(
9292
)
9393
if not namespace_key:
9494
raise RuntimeTokenError("namespace_key is required to mint a runtime token")
95+
if not actor_id:
96+
raise RuntimeTokenError("actor_id is required to mint a runtime token")
97+
if not target_type:
98+
raise RuntimeTokenError("target_type is required to mint a runtime token")
99+
if not target_id:
100+
raise RuntimeTokenError("target_id is required to mint a runtime token")
95101
if ttl_seconds <= 0:
96102
raise RuntimeTokenError("ttl_seconds must be positive")
97103
if upstream_expires_at is not None and (

server/src/agent_control_server/endpoints/agents.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,23 @@ def _ensure_target_principal_matches_namespace(
174174
)
175175

176176

177+
async def _authorize_existing_agent_overwrite(
178+
request: Request,
179+
principal: Principal,
180+
) -> None:
181+
update_principal = await get_authorizer(Operation.AGENTS_UPDATE).authorize(
182+
request,
183+
Operation.AGENTS_UPDATE,
184+
)
185+
if update_principal.namespace_key == principal.namespace_key:
186+
return
187+
raise ForbiddenError(
188+
error_code=ErrorCode.AUTH_INSUFFICIENT_PRIVILEGES,
189+
detail="Update authorization resolved to a different namespace.",
190+
hint="Ensure the credential is scoped to the requested agent namespace.",
191+
)
192+
193+
177194
# =============================================================================
178195
# List Agents Models
179196
# =============================================================================
@@ -532,6 +549,7 @@ async def list_agents(
532549
)
533550
async def init_agent(
534551
request: InitAgentRequest,
552+
http_request: Request,
535553
db: AsyncSession = Depends(get_async_db),
536554
principal: Principal = Depends(require_operation(Operation.AGENTS_CREATE)),
537555
target_principal: Principal | None = Depends(_init_agent_target_principal),
@@ -664,6 +682,9 @@ async def init_agent(
664682
)
665683
return InitAgentResponse(created=created, controls=controls)
666684

685+
if request.force_replace or request.conflict_mode == ConflictMode.OVERWRITE:
686+
await _authorize_existing_agent_overwrite(http_request, principal)
687+
667688
# Parse existing data via AgentData Pydantic model
668689
try:
669690
data_model = AgentData.model_validate(existing.data)

server/src/agent_control_server/endpoints/auth.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from __future__ import annotations
1515

16+
import hashlib
1617
from datetime import datetime
1718
from typing import Any
1819

@@ -34,6 +35,10 @@
3435
_logger = get_logger(__name__)
3536

3637

38+
def _log_hash(value: str) -> str:
39+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
40+
41+
3742
class RuntimeTokenExchangeRequest(BaseModel):
3843
"""Body for the runtime token exchange endpoint."""
3944

@@ -181,7 +186,7 @@ async def runtime_token_exchange(
181186
"Runtime token exchanged",
182187
extra={
183188
"namespace_key": claims.namespace_key,
184-
"actor_id": claims.actor_id,
189+
"actor_id_hash": _log_hash(claims.actor_id),
185190
"target_type": claims.target_type,
186191
"target_id": claims.target_id,
187192
"scopes": list(claims.scopes),

server/src/agent_control_server/endpoints/evaluation.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,13 @@ async def _evaluation_context(request: Request) -> dict[str, object]:
127127
return {}
128128
if not isinstance(body, dict):
129129
return {}
130-
return {
131-
"target_type": body.get("target_type"),
132-
"target_id": body.get("target_id"),
133-
}
130+
target_type = body.get("target_type")
131+
target_id = body.get("target_id")
132+
if not isinstance(target_type, str) or not isinstance(target_id, str):
133+
return {}
134+
if not target_type or not target_id:
135+
return {}
136+
return {"target_type": target_type, "target_id": target_id}
134137

135138

136139
@router.post(

0 commit comments

Comments
 (0)