Skip to content

Commit 728e692

Browse files
fix(authz): avoid stale authorization checks
1 parent 9a5b28f commit 728e692

4 files changed

Lines changed: 295 additions & 19 deletions

File tree

agentex/src/api/authentication_cache.py

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ async def clear(self) -> None:
8585
async with self._lock:
8686
self.cache.clear()
8787

88+
async def delete_by_prefix(self, prefix: str) -> int:
89+
"""Delete cache entries whose keys start with the given prefix."""
90+
async with self._lock:
91+
keys = [key for key in self.cache if key.startswith(prefix)]
92+
for key in keys:
93+
del self.cache[key]
94+
return len(keys)
95+
8896
async def remove_expired(self) -> None:
8997
"""Remove all expired entries from cache."""
9098
async with self._lock:
@@ -258,17 +266,20 @@ def _contains_api_key(principal_context: Any) -> bool:
258266
# Authorization Check Cache Methods (Async)
259267

260268
@staticmethod
261-
def _create_authorization_cache_key(
269+
def _create_authorization_resource_cache_prefix(
262270
resource_type: str,
263271
resource_selector: str,
264-
operation: str,
265-
principal_context: Any,
266272
) -> str:
267-
"""
268-
Create a cache key for authorization checks.
273+
resource_key = AuthenticationCache._hash_dict(
274+
{
275+
"resource_type": resource_type,
276+
"resource_selector": resource_selector,
277+
}
278+
)
279+
return f"authz:{resource_key}:"
269280

270-
Combines resource info, operation, and principal context into a unique key.
271-
"""
281+
@staticmethod
282+
def _authorization_principal_key_data(principal_context: Any) -> dict[str, Any]:
272283
# Extract relevant fields from principal context for cache key
273284
principal_key_data = {}
274285
if principal_context:
@@ -301,15 +312,48 @@ def _create_authorization_cache_key(
301312
"context_hash": AuthenticationCache._hash_dict(context_dict)
302313
}
303314

304-
# Create the cache key components
305-
cache_data = {
306-
"resource_type": resource_type,
307-
"resource_selector": resource_selector,
308-
"operation": operation,
309-
"principal": principal_key_data,
310-
}
315+
return principal_key_data
316+
317+
@staticmethod
318+
def _create_authorization_principal_cache_key(principal_context: Any) -> str:
319+
return AuthenticationCache._hash_dict(
320+
AuthenticationCache._authorization_principal_key_data(principal_context)
321+
)
311322

312-
return f"authz:{AuthenticationCache._hash_dict(cache_data)}"
323+
@staticmethod
324+
def _create_authorization_resource_principal_cache_prefix(
325+
resource_type: str,
326+
resource_selector: str,
327+
principal_context: Any,
328+
) -> str:
329+
return (
330+
AuthenticationCache._create_authorization_resource_cache_prefix(
331+
resource_type, resource_selector
332+
)
333+
+ AuthenticationCache._create_authorization_principal_cache_key(
334+
principal_context
335+
)
336+
+ ":"
337+
)
338+
339+
@staticmethod
340+
def _create_authorization_cache_key(
341+
resource_type: str,
342+
resource_selector: str,
343+
operation: str,
344+
principal_context: Any,
345+
) -> str:
346+
"""
347+
Create a cache key for authorization checks.
348+
349+
Combines resource info, operation, and principal context into a unique key.
350+
"""
351+
return (
352+
AuthenticationCache._create_authorization_resource_principal_cache_prefix(
353+
resource_type, resource_selector, principal_context
354+
)
355+
+ AuthenticationCache._hash_dict({"operation": operation})
356+
)
313357

314358
async def get_authorization_check(
315359
self,
@@ -319,6 +363,12 @@ async def get_authorization_check(
319363
principal_context: Any,
320364
) -> bool | None:
321365
"""Get cached authorization check result."""
366+
if self._contains_api_key(principal_context):
367+
logger.debug(
368+
"Skipping authorization check cache lookup for API key principal"
369+
)
370+
return None
371+
322372
cache_key = self._create_authorization_cache_key(
323373
resource_type, resource_selector, operation, principal_context
324374
)
@@ -339,6 +389,12 @@ async def set_authorization_check(
339389
allowed: bool,
340390
) -> None:
341391
"""Cache authorization check result."""
392+
if self._contains_api_key(principal_context):
393+
logger.debug(
394+
"Skipping authorization check cache write for API key principal"
395+
)
396+
return
397+
342398
cache_key = self._create_authorization_cache_key(
343399
resource_type, resource_selector, operation, principal_context
344400
)
@@ -358,6 +414,24 @@ async def clear_all(self) -> None:
358414
await self.authorization_check_cache.clear()
359415
logger.info("All authentication and authorization caches cleared")
360416

417+
async def clear_authorization_checks_for_resource_principal(
418+
self,
419+
resource_type: str,
420+
resource_selector: str,
421+
principal_context: Any,
422+
) -> None:
423+
"""Clear cached authorization check results for one resource/principal."""
424+
prefix = self._create_authorization_resource_principal_cache_prefix(
425+
resource_type, resource_selector, principal_context
426+
)
427+
deleted = await self.authorization_check_cache.delete_by_prefix(prefix)
428+
logger.info(
429+
"Authorization check cache cleared for %s:%s matched_entries=%d",
430+
resource_type,
431+
resource_selector,
432+
deleted,
433+
)
434+
361435
async def cleanup_expired(self) -> None:
362436
"""Remove expired entries from all caches."""
363437
await self.agent_identity_cache.remove_expired()

agentex/src/domain/services/authorization_service.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ def _bypass(self) -> bool:
3939
def is_enabled(self) -> bool:
4040
return self.enabled
4141

42+
async def _clear_authorization_cache(
43+
self, resource: AgentexResource, principal_context
44+
) -> None:
45+
auth_cache = await get_auth_cache()
46+
await auth_cache.clear_authorization_checks_for_resource_principal(
47+
resource_type=str(resource.type),
48+
resource_selector=resource.selector,
49+
principal_context=principal_context,
50+
)
51+
4252
async def grant(
4353
self, resource: AgentexResource, *, commit: bool = True, principal_context=...
4454
) -> None:
@@ -54,13 +64,17 @@ async def grant(
5464
resource.type,
5565
resource.selector,
5666
)
57-
result = await self.gateway.grant(
67+
effective_principal = (
5868
principal_context
5969
if principal_context is not ...
60-
else self.principal_context,
70+
else self.principal_context
71+
)
72+
result = await self.gateway.grant(
73+
effective_principal,
6174
resource,
6275
AuthorizedOperationType.create,
6376
)
77+
await self._clear_authorization_cache(resource, effective_principal)
6478
return result
6579

6680
async def revoke(
@@ -77,16 +91,20 @@ async def revoke(
7791
resource.selector,
7892
)
7993

80-
result = await self.gateway.revoke(
94+
effective_principal = (
8195
principal_context
8296
if principal_context is not ...
83-
else self.principal_context,
97+
else self.principal_context
98+
)
99+
result = await self.gateway.revoke(
100+
effective_principal,
84101
resource,
85102
AuthorizedOperationType.delete,
86103
)
87104
logger.info(
88105
f"Revoked {AuthorizedOperationType.delete} permission on {resource.type}:{resource.selector}"
89106
)
107+
await self._clear_authorization_cache(resource, effective_principal)
90108
return result
91109

92110
async def check(
@@ -214,6 +232,7 @@ async def register_resource(
214232
f"{parent.type}:{parent.selector}" if parent is not None else None,
215233
)
216234
await self.gateway.register_resource(effective_principal, resource, parent)
235+
await self._clear_authorization_cache(resource, effective_principal)
217236

218237
async def deregister_resource(
219238
self,
@@ -237,6 +256,7 @@ async def deregister_resource(
237256
resource.selector,
238257
)
239258
await self.gateway.deregister_resource(effective_principal, resource)
259+
await self._clear_authorization_cache(resource, effective_principal)
240260

241261

242262
DAuthorizationService = Annotated[AuthorizationService, Depends(AuthorizationService)]

agentex/tests/unit/api/test_authentication_cache_metrics.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,53 @@ async def test_auth_gateway_response_with_api_key_principal_is_not_cached():
102102
await cache.set_auth_gateway_response(headers, principal)
103103

104104
assert await cache.get_auth_gateway_response(headers) is None
105+
106+
107+
@pytest.mark.unit
108+
@pytest.mark.asyncio
109+
async def test_authorization_check_with_api_key_principal_is_not_cached():
110+
cache = AuthenticationCache()
111+
principal = {"user_id": "user-1", "account_id": "acct-1", "api_key": "secret-key"}
112+
113+
await cache.set_authorization_check(
114+
resource_type="agent",
115+
resource_selector="agent-1",
116+
operation="execute",
117+
principal_context=principal,
118+
allowed=True,
119+
)
120+
121+
assert (
122+
await cache.get_authorization_check(
123+
resource_type="agent",
124+
resource_selector="agent-1",
125+
operation="execute",
126+
principal_context=principal,
127+
)
128+
is None
129+
)
130+
131+
132+
@pytest.mark.unit
133+
@pytest.mark.asyncio
134+
async def test_authorization_check_without_api_key_principal_is_cached():
135+
cache = AuthenticationCache()
136+
principal = {"user_id": "user-1", "account_id": "acct-1"}
137+
138+
await cache.set_authorization_check(
139+
resource_type="agent",
140+
resource_selector="agent-1",
141+
operation="read",
142+
principal_context=principal,
143+
allowed=True,
144+
)
145+
146+
assert (
147+
await cache.get_authorization_check(
148+
resource_type="agent",
149+
resource_selector="agent-1",
150+
operation="read",
151+
principal_context=principal,
152+
)
153+
is True
154+
)

0 commit comments

Comments
 (0)