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.
12 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": "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;
| 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 (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 <target>.<verb>, 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:
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
AuditViewMixinfrom DRF'sfinalize_responsehook, which runs for every response, successful or not. The ECS category and types come from the action, elseapiand the DRF action. The outcome, reason and status code come from the response status: 401, 403 and 429 aredenied, other errorsfailure, and a 401 is always filed underauthentication. The target is the objectget_object()returned, unless the view assignsaudit_target. A view can also assignaudit_actorandaudit_details, or overrideget_audit_fields(). -
Anything else calls
audit.logwith the request at hand. Acategoryortypesgiven here wins over the action's: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 byAuditLogMiddlewareright after dockerflow assigned it, and echoed in theDOCKERFLOW_REQUEST_ID_HEADER_NAMEresponse header (X-Request-IDby default). The inbound id is kept only whenREQUEST_ID_TRUST_HEADERis set; otherwise the backend generates one, so a client never picks it. -
Actors are derived from
request.user,request.authand the DRF authenticator, whose class is mapped to an auth method byaudit.register_auth_method(see Configuration), as described in Actors. Without a request, the actor is the system. It is possible to override the actor withactor=,actor_type=,auth_method=andclient_id=. -
Targets are described by their model name, primary key and the
fieldstheir model is registered with. A model that is not registered is still identified. Extra keyword arguments land underlasuite.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=...): thefieldsdescribing a model as a target. A proxy model falls back to its concrete model. Registering a model twice raisesAlreadyRegistered.audit.register_auth_method(klass, name): thelasuite.auth.methodof 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 subclassModelBackendfor its permission checks alone;ModelBackendis built in aspassword.