Files
meet/docs/features/audit-logging.md
T
briquet dddbd34ee6 🔒️(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:
the target itself when it is a user, else the account its model
registers as user_target, as the user an access grants a role to. The
emitter derives it for every event, so user.provision names the user it
creates as well.
2026-10-13 23:08:27 +02:00

30 KiB

Audit logging

La Suite Meet keeps an audit log: a record of the notable things that happen in the product, such as a room created through the external API, a sign-in, or a change made in the Django admin. Every entry answers the same questions: who did what, to what, on whose behalf, from where, and did it work?

It is written for:

  • operators and security teams investigating an incident or watching for abuse;
  • data protection officers, who need to know who accessed or changed what;
  • developers adding a new audited action (see For developers).

Each event is a single line of JSON written to standard output, kept apart from the application logs so that your log collector can route it to its own index. Events follow the Elastic Common Schema (ECS) 9.5.0, which most SIEMs and Elasticsearch understand out of the box.

Personal data is kept to a minimum. A person is identified by their account id, their OIDC sub and the domain of their email address, never by the address itself. Secrets and the messages of unexpected exceptions are never recorded.

What an event looks like

Events are written on the dedicated audit logger, one per line. Pretty-printed, one looks like this:

{
  "@timestamp": "2026-09-15T08:41:12.345+00:00",
  "ecs": {"version": "9.5.0"},
  "data_stream": {"type": "logs", "dataset": "meet.audit", "namespace": "default"},
  "service": {"name": "meet", "environment": "production", "version": "1.34.0", "node": {"name": "meet-backend-6d8f7b9c4-x2x7q"}},
  "event": {
    "kind": "event",
    "id": "0b3f6a0e-5d1c-4f43-9a8e-2b7c1d0e4f5a",
    "action": "room.create",
    "category": ["api"],
    "type": ["creation"],
    "outcome": "success",
    "dataset": "meet.audit"
  },
  "client": {"ip": "1.2.3.4"},
  "source": {"ip": "1.2.3.4"},
  "http": {"request": {"id": "6f1c0d0e-2a8b-4c1d-9e7f-0a1b2c3d4e5f", "method": "POST"}, "response": {"status_code": 201}},
  "url": {"path": "/external-api/v1.0/rooms/"},
  "user_agent": {"original": "calendar-app/2.3"},
  "user": {"id": "beecd833-4be4-4675-b139-a196b07144a9", "domain": "gouv.fr"},
  "organization": {"id": "calendar-app"},
  "entity": {
    "target": {
      "id": "9ae54744-ae64-44f4-b094-71e137556b66",
      "sub_type": "room",
      "name": "Daily standup",
      "raw": {"slug": "daily-standup", "access_level": "trusted"}
    }
  },
  "lasuite": {
    "actor": {"type": "application", "sub": "0edebfa3-1355-4891-ae63-daf9fd37ac04"},
    "auth": {"method": "application_jwt"},
    "application": {"client_id": "calendar-app"},
    "outcome": "success"
  },
  "log": {"level": "info", "logger": "audit"}
}

Read it as: the application calendar-app, acting on behalf of a user from gouv.fr, created the room "Daily standup" from 1.2.3.4, and it worked.

When an action is refused, the event keeps the name of the action that was attempted, here still room.create. Only the fields describing the outcome change:

{
  "event": {"action": "room.create", "outcome": "failure", "type": ["creation", "denied"], "reason": "permission_denied"},
  "lasuite": {"outcome": "denied"},
  "http": {"response": {"status_code": 403}},
  "error": {"message": "..."},
  "log": {"level": "warning", "logger": "audit"}
}

Key concepts

These words are used throughout this page and in the code (core/audit/). The In the event column says where to find each one in the JSON above.

The record

Term Meaning In the event
Audit Event One immutable record that a notable action was attempted, by whom, on what, and with what outcome. Written as a single JSON line on the Audit Log. The whole document, identified by event.id
Audit Log The stream of Audit Events. It is distinct from the application logs: a logger and a dataset of its own. data_stream.*, event.dataset
Action The dotted, stable name of what was attempted, such as room.create or application.token.issue. It names the attempt, not its result: a refused room.create is still a room.create. Every Action belongs to the Catalogue. event.action
Catalogue The closed list of Actions the code may emit, with their meaning and fields: see Catalogue. Writes made through the Django admin are its one templated family, admin.<target>.<verb>. —
Details The fields specific to one Action, such as the scopes granted with a token or the fields a room update changed. The Catalogue lists them. lasuite.details.*

Who acted

Term Meaning In the event
Actor Who is behind an Action. It always has an Actor Type. When a person's account is involved, the event also carries the account id, its OIDC sub when it has one, and its email domain. lasuite.actor.*, user.*
Actor Type What kind of Actor it is: user, application, service, system or anonymous. See Actors. Device accounts are users like any other account. lasuite.actor.type
Auth Method How the Actor proved who it is for this request: session, oidc, password, application_jwt, client_credentials, addons_jwt, resource_server, livekit_token, shared_secret, none when it did not authenticate, or unknown. lasuite.auth.method
Application A registered third-party integration, identified by its client id, that calls the external API on behalf of users. lasuite.application.client_id
Delegation An Application acting for one specific user, through the delegated token it obtained with application.token.issue. The event then names both: the Application as the Actor, and the user in user.*. lasuite.actor.type = application, with user.* set
Provisional User An account an Application created from an email address before that person ever signed in. It has no OIDC sub yet, so its account id is what links its user.provision event to its later user.login. event.action = user.provision
Machine User The identity of an internal service calling the backend with a shared secret: the LiveKit SIP bridge (roomkit) and the summary service (summary). It is an Actor of type service, never an account. service.origin.name
Tenant The organisation an event belongs to: the calling Application when there is one, otherwise the Actor's email domain. organization.id

What it was done to

Term Meaning In the event
Target The resource the Action was attempted on: a room, a recording, an access grant, an application or a user. Described by its type, its id and a few stable fields. See Targets. entity.target.*
User Target The person the Action was about, when there is one: a provisioned user, the user an access grants a role to, or the account an admin edits. Not to be confused with user.*, the account whose authority was used. user.target.*

How it went

Term Meaning In the event
Outcome Whether the Action succeeded (success), failed (failure), was refused (denied), or ended in a way the backend could not interpret (unknown). ECS has no denied: in event.outcome, a refusal is a failure. lasuite.outcome, event.outcome
Reason Why an Action did not succeed, from a closed list: authentication_failed, permission_denied, rate_limited, validation_error, not_found, conflict, internal_error. event.reason
Refusal An Action the backend would not let happen, such as a 401, 403 or 429. It is recorded under the Action that was attempted, with Outcome denied, never as an Action of its own. lasuite.outcome = denied

Where and when

Term Meaning In the event
Source Where the request came from: the real client IP (never a proxy's), the HTTP method, the URL path and the user agent. Empty when no request is behind the event. client.ip, source.ip, http.request.method, url.path, user_agent.original
Request Id The id shared by the Audit Events, the application logs and the web server access log of one HTTP request, what other tools call a trace id. The backend generates it, unless the ingress is trusted to set it. http.request.id
Emitter The backend instance that wrote the event: its name, environment, release and host (the pod, on Kubernetes). Never the caller. service.name, service.environment, service.version, service.node.name
Peer Service Another service of the deployment taking part in the Action: the one that called the backend, or the one the backend called. service.origin.name, service.target.name

Where events go

Term Meaning In the event
Sink Where Audit Events are written: the audit logger, whose handler writes to AUDIT_LOG_STREAM, standard output by default. A log shipper takes them from there. log.logger = audit
Data Stream The data stream events are meant for, logs-meet.audit-default by default. It is what lets a shipper route audit events away from the application logs. data_stream.*

Answering common questions

These are the fields to filter on for the questions that come up most. They work the same whatever tool you search your logs with.

Who created, changed or deleted this room? Filter on entity.target.sub_type: room with the room's id in entity.target.id, or its slug in entity.target.raw.slug. event.action says what happened, and user.id or lasuite.application.client_id who did it. Rooms are audited when they are handled through the external API (room.*) or the Django admin (admin.room.*).

What has this application done? Filter on lasuite.application.client_id. To include the attempts made before its credentials were verified, such as a wrong secret or an inactive application, also search lasuite.details.claimed_client_id.

What did this person do, and what was done to their account? user.id is the account whose authority an action used, and user.target.id the account an action was about: search both. To follow someone across the other La Suite products, use their OIDC sub, in lasuite.actor.sub.

Which requests were refused, and why? Filter on lasuite.outcome: denied and group by event.reason. A burst of authentication_failed on user.login or application.token.issue from a single source.ip deserves a closer look.

What else happened during this request? Take its http.request.id. The same id is sent back in the X-Request-ID response header, appears on every audit event and application log line of the request, and in the Gunicorn access log as rid=. A single request may produce several events: an application obtaining a token for someone who never signed in produces both user.provision and application.token.issue.

Fields reference

Events follow the Elastic Common Schema 9.5.0. Only two namespaces hold what ECS does not define: lasuite.*, and entity.target.raw.* for the fields of a target.

Field Meaning
@timestamp When the event was emitted: ISO 8601, in UTC, with millisecond precision
data_stream.*, event.dataset logs, <AUDIT_LOG_SERVICE_NAME>.audit and AUDIT_LOG_DATA_STREAM_NAMESPACE: what sets the audit stream apart from the application logs
service.name, service.environment, service.version, service.node.name The Emitter: AUDIT_LOG_SERVICE_NAME, the current environment, the release and the host name (the pod, on Kubernetes). Never the caller
service.origin.name The internal service that called the backend, when the actor is a service: roomkit or summary
service.target.name The service the backend called, when one is named
event.id Unique id of the event, so that a shipper retrying it cannot create a duplicate
event.action What was attempted, from the Catalogue
event.category, event.type The ECS classification (api, authentication, iam... and creation, change, access, denied, user...). Always a combination ECS expects, see Classification
event.outcome ECS success or failure (refusals included), or unknown when the result was reported in terms the backend does not recognise
event.reason Why it did not succeed: authentication_failed, permission_denied, rate_limited, validation_error, not_found, conflict, internal_error
lasuite.outcome success, failure, denied or unknown: the same as event.outcome, with refusals kept apart
lasuite.actor.type user, application, service, system or anonymous, see Actors
lasuite.actor.sub The OIDC sub of the account in user.*, when it has one: the identity it shares with the other La Suite products
lasuite.auth.method session, application_jwt, addons_jwt, resource_server, livekit_token, shared_secret, client_credentials, oidc, password, none, or unknown for an authentication class that is not registered. Requests served outside DRF, such as the admin and logout, report session when signed in
lasuite.application.client_id The external application acting, if any. Only set once its credentials are verified
user.id, user.domain The account whose authority the action used (see Actors): its primary key and email domain. The email address itself is never recorded
user.roles That account's privileges when the event happened: superuser, staff
user.target.id, user.target.domain The account the action was about: the target when it is a user, or the user an access grants a role to. user.* stays the actor
organization.id The Tenant: the application's client id when there is one, otherwise the user's email domain
entity.target.* The resource acted on, see Targets
lasuite.details Fields specific to the action, see the Catalogue
client.ip, source.ip The real client address, the same one DRF's throttles identify (see NUM_PROXIES)
http.request.method, url.path The request as received
user_agent.original The client's User-Agent, cut at 1024 characters
http.request.id The Request Id. Also sent back as the X-Request-ID response header and logged by Gunicorn as rid=. Generated by the backend unless REQUEST_ID_TRUST_HEADER is set
http.response.status_code The status of the response, for API calls and refusals
error.message A human-readable reason for a failure
error.type The class of an unhandled exception. Its message is left out, as it may contain personal data
log.level info for a success, warning for failures, refusals and unknown outcomes, error for internal errors

Targets

The resource an action was performed on is described with ECS entity.target, the field set ECS 9.3 introduced for "the targeted entity of an action taken". ECS still flags the entity fields as beta.

Field Meaning
entity.target.id Its primary key
entity.target.sub_type Its model: room, recording, user, application, resourceaccess...
entity.target.type Its ECS entity type, when one fits: user for an account, application for an application
entity.target.name Its name, when its model registers one
entity.target.raw.* The other fields its model registers, such as a room's slug and access_level, or the OIDC sub of a user

Actors

lasuite.actor.type says who acted, and user.* whose authority the action used. They are not always the same: when an application acts for a user, the actor is the application, but user.* is that user.

lasuite.actor.type Who user.*
user A person's account acting for itself: through a session, an OIDC or password login, an add-on token, or the LiveKit token of a known account That account
application A client application acting on behalf of a user. Either 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 service of the deployment, named by service.origin.name: the LiveKit SIP bridge (roomkit) or 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

The backend picks the actor type by applying these rules in order:

  1. A client id is known, usually from the token payload: application.
  2. There is neither a request nor an actor: system.
  3. The caller authenticated without an account, as a Machine User: service.
  4. The caller is an account: user.
  5. Anything else: anonymous.

Classification

event.category and event.type are only ever combined in the ways the ECS 9.5.0 expected_event_types allow:

  • Each action declares its category, api by default, and its types. A type its category does not expect fails at import time, so a misclassified action can't ship.
  • A refusal adds the denied type only when the category expects it. An API call becomes ["creation", "denied"]. A failed login stays ["start"], and event.outcome, event.reason and lasuite.outcome show that it was refused.
  • A 401 is also filed under authentication, as in ["api", "authentication"], so that it counts as a failed authentication.

When an audited API action raises an exception DRF does not handle, the event is still recorded before the exception propagates: as a failure with reason internal_error, status code 500 and error.type.

Catalogue

Action names are dotted and lower-case, in the form <target>.<verb>. They name what was attempted: whether it worked is told by event.outcome, lasuite.outcome and event.reason, never by the name.

Applications and delegation

event.action Emitted when Notable fields
application.token.issue An application asks for a delegated token (POST /external-api/v1.0/application/token/), whether it gets one or is refused. It can be refused for bad credentials, an inactive application, an invalid or unauthorized email domain, an unknown user, or a provisioning conflict On success: user.* is the delegated user, entity.target the application, and lasuite.details holds scopes, user_provisioned and expires_in. On refusal: event.reason, http.response.status_code and lasuite.details.requested_domain. Until its credentials are verified, the client id the caller submitted is only recorded as lasuite.details.claimed_client_id: it never sets lasuite.application or organization
user.provision An application creates a provisional user from an email address, or fails to. It is a failure with reason conflict when a concurrent request created the user first user.target and entity.target are the user, the existing one on a conflict. A provisional user has no sub yet: its id links this event to the winning one and to its later user.login

Rooms

Rooms are audited on the external API only.

event.action Emitted when Notable fields
room.create A room is created through the external API, or the attempt fails entity.target is the room
room.update A room is updated through the external API, or the attempt fails entity.target is the room, refusals included. lasuite.details.updated_fields and previous_access_level
room.retrieve A room is read through the external API, or the attempt fails entity.target is the room
room.list Rooms are listed through the external API, or the attempt fails lasuite.details.total

Sign-in and the admin

event.action Emitted when Notable fields
user.login A user signs in, or a sign-in attempt fails: denied, with reason authentication_failed lasuite.auth.method is oidc, password or unknown. On success, it is named after the login backend, also given in lasuite.details.auth_backend. On failure, it is named after the credentials submitted: a password, or the nonce of the OIDC callback
user.logout A user signs out
admin.access A signed-in account without staff access reaches an admin page. Always denied, once per refused page event.category is web and event.type is access. event.reason, and http.response.status_code: the redirect to the login page
admin.<target>.<verb> A write is made through the Django admin, see Django admin

Django admin

The admin is the most sensitive part of the product, so every write made through it produces an audit event, alongside the LogEntry Django already writes. Nothing is replaced and there is no extra table: the admin history keeps working.

The action name follows a template, 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, and 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

This gives names such as admin.room.update, admin.user.delete or admin.recording.action. event.category is iam for anything that grants access to the product, and configuration otherwise. Writes on a user or a group put user or group first in event.type, as in ["user", "change"].

What changed

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

Inlines

Objects edited through an inline produce their own event, linked to the parent's by http.request.id. Granting a role on a room produces both admin.room.update and admin.resourceaccess.create, whose user.target is the account granted the role.

What is 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.
  • Custom actions that bypass the ORM hooks. An action calling queryset.update() or queryset.delete() directly is reported as a single 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 per object and no action event.

How it is wired

The wiring lives in core/audit/admin.py. AuditedAdminSite adds auditing to every admin class when it is registered, including those Django declares 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 Registering models) only adds its category and its allowed values.

For developers: emitting events

Declaring an action

Every action is declared once, in core/auditing.py, as an audit.Action constant. 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")

Auditing a DRF view

A DRF view only declares the actions it audits: everything else is derived from the response. CRUD actions are mapped in audit_actions. 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): ...

AuditViewMixin fills in the rest:

  • It runs from DRF's finalize_response hook, which every response goes through, successful or not.
  • The ECS category and types come from the action, or default to api and the DRF action.
  • The outcome, reason and status code come from the response status: 401, 403 and 429 are denied, other errors are 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 (None records no account) and audit_details, or override get_audit_fields().

Auditing from anywhere else

Outside of AuditViewMixin, from a view or deeper in a service, call audit.log. A category or types given here wins over the action's:

audit.log(auditing.USER_PROVISION, target=user)

Without request=, the event reads the context of the request being served, audit.request_context(), which AuditLogMiddleware captures when the request comes in. Outside a request, as in a Celery task, the context is empty and the actor is the system.

Note

That context holds the Django request. 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=.

How fields are filled

  • Request fields are read from the request: the real client address, the path and the user agent. http.request.id is the request id, settled by AuditLogMiddleware right after dockerflow assigns it, and echoed in the DOCKERFLOW_REQUEST_ID_HEADER_NAME response header (X-Request-ID by default). The inbound id is only kept when REQUEST_ID_TRUST_HEADER is set. Otherwise the backend generates one, so a client never picks it.
  • The actor is derived from request.user, request.auth and the DRF authenticator, as described in Actors. The authenticator's class is mapped to an auth method by audit.register_auth_method (see Configuration). Without a request, the actor is the system. You can override it with actor=, actor_type=, auth_method= and client_id=. An explicit actor_type= keeps the account the request is signed in as. actor=None records no account: for a caller failing to authenticate, an application acting before it is given a delegated user, or the system.
  • The target is described by its model name, its primary key and the fields its model is registered with, see Targets. A model that is not registered is still identified. The account the action is about, user.target, is found from the target, unless given as user_target=. Name a service the backend calls with target_service=. Any other keyword argument lands under lasuite.details.

Emission never raises

A broken configuration or value is reported on the application logger (and to Sentry), and the business operation goes on. 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, and the dataset <name>.audit, with any - turned into _
AUDIT_LOG_DATA_STREAM_NAMESPACE default data_stream.namespace: events are routed to the data stream logs-meet.audit-<namespace>. It must not contain -
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 the ingress-nginx defaults; use 2 behind a load balancer that also appends
REQUEST_ID_TRUST_HEADER False Reuse the inbound request id as http.request.id, so that 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): otherwise a client could pick the id of someone else's request
DOCKERFLOW_REQUEST_ID_HEADER_NAME X-Request-ID The header carrying that id. Read on the request only when REQUEST_ID_TRUST_HEADER is set, always echoed on the response

Registering models and authentication methods

The project describes itself to the audit facility in code, in core/auditing.py. Once the audit app is ready, it imports the auditing module of every installed app.

audit.register(Model, ...) tells the audit log how to handle a model:

  • fields: the fields describing it as a target;
  • admin_values: the fields whose before and after values may be recorded for admin writes;
  • category: the ECS category of its admin writes;
  • entity_type: its ECS entity type, when one of the allowed values fits (application, user...);
  • user_target: the attribute holding the account an event on it is about, such as user for an access.

A proxy model falls back to its concrete model. Registering a model twice raises AlreadyRegistered, and an entity type ECS does not allow raises ValueError.

audit.register_auth_method(klass, name) sets 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, because custom backends often subclass ModelBackend just for its permission checks. ModelBackend is built in as password.