Files
meet/docs/features/audit-logging.md
T
briquet adcc18513d 🔒️(backend) audit writes made through the Django admin
Every ModelAdmin now emits an ECS audit event when an object is created,
changed or deleted, and when a bulk action runs. Actions are templated
as admin.<target>.<verb>, after the model name. A signed-in account
without staff access reaching the admin is recorded as a denied
admin.access.

The wiring is a custom AdminSite installed through AdminConfig.default_site:
it mixes the auditing into every admin class at registration, including the
ones Django declares, so a new ModelAdmin is covered without doing anything.
It hangs off log_addition, log_change, log_deletions and get_actions, which
Django calls in every path, rather than off save_model. Deletions noted by
log_deletions are emitted from delete_model and delete_queryset, once their
outcome is known.

Changed field names are always reported; their values only for the
admin_values each model is registered with, and never for anything that
looks like a secret. An account acted upon is reported as user.target.
2026-10-07 13:06:32 +02:00

16 KiB

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::

{
  "@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": "9ae54744-ae64-44f4-b094-71e137556b66", "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;

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
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 account whose authority the action used, see Actors: primary key, OIDC sub when the account has one, and email domain. The email address is never recorded
user.target.id, user.target.sub, user.target.domain The account an IAM action was performed on, when the target is a user. user.* stays the actor
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: the LiveKit SIP bridge (roomkit), the summary service (summary). 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, or fails to: failure with reason conflict when a concurrent request created it first lasuite.target = user, the existing one on a conflict. A provisional user has no sub yet: its id joins this event to the winning one and to its later user.login
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
admin.access A signed-in account without staff access reaches an admin page (always denied), once per refused page event.reason, http.response.status_code: the redirect to the login page
admin.<target>.<verb> A write is made through the Django admin, see below

Actions are always dotted, lower-case, with the format <target>.<verb>, and name what was attempted: whether it succeeded is told by event.outcome, lasuite.outcome and event.reason, never by the action.

Django admin

The admin is the most sensitive surface of the product, so every write made through it emits an audit event next to the LogEntry Django writes itself. Nothing is replaced and there is no extra table: the admin history keeps working.

The action is templated rather than listed: admin.<target>.<verb>, where <target> is the model name and <verb> one of:

<verb> Emitted when Notable fields
create An object is added lasuite.details.changed_fields, changes
update An object is changed lasuite.details.changed_fields, changes
delete An object is deleted, one event per object, once the deletion has run. A deletion that raises is a failure with reason internal_error; in a bulk deletion every selected object is then reported as failed error.message on failure
action A bulk action runs lasuite.details.admin_action, count

So admin.room.update, admin.user.delete, admin.recording.action. event.category is iam for anything granting access to the product and configuration otherwise. Writes on a user or a group lead event.type with user or group, as in ["user", "change"].

lasuite.details.changed_fields always carries the names of the fields a form changed, exactly the ones Django reports in its own history. lasuite.details.changes carries their values, as {"from": ..., "to": ...}, and only for the fields a model explicitly allows in the admin_values it is registered with. Anything that looks like a secret is refused there whatever the allow-list says, so a password change is reported as a change to password and never with its value. A JSONField on the allow-list, such as a room's configuration, is recorded as JSON rather than stringified, and both versions are kept whole.

Objects edited through an inline emit their own event, joined to the parent's by trace.id: granting a role on a room produces both admin.room.update and admin.resourceaccess.create.

What is deliberately not covered:

  • Reads. Opening a change list, a change form or the history page emits nothing. Django's own LogEntry remains the record of who touched what.
  • A custom action bypassing the ORM hooks. An action calling queryset.update() or queryset.delete() directly is reported as admin.<target>.action with its name and the number of objects, not one event per object. delete_selected is the exception: Django reports its objects through log_deletions, so it emits one admin.<target>.delete each and no action event.

The wiring lives in core/audit/admin.py: AuditedAdminSite mixes the auditing into every admin class at registration, including those declared by Django itself, and is installed through core.audit.apps.AuditedAdminConfig in INSTALLED_APPS. A new ModelAdmin is therefore covered without doing anything; registering its model (see below) only adds its category and its allowed values.

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:

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:

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, from a view or deeper, as a service. request defaults to the request being served, kept in a context variable by AuditLogMiddleware. Outside a request, as in a Celery task, there is none and the actor is the system. That request is the Django one: DRF copies its user and auth onto it, but not its authenticator, so a call made inside a DRF view without request= should pass auth_method=. A category or types given here wins over the action's:

    audit.log(auditing.USER_PROVISION, 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. 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=..., admin_values=..., category=...): the fields describing a model as a target, the admin_values whose before and after values may be recorded in the admin, and the category of its admin writes. 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.