Skip to content

Commit f20728e

Browse files
feat(server): add runtime auth namespace cutover
Add explicit none, api_key, and jwt runtime auth modes, including a generic no-auth provider. Move controls, bindings, policies, agents, and evaluation storage lookups onto principal namespace scoping. Cover auth mode selection and principal namespace isolation with server tests.
1 parent ad586bb commit f20728e

21 files changed

Lines changed: 761 additions & 268 deletions

server/src/agent_control_server/auth_framework/__init__.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
33
Endpoints declare an :class:`Operation` they need; an installed
44
:class:`RequestAuthorizer` decides whether the request is allowed and
5-
returns the resulting :class:`Principal`. Two providers ship in-tree:
6-
:class:`HeaderAuthProvider` (uses local credential checks) and
7-
:class:`HttpUpstreamAuthProvider` (delegates to a configurable
8-
upstream HTTP service).
5+
returns the resulting :class:`Principal`. Providers ship in-tree for
6+
disabled auth, local credential checks, upstream HTTP authorization,
7+
and local runtime-JWT verification.
98
"""
109

1110
from .core import (

server/src/agent_control_server/auth_framework/config.py

Lines changed: 87 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@
88
99
- **Default flow** (everything except runtime). One authorizer handles
1010
every operation that does not have a specific override:
11-
:class:`HeaderAuthProvider` (local credentials) or
11+
:class:`NoAuthProvider` (no credentials),
12+
:class:`HeaderAuthProvider` (local API keys), or
1213
:class:`HttpUpstreamAuthProvider` (forwards to a configurable URL).
13-
- **Runtime flow.** When ``AGENT_CONTROL_RUNTIME_TOKEN_SECRET`` is
14-
configured, :class:`LocalJwtVerifyProvider` is registered as the
15-
override for :data:`Operation.RUNTIME_USE`; the
16-
``runtime.token_exchange`` operation continues to flow through the
17-
default authorizer because the exchange itself is shaped like a
18-
management call (forward credential, get grant). Without the secret,
19-
no runtime override is installed.
14+
- **Runtime flow.** ``AGENT_CONTROL_RUNTIME_AUTH_MODE`` selects the
15+
override for :data:`Operation.RUNTIME_USE`: ``none`` uses
16+
:class:`NoAuthProvider`, ``api_key`` uses
17+
:class:`HeaderAuthProvider`, and ``jwt`` uses
18+
:class:`LocalJwtVerifyProvider`. When the mode is unset, startup
19+
preserves historical behavior by selecting ``jwt`` if
20+
``AGENT_CONTROL_RUNTIME_TOKEN_SECRET`` is set, otherwise ``api_key``.
21+
The ``runtime.token_exchange`` operation continues to flow through
22+
the default authorizer because the exchange itself is shaped like a
23+
management call (forward credential, get grant).
2024
"""
2125

2226
from __future__ import annotations
@@ -30,6 +34,7 @@
3034
HeaderAuthProvider,
3135
HttpUpstreamAuthProvider,
3236
LocalJwtVerifyProvider,
37+
NoAuthProvider,
3338
)
3439
from .providers.http_upstream import HttpUpstreamConfig
3540

@@ -43,6 +48,7 @@
4348
_UPSTREAM_TOKEN_HEADER_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN_HEADER"
4449

4550
# Runtime flow.
51+
_RUNTIME_MODE_ENV = "AGENT_CONTROL_RUNTIME_AUTH_MODE"
4652
_RUNTIME_TOKEN_SECRET_ENV = "AGENT_CONTROL_RUNTIME_TOKEN_SECRET"
4753
_RUNTIME_TOKEN_TTL_ENV = "AGENT_CONTROL_RUNTIME_TOKEN_TTL_SECONDS"
4854
_DEFAULT_RUNTIME_TOKEN_TTL_SECONDS = 300
@@ -80,15 +86,19 @@ def configure_auth_from_env() -> None:
8086
8187
Default flow:
8288
83-
- ``AGENT_CONTROL_AUTH_MODE=header`` (default): :class:`HeaderAuthProvider`.
89+
- ``AGENT_CONTROL_AUTH_MODE=none``: :class:`NoAuthProvider`.
90+
- ``AGENT_CONTROL_AUTH_MODE=api_key`` (default): :class:`HeaderAuthProvider`.
91+
``header`` remains accepted as a backwards-compatible alias.
8492
- ``AGENT_CONTROL_AUTH_MODE=http_upstream``: :class:`HttpUpstreamAuthProvider`
8593
pointed at ``AGENT_CONTROL_AUTH_UPSTREAM_URL``.
8694
8795
Runtime flow:
8896
89-
- When ``AGENT_CONTROL_RUNTIME_TOKEN_SECRET`` is set, register
90-
:class:`LocalJwtVerifyProvider` as an override for
91-
:data:`Operation.RUNTIME_USE`.
97+
- ``AGENT_CONTROL_RUNTIME_AUTH_MODE=none``: :class:`NoAuthProvider`.
98+
- ``AGENT_CONTROL_RUNTIME_AUTH_MODE=api_key`` (default when no runtime
99+
token secret is configured): :class:`HeaderAuthProvider`.
100+
- ``AGENT_CONTROL_RUNTIME_AUTH_MODE=jwt`` (default when a runtime token
101+
secret is configured): :class:`LocalJwtVerifyProvider`.
92102
93103
Clears any previously-installed default and operation overrides
94104
before installing fresh ones, so reconfiguration cannot leave
@@ -101,27 +111,27 @@ def configure_auth_from_env() -> None:
101111
global _runtime_auth_config
102112
clear_authorizers()
103113
_active_providers.clear()
104-
_runtime_auth_config = _load_runtime_auth_config()
114+
runtime_mode = _resolve_runtime_mode()
115+
_runtime_auth_config = (
116+
_load_runtime_auth_config(require_secret=True) if runtime_mode == "jwt" else None
117+
)
105118

106119
default = _build_default_provider()
107120
set_authorizer(default)
108121
_active_providers.append(default)
109122

110-
if _runtime_auth_config is not None:
111-
runtime_provider = LocalJwtVerifyProvider(secret=_runtime_auth_config.secret)
112-
set_authorizer(runtime_provider, operation=Operation.RUNTIME_USE)
113-
_active_providers.append(runtime_provider)
123+
runtime_provider = _build_runtime_provider(runtime_mode, _runtime_auth_config)
124+
set_authorizer(runtime_provider, operation=Operation.RUNTIME_USE)
125+
_active_providers.append(runtime_provider)
126+
if runtime_mode == "jwt":
114127
_logger.info(
115-
"Runtime auth enabled: LocalJwtVerifyProvider override installed for %s",
128+
"Runtime auth provider: jwt override installed for %s",
116129
Operation.RUNTIME_USE.value,
117130
)
118131
else:
119-
_logger.warning(
120-
"Runtime auth disabled (%s not set); %s falls through to the "
121-
"default authorizer, which may grant any authenticated credential. "
122-
"Set the runtime token secret to bind runtime calls to a "
123-
"short-lived target-scoped JWT.",
124-
_RUNTIME_TOKEN_SECRET_ENV,
132+
_logger.info(
133+
"Runtime auth provider: %s override installed for %s",
134+
runtime_mode,
125135
Operation.RUNTIME_USE.value,
126136
)
127137

@@ -172,9 +182,12 @@ def set_runtime_auth_config(config: RuntimeAuthConfig | None) -> None:
172182

173183

174184
def _build_default_provider() -> RequestAuthorizer:
175-
mode = os.environ.get(_MODE_ENV, "header").strip().lower()
176-
if mode == "header":
177-
_logger.info("Default auth provider: header (local credentials)")
185+
mode = os.environ.get(_MODE_ENV, "api_key").strip().lower()
186+
if mode in {"none", "no_auth"}:
187+
_logger.info("Default auth provider: none")
188+
return NoAuthProvider()
189+
if mode in {"api_key", "header"}:
190+
_logger.info("Default auth provider: api_key (local credentials)")
178191
return HeaderAuthProvider()
179192
if mode == "http_upstream":
180193
url = os.environ.get(_UPSTREAM_URL_ENV)
@@ -192,19 +205,60 @@ def _build_default_provider() -> RequestAuthorizer:
192205
service_token_header=token_header,
193206
)
194207
)
195-
raise RuntimeError(f"Unknown {_MODE_ENV}={mode!r}; expected 'header' or 'http_upstream'.")
208+
raise RuntimeError(
209+
f"Unknown {_MODE_ENV}={mode!r}; expected 'none', 'api_key', or 'http_upstream'."
210+
)
211+
212+
213+
def _resolve_runtime_mode() -> str:
214+
raw = os.environ.get(_RUNTIME_MODE_ENV)
215+
if raw is None or not raw.strip():
216+
return "jwt" if os.environ.get(_RUNTIME_TOKEN_SECRET_ENV) else "api_key"
217+
218+
mode = raw.strip().lower()
219+
if mode in {"none", "no_auth"}:
220+
return "none"
221+
if mode in {"api_key", "header"}:
222+
return "api_key"
223+
if mode == "jwt":
224+
return mode
225+
raise RuntimeError(
226+
f"Unknown {_RUNTIME_MODE_ENV}={mode!r}; expected 'none', 'api_key', or 'jwt'."
227+
)
228+
229+
230+
def _build_runtime_provider(
231+
mode: str,
232+
config: RuntimeAuthConfig | None,
233+
) -> RequestAuthorizer:
234+
if mode == "none":
235+
return NoAuthProvider()
236+
if mode == "api_key":
237+
return HeaderAuthProvider()
238+
if mode == "jwt":
239+
if config is None:
240+
raise RuntimeError(f"{_RUNTIME_MODE_ENV}=jwt but runtime auth config is missing.")
241+
return LocalJwtVerifyProvider(secret=config.secret)
242+
raise RuntimeError(
243+
f"Unknown runtime auth mode {mode!r}; expected 'none', 'api_key', or 'jwt'."
244+
)
196245

197246

198-
def _load_runtime_auth_config() -> RuntimeAuthConfig | None:
247+
def _load_runtime_auth_config(*, require_secret: bool = False) -> RuntimeAuthConfig | None:
199248
"""Parse, validate, and return the runtime-auth config from env.
200249
201-
Returns ``None`` when no runtime secret is configured. Raises
202-
``RuntimeError`` when the secret is too short or the TTL is invalid
203-
so misconfiguration surfaces at startup, not on the first
204-
request-time mint.
250+
Returns ``None`` when no runtime secret is configured and
251+
``require_secret`` is false. Raises ``RuntimeError`` when the
252+
secret is required, too short, or the TTL is invalid so
253+
misconfiguration surfaces at startup, not on the first request-time
254+
mint.
205255
"""
206256
secret = os.environ.get(_RUNTIME_TOKEN_SECRET_ENV)
207257
if not secret:
258+
if require_secret:
259+
raise RuntimeError(
260+
f"{_RUNTIME_MODE_ENV}=jwt requires {_RUNTIME_TOKEN_SECRET_ENV} to be set."
261+
)
208262
return None
209263
if len(secret.encode("utf-8")) < _RUNTIME_TOKEN_SECRET_MIN_BYTES:
210264
raise RuntimeError(

server/src/agent_control_server/auth_framework/core.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,21 @@ class Operation(StrEnum):
4242
CONTROL_BINDINGS_READ = "control_bindings.read"
4343
CONTROL_BINDINGS_WRITE = "control_bindings.write"
4444

45-
# Runtime token exchange wired on the exchange endpoint.
45+
# Runtime token exchange - wired on the exchange endpoint.
4646
RUNTIME_TOKEN_EXCHANGE = "runtime.token_exchange"
4747

48-
# Reserved for follow-up migrations; not yet wired on endpoints.
4948
CONTROLS_READ = "controls.read"
5049
CONTROLS_CREATE = "controls.create"
5150
CONTROLS_UPDATE = "controls.update"
5251
CONTROLS_DELETE = "controls.delete"
52+
POLICIES_READ = "policies.read"
53+
POLICIES_CREATE = "policies.create"
54+
POLICIES_UPDATE = "policies.update"
55+
POLICIES_DELETE = "policies.delete"
56+
AGENTS_READ = "agents.read"
57+
AGENTS_CREATE = "agents.create"
58+
AGENTS_UPDATE = "agents.update"
59+
AGENTS_DELETE = "agents.delete"
5360
RUNTIME_USE = "runtime.use"
5461

5562

@@ -61,8 +68,7 @@ class Principal:
6168
namespace_key: The namespace the request runs in. Endpoints use
6269
this to scope every read and write.
6370
is_admin: Whether the caller has admin privileges in the
64-
current namespace. Mostly informational for endpoints that
65-
still gate on the legacy admin-key contract.
71+
current namespace.
6672
caller_id: Opaque, provider-supplied identifier for the caller
6773
(e.g., a key fingerprint or user id). Useful for audit
6874
logging; never echo back to clients.
@@ -122,7 +128,7 @@ def set_authorizer(
122128
123129
Without ``operation``, this becomes the default authorizer used by
124130
every operation that does not have a specific override. With
125-
``operation``, it overrides the default for that operation only
131+
``operation``, it overrides the default for that operation only -
126132
used to route a different family (e.g., runtime) through a
127133
different provider.
128134

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from .header import AccessLevel, HeaderAuthProvider
44
from .http_upstream import HttpUpstreamAuthProvider
55
from .local_jwt import LocalJwtVerifyProvider
6+
from .no_auth import NoAuthProvider
67

78
__all__ = [
89
"AccessLevel",
910
"HeaderAuthProvider",
1011
"HttpUpstreamAuthProvider",
1112
"LocalJwtVerifyProvider",
13+
"NoAuthProvider",
1214
]

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

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
"""Default :class:`RequestAuthorizer` that uses local credentials only.
22
3-
Resolves the namespace from a header (or falls back to
4-
``DEFAULT_NAMESPACE_KEY``) and enforces a per-operation access level
5-
using the legacy API-key + session-cookie credential check from
6-
:mod:`agent_control_server.auth`. Behavior matches the pre-framework
7-
local auth path verbatim:
3+
Returns ``DEFAULT_NAMESPACE_KEY`` and enforces a per-operation access
4+
level using the local API-key + session-cookie credential check from
5+
:mod:`agent_control_server.auth`:
86
97
- ``ADMIN`` operations require an admin key (or admin session).
108
- ``AUTHENTICATED`` operations require any valid credential.
119
- ``PUBLIC`` operations are open.
12-
- When ``api_key_enabled`` is ``False`` (no-auth mode), every
13-
operation succeeds with a non-admin :class:`Principal` — preserved
14-
by the underlying credential check.
15-
16-
The header lookup is wired but currently inert: the provider always
17-
returns the default namespace because non-binding write endpoints
18-
still hardcode it. The header is kept here so a follow-up that
19-
threads namespace resolution through the rest of the API can flip it
20-
on without changing the provider contract.
10+
- When the underlying local credential layer is disabled, every
11+
operation succeeds with a non-admin :class:`Principal`.
2112
"""
2213

2314
from __future__ import annotations
@@ -51,6 +42,14 @@ class AccessLevel(Enum):
5142
Operation.CONTROLS_CREATE: AccessLevel.ADMIN,
5243
Operation.CONTROLS_UPDATE: AccessLevel.ADMIN,
5344
Operation.CONTROLS_DELETE: AccessLevel.ADMIN,
45+
Operation.POLICIES_READ: AccessLevel.AUTHENTICATED,
46+
Operation.POLICIES_CREATE: AccessLevel.ADMIN,
47+
Operation.POLICIES_UPDATE: AccessLevel.ADMIN,
48+
Operation.POLICIES_DELETE: AccessLevel.ADMIN,
49+
Operation.AGENTS_READ: AccessLevel.AUTHENTICATED,
50+
Operation.AGENTS_CREATE: AccessLevel.AUTHENTICATED,
51+
Operation.AGENTS_UPDATE: AccessLevel.ADMIN,
52+
Operation.AGENTS_DELETE: AccessLevel.ADMIN,
5453
Operation.RUNTIME_TOKEN_EXCHANGE: AccessLevel.AUTHENTICATED,
5554
Operation.RUNTIME_USE: AccessLevel.AUTHENTICATED,
5655
}
@@ -60,7 +59,7 @@ class HeaderAuthProvider(RequestAuthorizer):
6059
"""Default authorizer.
6160
6261
For each operation's configured access level, validates the
63-
request's credentials via the legacy local check; on success,
62+
request's credentials via the local credential check; on success,
6463
returns a :class:`Principal` scoped to the resolved namespace.
6564
"""
6665

@@ -100,8 +99,7 @@ async def authorize(
10099
)
101100
# Runtime token exchange returns a normalized scope grant so the
102101
# exchange endpoint can require ``runtime.use`` uniformly across
103-
# providers; an upstream that explicitly grants no scopes ends
104-
# up with an empty tuple and is rejected.
102+
# providers.
105103
scopes: tuple[str, ...] = (
106104
(Operation.RUNTIME_USE.value,) if operation is Operation.RUNTIME_TOKEN_EXCHANGE else ()
107105
)
@@ -113,10 +111,7 @@ async def authorize(
113111
)
114112

115113
def _resolve_namespace_key(self, request: Request) -> str:
116-
# The provider always returns the default namespace because
117-
# non-binding write endpoints still hardcode it; serving
118-
# anything else here would create rows the rest of the API
119-
# cannot find. The branch is preserved so a future change can
120-
# lift the lock without touching the provider contract.
114+
# Local credentials do not carry namespace metadata. Providers
115+
# that resolve a namespace can return a different principal.
121116
del request
122117
return self._default_namespace_key

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ class _UpstreamGrant(BaseModel):
6767
"""Strict schema for the upstream authorization-service response.
6868
6969
Unknown fields are tolerated (so the upstream can evolve), but every
70-
*known* field is type-checked. A wrong type on any field or a
71-
half-supplied target binding causes the provider to fail closed
70+
*known* field is type-checked. A wrong type on any field - or a
71+
half-supplied target binding - causes the provider to fail closed
7272
with a 502.
7373
"""
7474

@@ -108,7 +108,7 @@ def _target_must_be_paired(self) -> _UpstreamGrant:
108108
A target is meaningful only as a ``(target_type, target_id)``
109109
pair; allowing one side without the other would let a malformed
110110
grant pass and the exchange endpoint mint a token for the
111-
request's value of the missing half outside the upstream's
111+
request's value of the missing half - outside the upstream's
112112
intended authorization.
113113
"""
114114
if (self.target_type is None) != (self.target_id is None):

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
returns a :class:`Principal` carrying the bound target. When a
77
``context_builder`` on the dependency surfaces ``target_type`` /
88
``target_id``, the provider also enforces that they match the token's
9-
binding runtime endpoints get the request-target check for free.
9+
binding - runtime endpoints get the request-target check for free.
1010
"""
1111

1212
from __future__ import annotations
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Authorizer for deployments that intentionally disable authentication."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from fastapi import Request
8+
9+
from ...models import DEFAULT_NAMESPACE_KEY
10+
from ..core import Operation, Principal, RequestAuthorizer
11+
12+
13+
class NoAuthProvider(RequestAuthorizer):
14+
"""Allows every operation and returns the default namespace."""
15+
16+
def __init__(self, *, default_namespace_key: str = DEFAULT_NAMESPACE_KEY) -> None:
17+
self._default_namespace_key = default_namespace_key
18+
19+
async def authorize(
20+
self,
21+
request: Request,
22+
operation: Operation,
23+
context: dict[str, Any] | None = None,
24+
) -> Principal:
25+
del request, context
26+
scopes: tuple[str, ...] = (
27+
(Operation.RUNTIME_USE.value,) if operation is Operation.RUNTIME_TOKEN_EXCHANGE else ()
28+
)
29+
return Principal(namespace_key=self._default_namespace_key, scopes=scopes)

0 commit comments

Comments
 (0)