refac
This commit is contained in:
@@ -50,10 +50,10 @@ class AuditLogEntry:
|
||||
|
||||
|
||||
class AuditLevel(str, Enum):
|
||||
NONE = "NONE"
|
||||
METADATA = "METADATA"
|
||||
REQUEST = "REQUEST"
|
||||
REQUEST_RESPONSE = "REQUEST_RESPONSE"
|
||||
NONE = 'NONE'
|
||||
METADATA = 'METADATA'
|
||||
REQUEST = 'REQUEST'
|
||||
REQUEST_RESPONSE = 'REQUEST_RESPONSE'
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
@@ -64,25 +64,24 @@ class AuditLogger:
|
||||
logger (Logger): An instance of Loguru’s logger.
|
||||
"""
|
||||
|
||||
def __init__(self, logger: "Logger"):
|
||||
def __init__(self, logger: 'Logger'):
|
||||
self.logger = logger.bind(auditable=True)
|
||||
|
||||
def write(
|
||||
self,
|
||||
audit_entry: AuditLogEntry,
|
||||
*,
|
||||
log_level: str = "INFO",
|
||||
log_level: str = 'INFO',
|
||||
extra: Optional[dict] = None,
|
||||
):
|
||||
|
||||
entry = asdict(audit_entry)
|
||||
|
||||
if extra:
|
||||
entry["extra"] = extra
|
||||
entry['extra'] = extra
|
||||
|
||||
self.logger.log(
|
||||
log_level,
|
||||
"",
|
||||
'',
|
||||
**entry,
|
||||
)
|
||||
|
||||
@@ -106,15 +105,11 @@ class AuditContext:
|
||||
|
||||
def add_request_chunk(self, chunk: bytes):
|
||||
if len(self.request_body) < self.max_body_size:
|
||||
self.request_body.extend(
|
||||
chunk[: self.max_body_size - len(self.request_body)]
|
||||
)
|
||||
self.request_body.extend(chunk[: self.max_body_size - len(self.request_body)])
|
||||
|
||||
def add_response_chunk(self, chunk: bytes):
|
||||
if len(self.response_body) < self.max_body_size:
|
||||
self.response_body.extend(
|
||||
chunk[: self.max_body_size - len(self.response_body)]
|
||||
)
|
||||
self.response_body.extend(chunk[: self.max_body_size - len(self.response_body)])
|
||||
|
||||
|
||||
class AuditLoggingMiddleware:
|
||||
@@ -122,7 +117,7 @@ class AuditLoggingMiddleware:
|
||||
ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle.
|
||||
"""
|
||||
|
||||
AUDITED_METHODS = {"PUT", "PATCH", "DELETE", "POST"}
|
||||
AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -142,8 +137,8 @@ class AuditLoggingMiddleware:
|
||||
|
||||
if self.included_paths and self.excluded_paths:
|
||||
logger.warning(
|
||||
"Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. "
|
||||
"AUDIT_INCLUDED_PATHS (whitelist) takes precedence."
|
||||
'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. '
|
||||
'AUDIT_INCLUDED_PATHS (whitelist) takes precedence.'
|
||||
)
|
||||
|
||||
async def __call__(
|
||||
@@ -152,7 +147,7 @@ class AuditLoggingMiddleware:
|
||||
receive: ASGIReceiveCallable,
|
||||
send: ASGISendCallable,
|
||||
) -> None:
|
||||
if scope["type"] != "http":
|
||||
if scope['type'] != 'http':
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
request = Request(scope=cast(MutableMapping, scope))
|
||||
@@ -185,9 +180,7 @@ class AuditLoggingMiddleware:
|
||||
await self.app(scope, receive_wrapper, send_wrapper)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _audit_context(
|
||||
self, request: Request
|
||||
) -> AsyncGenerator[AuditContext, None]:
|
||||
async def _audit_context(self, request: Request) -> AsyncGenerator[AuditContext, None]:
|
||||
"""
|
||||
async context manager that ensures that an audit log entry is recorded after the request is processed.
|
||||
"""
|
||||
@@ -198,29 +191,24 @@ class AuditLoggingMiddleware:
|
||||
await self._log_audit_entry(request, context)
|
||||
|
||||
async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
try:
|
||||
user = await get_current_user(
|
||||
request, None, None, get_http_authorization_cred(auth_header)
|
||||
)
|
||||
user = await get_current_user(request, None, None, get_http_authorization_cred(auth_header))
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get authenticated user: {str(e)}")
|
||||
logger.debug(f'Failed to get authenticated user: {str(e)}')
|
||||
|
||||
return None
|
||||
|
||||
def _should_skip_auditing(self, request: Request) -> bool:
|
||||
if (
|
||||
request.method not in {"POST", "PUT", "PATCH", "DELETE"}
|
||||
or AUDIT_LOG_LEVEL == "NONE"
|
||||
):
|
||||
if request.method not in {'POST', 'PUT', 'PATCH', 'DELETE'} or AUDIT_LOG_LEVEL == 'NONE':
|
||||
return True
|
||||
|
||||
ALWAYS_LOG_ENDPOINTS = {
|
||||
"/api/v1/auths/signin",
|
||||
"/api/v1/auths/signout",
|
||||
"/api/v1/auths/signup",
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signout',
|
||||
'/api/v1/auths/signup',
|
||||
}
|
||||
path = request.url.path.lower()
|
||||
for endpoint in ALWAYS_LOG_ENDPOINTS:
|
||||
@@ -229,55 +217,47 @@ class AuditLoggingMiddleware:
|
||||
|
||||
# Skip logging if the request is not authenticated
|
||||
# Check both Authorization header (API keys) and token cookie (browser sessions)
|
||||
if not request.headers.get("authorization") and not request.cookies.get(
|
||||
"token"
|
||||
):
|
||||
if not request.headers.get('authorization') and not request.cookies.get('token'):
|
||||
return True
|
||||
|
||||
# Whitelist mode: only log paths that match included_paths
|
||||
if self.included_paths:
|
||||
pattern = re.compile(
|
||||
r"^/api(?:/v1)?/(" + "|".join(self.included_paths) + r")\b"
|
||||
)
|
||||
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b')
|
||||
if not pattern.match(request.url.path):
|
||||
return True # Skip: path not in whitelist
|
||||
return False # Do NOT skip: path is in whitelist
|
||||
|
||||
# Blacklist mode: skip paths that match excluded_paths
|
||||
pattern = re.compile(
|
||||
r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b"
|
||||
)
|
||||
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b')
|
||||
if pattern.match(request.url.path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _capture_request(self, message: ASGIReceiveEvent, context: AuditContext):
|
||||
if message["type"] == "http.request":
|
||||
body = message.get("body", b"")
|
||||
if message['type'] == 'http.request':
|
||||
body = message.get('body', b'')
|
||||
context.add_request_chunk(body)
|
||||
|
||||
async def _capture_response(self, message: ASGISendEvent, context: AuditContext):
|
||||
if message["type"] == "http.response.start":
|
||||
context.metadata["response_status_code"] = message["status"]
|
||||
if message['type'] == 'http.response.start':
|
||||
context.metadata['response_status_code'] = message['status']
|
||||
|
||||
elif message["type"] == "http.response.body":
|
||||
body = message.get("body", b"")
|
||||
elif message['type'] == 'http.response.body':
|
||||
body = message.get('body', b'')
|
||||
context.add_response_chunk(body)
|
||||
|
||||
async def _log_audit_entry(self, request: Request, context: AuditContext):
|
||||
try:
|
||||
user = await self._get_authenticated_user(request)
|
||||
|
||||
user = (
|
||||
user.model_dump(include={"id", "name", "email", "role"}) if user else {}
|
||||
)
|
||||
user = user.model_dump(include={'id', 'name', 'email', 'role'}) if user else {}
|
||||
|
||||
request_body = context.request_body.decode("utf-8", errors="replace")
|
||||
response_body = context.response_body.decode("utf-8", errors="replace")
|
||||
request_body = context.request_body.decode('utf-8', errors='replace')
|
||||
response_body = context.response_body.decode('utf-8', errors='replace')
|
||||
|
||||
# Redact sensitive information
|
||||
if "password" in request_body:
|
||||
if 'password' in request_body:
|
||||
request_body = re.sub(
|
||||
r'"password":\s*"(.*?)"',
|
||||
'"password": "********"',
|
||||
@@ -290,13 +270,13 @@ class AuditLoggingMiddleware:
|
||||
audit_level=self.audit_level.value,
|
||||
verb=request.method,
|
||||
request_uri=str(request.url),
|
||||
response_status_code=context.metadata.get("response_status_code", None),
|
||||
response_status_code=context.metadata.get('response_status_code', None),
|
||||
source_ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
user_agent=request.headers.get('user-agent'),
|
||||
request_object=request_body,
|
||||
response_object=response_body,
|
||||
)
|
||||
|
||||
self.audit_logger.write(entry)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log audit entry: {str(e)}")
|
||||
logger.error(f'Failed to log audit entry: {str(e)}')
|
||||
|
||||
Reference in New Issue
Block a user