diff --git a/netx_api/audit_async.py b/netx_api/audit_async.py index 54876e7..a035a13 100644 --- a/netx_api/audit_async.py +++ b/netx_api/audit_async.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import queue import threading +import time from typing import Any from .config import settings @@ -18,6 +19,13 @@ _lock = threading.Lock() _counter = 0 _dropped = 0 +# Dedupe unauthenticated request floods (SPA fires many parallel 401s). +_unauth_lock = threading.Lock() +_unauth_recent: dict[str, float] = {} +_UNAUTH_DEDUP_GET_SEC = 60.0 +_UNAUTH_DEDUP_WRITE_SEC = 15.0 +_UNAUTH_RECENT_MAX = 4000 + # Middleware-tagged HTTP wrappers that duplicate semantic business audits. _MIDDLEWARE_NOISE_ACTIONS = frozenset( { @@ -61,6 +69,32 @@ def _sample_ok() -> bool: return (_counter % n) == 0 +def should_audit_unauthorized(*, client_ip: str, method: str, path: str) -> bool: + """Rate-limit auth.unauthorized writes: one row per IP+method+path per window. + + Unauthenticated page loads often fan out dozens of GETs in the same second; + keeping every 401 drowns real login/security events. + """ + method_u = str(method or "GET").upper() + window = ( + _UNAUTH_DEDUP_WRITE_SEC + if method_u in ("POST", "PUT", "PATCH", "DELETE") + else _UNAUTH_DEDUP_GET_SEC + ) + key = f"{client_ip or '-'}|{method_u}|{path or '/'}" + now = time.monotonic() + with _unauth_lock: + global _unauth_recent + last = float(_unauth_recent.get(key) or 0.0) + if now - last < window: + return False + _unauth_recent[key] = now + if len(_unauth_recent) > _UNAUTH_RECENT_MAX: + cutoff = now - max(_UNAUTH_DEDUP_GET_SEC * 5, 300.0) + _unauth_recent = {k: v for k, v in _unauth_recent.items() if float(v) >= cutoff} + return True + + def audit_should_persist( *, action: str, diff --git a/netx_api/auth_middleware.py b/netx_api/auth_middleware.py index ed663b9..b21fda1 100644 --- a/netx_api/auth_middleware.py +++ b/netx_api/auth_middleware.py @@ -113,16 +113,20 @@ class AuthAuditMiddleware(BaseHTTPMiddleware): try: resolved = resolve_user_from_token(db, token) if token else None if resolved is None: - write_audit( - db, - action="auth.unauthorized", - method=request.method, - path=path, - status_code=401, - client_ip=_client_ip(request), - user_agent=str(request.headers.get("user-agent") or "")[:512], - detail={}, - ) + client_ip = _client_ip(request) + from .audit_async import should_audit_unauthorized + + if should_audit_unauthorized(client_ip=client_ip, method=request.method, path=path): + write_audit( + db, + action="auth.unauthorized", + method=request.method, + path=path, + status_code=401, + client_ip=client_ip, + user_agent=str(request.headers.get("user-agent") or "")[:512], + detail={}, + ) return JSONResponse(status_code=401, content={"detail": "unauthorized"}) user, via, scopes, token_id, jti = resolved if bool(getattr(user, "must_change_password", False)): diff --git a/netx_api/auth_service.py b/netx_api/auth_service.py index ebda386..fac13b3 100644 --- a/netx_api/auth_service.py +++ b/netx_api/auth_service.py @@ -747,6 +747,7 @@ def list_audit_logs( "api_tokens.get", "auth.me", "auth.sessions", + "auth.unauthorized", ) q = q.filter(~AuditLog.action.like("http.%")) q = q.filter(~AuditLog.action.in_(noise_actions)) diff --git a/tests/test_audit_noise.py b/tests/test_audit_noise.py index 4d4da51..c12a44d 100644 --- a/tests/test_audit_noise.py +++ b/tests/test_audit_noise.py @@ -60,5 +60,33 @@ class AuditShouldPersistTests(unittest.TestCase): self.assertFalse(audit_should_persist(action="ume.token.get", method="GET", status_code=200)) +class UnauthorizedDedupeTests(unittest.TestCase): + def setUp(self) -> None: + from netx_api import audit_async as aa + + with aa._unauth_lock: + aa._unauth_recent.clear() + + def test_dedupes_same_ip_path(self) -> None: + from netx_api.audit_async import should_audit_unauthorized + + self.assertTrue( + should_audit_unauthorized(client_ip="127.0.0.1", method="GET", path="/v1/topology") + ) + self.assertFalse( + should_audit_unauthorized(client_ip="127.0.0.1", method="GET", path="/v1/topology") + ) + # Different path still recorded once. + self.assertTrue( + should_audit_unauthorized(client_ip="127.0.0.1", method="GET", path="/v1/managed-ne") + ) + + def test_different_ip_not_deduped(self) -> None: + from netx_api.audit_async import should_audit_unauthorized + + self.assertTrue(should_audit_unauthorized(client_ip="1.1.1.1", method="GET", path="/v1/x")) + self.assertTrue(should_audit_unauthorized(client_ip="2.2.2.2", method="GET", path="/v1/x")) + + if __name__ == "__main__": unittest.main() diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index b01eabe..0f403f2 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -87,6 +87,11 @@ export function auditSummary( }); } if (action.startsWith("auth.")) { + if (action === "auth.unauthorized") { + const method = row?.method || "GET"; + const path = row?.path || ""; + return path ? `${method} ${path}` : "unauthorized"; + } return action.replace(/^auth\./, ""); } if (action.startsWith("http.") || action.startsWith("ume.")) {