mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-14 00:50:48 +00:00
🔒️(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.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# 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, an application obtaining a token, or a sign-in. Every entry answers the
|
||||
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:
|
||||
@@ -89,7 +89,7 @@ find each one in the JSON above.
|
||||
| **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](#catalogue). | — |
|
||||
| **Catalogue** | The closed list of Actions the code may emit, with their meaning and fields: see [Catalogue](#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
|
||||
@@ -110,6 +110,7 @@ find each one in the JSON above.
|
||||
| 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](#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
|
||||
|
||||
@@ -140,18 +141,18 @@ find each one in the JSON above.
|
||||
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 or changed this room?**
|
||||
**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.*`).
|
||||
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?**
|
||||
Filter on `user.id`, the account whose authority an action used. To follow someone across the other La Suite products,
|
||||
use their OIDC `sub`, in `lasuite.actor.sub`.
|
||||
**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
|
||||
@@ -187,6 +188,7 @@ what ECS does not define: `lasuite.*`, and `entity.target.raw.*` for the fields
|
||||
| `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](#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](#targets) |
|
||||
| `lasuite.details` | Fields specific to the action, see the [Catalogue](#catalogue) |
|
||||
@@ -257,7 +259,7 @@ is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the n
|
||||
| `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 | `entity.target` is 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` |
|
||||
| `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
|
||||
|
||||
@@ -270,12 +272,66 @@ Rooms are audited on the external API only.
|
||||
| `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
|
||||
### 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) | |
|
||||
|
||||
## 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](#registering-models-and-authentication-methods)).
|
||||
- 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](#registering-models-and-authentication-methods)) only adds its category and its allowed values.
|
||||
|
||||
## For developers: emitting events
|
||||
|
||||
@@ -352,7 +408,8 @@ and the actor is the system.
|
||||
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](#targets). A model that is not registered is still identified. Name a service the backend calls with
|
||||
[Targets](#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
|
||||
@@ -380,7 +437,10 @@ 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;
|
||||
- `entity_type`: its ECS entity type, when one of the allowed values fits (`application`, `user`...).
|
||||
- `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`.
|
||||
|
||||
Reference in New Issue
Block a user