mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-20 03:31:55 +00:00
4e9f76bec5
Add a core.audit package emitting one Elastic Common Schema JSON line per security-relevant action on a dedicated "audit" logger. Only lasuite.* and entity.target.raw.* extends on ECS core fields. Each event names the data stream it belongs to (data_stream.*, event.dataset) and carries a unique event.id. The audit logs reads the actor, auth method, tenant and network fields from the request it is given, else from the request being served, which AuditLogMiddleware keeps in a context variable so that a service needs no request argument. A person is identified by primary key, OIDC sub when the account has one and email domain, with its privileges as user.roles; an internal peer calling the backend by service.origin.name. The tenant is the application client id, else that domain. The client IP is DRF's throttle ident, which now honours a NUM_PROXIES setting. The middleware also replaces the inbound X-Request-ID with a fresh id, unless REQUEST_ID_TRUST_HEADER says the ingress overwrites it, and echoes it in the response and the gunicorn access log. Audit events carry it as http.request.id, next to the client's user agent. An action is defined with a dotted name with its default ECS category and types, validated at import against the types ECS expects in that category, so a call site only names what was attempted. A category or types given to audit.log win over them, and a bare dotted name is accepted too. Meet declares all of the events core/auditing.py: - audit.register lists the fields describing a model as a target, an ECS entity.target, and its ECS entity type. Every target carries its model name and primary key, so an unregistered one is still identified, and a user target its OIDC sub. - audit.register_auth_method names the lasuite.auth.method of a DRF authentication class AuditViewMixin audits every response of the CRUD actions a view maps in audit_actions, and of the extra actions naming theirs with @action(audit_action=...), from finalize_response: the outcome, reason and status come from the response. An exception DRF does not handle is recorded as an internal error before it propagates. The core.audit app also records Django's login, failed login and logout signals.
115 lines
3.9 KiB
Python
115 lines
3.9 KiB
Python
"""Read the network fields and the request id behind an audit event."""
|
|
|
|
import uuid
|
|
from contextvars import ContextVar, Token
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from django.conf import settings
|
|
|
|
from dockerflow.logging import request_id_context
|
|
from rest_framework.throttling import BaseThrottle
|
|
|
|
USER_AGENT_MAX_LENGTH = 1024
|
|
|
|
|
|
def current_request_id() -> str | None:
|
|
"""Return the id of the request being served, if any."""
|
|
return request_id_context.get(None)
|
|
|
|
|
|
def resolve_client_ip(request) -> str | None:
|
|
"""Return the address of the real client, never the one of a proxy.
|
|
|
|
It reuses DRF's throttles to identify the client.
|
|
"""
|
|
return BaseThrottle().get_ident(request) or request.META.get("REMOTE_ADDR")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RequestContext:
|
|
"""What an audit event reads from the request behind it.
|
|
|
|
The network fields are read once, when the request comes in. The request
|
|
itself is kept for the actor, only known once it is authenticated: it is
|
|
the Django request, onto which DRF copies its user and auth, but not its
|
|
authenticator, so the auth method of a DRF view is not known from it.
|
|
Outside a request, every field is ``None``.
|
|
"""
|
|
|
|
request: Any = None
|
|
request_id: str | None = None
|
|
method: str | None = None
|
|
path: str | None = None
|
|
client_ip: str | None = None
|
|
user_agent: str | None = None
|
|
|
|
@classmethod
|
|
def from_request(cls, request) -> "RequestContext":
|
|
"""Read the context of ``request``, its user agent cut where ECS stops."""
|
|
meta = getattr(request, "META", None) or {}
|
|
return cls(
|
|
request=request,
|
|
request_id=current_request_id(),
|
|
method=getattr(request, "method", None),
|
|
path=getattr(request, "path", None) or None,
|
|
client_ip=resolve_client_ip(request),
|
|
user_agent=meta.get("HTTP_USER_AGENT", "")[:USER_AGENT_MAX_LENGTH] or None,
|
|
)
|
|
|
|
|
|
_request_context: ContextVar[RequestContext | None] = ContextVar(
|
|
"audit_request_context", default=None
|
|
)
|
|
|
|
|
|
def request_context() -> RequestContext:
|
|
"""Return the context of the request being served, as set by ``AuditLogMiddleware``.
|
|
|
|
It is empty outside a request.
|
|
"""
|
|
return _request_context.get() or RequestContext()
|
|
|
|
|
|
def set_request_context(context: RequestContext) -> Token:
|
|
"""Make ``context`` the one of the request being served, until reset."""
|
|
return _request_context.set(context)
|
|
|
|
|
|
def reset_request_context(token: Token) -> None:
|
|
"""Restore the context that was current before ``set_request_context``."""
|
|
_request_context.reset(token)
|
|
|
|
|
|
class AuditLogMiddleware:
|
|
"""Settle the request id and the request context, then echo the id.
|
|
|
|
It must come right after ``DockerflowMiddleware``, which sets the id from
|
|
the inbound ``DOCKERFLOW_REQUEST_ID_HEADER_NAME`` header. Unless
|
|
``REQUEST_ID_TRUST_HEADER`` says the ingress overwrites that header, the id
|
|
is replaced by a fresh one before anything logs, so that a client, the web
|
|
server access log and the audit events of a request can be joined on an id
|
|
the client did not choose.
|
|
|
|
The context of the request is kept while it is served, so that an audit
|
|
event emitted far from the view, as from a service, still reads its actor
|
|
and network fields from it.
|
|
"""
|
|
|
|
def __init__(self, get_response):
|
|
self.get_response = get_response
|
|
|
|
def __call__(self, request):
|
|
if not settings.REQUEST_ID_TRUST_HEADER:
|
|
request_id_context.set(str(uuid.uuid4()))
|
|
|
|
token = set_request_context(RequestContext.from_request(request))
|
|
try:
|
|
response = self.get_response(request)
|
|
finally:
|
|
reset_request_context(token)
|
|
header = settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME
|
|
if not response.has_header(header):
|
|
response[header] = current_request_id()
|
|
return response
|