Skip to content

Commit 5008ab3

Browse files
fix(server): harden auth scoping
1 parent cbb098b commit 5008ab3

8 files changed

Lines changed: 402 additions & 24 deletions

File tree

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This repository keeps documentation concise. The full documentation lives on the
1010
- [Controls](https://docs.agentcontrol.dev/concepts/controls) — Define and configure control rules
1111
- [Reference](https://docs.agentcontrol.dev/core/reference) — SDK and server API reference
1212
- [Configuration](https://docs.agentcontrol.dev/core/configuration) — Environment variables, auth, and database settings
13+
- [Server auth contract](auth.md) - Pluggable auth modes, HTTP upstream contract, and runtime JWT claims
1314
- [UI Quickstart](https://docs.agentcontrol.dev/core/ui-quickstart) — Run the dashboard and manage controls visually
1415

1516
## Examples

docs/auth.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Server Auth Contract
2+
3+
Agent Control keeps authentication and authorization provider-neutral. The server asks a configured provider whether a request may perform an operation, then scopes all data access with the returned `Principal`.
4+
5+
## Operations
6+
7+
Operations are stable strings. Deployers map them to their own permission model.
8+
9+
```text
10+
controls.read
11+
controls.create
12+
controls.update
13+
controls.delete
14+
policies.read
15+
policies.create
16+
policies.update
17+
agents.read
18+
agents.create
19+
agents.update
20+
control_bindings.read
21+
control_bindings.write
22+
runtime.token_exchange
23+
runtime.use
24+
```
25+
26+
## Principal
27+
28+
Providers return a generic principal. Agent Control treats `namespace_key`, `caller_id`, `target_type`, and `target_id` as opaque strings.
29+
30+
```json
31+
{
32+
"namespace_key": "tenant-a",
33+
"is_admin": false,
34+
"caller_id": "user-or-key-id",
35+
"target_type": "session",
36+
"target_id": "target-123",
37+
"scopes": ["runtime.use"],
38+
"expires_at": "2026-05-11T15:00:00Z"
39+
}
40+
```
41+
42+
`namespace_key` is the tenancy boundary. Server queries filter by it, and namespace-aware foreign keys prevent cross-namespace references.
43+
44+
## Auth Modes
45+
46+
Management auth is selected by `AGENT_CONTROL_AUTH_MODE`.
47+
48+
| Mode | Meaning |
49+
| --- | --- |
50+
| `none` | No credentials required. Intended for local development only. |
51+
| `api_key` | Validate caller credentials locally with `AGENT_CONTROL_API_KEYS`. This is the default. `header` is accepted as a backwards-compatible alias. |
52+
| `http_upstream` | POST each management authorization decision to `AGENT_CONTROL_AUTH_UPSTREAM_URL`. |
53+
54+
Runtime auth is selected by `AGENT_CONTROL_RUNTIME_AUTH_MODE`.
55+
56+
| Mode | Meaning |
57+
| --- | --- |
58+
| unset | Use `jwt` when `AGENT_CONTROL_RUNTIME_TOKEN_SECRET` is set. Otherwise runtime requests fall through to management auth. |
59+
| `none` | No runtime credentials required. Intended for local development only. |
60+
| `api_key` | Validate runtime requests with the same local API-key mechanism. |
61+
| `jwt` | Require target-bound runtime tokens minted by `/api/v1/auth/runtime-token-exchange`. |
62+
63+
Common combinations:
64+
65+
| Management | Runtime | Use case |
66+
| --- | --- | --- |
67+
| `api_key` | unset | Existing standalone deployments. |
68+
| `api_key` | `jwt` | Local management keys with short-lived target-bound runtime tokens. |
69+
| `http_upstream` | `jwt` | External identity or authorization service for management, local token verify for high-volume runtime calls. |
70+
| `none` | `none` | Single-process local development. Do not use in production. |
71+
72+
## HTTP Upstream Contract
73+
74+
When `AGENT_CONTROL_AUTH_MODE=http_upstream`, the server sends:
75+
76+
```http
77+
POST {AGENT_CONTROL_AUTH_UPSTREAM_URL}
78+
```
79+
80+
```json
81+
{
82+
"operation": "control_bindings.write",
83+
"context": {
84+
"target_type": "session",
85+
"target_id": "target-123"
86+
}
87+
}
88+
```
89+
90+
The provider forwards inbound `X-API-Key`, `Authorization`, and `Cookie` headers. Add deployer-specific header names with `AGENT_CONTROL_AUTH_UPSTREAM_EXTRA_FORWARD_HEADERS`, for example:
91+
92+
```text
93+
AGENT_CONTROL_AUTH_UPSTREAM_EXTRA_FORWARD_HEADERS=Vendor-API-Key,X-Workspace-Id
94+
```
95+
96+
If `AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN` is set, it is forwarded on `AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN_HEADER` or `X-Agent-Control-Service-Token` by default.
97+
98+
A successful upstream response is:
99+
100+
```json
101+
{
102+
"namespace_key": "tenant-a",
103+
"is_admin": false,
104+
"caller_id": "user-or-key-id",
105+
"target_type": "session",
106+
"target_id": "target-123",
107+
"scopes": ["runtime.use"],
108+
"expires_at": "2026-05-11T15:00:00Z"
109+
}
110+
```
111+
112+
Only `namespace_key` is always required. `target_type` and `target_id` must be returned together when present. `expires_at` must include timezone information.
113+
114+
Status handling:
115+
116+
| Upstream status | Agent Control result |
117+
| --- | --- |
118+
| `200` | Parse the principal grant. |
119+
| `401` | Authentication error. |
120+
| `403` | Forbidden error. |
121+
| `404` | Not found error. |
122+
| `429` | `503` with a rate-limit detail and `Retry-After` hint when present. |
123+
| Other statuses or malformed JSON | Fail closed with `503` or `502`. |
124+
125+
## Runtime JWT Claims
126+
127+
`/api/v1/auth/runtime-token-exchange` is a management-style request. The configured management provider authorizes `runtime.token_exchange` for the requested target. Agent Control then mints its own HS256 JWT with `AGENT_CONTROL_RUNTIME_TOKEN_SECRET`.
128+
129+
The token payload contains:
130+
131+
```json
132+
{
133+
"iss": "agent-control/server",
134+
"domain": "runtime",
135+
"namespace_key": "tenant-a",
136+
"actor_id": "user-or-key-id",
137+
"target_type": "session",
138+
"target_id": "target-123",
139+
"scopes": ["runtime.use"],
140+
"iat": 1778509800,
141+
"exp": 1778510100,
142+
"jti": "opaque-token-id"
143+
}
144+
```
145+
146+
Verification requires the expected issuer, `domain="runtime"`, a valid signature, an unexpired `exp`, and `runtime.use` in `scopes`. The token is accepted only for requests whose `target_type` and `target_id` match the bound target.
147+
148+
The expiry is the earlier of `AGENT_CONTROL_RUNTIME_TOKEN_TTL_SECONDS` and the upstream grant's `expires_at` when supplied. Runtime token TTLs are capped at 86400 seconds.

models/src/agent_control_models/server.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,7 @@ class CreateControlBindingRequest(BaseModel):
640640

641641
target_type: ControlBindingTargetField = Field(
642642
...,
643-
description="Opaque attachment kind (caller-defined; e.g. 'env', 'log_stream').",
643+
description="Opaque attachment kind (caller-defined; e.g. 'environment', 'session').",
644644
)
645645
target_id: ControlBindingTargetField = Field(
646646
..., description="Opaque external identifier within the target_type."
@@ -760,4 +760,3 @@ class DeleteControlBindingByKeyResponse(BaseModel):
760760
),
761761
)
762762

763-

server/src/agent_control_server/endpoints/agents.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,21 @@
2929
SetPolicyResponse,
3030
StepKey,
3131
)
32-
from fastapi import APIRouter, Depends, Query
32+
from fastapi import APIRouter, Depends, Query, Request
3333
from jsonschema_rs import ValidationError as JSONSchemaValidationError
3434
from pydantic import BaseModel, ValidationError
3535
from sqlalchemy import delete, func, select
3636
from sqlalchemy.dialects.postgresql import insert as pg_insert
3737
from sqlalchemy.ext.asyncio import AsyncSession
3838

39-
from ..auth_framework import Operation, Principal, require_operation
39+
from ..auth_framework import Operation, Principal, get_authorizer, require_operation
4040
from ..db import get_async_db
4141
from ..errors import (
4242
APIValidationError,
4343
BadRequestError,
4444
ConflictError,
4545
DatabaseError,
46+
ForbiddenError,
4647
NotFoundError,
4748
)
4849
from ..logging_utils import get_logger
@@ -85,6 +86,81 @@
8586
type StepKeyTuple = tuple[str, str]
8687

8788

89+
def _complete_target_context(
90+
target_type: object | None,
91+
target_id: object | None,
92+
) -> dict[str, str] | None:
93+
"""Return target context only when both halves are present strings."""
94+
if not isinstance(target_type, str) or not isinstance(target_id, str):
95+
return None
96+
if not target_type or not target_id:
97+
return None
98+
return {"target_type": target_type, "target_id": target_id}
99+
100+
101+
async def _init_agent_target_context(request: Request) -> dict[str, str] | None:
102+
"""Extract optional target context from an ``initAgent`` body."""
103+
try:
104+
body = await request.json()
105+
except Exception: # noqa: BLE001 malformed JSON, defer to endpoint validation
106+
return None
107+
if not isinstance(body, dict):
108+
return None
109+
return _complete_target_context(body.get("target_type"), body.get("target_id"))
110+
111+
112+
def _agent_controls_target_context(request: Request) -> dict[str, str] | None:
113+
"""Extract optional target context from ``GET /agents/{name}/controls``."""
114+
return _complete_target_context(
115+
request.query_params.get("target_type"),
116+
request.query_params.get("target_id"),
117+
)
118+
119+
120+
async def _authorize_target_read_if_present(
121+
request: Request,
122+
context: dict[str, str] | None,
123+
) -> Principal | None:
124+
"""Require target read authorization before returning target-merged controls."""
125+
if context is None:
126+
return None
127+
return await get_authorizer(Operation.CONTROL_BINDINGS_READ).authorize(
128+
request,
129+
Operation.CONTROL_BINDINGS_READ,
130+
context,
131+
)
132+
133+
134+
async def _init_agent_target_principal(request: Request) -> Principal | None:
135+
return await _authorize_target_read_if_present(
136+
request,
137+
await _init_agent_target_context(request),
138+
)
139+
140+
141+
async def _agent_controls_target_principal(request: Request) -> Principal | None:
142+
return await _authorize_target_read_if_present(
143+
request,
144+
_agent_controls_target_context(request),
145+
)
146+
147+
148+
def _ensure_target_principal_matches_namespace(
149+
principal: Principal,
150+
target_principal: Principal | None,
151+
) -> None:
152+
"""Fail closed if the target authorization resolves to a different namespace."""
153+
if target_principal is None:
154+
return
155+
if target_principal.namespace_key == principal.namespace_key:
156+
return
157+
raise ForbiddenError(
158+
error_code=ErrorCode.AUTH_INSUFFICIENT_PRIVILEGES,
159+
detail="Target authorization resolved to a different namespace.",
160+
hint="Ensure the credential is scoped to the requested target and namespace.",
161+
)
162+
163+
88164
# =============================================================================
89165
# List Agents Models
90166
# =============================================================================
@@ -445,6 +521,7 @@ async def init_agent(
445521
request: InitAgentRequest,
446522
db: AsyncSession = Depends(get_async_db),
447523
principal: Principal = Depends(require_operation(Operation.AGENTS_CREATE)),
524+
target_principal: Principal | None = Depends(_init_agent_target_principal),
448525
) -> InitAgentResponse:
449526
"""
450527
Register a new agent or update an existing agent's steps and metadata.
@@ -474,6 +551,7 @@ async def init_agent(
474551
InitAgentResponse with created flag and the effective controls
475552
"""
476553
namespace_key = principal.namespace_key
554+
_ensure_target_principal_matches_namespace(principal, target_principal)
477555

478556
# Check for evaluator name collisions with built-in evaluators
479557
builtin_names = _get_builtin_evaluator_names()
@@ -1493,6 +1571,7 @@ async def list_agent_controls(
14931571
),
14941572
db: AsyncSession = Depends(get_async_db),
14951573
principal: Principal = Depends(require_operation(Operation.AGENTS_READ)),
1574+
target_principal: Principal | None = Depends(_agent_controls_target_principal),
14961575
) -> AgentControlsResponse:
14971576
"""
14981577
List protection controls effective for an agent.
@@ -1527,6 +1606,7 @@ async def list_agent_controls(
15271606
HTTPException 404: Agent not found
15281607
"""
15291608
namespace_key = principal.namespace_key
1609+
_ensure_target_principal_matches_namespace(principal, target_principal)
15301610

15311611
if (target_type is None) != (target_id is None):
15321612
raise BadRequestError(

server/src/agent_control_server/endpoints/auth.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
mint_runtime_token,
2929
)
3030
from ..errors import APIError, BadRequestError
31+
from ..logging_utils import get_logger
3132

3233
router = APIRouter(prefix="/auth", tags=["auth"])
34+
_logger = get_logger(__name__)
3335

3436

3537
class RuntimeTokenExchangeRequest(BaseModel):
@@ -38,7 +40,7 @@ class RuntimeTokenExchangeRequest(BaseModel):
3840
model_config = ConfigDict(extra="forbid")
3941

4042
target_type: str = Field(
41-
..., description="Opaque target kind (e.g., ``log_stream``).", min_length=1
43+
..., description="Opaque target kind (e.g., ``session``).", min_length=1
4244
)
4345
target_id: str = Field(..., description="Opaque target identifier.", min_length=1)
4446

@@ -175,6 +177,19 @@ async def runtime_token_exchange(
175177
hint="Check the runtime token configuration.",
176178
) from exc
177179

180+
_logger.info(
181+
"Runtime token exchanged",
182+
extra={
183+
"namespace_key": claims.namespace_key,
184+
"actor_id": claims.actor_id,
185+
"target_type": claims.target_type,
186+
"target_id": claims.target_id,
187+
"scopes": list(claims.scopes),
188+
"expires_at": claims.expires_at.isoformat(),
189+
"jti": claims.jti,
190+
},
191+
)
192+
178193
return RuntimeTokenExchangeResponse(
179194
token=token,
180195
expires_at=claims.expires_at,

0 commit comments

Comments
 (0)