Skip to content

Commit 4569392

Browse files
committed
feat: add authorization monitoring metrics
Signed-off-by: Major Hayden <major@redhat.com>
1 parent fa19cbd commit 4569392

5 files changed

Lines changed: 291 additions & 32 deletions

File tree

src/authorization/middleware.py

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Authorization middleware and decorators."""
22

3+
import time
34
from collections.abc import Callable
45
from functools import lru_cache, wraps
56
from typing import Any, Optional
@@ -18,6 +19,7 @@
1819
)
1920
from configuration import configuration
2021
from log import get_logger
22+
from metrics import recording
2123
from models.api.responses.error import (
2224
ForbiddenResponse,
2325
InternalServerErrorResponse,
@@ -27,6 +29,31 @@
2729
logger = get_logger(__name__)
2830

2931

32+
def _record_authorization_metrics(
33+
action: Action,
34+
result: str,
35+
start_time: float,
36+
) -> None:
37+
"""Record authorization metrics without affecting request authorization flow.
38+
39+
Args:
40+
action: Protected action being authorized.
41+
result: Authorization result label.
42+
start_time: Monotonic timestamp captured before authorization began.
43+
"""
44+
duration = time.monotonic() - start_time
45+
46+
try:
47+
recording.record_authorization_check(action.value, result)
48+
except Exception: # pylint: disable=broad-exception-caught
49+
logger.warning("Failed to record authorization check metric", exc_info=True)
50+
51+
try:
52+
recording.record_authorization_duration(action.value, result, duration)
53+
except Exception: # pylint: disable=broad-exception-caught
54+
logger.warning("Failed to record authorization duration metric", exc_info=True)
55+
56+
3057
@lru_cache(maxsize=1)
3158
def get_authorization_resolvers() -> tuple[RolesResolver, AccessResolver]:
3259
"""Get authorization resolvers from configuration (cached).
@@ -124,39 +151,47 @@ async def _perform_authorization_check(
124151
HTTPException: with 403 Forbidden if the resolved roles are not
125152
permitted to perform `action`.
126153
"""
127-
role_resolver, access_resolver = get_authorization_resolvers()
154+
start_time = time.monotonic()
155+
result = "error"
128156

129157
try:
130-
auth = kwargs["auth"]
131-
except KeyError as exc:
132-
logger.error(
133-
"Authorization only allowed on endpoints that accept "
134-
"'auth: Any = Depends(get_auth_dependency())'"
135-
)
136-
response = InternalServerErrorResponse.generic()
137-
raise HTTPException(**response.model_dump()) from exc
138-
139-
# Everyone gets the everyone (aka *) role
140-
everyone_roles = {"*"}
141-
142-
user_roles = await role_resolver.resolve_roles(auth) | everyone_roles
143-
144-
if not access_resolver.check_access(action, user_roles):
145-
response = ForbiddenResponse.endpoint(user_id=auth[0])
146-
raise HTTPException(**response.model_dump())
147-
148-
authorized_actions = access_resolver.get_actions(user_roles)
149-
150-
req: Optional[Request] = None
151-
if "request" in kwargs and isinstance(kwargs["request"], Request):
152-
req = kwargs["request"]
153-
else:
154-
for arg in args:
155-
if isinstance(arg, Request):
156-
req = arg
157-
break
158-
if req is not None:
159-
req.state.authorized_actions = authorized_actions
158+
role_resolver, access_resolver = get_authorization_resolvers()
159+
160+
try:
161+
auth = kwargs["auth"]
162+
except KeyError as exc:
163+
logger.error(
164+
"Authorization only allowed on endpoints that accept "
165+
"'auth: Any = Depends(get_auth_dependency())'"
166+
)
167+
response = InternalServerErrorResponse.generic()
168+
raise HTTPException(**response.model_dump()) from exc
169+
170+
# Everyone gets the everyone (aka *) role
171+
everyone_roles = {"*"}
172+
173+
user_roles = await role_resolver.resolve_roles(auth) | everyone_roles
174+
175+
if not access_resolver.check_access(action, user_roles):
176+
response = ForbiddenResponse.endpoint(user_id=auth[0])
177+
result = "denied"
178+
raise HTTPException(**response.model_dump())
179+
180+
authorized_actions = access_resolver.get_actions(user_roles)
181+
182+
req: Optional[Request] = None
183+
if "request" in kwargs and isinstance(kwargs["request"], Request):
184+
req = kwargs["request"]
185+
else:
186+
for arg in args:
187+
if isinstance(arg, Request):
188+
req = arg
189+
break
190+
if req is not None:
191+
req.state.authorized_actions = authorized_actions
192+
result = "success"
193+
finally:
194+
_record_authorization_metrics(action, result, start_time)
160195

161196

162197
def authorize(action: Action) -> Callable:

src/metrics/__init__.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@
2222
float("inf"),
2323
)
2424

25+
AUTHORIZATION_DURATION_BUCKETS: Final[tuple[float, ...]] = (
26+
0.001,
27+
0.005,
28+
0.01,
29+
0.025,
30+
0.05,
31+
0.1,
32+
0.25,
33+
0.5,
34+
1.0,
35+
2.5,
36+
5.0,
37+
float("inf"),
38+
)
39+
2540
# Counter to track REST API calls
2641
# This will be used to count how many times each API endpoint is called
2742
# and the status code of the response
@@ -73,9 +88,25 @@
7388
)
7489

7590
# Histogram to measure the latency of direct LLM inference backend calls.
76-
llm_inference_duration_seconds = Histogram(
91+
llm_inference_duration_seconds: Final[Histogram] = Histogram(
7792
"ls_llm_inference_duration_seconds",
7893
"LLM inference call duration",
7994
["provider", "model", "endpoint", "result"],
8095
buckets=LLM_INFERENCE_DURATION_BUCKETS,
8196
)
97+
98+
# Counter to track authorization checks by bounded protected action and result.
99+
# Actions are normalized against the Action enum; results are success, denied, or error.
100+
authorization_checks_total: Final[Counter] = Counter(
101+
"ls_authorization_checks_total",
102+
"Authorization checks",
103+
["action", "result"],
104+
)
105+
106+
# Histogram to measure authorization check latency by bounded action and result.
107+
authorization_duration_seconds: Final[Histogram] = Histogram(
108+
"ls_authorization_duration_seconds",
109+
"Authorization check duration",
110+
["action", "result"],
111+
buckets=AUTHORIZATION_DURATION_BUCKETS,
112+
)

src/metrics/recording.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,58 @@
77

88
from collections.abc import Iterator
99
from contextlib import contextmanager
10+
from typing import Final
1011

1112
import metrics
1213
from log import get_logger
14+
from models.config import Action
1315

1416
logger = get_logger(__name__)
1517

18+
AUTHORIZATION_ACTION_UNKNOWN: Final[str] = "unknown"
19+
AUTHORIZATION_RESULT_SUCCESS: Final[str] = "success"
20+
AUTHORIZATION_RESULT_DENIED: Final[str] = "denied"
21+
AUTHORIZATION_RESULT_ERROR: Final[str] = "error"
22+
23+
ALLOWED_AUTHORIZATION_ACTIONS: Final[frozenset[str]] = frozenset(
24+
action.value for action in Action
25+
)
26+
ALLOWED_AUTHORIZATION_RESULTS: Final[frozenset[str]] = frozenset(
27+
{
28+
AUTHORIZATION_RESULT_SUCCESS,
29+
AUTHORIZATION_RESULT_DENIED,
30+
AUTHORIZATION_RESULT_ERROR,
31+
}
32+
)
33+
34+
35+
def normalize_authorization_action(action: str) -> str:
36+
"""Normalize authorization action labels to the bounded Action enum values.
37+
38+
Args:
39+
action: Raw authorization action label.
40+
41+
Returns:
42+
The action when it is a known protected action, otherwise ``unknown``.
43+
"""
44+
if action in ALLOWED_AUTHORIZATION_ACTIONS:
45+
return action
46+
return AUTHORIZATION_ACTION_UNKNOWN
47+
48+
49+
def normalize_authorization_result(result: str) -> str:
50+
"""Normalize authorization result labels to the bounded result set.
51+
52+
Args:
53+
result: Raw authorization result label.
54+
55+
Returns:
56+
The result when it is allowed, otherwise ``error``.
57+
"""
58+
if result in ALLOWED_AUTHORIZATION_RESULTS:
59+
return result
60+
return AUTHORIZATION_RESULT_ERROR
61+
1662

1763
@contextmanager
1864
def measure_response_duration(path: str) -> Iterator[None]:
@@ -129,3 +175,40 @@ def record_llm_inference_duration(
129175
).observe(duration)
130176
except (AttributeError, TypeError, ValueError):
131177
logger.warning("Failed to update LLM inference duration metric", exc_info=True)
178+
179+
180+
def record_authorization_check(action: str, result: str) -> None:
181+
"""Record one authorization check.
182+
183+
Args:
184+
action: Protected action name. Unknown values are recorded as ``unknown``.
185+
result: Bounded result label. Unknown values are recorded as ``error``.
186+
"""
187+
normalized_action = normalize_authorization_action(action)
188+
normalized_result = normalize_authorization_result(result)
189+
190+
try:
191+
metrics.authorization_checks_total.labels(
192+
normalized_action, normalized_result
193+
).inc()
194+
except (AttributeError, TypeError, ValueError):
195+
logger.warning("Failed to update authorization metric", exc_info=True)
196+
197+
198+
def record_authorization_duration(action: str, result: str, duration: float) -> None:
199+
"""Record authorization check duration.
200+
201+
Args:
202+
action: Protected action name. Unknown values are recorded as ``unknown``.
203+
result: Bounded result label. Unknown values are recorded as ``error``.
204+
duration: Authorization check duration in seconds.
205+
"""
206+
normalized_action = normalize_authorization_action(action)
207+
normalized_result = normalize_authorization_result(result)
208+
209+
try:
210+
metrics.authorization_duration_seconds.labels(
211+
normalized_action, normalized_result
212+
).observe(duration)
213+
except (AttributeError, TypeError, ValueError):
214+
logger.warning("Failed to update authorization duration metric", exc_info=True)

tests/unit/authorization/test_middleware.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,37 @@ async def test_everyone_role_added(
322322
Action.QUERY, {"employee", "*"}
323323
)
324324

325+
@pytest.mark.asyncio
326+
async def test_authorization_metric_errors_do_not_mask_success(
327+
self,
328+
mocker: MockerFixture,
329+
dummy_auth_tuple: AuthTuple,
330+
mock_resolvers: tuple[MockType, MockType],
331+
) -> None:
332+
"""Test metric recorder failures do not fail successful authorization."""
333+
mocker.patch(
334+
"authorization.middleware.get_authorization_resolvers",
335+
return_value=mock_resolvers,
336+
)
337+
mock_check = mocker.patch(
338+
"authorization.middleware.recording.record_authorization_check",
339+
side_effect=RuntimeError("metric backend unavailable"),
340+
)
341+
mock_duration = mocker.patch(
342+
"authorization.middleware.recording.record_authorization_duration"
343+
)
344+
mock_logger = mocker.patch("authorization.middleware.logger")
345+
346+
await _perform_authorization_check(Action.QUERY, (), {"auth": dummy_auth_tuple})
347+
348+
mock_check.assert_called_once_with(Action.QUERY.value, "success")
349+
mock_duration.assert_called_once()
350+
assert mock_duration.call_args.args[:2] == (Action.QUERY.value, "success")
351+
assert mock_duration.call_args.args[2] >= 0
352+
mock_logger.warning.assert_called_once_with(
353+
"Failed to record authorization check metric", exc_info=True
354+
)
355+
325356

326357
class TestAuthorizeDecorator:
327358
"""Test cases for authorize decorator."""

0 commit comments

Comments
 (0)