Skip to content

Commit 39138dd

Browse files
committed
fix(server): authorize binding IDs with stored targets [SAO-17527]
Resolve caller identity before the namespace-scoped binding lookup, then authorize against the stored target. Preserve 401 responses and mask missing or target-denied bindings as 404. Add provider and endpoint regression coverage and refresh generated TypeScript route descriptions.
1 parent 364ff45 commit 39138dd

13 files changed

Lines changed: 743 additions & 97 deletions

File tree

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,13 @@ import { APICall, APIPromise } from "../types/async.js";
2828
import { Result } from "../types/fp.js";
2929

3030
/**
31-
* Delete a control binding (namespace-wide)
31+
* Delete a control binding
3232
*
3333
* @remarks
3434
* Delete a control binding by surrogate ID.
3535
*
36-
* See the GET-by-id docstring for the authorization scope: this route
37-
* is namespace-wide because the target identifiers are not available
38-
* before the binding is loaded. Use ``POST /by-key:delete`` for
39-
* target-scoped detach that includes the target in the request context.
36+
* Authorization uses the binding's stored target identifiers. The deletion
37+
* remains scoped to the namespace resolved by the authorizer.
4038
*/
4139
export function controlBindingsDelete(
4240
client: AgentControlSDKCore,

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,14 @@ import { APICall, APIPromise } from "../types/async.js";
2828
import { Result } from "../types/fp.js";
2929

3030
/**
31-
* Get a control binding (namespace-wide)
31+
* Get a control binding
3232
*
3333
* @remarks
3434
* Read a single control binding by surrogate ID.
3535
*
36-
* Authorization is namespace-wide: the binding's target identifiers
37-
* are not available until after the row is loaded.
38-
* Callers whose authorization model requires per-target permissions
39-
* should use the natural-key endpoints (``PUT /by-key``,
40-
* ``POST /by-key:delete``) and the target-filtered list endpoint, all
41-
* of which include ``(target_type, target_id)`` in the request context.
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.
4239
*/
4340
export function controlBindingsGet(
4441
client: AgentControlSDKCore,

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,13 @@ import { APICall, APIPromise } from "../types/async.js";
2828
import { Result } from "../types/fp.js";
2929

3030
/**
31-
* Update a control binding (namespace-wide)
31+
* Update a control binding
3232
*
3333
* @remarks
3434
* Update the ``enabled`` flag on a control binding.
3535
*
36-
* See the GET-by-id docstring for the authorization scope: this route
37-
* is namespace-wide because the target identifiers are not available
38-
* before the binding is loaded. Use ``PUT /by-key`` for target-scoped
39-
* upserts that include the target in the request context.
36+
* Authorization uses the binding's stored target identifiers. The mutation
37+
* remains scoped to the namespace resolved by the authorizer.
4038
*/
4139
export function controlBindingsUpdate(
4240
client: AgentControlSDKCore,

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

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,13 @@ export class ControlBindings extends ClientSDK {
118118
}
119119

120120
/**
121-
* Delete a control binding (namespace-wide)
121+
* Delete a control binding
122122
*
123123
* @remarks
124124
* Delete a control binding by surrogate ID.
125125
*
126-
* See the GET-by-id docstring for the authorization scope: this route
127-
* is namespace-wide because the target identifiers are not available
128-
* before the binding is loaded. Use ``POST /by-key:delete`` for
129-
* target-scoped detach that includes the target in the request context.
126+
* Authorization uses the binding's stored target identifiers. The deletion
127+
* remains scoped to the namespace resolved by the authorizer.
130128
*/
131129
async delete(
132130
request:
@@ -141,17 +139,14 @@ export class ControlBindings extends ClientSDK {
141139
}
142140

143141
/**
144-
* Get a control binding (namespace-wide)
142+
* Get a control binding
145143
*
146144
* @remarks
147145
* Read a single control binding by surrogate ID.
148146
*
149-
* Authorization is namespace-wide: the binding's target identifiers
150-
* are not available until after the row is loaded.
151-
* Callers whose authorization model requires per-target permissions
152-
* should use the natural-key endpoints (``PUT /by-key``,
153-
* ``POST /by-key:delete``) and the target-filtered list endpoint, all
154-
* of which include ``(target_type, target_id)`` in the request context.
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.
155150
*/
156151
async get(
157152
request:
@@ -166,15 +161,13 @@ export class ControlBindings extends ClientSDK {
166161
}
167162

168163
/**
169-
* Update a control binding (namespace-wide)
164+
* Update a control binding
170165
*
171166
* @remarks
172167
* Update the ``enabled`` flag on a control binding.
173168
*
174-
* See the GET-by-id docstring for the authorization scope: this route
175-
* is namespace-wide because the target identifiers are not available
176-
* before the binding is loaded. Use ``PUT /by-key`` for target-scoped
177-
* upserts that include the target in the request context.
169+
* Authorization uses the binding's stored target identifiers. The mutation
170+
* remains scoped to the namespace resolved by the authorizer.
178171
*/
179172
async update(
180173
request:

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
# Default flow.
5454
_MODE_ENV = "AGENT_CONTROL_AUTH_MODE"
5555
_UPSTREAM_URL_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_URL"
56+
_UPSTREAM_IDENTITY_URL_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_IDENTITY_URL"
5657
_UPSTREAM_TIMEOUT_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_TIMEOUT_SECONDS"
5758
_UPSTREAM_TOKEN_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN"
5859
_UPSTREAM_TOKEN_HEADER_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN_HEADER"
@@ -110,7 +111,9 @@ def configure_auth_from_env() -> None:
110111
is unset, startup selects ``api_key`` only if local API-key validation is
111112
enabled; otherwise it selects ``none``.
112113
- ``AGENT_CONTROL_AUTH_MODE=http_upstream``: :class:`HttpUpstreamAuthProvider`
113-
pointed at ``AGENT_CONTROL_AUTH_UPSTREAM_URL``.
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.
114117
115118
Runtime flow:
116119
@@ -245,6 +248,7 @@ def _build_default_provider() -> RequestAuthorizer:
245248
try:
246249
upstream_config = HttpUpstreamConfig(
247250
url=url,
251+
identity_url=(os.environ.get(_UPSTREAM_IDENTITY_URL_ENV) or "").strip() or None,
248252
timeout_seconds=timeout,
249253
service_token=token,
250254
service_token_header=token_header,

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from dataclasses import dataclass
2727
from datetime import datetime
2828
from enum import StrEnum
29-
from typing import Any, Protocol
29+
from typing import Any, Protocol, runtime_checkable
3030

3131
from fastapi import Request
3232

@@ -115,6 +115,17 @@ async def authorize(
115115
) -> Principal: ...
116116

117117

118+
@runtime_checkable
119+
class IdentityResolver(Protocol):
120+
"""Optional credential and namespace lookup for target-bound authorizers.
121+
122+
It does not grant resource access. The route still calls ``authorize``
123+
with the stored target after the namespace-scoped lookup.
124+
"""
125+
126+
async def resolve_identity(self, request: Request, operation: Operation) -> Principal: ...
127+
128+
118129
_default_authorizer: RequestAuthorizer | None = None
119130
_operation_authorizers: dict[Operation, RequestAuthorizer] = {}
120131

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ 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+
7882
async def authorize(
7983
self,
8084
request: Request,

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

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from datetime import datetime
4747
from time import perf_counter
4848
from typing import Any
49+
from urllib.parse import urlsplit, urlunsplit
4950

5051
import httpx
5152
from agent_control_models.errors import ErrorCode, ErrorReason
@@ -67,6 +68,9 @@
6768
_logger = get_logger(__name__)
6869

6970
_DEFAULT_FORWARDED_HEADERS = ("X-API-Key", "Authorization", "Cookie")
71+
_ORBIT_MANAGEMENT_PATH = "/internal/auth/agent_control/check_management_access"
72+
_ORBIT_IDENTITY_PATH = "/internal/auth/resolve_tenant_context"
73+
_IDENTITY_OPERATION = "identity.resolve"
7074

7175
_AUTH_UPSTREAM_ATTEMPTS = Counter(
7276
"agent_control_server_auth_upstream_attempts_total",
@@ -180,6 +184,10 @@ class HttpUpstreamConfig:
180184
max_keepalive_connections: int = 20
181185
"""Maximum idle connections retained for the auth upstream."""
182186

187+
identity_url: str | None = None
188+
"""URL for credential and namespace resolution. If omitted, the Orbit
189+
identity URL is derived from the known management authorization path."""
190+
183191
def __post_init__(self) -> None:
184192
if self.keepalive_expiry_seconds < 0:
185193
raise ValueError("keepalive_expiry_seconds must be greater than or equal to 0")
@@ -213,6 +221,7 @@ def __init__(
213221
client: httpx.AsyncClient | None = None,
214222
) -> None:
215223
self._config = config
224+
self._identity_url = config.identity_url or _derive_orbit_identity_url(config.url)
216225
self._owns_client = client is None
217226
if client is not None:
218227
self._client = client
@@ -245,19 +254,49 @@ async def authorize(
245254
if context:
246255
payload["context"] = context
247256

248-
response = await self._post_upstream(operation, payload, headers)
249-
return self._handle_response(response, operation, context)
257+
response = await self._post_upstream(operation.value, payload, headers)
258+
return self._handle_response(response, operation.value, context)
259+
260+
async def resolve_identity(self, request: Request, operation: Operation) -> Principal:
261+
"""Authenticate with the upstream before a namespace-scoped lookup."""
262+
del operation # Target-specific permission is checked by authorize afterward.
263+
if self._identity_url is None:
264+
raise APIError(
265+
status_code=500,
266+
error_code=ErrorCode.AUTH_MISCONFIGURED,
267+
reason=ErrorReason.INTERNAL_ERROR,
268+
detail="Authorization identity endpoint is not configured.",
269+
hint="Set AGENT_CONTROL_AUTH_UPSTREAM_IDENTITY_URL.",
270+
)
271+
response = await self._post_upstream(
272+
_IDENTITY_OPERATION,
273+
None,
274+
self._forward_headers(request),
275+
url=self._identity_url,
276+
)
277+
if response.status_code == 404:
278+
raise APIError(
279+
status_code=502,
280+
error_code=ErrorCode.AUTH_UPSTREAM_REJECTED,
281+
reason=ErrorReason.INTERNAL_ERROR,
282+
detail="Authorization identity endpoint was not found.",
283+
hint="Check the configured authorization identity URL.",
284+
)
285+
principal = self._handle_response(response, _IDENTITY_OPERATION, None)
286+
return Principal(namespace_key=principal.namespace_key, caller_id=principal.caller_id)
250287

251288
async def _post_upstream(
252289
self,
253-
operation: Operation,
254-
payload: dict[str, Any],
290+
operation: str,
291+
payload: dict[str, Any] | None,
255292
headers: dict[str, str],
293+
*,
294+
url: str | None = None,
256295
) -> httpx.Response:
257296
started = perf_counter()
258297
try:
259298
response = await self._client.post(
260-
self._config.url,
299+
url or self._config.url,
261300
json=payload,
262301
headers=headers,
263302
)
@@ -270,7 +309,7 @@ async def _post_upstream(
270309
)
271310
_logger.warning(
272311
"Auth upstream unreachable for operation %s: %s",
273-
operation.value,
312+
operation,
274313
exc,
275314
)
276315
raise _authorization_service_unavailable_error() from exc
@@ -301,7 +340,7 @@ def _forward_headers(self, request: Request) -> dict[str, str]:
301340
def _handle_response(
302341
self,
303342
response: httpx.Response,
304-
operation: Operation,
343+
operation: str,
305344
context: dict[str, Any] | None,
306345
) -> Principal:
307346
status = response.status_code
@@ -318,7 +357,7 @@ def _handle_response(
318357
if status == 403:
319358
raise ForbiddenError(
320359
error_code=ErrorCode.AUTH_INSUFFICIENT_PRIVILEGES,
321-
detail=f"Not authorized to perform {operation.value!r}.",
360+
detail=f"Not authorized to perform {operation!r}.",
322361
hint="Contact your administrator if you expected access.",
323362
)
324363
if status == 404:
@@ -343,7 +382,7 @@ def _handle_response(
343382
hint = f"{hint} Retry-After: {retry_after}."
344383
_logger.warning(
345384
"Upstream returned 429 for operation %s",
346-
operation.value,
385+
operation,
347386
)
348387
raise APIError(
349388
status_code=503,
@@ -355,7 +394,7 @@ def _handle_response(
355394
if 400 <= status < 500:
356395
_logger.warning(
357396
"Authorization upstream rejected operation %s with status %d",
358-
operation.value,
397+
operation,
359398
status,
360399
)
361400
raise APIError(
@@ -375,7 +414,7 @@ def _handle_response(
375414
_logger.warning(
376415
"Unexpected upstream status %d for operation %s",
377416
status,
378-
operation.value,
417+
operation,
379418
)
380419
raise APIError(
381420
status_code=503,
@@ -420,21 +459,21 @@ def _parse_principal(self, response: httpx.Response) -> Principal:
420459

421460

422461
def _observe_upstream_attempt(
423-
operation: Operation,
462+
operation: str,
424463
duration_seconds: float,
425464
*,
426465
outcome: str,
427466
status_code: int | None = None,
428467
error: httpx.HTTPError | None = None,
429468
) -> None:
430469
_AUTH_UPSTREAM_ATTEMPTS.labels(
431-
operation=operation.value,
470+
operation=operation,
432471
outcome=outcome,
433472
status_code=str(status_code) if status_code is not None else "none",
434473
error_type=type(error).__name__ if error is not None else "none",
435474
).inc()
436475
_AUTH_UPSTREAM_ATTEMPT_DURATION.labels(
437-
operation=operation.value,
476+
operation=operation,
438477
outcome=outcome,
439478
).observe(duration_seconds)
440479

@@ -449,6 +488,17 @@ def _authorization_service_unavailable_error() -> APIError:
449488
)
450489

451490

491+
def _derive_orbit_identity_url(url: str) -> str | None:
492+
"""Resolve Orbit's existing identity route from its management route."""
493+
parts = urlsplit(url)
494+
if not parts.path.endswith(_ORBIT_MANAGEMENT_PATH):
495+
return None
496+
prefix = parts.path[: -len(_ORBIT_MANAGEMENT_PATH)]
497+
return urlunsplit(
498+
(parts.scheme, parts.netloc, prefix + _ORBIT_IDENTITY_PATH, parts.query, parts.fragment)
499+
)
500+
501+
452502
def _ensure_target_context_matches_grant(
453503
context: dict[str, Any] | None,
454504
principal: Principal,

0 commit comments

Comments
 (0)