4646from datetime import datetime
4747from time import perf_counter
4848from typing import Any
49+ from urllib .parse import urlsplit , urlunsplit
4950
5051import httpx
5152from agent_control_models .errors import ErrorCode , ErrorReason
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
422461def _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+
452502def _ensure_target_context_matches_grant (
453503 context : dict [str , Any ] | None ,
454504 principal : Principal ,
0 commit comments