fix(authz): grant legacy agent register principal#325
Conversation
✱ Stainless preview buildsThis PR will update the openapi python typescript
|
| if not _has_resolvable_creator(principal_context): | ||
| principal_context = request.principal_context | ||
| enforce_ownership = _has_resolvable_creator(principal_context) |
There was a problem hiding this comment.
Security / trust-boundary concern: unauthenticated body principal injection
/agents/register is in WHITELISTED_ROUTES, so the auth middleware sets request.state.principal_context = None and skips all credential validation for every request to this path — including requests from external, untrusted callers.
With this PR, any caller to the endpoint can supply:
{"principal_context": {"user_id": "any-user-id", "account_id": "any-account"}}_has_resolvable_creator will return True for that dict, enforce_ownership becomes True, and the code proceeds to call authorization_service.grant(AgentexResource.agent(...), principal_context=<attacker_dict>). The AgentexAuthorizationProxy.grant then forwards this dict verbatim to agentex-auth /v1/authz/grant with no cryptographic proof that the caller is actually that user.
Whether this results in a successful ownership escalation depends on whether agentex-auth performs independent principal validation server-side. If it accepts the principal dict at face value (as a trusted internal call), any caller who can reach this endpoint can claim ownership of agents under any identity.
Recommendation: If the body-principal trust model depends on network isolation (only internal pods can reach this endpoint), document that assumption explicitly. Consider adding a comment on the principal_context field and here explaining the trust model, and/or adding a check that explicitly limits this fallback to only when AGENTEX_AUTH_URL is configured in a way that signals internal-only trust.
Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/api/routes/agents.py
Line: 199-201
Comment:
**Security / trust-boundary concern: unauthenticated body principal injection**
`/agents/register` is in `WHITELISTED_ROUTES`, so the auth middleware sets `request.state.principal_context = None` and skips all credential validation for every request to this path — including requests from external, untrusted callers.
With this PR, any caller to the endpoint can supply:
```json
{"principal_context": {"user_id": "any-user-id", "account_id": "any-account"}}
```
`_has_resolvable_creator` will return `True` for that dict, `enforce_ownership` becomes `True`, and the code proceeds to call `authorization_service.grant(AgentexResource.agent(...), principal_context=<attacker_dict>)`. The `AgentexAuthorizationProxy.grant` then forwards this dict verbatim to `agentex-auth /v1/authz/grant` with no cryptographic proof that the caller is actually that user.
Whether this results in a successful ownership escalation depends on whether agentex-auth performs independent principal validation server-side. If it accepts the principal dict at face value (as a trusted internal call), any caller who can reach this endpoint can claim ownership of agents under any identity.
**Recommendation:** If the body-principal trust model depends on network isolation (only internal pods can reach this endpoint), document that assumption explicitly. Consider adding a comment on the `principal_context` field and here explaining the trust model, and/or adding a check that explicitly limits this fallback to only when `AGENTEX_AUTH_URL` is configured in a way that signals internal-only trust.
How can I resolve this? If you propose a fix, please make it concise.| @@ -413,6 +414,21 @@ async def test_unresolvable_creator_skips_check_and_grant(self, principal_contex | |||
| use_case.register_agent.assert_awaited_once() | |||
| assert resp.agent_api_key == "internal-key" | |||
There was a problem hiding this comment.
Missing test: unresolvable non-None auth-service principal + resolvable body principal
The parametrize covers [None, {}, {"account_id":"acct"}] for the auth-service principal, but always calls self._request() with no body principal_context. This means the case:
- auth service has
{"account_id": "acct"}(unresolvable) AND body has{"user_id": "u"}(resolvable)
is never tested. With the new fallback logic, the body principal would be used here (enforce_ownership=True), which is the intended behavior — but there is zero coverage for it. A regression that skips enforcement in this case would pass all current tests.
Consider adding a parametrized variant like:
@pytest.mark.parametrize(
"auth_principal,body_principal",
[
({}, {"user_id": "u", "account_id": "acct"}),
({"account_id": "acct"}, {"user_id": "u", "account_id": "acct"}),
],
)
async def test_unresolvable_auth_service_with_resolvable_body_enforces(self, auth_principal, body_principal):
authz, use_case, api_keys = self._mocks(principal_context=auth_principal)
await register_agent(self._request(principal_context=body_principal), use_case, authz, api_keys)
authz.check.assert_awaited_once()
assert authz.check.await_args.kwargs["principal_context"] == body_principal
authz.grant.assert_awaited_once()
assert authz.grant.await_args.kwargs["principal_context"] == body_principalPrompt To Fix With AI
This is a comment left during a code review.
Path: agentex/tests/unit/api/test_agents_authz.py
Line: 404-415
Comment:
**Missing test: unresolvable non-None auth-service principal + resolvable body principal**
The parametrize covers `[None, {}, {"account_id":"acct"}]` for the auth-service principal, but always calls `self._request()` with no body `principal_context`. This means the case:
- auth service has `{"account_id": "acct"}` (unresolvable) **AND** body has `{"user_id": "u"}` (resolvable)
is never tested. With the new fallback logic, the body principal would be used here (`enforce_ownership=True`), which is the intended behavior — but there is zero coverage for it. A regression that skips enforcement in this case would pass all current tests.
Consider adding a parametrized variant like:
```python
@pytest.mark.parametrize(
"auth_principal,body_principal",
[
({}, {"user_id": "u", "account_id": "acct"}),
({"account_id": "acct"}, {"user_id": "u", "account_id": "acct"}),
],
)
async def test_unresolvable_auth_service_with_resolvable_body_enforces(self, auth_principal, body_principal):
authz, use_case, api_keys = self._mocks(principal_context=auth_principal)
await register_agent(self._request(principal_context=body_principal), use_case, authz, api_keys)
authz.check.assert_awaited_once()
assert authz.check.await_args.kwargs["principal_context"] == body_principal
authz.grant.assert_awaited_once()
assert authz.grant.await_args.kwargs["principal_context"] == body_principal
```
How can I resolve this? If you propose a fix, please make it concise.
Summary
/agents/registercompatibility by acceptingprincipal_contextin the register request body.Validation
uv run pytest agentex/tests/unit/api/test_agents_authz.py -quv run ruff check agentex/src/api/routes/agents.py agentex/src/api/schemas/agents.py agentex/tests/unit/api/test_agents_authz.pyuv run ruff format --check agentex/src/api/routes/agents.py agentex/src/api/schemas/agents.py agentex/tests/unit/api/test_agents_authz.pymake gen-openapiGreptile Summary
Overview
This PR restores legacy
/agents/registercompatibility by acceptingprincipal_contextin the request body and falling back to it when the request-state principal (set by auth middleware) is not resolvable. The newprincipal_context: Any | Nonefield onRegisterAgentRequestis forwarded directly toauthorization_service.check(...)andauthorization_service.grant(...)when the endpoint is hit without an authenticated session.Key Changes
RegisterAgentRequestgainsprincipal_context: Any | None(defaults toNone), exposed publicly in the OpenAPI schema.register_agentroute logic: ifauthorization_service.principal_contextis unresolvable (nouser_id/service_account_id), falls back torequest.principal_contextfrom the body. Bothcheckandgrantnow receive an explicitprincipal_context=kwarg.test_body_principal_enforces_check_and_grant) verifies the body principal is forwarded to bothcheckandgrant.Issues Found
Trust Boundary — Body principal injection on whitelisted endpoint
/agents/registeris inWHITELISTED_ROUTES; the auth middleware setsrequest.state.principal_context = Noneand performs no authentication. Any external caller can now POST to this endpoint with an arbitraryprincipal_context: {"user_id": "victim-id"}in the body. When the request-state principal is None, the code falls back to this body-supplied dict,_has_resolvable_creatorreturns True, andauthorization_service.grant(...)is called forwarding the attacker-controlled dict toagentex-auth /v1/authz/grantwith no cryptographic proof that the caller is that identity. Whether exploitation succeeds depends entirely on whether agentex-auth validates the principal independently.Missing test coverage for priority logic
test_unresolvable_creator_skips_check_and_grantalways uses_request()(body=None). The caseauth_service.principal={"account_id":"acct"}+body={"user_id":"u"}— where body fallback triggersenforce_ownership=True— is not tested.Existing tests don't assert
principal_contextkwarg on the request-state principal pathtest_dict_principal_enforces_check_and_grantandtest_object_principal_enforces_check_and_grantassertcheck/grantwere called once but do not inspectawait_args.kwargs["principal_context"]. A regression where the wrong principal is passed would not be caught, unlike the new body-principal test which does check kwargs.Confidence Score: 3/5
Functionally correct for the intended legacy case, but the trust model relies on network isolation with no code-level guard, and three meaningful test coverage gaps exist in the priority/fallback logic.
The core fallback logic is correct and the new test covers the primary intended case. However: (1) the security trust boundary is fully open at the code layer — any caller who can reach the whitelisted endpoint can forge any principal; (2) two critical test gaps leave the priority ordering (request-state > body, and unresolvable-state + resolvable-body) completely uncovered; (3) the existing request-state tests don't assert the correct principal_context kwarg after the API change. These are real risks to correctness and security, not theoretical ones.
agentex/src/api/routes/agents.py (lines 199-201, trust boundary), agentex/tests/unit/api/test_agents_authz.py (missing priority and fallback test cases), agentex/src/api/schemas/agents.py (Any type too permissive)
Security Review
agentex/src/api/routes/agents.py:199-200,agentex/src/api/schemas/agents.py:91-93):/agents/registeris whitelisted so authentication is skipped for all callers. The newprincipal_context: Any | Nonebody field means any external caller can supply{"user_id": "arbitrary-id"}. The code falls back to this dict when the request-state principal is None, callsauthorization_service.check(...)andauthorization_service.grant(...)with the caller-supplied identity, and forwards it directly to agentex-auth without any cryptographic validation at the route layer. Trust relies entirely on network isolation of the endpoint.Important Files Changed
Comments Outside Diff (2)
agentex/tests/unit/api/test_agents_authz.py, line 432-440 (link)There is no test for the case where both the request-state principal (resolvable, e.g.
{"user_id":"req-user"}) and the bodyprincipal_context(e.g.{"user_id":"body-user"}) are present. The request-state principal should win and the body should be silently ignored.Without a test, a future refactor that accidentally reverses the precedence (falling back to body even when the request-state is resolvable) would pass all current tests. This is a straightforward case to add:
Prompt To Fix With AI
agentex/tests/unit/api/test_agents_authz.py, line 432-453 (link)principal_contextkwarg for the request-state principal pathBoth
test_dict_principal_enforces_check_and_grantandtest_object_principal_enforces_check_and_grantonly checkassert_awaited_once()without inspectingawait_args.kwargs["principal_context"]. Now that the route explicitly passesprincipal_context=principal_contextas a kwarg (new in this PR), the test should verify the correct principal is forwarded — especially since a bug where the wrong principal was passed (e.g.,Noneor the body principal) would be invisible to the current assertions.The new
test_body_principal_enforces_check_and_grantcorrectly assertsawait_args.kwargs["principal_context"] == body_principal. The same pattern should be applied to these two existing tests:Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix(authz): grant legacy agent register ..." | Re-trigger Greptile