Skip to content

Commit a63d331

Browse files
committed
fix(server): preserve legacy binding ID authorization
1 parent 39138dd commit a63d331

12 files changed

Lines changed: 195 additions & 42 deletions

File tree

‎sdks/typescript/src/generated/funcs/control-bindings-delete.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ import { Result } from "../types/fp.js";
3333
* @remarks
3434
* Delete a control binding by surrogate ID.
3535
*
36-
* Authorization uses the binding's stored target identifiers. The deletion
37-
* remains scoped to the namespace resolved by the authorizer.
36+
* Target-aware authorizers use the binding's stored target identifiers.
37+
* Other authorizers retain namespace-wide authorization. The deletion
38+
* remains scoped to the authorized namespace.
3839
*/
3940
export function controlBindingsDelete(
4041
client: AgentControlSDKCore,

‎sdks/typescript/src/generated/funcs/control-bindings-get.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ import { Result } from "../types/fp.js";
3333
* @remarks
3434
* Read a single control binding by surrogate ID.
3535
*
36-
* Authorization uses the binding's stored target identifiers. After
37-
* authorization succeeds, the row is loaded again using the namespace
38-
* resolved by the authorizer before any binding data is returned.
36+
* Target-aware authorizers use the binding's stored target identifiers.
37+
* Other authorizers retain namespace-wide authorization. The row is loaded
38+
* using the authorized namespace before any binding data is returned.
3939
*/
4040
export function controlBindingsGet(
4141
client: AgentControlSDKCore,

‎sdks/typescript/src/generated/funcs/control-bindings-update.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ import { Result } from "../types/fp.js";
3333
* @remarks
3434
* Update the ``enabled`` flag on a control binding.
3535
*
36-
* Authorization uses the binding's stored target identifiers. The mutation
37-
* remains scoped to the namespace resolved by the authorizer.
36+
* Target-aware authorizers use the binding's stored target identifiers.
37+
* Other authorizers retain namespace-wide authorization. The mutation
38+
* remains scoped to the authorized namespace.
3839
*/
3940
export function controlBindingsUpdate(
4041
client: AgentControlSDKCore,

‎sdks/typescript/src/generated/sdk/control-bindings.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,9 @@ export class ControlBindings extends ClientSDK {
123123
* @remarks
124124
* Delete a control binding by surrogate ID.
125125
*
126-
* Authorization uses the binding's stored target identifiers. The deletion
127-
* remains scoped to the namespace resolved by the authorizer.
126+
* Target-aware authorizers use the binding's stored target identifiers.
127+
* Other authorizers retain namespace-wide authorization. The deletion
128+
* remains scoped to the authorized namespace.
128129
*/
129130
async delete(
130131
request:
@@ -144,9 +145,9 @@ export class ControlBindings extends ClientSDK {
144145
* @remarks
145146
* Read a single control binding by surrogate ID.
146147
*
147-
* Authorization uses the binding's stored target identifiers. After
148-
* authorization succeeds, the row is loaded again using the namespace
149-
* resolved by the authorizer before any binding data is returned.
148+
* Target-aware authorizers use the binding's stored target identifiers.
149+
* Other authorizers retain namespace-wide authorization. The row is loaded
150+
* using the authorized namespace before any binding data is returned.
150151
*/
151152
async get(
152153
request:
@@ -166,8 +167,9 @@ export class ControlBindings extends ClientSDK {
166167
* @remarks
167168
* Update the ``enabled`` flag on a control binding.
168169
*
169-
* Authorization uses the binding's stored target identifiers. The mutation
170-
* remains scoped to the namespace resolved by the authorizer.
170+
* Target-aware authorizers use the binding's stored target identifiers.
171+
* Other authorizers retain namespace-wide authorization. The mutation
172+
* remains scoped to the authorized namespace.
171173
*/
172174
async update(
173175
request:

‎server/src/agent_control_server/auth_framework/config.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@ def configure_auth_from_env() -> None:
111111
is unset, startup selects ``api_key`` only if local API-key validation is
112112
enabled; otherwise it selects ``none``.
113113
- ``AGENT_CONTROL_AUTH_MODE=http_upstream``: :class:`HttpUpstreamAuthProvider`
114-
pointed at ``AGENT_CONTROL_AUTH_UPSTREAM_URL``. By-ID binding routes also
115-
use ``AGENT_CONTROL_AUTH_UPSTREAM_IDENTITY_URL`` when the identity URL
116-
cannot be derived from Orbit's management authorization URL.
114+
pointed at ``AGENT_CONTROL_AUTH_UPSTREAM_URL``. By-ID binding routes use
115+
stored-target authorization when an identity URL can be derived from
116+
Orbit's management URL or ``AGENT_CONTROL_AUTH_UPSTREAM_IDENTITY_URL`` is
117+
set. Other upstreams retain the prior namespace-wide authorization flow.
117118
118119
Runtime flow:
119120

‎server/src/agent_control_server/auth_framework/core.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,17 @@ async def authorize(
117117

118118
@runtime_checkable
119119
class IdentityResolver(Protocol):
120-
"""Optional credential and namespace lookup for target-bound authorizers.
120+
"""Opt-in credential and namespace lookup for target-bound authorizers.
121121
122-
It does not grant resource access. The route still calls ``authorize``
123-
with the stored target after the namespace-scoped lookup.
122+
Providers opt in only when they can resolve an identity without changing
123+
the authorization behavior of existing namespace-wide binding ID routes.
124+
The route then calls ``authorize`` with the stored target after the
125+
namespace-scoped lookup.
124126
"""
125127

128+
@property
129+
def binding_target_authorization(self) -> bool: ...
130+
126131
async def resolve_identity(self, request: Request, operation: Operation) -> Principal: ...
127132

128133

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,6 @@ def __init__(
7575
)
7676
self._default_namespace_key = default_namespace_key
7777

78-
async def resolve_identity(self, request: Request, operation: Operation) -> Principal:
79-
"""Apply the local operation gate before a namespace-scoped lookup."""
80-
return await self.authorize(request, operation)
81-
8278
async def authorize(
8379
self,
8480
request: Request,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,11 @@ def __init__(
238238
client_kwargs["verify"] = ssl.create_default_context(cafile=config.ca_file)
239239
self._client = httpx.AsyncClient(**client_kwargs)
240240

241+
@property
242+
def binding_target_authorization(self) -> bool:
243+
"""Use stored-target checks only when a separate identity URL exists."""
244+
return self._identity_url is not None
245+
241246
async def aclose(self) -> None:
242247
"""Release the HTTP client if this provider created it."""
243248
if self._owns_client:

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ def validate_http_field_name(name: str) -> str:
5353
class LocalJwtVerifyProvider(RequestAuthorizer):
5454
"""Verifies a runtime Bearer token and emits a target-bound :class:`Principal`."""
5555

56+
binding_target_authorization = True
57+
5658
def __init__(
5759
self,
5860
*,

‎server/src/agent_control_server/endpoints/control_bindings.py‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,20 @@ async def _binding_list_context(request: Request) -> dict[str, Any]:
7777
def _require_binding_operation(
7878
operation: Operation,
7979
) -> Callable[..., Awaitable[Principal]]:
80-
"""Authorize a by-ID route against the binding's stored target."""
80+
"""Use stored-target authorization when the provider opts in."""
8181

8282
async def dependency(request: Request, binding_id: int) -> Principal:
8383
authorizer = get_authorizer(operation)
84-
if isinstance(authorizer, IdentityResolver):
85-
identity = await authorizer.resolve_identity(request, operation)
86-
else:
87-
# Legacy providers can still resolve namespace-wide operations.
88-
# Target-bound providers should implement IdentityResolver.
89-
identity = await authorizer.authorize(request, operation)
84+
if (
85+
not isinstance(authorizer, IdentityResolver)
86+
or not authorizer.binding_target_authorization
87+
):
88+
# Preserve the existing single namespace-wide authorization call
89+
# for providers without an independent identity lookup. Their
90+
# operation contract may reject a new target context altogether.
91+
return await authorizer.authorize(request, operation)
92+
93+
identity = await authorizer.resolve_identity(request, operation)
9094
# Use a short-lived session so no database connection is held while the
9195
# authorization provider performs a potentially remote request.
9296
async with AsyncSessionLocal() as db:
@@ -236,9 +240,9 @@ async def get_control_binding(
236240
) -> GetControlBindingResponse:
237241
"""Read a single control binding by surrogate ID.
238242
239-
Authorization uses the binding's stored target identifiers. After
240-
authorization succeeds, the row is loaded again using the namespace
241-
resolved by the authorizer before any binding data is returned.
243+
Target-aware authorizers use the binding's stored target identifiers.
244+
Other authorizers retain namespace-wide authorization. The row is loaded
245+
using the authorized namespace before any binding data is returned.
242246
"""
243247
service = ControlBindingsService(db)
244248
binding = await service.get_binding_or_404(
@@ -295,8 +299,9 @@ async def patch_control_binding(
295299
) -> PatchControlBindingResponse:
296300
"""Update the ``enabled`` flag on a control binding.
297301
298-
Authorization uses the binding's stored target identifiers. The mutation
299-
remains scoped to the namespace resolved by the authorizer.
302+
Target-aware authorizers use the binding's stored target identifiers.
303+
Other authorizers retain namespace-wide authorization. The mutation
304+
remains scoped to the authorized namespace.
300305
"""
301306
service = ControlBindingsService(db)
302307
binding = await service.set_enabled(
@@ -321,8 +326,9 @@ async def delete_control_binding(
321326
) -> DeleteControlBindingResponse:
322327
"""Delete a control binding by surrogate ID.
323328
324-
Authorization uses the binding's stored target identifiers. The deletion
325-
remains scoped to the namespace resolved by the authorizer.
329+
Target-aware authorizers use the binding's stored target identifiers.
330+
Other authorizers retain namespace-wide authorization. The deletion
331+
remains scoped to the authorized namespace.
326332
"""
327333
service = ControlBindingsService(db)
328334
await service.delete_binding(namespace_key=principal.namespace_key, binding_id=binding_id)

0 commit comments

Comments
 (0)