From af705d77d776990c87248d0bf480f1b98dcf5a5e Mon Sep 17 00:00:00 2001 From: briquet Date: Sun, 4 Oct 2026 21:10:00 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D(docs)=20document=20audit=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe the audit event shape, the catalogue, how views and other code emit events, the environment variables configuring them, and how the project registers its actions, models and authentication classes. --- docs/features/audit-logging.md | 178 +++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/features/audit-logging.md diff --git a/docs/features/audit-logging.md b/docs/features/audit-logging.md new file mode 100644 index 00000000..de651163 --- /dev/null +++ b/docs/features/audit-logging.md @@ -0,0 +1,178 @@ +# Audit logging + +La Suite Meet emits a structured **audit log**: one JSON line per notable action, saying who did what, on behalf of +whom, on which resource, from where, and whether it succeeded. + +## What an event looks like + +Events are written on the dedicated `audit` logger, one per line and look like this: + +```json +{ + "@timestamp": "2026-09-15T08:41:12.345+00:00", + "ecs": {"version": "8.11.0"}, + "log_type": "audit", + "service": {"name": "meet", "environment": "production"}, + "event": { + "kind": "event", + "action": "room.create", + "category": ["api"], + "type": ["creation"], + "outcome": "success" + }, + "trace": {"id": "6f1c0d0e2a8b4c1d9e7f0a1b2c3d4e5f"}, + "client": {"ip": "1.2.3.4"}, + "source": {"ip": "1.2.3.4"}, + "http": {"request": {"method": "POST"}}, + "url": {"path": "/external-api/v1.0/rooms/"}, + "user": {"id": "beecd833-4be4-4675-b139-a196b07144a9", "sub": "0edebfa3-1355-4891-ae63-daf9fd37ac04", "domain": "gouv.fr"}, + "organization": {"id": "calendar-app"}, + "lasuite": { + "actor": {"type": "application"}, + "auth": {"method": "application_jwt"}, + "application": {"client_id": "calendar-app"}, + "outcome": "success", + "target": {"type": "room", "id": "3b1d…", "slug": "daily-standup", "name": "Daily standup", "access_level": "trusted"} + }, + "log": {"level": "info", "logger": "audit"} +} +``` + +A refusal is recorded under the action that was attempted: the same `room.create`, with +`"event": {"outcome": "failure", "type": ["creation", "denied"], "reason": "permission_denied"}`, +`"lasuite": {"outcome": "denied"}`, `"http": {"response": {"status_code": 403}}` and `"error": {"message": "…"}`. + +## Fields + +Standard fields follow the [Elastic Common Schema](https://www.elastic.co/guide/en/ecs/current/index.html); + +| Field | Meaning | +|---|---| +| `@timestamp` | ISO 8601 with millisecond precision in UTC timezone` | +| `log_type` | Always `audit` | +| `service.name`, `service.environment` | `AUDIT_LOG_SERVICE_NAME` and current environment | +| `event.action` | What was attempted, from the catalogue below | +| `event.category`, `event.type` | ECS classification (`api`, `authentication`, `iam`... / `creation`, `change`, `access`, `denied`, `user`...) | +| `event.outcome` | ECS `success` or `failure` | +| `event.reason` | Why it did not succeed: `authentication_failed`, `permission_denied`, `rate_limited`, `validation_error`, `not_found`, `conflict`, `internal_error` | +| `lasuite.outcome` | `success`, `failure` or `denied` | +| `lasuite.actor.type` | `user`, `application`, `device`, `service`, `system` or `anonymous` | +| `lasuite.actor.name` | Name of a service actor | +| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `oidc`, `password`, `none`, or `unknown` for a class that is not registered. Requests served outside DRF, as the admin and logout are, report `session` when signed in | +| `lasuite.application.client_id` | The external application acting, when there is one. Only set once its credentials are verified | +| `user.id`, `user.sub`, `user.domain` | The (delegated) human user: primary key, OIDC sub when the account has one, and email domain. The email address is never recorded | +| `organization.id` | Tenant: the application client id when present, else the user's email domain | +| `lasuite.target` | The resource acted on: `type`, `id` and a few stable fields per type | +| `lasuite.details` | Action-specific fields (see catalogue) | +| `client.ip`, `source.ip` | Real client address, the one DRF's throttles identify (see `NUM_PROXIES`) | +| `http.request.method`, `url.path` | Request as received | +| `http.response.status_code` | Set on refusals and failures | +| `trace.id` | Request id, also echoed as the `X-Request-ID` response header and logged by Gunicorn as `rid=`. Generated by the backend unless `REQUEST_ID_TRUST_HEADER` is set | +| `error.message` | Human-readable reason of a failure | +| `error.type` | Class of an unhandled exception. Its message is left out, as it may carry personal data | +| `log.level` | `info` for success, `warning` for failures and denials, `error` for internal errors | + +An audited API action that raises an exception DRF does not handle is still recorded, as a `failure` with reason +`internal_error`, status code `500` and `error.type`, before the exception propagates. + +## Catalogue + +| `event.action` | Emitted when | Notable fields | +|---|---|---| +| `application.token.issue` | An application requests a delegated token (`POST /external-api/v1.0/application/token/`), whether it obtains one or is refused: bad credentials, inactive application, invalid or unauthorized email domain, unknown user, provisioning conflict | On success: `user.*` = delegated user, `lasuite.target` = application, `lasuite.details.scopes`, `user_provisioned`, `expires_in`. On refusal: `event.reason`, `http.response.status_code`, `lasuite.details.requested_domain`. Until the credentials are verified, the submitted client id is only `lasuite.details.claimed_client_id`: it never sets `lasuite.application` or `organization` | +| `user.provision` | An application creates a provisional user by email | `lasuite.target` = user | +| `room.create` | A room is created through the external API, or the attempt fails | `lasuite.target` = room | +| `room.update` | A room is updated through the external API, or the attempt fails | `lasuite.target` = room, refusals included, `lasuite.details.updated_fields`, `previous_access_level` | +| `room.retrieve` | A room is read through the external API, or the attempt fails | `lasuite.target` = room | +| `room.list` | Rooms are listed through the external API, or the attempt fails | `lasuite.details.total` | +| `user.login` | A user logs in or a login attempt fails | `lasuite.auth.method` = `oidc` or `password`, or `unknown`: named after the backend on success, `lasuite.details.auth_backend`, and after the credentials submitted on failure (a password, or the nonce of the OIDC callback) | +| `user.logout` | A user logs out | | + +Actions are always dotted, lower-case, with the format `.`, and name what was attempted: whether it +succeeded is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the action. + +## Emitting events + +Actions are declared once, in `core/auditing.py`, as `audit.Action` constants. An action may carry its ECS category +and types, which then apply to every event it emits: + +```python +APPLICATION_TOKEN_ISSUE = audit.Action( + "application.token.issue", + category=EventCategory.AUTHENTICATION, + types=(EventType.START,), +) +ROOM_CREATE = audit.Action("room.create") +``` + +DRF views declare the actions they audit; everything else is derived from the response. CRUD actions are mapped in +`audit_actions`, and an extra action names its own on its route, so that renaming its method cannot silently stop +auditing it: + +```python +from core import audit, auditing + + +class RoomViewSet(audit.AuditViewMixin, viewsets.GenericViewSet): + audit_actions = {"create": auditing.ROOM_CREATE, "retrieve": auditing.ROOM_RETRIEVE} + + def perform_create(self, serializer): + self.audit_target = serializer.save() + + @action(detail=True, methods=["post"], audit_action=auditing.ROOM_INVITE) + def invite(self, request, pk=None): ... +``` + +- **Views are audited by `AuditViewMixin`** from DRF's `finalize_response` hook, which runs for every response, +successful or not. The ECS category and types come from the action, else `api` and the DRF action (`creation` for +`create`...). The outcome, reason and status code come from the response status: 401, 403 and 429 are `denied`, other +errors `failure`, and a 401 is always filed under `authentication`. The target is the object `get_object()` returned, +unless the view assigns `audit_target`. A view can also assign `audit_actor` and `audit_details`, or override +`get_audit_fields()`. `audit_actions` only accepts CRUD actions: any other key raises a `TypeError` when the class is +defined. + +- **Anything else calls `audit.log`** with the request at hand. A `category` or `types` given here wins over the +action's: + + ```python + audit.log(auditing.USER_PROVISION, request=request, target=user) + ``` + +- **Request fields are read from `request`**: the real client address and the path. The trace id is the request id, +settled by `RequestIdHeaderMiddleware` right after dockerflow assigned it, and echoed in the +`DOCKERFLOW_REQUEST_ID_HEADER_NAME` response header (`X-Request-ID` by default). The inbound id is kept only when +`REQUEST_ID_TRUST_HEADER` is set; otherwise the backend generates one, so a client never picks it. + +- **Actors are derived** from `request.user`, `request.auth` and the DRF authenticator, whose class is mapped to an +auth method by `audit.register_auth_method` (see Configuration). It is possible to override the actor with `actor=`, +`actor_type=`, `auth_method=` and `client_id=`. + +- **Targets are described** by their model name, primary key and the `fields` their model is registered with. A model +that is not registered is still identified. Extra keyword arguments land under `lasuite.details`. + +- **Emission never raises.** A broken configuration or value is reported on the application logger (and Sentry) and the +business operation proceeds. A registered field that cannot be read is left out of the target, and the event is still +emitted. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `AUDIT_LOG_LEVEL` | `INFO` | Level of the `audit` logger. | +| `AUDIT_LOG_STREAM` | `ext://sys.stdout` | Where the handler writes | +| `AUDIT_LOG_SERVICE_NAME` | `meet` | `service.name` | +| `NUM_PROXIES` | `1` | DRF's number of trusted proxies appending to `X-Forwarded-For`, shared with the throttles. The client is the entry that many positions from the right; anything a client injects lands further left and is ignored. `1` matches ingress-nginx defaults; use `2` behind a load balancer that also appends | +| `REQUEST_ID_TRUST_HEADER` | `False` | Reuse the inbound request id as `trace.id`, so the ingress, Gunicorn, application logs and audit events share one id. Only set it when the ingress overwrites the header (`proxy_set_header X-Request-ID $request_id;` on ingress-nginx, which otherwise forwards the client's one): a client could else pick the id of someone else's request | +| `DOCKERFLOW_REQUEST_ID_HEADER_NAME` | `X-Request-ID` | Header carrying that id: read on the request only when `REQUEST_ID_TRUST_HEADER` is set, always echoed on the response | + +The project describes itself to the facility in code, from `core/auditing.py`. The audit app imports the `auditing` +module of every installed app once it is ready: + +- `audit.register(Model, fields=...)`: the `fields` describing a model as a target. A proxy model falls back to its + concrete model. Registering a model twice raises `AlreadyRegistered`. +- `audit.register_auth_method(klass, name)`: the `lasuite.auth.method` of a DRF authentication class or of a login + backend. A DRF class inherits the name of its closest registered base, and DRF's own classes are built in. A login + backend must be registered itself, as custom backends often subclass `ModelBackend` for its permission checks + alone; `ModelBackend` is built in as `password`. + +The `audit` logger does not propagate and is ignored by Sentry. Application logs keep their text format; only the audit stream is JSON.