Files
plane/apps/api/plane/middleware/logger.py
T
sriram veeraghantaandGitHub edf2475413 refactor: logging with retention + API token hardening (#9148)
* fix: harden API token handling against rate-limit tampering and plaintext logging

- Make `allowed_rate_limit` read-only on APITokenSerializer so users can no
  longer raise their own API token rate limit via PATCH (GHSA-xfgr-2x3f-g2cf).
- Stop persisting API keys in plaintext in APITokenLogMiddleware: store a
  SHA-256 hash as the token identifier and redact sensitive request headers
  (X-Api-Key, Authorization, Cookie) before logging (GHSA-r5p8-cj3q-38cc).

* refactor: remove MongoDB log sink and add per-log-type retention

Logs are now written to and cleared from PostgreSQL only; MongoDB is no
longer used as a log sink or archive.

- Drop the MongoDB write/archival paths from the API request logger, the
  webhook log writer, and the cleanup tasks; Postgres is the sole sink.
- Cleanup tasks now hard-delete expired rows in batches via `all_objects`
  (rows are removed immediately, not soft-deleted).
- Add env-backed, per-log-type retention settings: API activity logs
  (API_ACTIVITY_LOG_RETENTION_DAYS, default 14), webhook logs
  (WEBHOOK_LOG_RETENTION_DAYS, default 14), email logs
  (EMAIL_LOG_RETENTION_DAYS, default 7). HARD_DELETE_AFTER_DAYS no longer
  drives any log cleanup.
- Delete settings/mongo.py, remove MONGO_DB_* settings and the plane.mongo
  loggers, and drop the pymongo dependency.

* chore: gitignore local advisories.md notes file

* fix: use keyed HMAC-SHA256 for API token log identifier

Address CodeQL "weak hashing of sensitive data" by hashing the API key with
a SECRET_KEY-keyed HMAC instead of a bare SHA-256. The identifier is a
non-reversible tokenization of a high-entropy key (not password storage);
keying it also prevents precomputing the digest from a known key value.

* chore: address review feedback on log cleanup and request logging

- process_logs accepts extra kwargs so jobs enqueued by an older release
  (with a mongo_log arg) don't fail during a rolling deploy.
- Log-cleanup batch delete failures are logged and skipped rather than
  aborting the run, so a single bad batch can't block the rest.
- Extend logger middleware test to assert Authorization and Cookie headers
  are redacted; add a test that a failing cleanup batch is swallowed.

* fix: fall back to default when a log retention env value is invalid

Negative (or unparseable) retention values would compute a future cutoff and
delete every log row. The retention settings now fall back to their defaults
in that case via a shared `_retention_days` helper.
2026-05-27 16:00:05 +05:30

164 lines
5.4 KiB
Python

# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import hashlib
import hmac
import logging
import time
# Django imports
from django.conf import settings
from django.http import HttpRequest
# Third party imports
from rest_framework.request import Request
# Module imports
from plane.utils.ip_address import get_client_ip
from plane.utils.exception_logger import log_exception
from plane.bgtasks.logger_task import process_logs
api_logger = logging.getLogger("plane.api.request")
class RequestLoggerMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def _should_log_route(self, request: Request | HttpRequest) -> bool:
"""
Determines whether a route should be logged based on the request and status code.
"""
# Don't log health checks
if request.path == "/" and request.method == "GET":
return False
return True
def __call__(self, request):
# get the start time
start_time = time.time()
# Get the response
response = self.get_response(request)
# calculate the duration
duration = time.time() - start_time
# Check if logging is required
log_true = self._should_log_route(request=request)
# If logging is not required, return the response
if not log_true:
return response
user_id = (
request.user.id if getattr(request, "user") and getattr(request.user, "is_authenticated", False) else None
)
user_agent = request.META.get("HTTP_USER_AGENT", "")
# Log the request information
api_logger.info(
f"{request.method} {request.get_full_path()} {response.status_code}",
extra={
"path": request.path,
"method": request.method,
"status_code": response.status_code,
"duration_ms": int(duration * 1000),
"remote_addr": get_client_ip(request),
"user_agent": user_agent,
"user_id": user_id,
},
)
# return the response
return response
class APITokenLogMiddleware:
"""
Middleware to log External API requests to PostgreSQL.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request_body = request.body
response = self.get_response(request)
self.process_request(request, response, request_body)
return response
def _safe_decode_body(self, content):
"""
Safely decodes request/response body content, handling binary data.
Returns None if content is None, or a string representation of the content.
"""
# If the content is None, return None
if content is None:
return None
# If the content is an empty bytes object, return None
if content == b"":
return None
# Check if content is binary by looking for common binary file signatures
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
return "[Binary Content]"
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return "[Could not decode content]"
# Headers whose values must never be persisted in plaintext logs
SENSITIVE_HEADERS = frozenset({"x-api-key", "authorization", "cookie"})
def _redacted_headers(self, request):
"""
Returns the request headers as a string with sensitive values redacted,
so that credentials such as the API key are never stored in plaintext.
"""
redacted = {
key: ("[REDACTED]" if key.lower() in self.SENSITIVE_HEADERS else value)
for key, value in request.headers.items()
}
return str(redacted)
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
# If the API key is not present, return
if not api_key:
return
try:
log_data = {
# Tokenize the (high-entropy) API key into a stable, non-reversible
# identifier so logs can be correlated to a token without ever
# persisting the raw key. A keyed HMAC is used rather than a bare
# hash so the digest cannot be precomputed from a known key value.
"token_identifier": hmac.new(
settings.SECRET_KEY.encode(), api_key.encode(), hashlib.sha256
).hexdigest(),
"path": request.path,
"method": request.method,
"query_params": request.META.get("QUERY_STRING", ""),
"headers": self._redacted_headers(request),
"body": self._safe_decode_body(request_body) if request_body else None,
"response_body": self._safe_decode_body(response.content) if response.content else None,
"response_code": response.status_code,
"ip_address": get_client_ip(request=request),
"user_agent": request.META.get("HTTP_USER_AGENT", None),
}
process_logs.delay(log_data=log_data)
except Exception as e:
log_exception(e)
return None