diff --git a/docs/features/audit-logging.md b/docs/features/audit-logging.md new file mode 100644 index 00000000..73fae40f --- /dev/null +++ b/docs/features/audit-logging.md @@ -0,0 +1,191 @@ +# 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: the emitter, never the caller | +| `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`, `service`, `system` or `anonymous`, see [Actors](#actors) | +| `lasuite.actor.name` | Name of a `service` actor: `roomkit`, `summary` | +| `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. + +### Actors + +`lasuite.actor.type` says who acted, and `user.*` whose authority the action used: + +| `lasuite.actor.type` | Who | `user.*` | +|---|---|---| +| `user` | A person's account acting for itself: session, OIDC or password login, add-on token, LiveKit token of a known account | That account | +| `application` | A client application acting on behalf of a user: a Meet application through its client credentials or its delegated token, or another La Suite application through the resource server. `lasuite.application.client_id` names it | The delegating user | +| `service` | An internal peer of the deployment acting on its own behalf with a shared secret: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`). Never an account. `lasuite.actor.name` names it | Absent | +| `system` | The backend itself, with no inbound request | Absent | +| `anonymous` | A caller that did not authenticate, or failed to | Absent | + +A `client_id` in the token payload makes an application, a principal authenticated without an account a service, and +an account a user. An event emitted with neither a request nor an actor is the system's. + +## 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, `denied` with reason `authentication_failed` | `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. +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()`. + +- **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 `AuditLogMiddleware` 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), as described in [Actors](#actors). Without a request, +the actor is the system. 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`.