diff --git a/agentex/src/api/middleware_utils.py b/agentex/src/api/middleware_utils.py index c884d22a..92ab4c22 100644 --- a/agentex/src/api/middleware_utils.py +++ b/agentex/src/api/middleware_utils.py @@ -43,8 +43,12 @@ def is_whitelisted_route( path: str, whitelisted_routes: set[str] = WHITELISTED_ROUTES ) -> bool: """Check if a route path is whitelisted (bypasses authentication).""" + # Boundary-aware prefix match: a whitelisted route only matches the route + # itself or a sub-path under it (route + "/..."). Plain startswith() would + # let "/agents/register" whitelist "/agents/register-build" too, which must + # stay authenticated so it can carry the caller's principal (owner grant). return path in whitelisted_routes or any( - path.startswith(route) for route in whitelisted_routes + path == route or path.startswith(route + "/") for route in whitelisted_routes ) diff --git a/agentex/src/domain/services/schedule_service.py b/agentex/src/domain/services/schedule_service.py index c6b3102d..8f5ae02f 100644 --- a/agentex/src/domain/services/schedule_service.py +++ b/agentex/src/domain/services/schedule_service.py @@ -132,8 +132,14 @@ async def _register_schedule_in_auth( identity is resolvable and registration is skipped. """ principal_context = self.authorization_service.principal_context - user_id = getattr(principal_context, "user_id", None) - service_account_id = getattr(principal_context, "service_account_id", None) + # principal_context is `Any` (a dict from /v1/authn), not a typed model, + # so getattr always yields None and silently skips the Spark register. + if isinstance(principal_context, dict): + user_id = principal_context.get("user_id") + service_account_id = principal_context.get("service_account_id") + else: + user_id = getattr(principal_context, "user_id", None) + service_account_id = getattr(principal_context, "service_account_id", None) if user_id is None and service_account_id is None: logger.warning( "Skipping auth registration for schedule: no creator resolvable", diff --git a/agentex/src/domain/use_cases/agent_api_keys_use_case.py b/agentex/src/domain/use_cases/agent_api_keys_use_case.py index d89d21be..faf10a2a 100644 --- a/agentex/src/domain/use_cases/agent_api_keys_use_case.py +++ b/agentex/src/domain/use_cases/agent_api_keys_use_case.py @@ -120,8 +120,14 @@ async def _register_api_key_in_auth( persisted api key that cannot be authorized. """ principal_context = self.authorization_service.principal_context - user_id = getattr(principal_context, "user_id", None) - service_account_id = getattr(principal_context, "service_account_id", None) + # principal_context is `Any` (a dict from /v1/authn), not a typed model, + # so getattr always yields None and silently skips the Spark register. + if isinstance(principal_context, dict): + user_id = principal_context.get("user_id") + service_account_id = principal_context.get("service_account_id") + else: + user_id = getattr(principal_context, "user_id", None) + service_account_id = getattr(principal_context, "service_account_id", None) if user_id is None and service_account_id is None: logger.warning( "Skipping auth registration for api_key: no creator resolvable", diff --git a/agentex/src/domain/use_cases/agents_use_case.py b/agentex/src/domain/use_cases/agents_use_case.py index 5853ae7d..de4b0c47 100644 --- a/agentex/src/domain/use_cases/agents_use_case.py +++ b/agentex/src/domain/use_cases/agents_use_case.py @@ -68,8 +68,16 @@ async def _register_in_auth(self, agent_id: str) -> bool: a resource it never registered. """ principal_context = self.authorization_service.principal_context - user_id = getattr(principal_context, "user_id", None) - service_account_id = getattr(principal_context, "service_account_id", None) + # principal_context is `Any` (a dict from /v1/authn), not a typed model, + # so attribute access via getattr always yields None and silently skips + # the Spark resource registration. Read from the dict (fall back to attr + # access for any object-shaped principal). + if isinstance(principal_context, dict): + user_id = principal_context.get("user_id") + service_account_id = principal_context.get("service_account_id") + else: + user_id = getattr(principal_context, "user_id", None) + service_account_id = getattr(principal_context, "service_account_id", None) if user_id is None and service_account_id is None: logger.warning( "Skipping authorization registration for agent: no creator resolvable", diff --git a/agentex/tests/integration/api/events/test_events_authz_api.py b/agentex/tests/integration/api/events/test_events_authz_api.py index 37463856..024e7f50 100644 --- a/agentex/tests/integration/api/events/test_events_authz_api.py +++ b/agentex/tests/integration/api/events/test_events_authz_api.py @@ -248,7 +248,7 @@ async def test_list_events_authorized_returns_200( "src.domain.services.authorization_service.AuthorizationService.is_enabled", return_value=True, ) - async def test_list_events_unauthorized_agent_returns_403( + async def test_list_events_unauthorized_agent_returns_404( self, is_enabled_authorization_mock, is_enabled_mock, @@ -257,7 +257,7 @@ async def test_list_events_unauthorized_agent_returns_403( test_agent, test_task, ): - """Direct-resource denials surface as 403 (convention from #249/#255).""" + """Denied agent-route access collapses to 404 (convention from #271).""" with patch( "src.utils.http_request_handler.HttpRequestHandler.post_with_error_handling", side_effect=_mock_post_factory(deny_agent_ids={test_agent.id}), @@ -265,4 +265,4 @@ async def test_list_events_unauthorized_agent_returns_403( response = await isolated_client.get( f"/events?task_id={test_task.id}&agent_id={test_agent.id}" ) - assert response.status_code == 403 + assert response.status_code == 404