-
Notifications
You must be signed in to change notification settings - Fork 53
fix(authz): fall back to body principal in _register_in_auth #373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3f1ab92
7de8922
5a7d240
1fb6d13
4bbce5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,54 +41,85 @@ def __init__( | |
| self.temporal_adapter = temporal_adapter | ||
| self.authorization_service = authorization_service | ||
|
|
||
| async def _safe_deregister(self, agent_id: str) -> None: | ||
| async def _safe_deregister( | ||
| self, agent_id: str, principal_context: Any = None | ||
| ) -> None: | ||
| """Best-effort removal of an agent from the authorization graph. | ||
|
|
||
| Swallows and logs any failure so a compensating/post-delete deregister | ||
| never masks the in-flight error (create) or fails a delete that already | ||
| succeeded. | ||
| succeeded. ``principal_context`` should be the same resolved principal | ||
| that was used for the preceding ``register_resource`` call so that | ||
| register and deregister are symmetric on all paths (including the | ||
| whitelisted pod path where the middleware principal is None). | ||
| """ | ||
| try: | ||
| await self.authorization_service.deregister_resource( | ||
| AgentexResource.agent(agent_id) | ||
| AgentexResource.agent(agent_id), | ||
| principal_context=principal_context, | ||
| ) | ||
| except Exception: | ||
| logger.exception( | ||
| "authorization deregister failed for agent %s; swallowed", agent_id | ||
| ) | ||
|
|
||
| async def _register_in_auth(self, agent_id: str) -> bool: | ||
| async def _register_in_auth( | ||
| self, | ||
| agent_id: str, | ||
| body_principal_context: Any = None, | ||
| ) -> tuple[bool, Any]: | ||
| """Register a newly created agent in the authorization graph. | ||
|
|
||
| Called before the row is persisted so a failure aborts the create with no | ||
| orphaned row; the _safe_deregister compensation undoes it. Skipped with a | ||
| warning when no creator identity is resolvable on the principal context: | ||
| an agent has no parent edge, so the principal is the sole anchor for | ||
| ownership and there is nothing to attribute it to. Returns whether a | ||
| register call was actually made so compensation can avoid deregistering | ||
| a resource it never registered. | ||
| ownership and there is nothing to attribute it to. Returns a tuple of | ||
| (registered, resolved_principal_context) so the caller can pass the same | ||
| principal to _safe_deregister — keeping register and deregister symmetric. | ||
|
|
||
| Prefer the middleware-derived principal (an authenticated user or service | ||
| account). The whitelisted ``/agents/register`` path clears the middleware | ||
| principal; the pod SDK ships the manifest-declared identity in the request | ||
| body instead, and ``body_principal_context`` carries that through so | ||
| ownership can still be minted from the manifest identity. Mirrors the | ||
| route-layer fallback the ``register_agent`` route applies to | ||
| ``check``/``grant``. | ||
| """ | ||
| principal_context = self.authorization_service.principal_context | ||
| # 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: | ||
| if not self._has_resolvable_creator(principal_context): | ||
| principal_context = body_principal_context | ||
| if not self._has_resolvable_creator(principal_context): | ||
| logger.warning( | ||
| "Skipping authorization registration for agent: no creator resolvable", | ||
| extra={"agent_id": agent_id}, | ||
| ) | ||
| return False | ||
| return False, None | ||
| await self.authorization_service.register_resource( | ||
| AgentexResource.agent(agent_id) | ||
| AgentexResource.agent(agent_id), | ||
| principal_context=principal_context, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. one asymmetry worth closing: register_resource now runs with the resolved (body) principal, but the compensating |
||
| ) | ||
| return True, principal_context | ||
|
|
||
| @staticmethod | ||
| def _has_resolvable_creator(principal_context: Any) -> bool: | ||
| """Whether a creator identity (user or service account) is present. | ||
|
|
||
| ``principal_context`` is ``Any`` (dict from ``/v1/authn`` or an object | ||
| for tests), so attribute access via getattr on a dict always yields | ||
| None. Read from the dict when applicable, fall back to attr access. | ||
| """ | ||
| if principal_context is None: | ||
| return False | ||
| if isinstance(principal_context, dict): | ||
| return bool( | ||
| principal_context.get("user_id") | ||
| or principal_context.get("service_account_id") | ||
| ) | ||
| return bool( | ||
| getattr(principal_context, "user_id", None) | ||
| or getattr(principal_context, "service_account_id", None) | ||
| ) | ||
| return True | ||
|
|
||
| async def register_agent( | ||
| self, | ||
|
|
@@ -99,6 +130,7 @@ async def register_agent( | |
| acp_type: ACPType = ACPType.ASYNC, | ||
| registration_metadata: dict[str, Any] | None = None, | ||
| agent_input_type: AgentInputType | None = None, | ||
| body_principal_context: Any = None, | ||
| ) -> AgentEntity: | ||
| deployment_id = (registration_metadata or {}).get("deployment_id") | ||
|
|
||
|
|
@@ -208,7 +240,9 @@ async def register_agent( | |
| # failure here aborts the create with no orphaned row. Only the | ||
| # genuine-create path registers — the update paths above must not, | ||
| # or re-registering would rewrite the owner to the current caller. | ||
| registered_in_auth = await self._register_in_auth(agent.id) | ||
| registered_in_auth, resolved_principal = await self._register_in_auth( | ||
| agent.id, body_principal_context=body_principal_context | ||
| ) | ||
| # This is a problem only if multiple pods spin up and then make a request all at the same time. | ||
| # In that case, the first pod will create the agent and the rest should succeed silently | ||
| try: | ||
|
|
@@ -221,11 +255,15 @@ async def register_agent( | |
| # registration and re-fetch the persisted agent so downstream | ||
| # code (complete_deployment_registration) uses the correct agent_id | ||
| if registered_in_auth: | ||
| await self._safe_deregister(agent.id) | ||
| await self._safe_deregister( | ||
| agent.id, principal_context=resolved_principal | ||
| ) | ||
| agent = await self.agent_repo.get(name=name) | ||
| except Exception: | ||
| if registered_in_auth: | ||
| await self._safe_deregister(agent.id) | ||
| await self._safe_deregister( | ||
| agent.id, principal_context=resolved_principal | ||
| ) | ||
| raise | ||
| await self.complete_deployment_registration( | ||
| agent, acp_url, registration_metadata | ||
|
|
@@ -276,7 +314,7 @@ async def register_build( | |
| # Record ownership before persisting, same as register_agent's genuine | ||
| # create branch. The early return above for an existing agent means we | ||
| # only ever register on a true first-time build create. | ||
| registered_in_auth = await self._register_in_auth(agent.id) | ||
| registered_in_auth, resolved_principal = await self._register_in_auth(agent.id) | ||
| # If multiple builds for the same new agent race, the first wins and the | ||
| # rest re-fetch the persisted row instead of erroring. | ||
| try: | ||
|
|
@@ -287,11 +325,15 @@ async def register_build( | |
| ) | ||
| # undo our ownership registration from _register_in_auth | ||
| if registered_in_auth: | ||
| await self._safe_deregister(agent.id) | ||
| await self._safe_deregister( | ||
| agent.id, principal_context=resolved_principal | ||
| ) | ||
| agent = await self.agent_repo.get(name=name) | ||
| except Exception: | ||
| if registered_in_auth: | ||
| await self._safe_deregister(agent.id) | ||
| await self._safe_deregister( | ||
| agent.id, principal_context=resolved_principal | ||
| ) | ||
| raise | ||
| return agent | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_safe_deregisternow always passesprincipal_context=principal_contexttoderegister_resource. When called fromdelete(line 440) as_safe_deregister(agent.id), the defaultNoneis forwarded.AuthorizationService.deregister_resourceuses an Ellipsis sentinel (principal_context=...) to mean "fall back to middleware"; receivingNoneinstead causes it to passNonestraight to the gateway, silently bypassing the middleware principal on every delete-path deregistration. Pre-PR, the call carried noprincipal_contextkwarg at all, so the middleware principal was used correctly. Propagating the same...sentinel as the default restores that behaviour.Prompt To Fix With AI