Skip to content

Commit 91ff835

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

5 files changed

Lines changed: 291 additions & 31 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@
3636
5.0,
3737
float("inf"),
3838
)
39+
40+
AUTHORIZATION_DURATION_BUCKETS: Final[tuple[float, ...]] = (
41+
0.001,
42+
0.005,
43+
0.01,
44+
0.025,
45+
0.05,
46+
0.1,
47+
0.25,
48+
0.5,
49+
1.0,
50+
2.5,
51+
5.0,
52+
float("inf"),
53+
)
3954
# Counter to track REST API calls
4055
# This will be used to count how many times each API endpoint is called
4156
# and the status code of the response
@@ -95,6 +110,7 @@
95110
buckets=LLM_INFERENCE_DURATION_BUCKETS,
96111
)
97112

113+
98114
# Counter to track authentication attempts with bounded auth_module, result, and reason labels.
99115
auth_attempts_total: Final[Counter] = Counter(
100116
"ls_auth_attempts_total",
@@ -109,3 +125,19 @@
109125
["auth_module", "result"],
110126
buckets=AUTH_DURATION_BUCKETS,
111127
)
128+
129+
# Counter to track authorization checks by bounded protected action and result.
130+
# Actions are normalized against the Action enum; results are success, denied, or error.
131+
authorization_checks_total: Final[Counter] = Counter(
132+
"ls_authorization_checks_total",
133+
"Authorization checks",
134+
["action", "result"],
135+
)
136+
137+
# Histogram to measure authorization check latency by bounded action and result.
138+
authorization_duration_seconds: Final[Histogram] = Histogram(
139+
"ls_authorization_duration_seconds",
140+
"Authorization check duration",
141+
["action", "result"],
142+
buckets=AUTHORIZATION_DURATION_BUCKETS,
143+
)

src/metrics/recording.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import metrics
1313
from constants import SUPPORTED_AUTHENTICATION_MODULES
1414
from log import get_logger
15+
from models.config import Action
1516

1617
logger = get_logger(__name__)
1718

@@ -84,6 +85,50 @@ def normalize_auth_reason(reason: str) -> str:
8485
return AUTH_REASON_UNKNOWN
8586

8687

88+
AUTHORIZATION_ACTION_UNKNOWN: Final[str] = "unknown"
89+
AUTHORIZATION_RESULT_SUCCESS: Final[str] = "success"
90+
AUTHORIZATION_RESULT_DENIED: Final[str] = "denied"
91+
AUTHORIZATION_RESULT_ERROR: Final[str] = "error"
92+
93+
ALLOWED_AUTHORIZATION_ACTIONS: Final[frozenset[str]] = frozenset(
94+
action.value for action in Action
95+
)
96+
ALLOWED_AUTHORIZATION_RESULTS: Final[frozenset[str]] = frozenset(
97+
{
98+
AUTHORIZATION_RESULT_SUCCESS,
99+
AUTHORIZATION_RESULT_DENIED,
100+
AUTHORIZATION_RESULT_ERROR,
101+
}
102+
)
103+
104+
105+
def normalize_authorization_action(action: str) -> str:
106+
"""Normalize authorization action labels to the bounded Action enum values.
107+
108+
Args:
109+
action: Raw authorization action label.
110+
111+
Returns:
112+
The action when it is a known protected action, otherwise ``unknown``.
113+
"""
114+
if action in ALLOWED_AUTHORIZATION_ACTIONS:
115+
return action
116+
return AUTHORIZATION_ACTION_UNKNOWN
117+
118+
119+
def normalize_authorization_result(result: str) -> str:
120+
"""Normalize authorization result labels to the bounded result set.
121+
122+
Args:
123+
result: Raw authorization result label.
124+
125+
Returns:
126+
The result when it is allowed, otherwise ``error``.
127+
"""
128+
if result in ALLOWED_AUTHORIZATION_RESULTS:
129+
return result
130+
return AUTHORIZATION_RESULT_ERROR
131+
87132
@contextmanager
88133
def measure_response_duration(path: str) -> Iterator[None]:
89134
"""Measure REST API response duration for a route path.
@@ -228,6 +273,7 @@ def record_llm_inference_duration(
228273
logger.warning("Failed to update LLM inference duration metric", exc_info=True)
229274

230275

276+
231277
def record_auth_attempt(auth_module: str, result: str, reason: str) -> None:
232278
"""Record one authentication attempt.
233279
@@ -266,3 +312,40 @@ def record_auth_duration(auth_module: str, result: str, duration: float) -> None
266312
).observe(duration)
267313
except (AttributeError, TypeError, ValueError):
268314
logger.warning("Failed to update authentication duration metric", exc_info=True)
315+
316+
317+
def record_authorization_check(action: str, result: str) -> None:
318+
"""Record one authorization check.
319+
320+
Args:
321+
action: Protected action name. Unknown values are recorded as ``unknown``.
322+
result: Bounded result label. Unknown values are recorded as ``error``.
323+
"""
324+
normalized_action = normalize_authorization_action(action)
325+
normalized_result = normalize_authorization_result(result)
326+
327+
try:
328+
metrics.authorization_checks_total.labels(
329+
normalized_action, normalized_result
330+
).inc()
331+
except (AttributeError, TypeError, ValueError):
332+
logger.warning("Failed to update authorization metric", exc_info=True)
333+
334+
335+
def record_authorization_duration(action: str, result: str, duration: float) -> None:
336+
"""Record authorization check duration.
337+
338+
Args:
339+
action: Protected action name. Unknown values are recorded as ``unknown``.
340+
result: Bounded result label. Unknown values are recorded as ``error``.
341+
duration: Authorization check duration in seconds.
342+
"""
343+
normalized_action = normalize_authorization_action(action)
344+
normalized_result = normalize_authorization_result(result)
345+
346+
try:
347+
metrics.authorization_duration_seconds.labels(
348+
normalized_action, normalized_result
349+
).observe(duration)
350+
except (AttributeError, TypeError, ValueError):
351+
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)