mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-07 14:00:56 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2eea61fa | |||
| adcc18513d | |||
| 1f8c4cf35b | |||
| 5151ac73b1 | |||
| da02bf13bb |
+6
-3
@@ -8,8 +8,6 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.34.0] - 2026-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) import environment variables from Secrets and ConfigMaps
|
||||
@@ -20,6 +18,10 @@ and this project adheres to
|
||||
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
|
||||
- ✅(frontend) add vitest so the frontend can carry unit tests
|
||||
- ♿️(frontend) make participant pagination readable and keyboard reachable #1775
|
||||
- ✨(backend) add structured audit logging facility
|
||||
- ✨(backend) audit external API token and room operations
|
||||
- 🔒️(backend) audit writes and bulk actions made in the Django admin
|
||||
- ✨(backend) audit recordings, transcripts and summaries
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -30,6 +32,7 @@ and this project adheres to
|
||||
|
||||
- 🐛(frontend) enforce recording-mode permissions on the checkboxes
|
||||
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
|
||||
- 🔒️(backend) identify throttled clients by IP using NUM_PROXIES
|
||||
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
|
||||
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
|
||||
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
|
||||
@@ -97,7 +100,7 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
|
||||
- 🐛(helm) probe liveness on **lbheartbeat** and readiness on **heartbeat**
|
||||
- 🐛(helm) render periodSeconds and failureThreshold on probes
|
||||
- 🐛(backend) report the app release to Sentry instead of "NA"
|
||||
- 🐛(frontend) play the waiting room notification sound on every arrival
|
||||
|
||||
@@ -169,7 +169,7 @@ run-summary: ## start only the summary application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||
.PHONY: run-summary
|
||||
|
||||
run-agents: ## start the LiveKit agents (opt-in, see docs/developping_locally.md)
|
||||
run-agents: ## start the multi-user-transcriber agent
|
||||
@$(MAKE) run-agent-multi-user-transcriber
|
||||
@$(MAKE) run-agent-metadata-collector
|
||||
.PHONY: run-agents
|
||||
@@ -186,6 +186,7 @@ run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(MAKE) run-summary
|
||||
@$(MAKE) run-agents
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
|
||||
+25
-76
@@ -16,54 +16,43 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Marketing / Brevo integration now uses `django-lasuite`
|
||||
### Purging inactive rooms
|
||||
|
||||
The in-house marketing service (`core.services.marketing`) has been removed and
|
||||
replaced by the shared implementation from `django-lasuite`
|
||||
(`lasuite.marketing`). This fixes a bug where updating a user's contact on
|
||||
Brevo overwrote their list memberships, removing lists set by other
|
||||
La Suite products. Existing lists are now preserved and merged.
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
**Celery worker required.** Newsletter signup on login
|
||||
(`SIGNUP_NEW_USER_TO_MARKETING_EMAIL=True`) is now dispatched as an
|
||||
asynchronous Celery task (`lasuite.marketing.tasks.create_or_update_contact`)
|
||||
instead of a synchronous call with a 1s timeout. Make sure a Celery worker is
|
||||
running alongside the backend, otherwise contacts will never be pushed to Brevo.
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
**Configuration changes.** The following environment variables / settings are
|
||||
**removed** and no longer read:
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
- `MARKETING_SERVICE_CLASS`
|
||||
- `BREVO_API_KEY`
|
||||
- `BREVO_API_CONTACT_LIST_IDS`
|
||||
- `BREVO_API_CONTACT_ATTRIBUTES` (previous default: `{"VISIO_USER": True}`)
|
||||
- `BREVO_API_TIMEOUT`
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
They are replaced by a single `LASUITE_MARKETING` setting, configured through:
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------ | ------------------------------------------------ | -------------------------------------------- |
|
||||
| `LASUITE_MARKETING_BACKEND` | `lasuite.marketing.backends.dummy.DummyBackend` | Backend class path |
|
||||
| `LASUITE_MARKETING_PARAMETERS` | `{}` | Keyword arguments passed to the backend |
|
||||
To migrate a local environment:
|
||||
|
||||
⚠️ The default backend is now a **dummy** (no-op). If you previously used
|
||||
Brevo, you must explicitly configure it, otherwise signups are silently dropped:
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
LASUITE_MARKETING_BACKEND=lasuite.marketing.backends.brevo.BrevoBackend
|
||||
LASUITE_MARKETING_PARAMETERS={"api_key": "<your-brevo-api-key>", "api_contact_list_ids": [1, 2], "api_contact_attributes": {"VISIO_USER": True}}
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
Migration mapping:
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
- `BREVO_API_KEY` → `api_key`
|
||||
- `BREVO_API_CONTACT_LIST_IDS` → `api_contact_list_ids`
|
||||
- `BREVO_API_CONTACT_ATTRIBUTES` → `api_contact_attributes` (re-add
|
||||
`{"VISIO_USER": True}` if you relied on the old default)
|
||||
- `BREVO_API_TIMEOUT` → no equivalent (the request runs in a background task)
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
Note: `BREVO_API_KEY` used to support being read from a secret file; the API key
|
||||
now lives inside `LASUITE_MARKETING_PARAMETERS`, so adapt how you inject that
|
||||
secret (e.g. build the whole variable from your secret store).
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
### Recording encoding settings replaced by a resolution/profile model
|
||||
|
||||
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||
@@ -186,46 +175,6 @@ Before enabling it:
|
||||
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||
|
||||
## v1.33.0
|
||||
|
||||
### Purging inactive rooms
|
||||
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
To migrate a local environment:
|
||||
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
|
||||
## v1.30.0
|
||||
|
||||
### Removing S3 storage-event webhooks for recordings
|
||||
|
||||
@@ -14,4 +14,7 @@ accesslog = "-"
|
||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||
access_log_format = (
|
||||
'%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||
" rid=%({x-request-id}o)s"
|
||||
)
|
||||
|
||||
@@ -107,37 +107,6 @@ $ npm i
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
### LiveKit agents (optional)
|
||||
|
||||
The LiveKit agents are not started by `make run`. Each one runs its own
|
||||
container and stays connected to LiveKit, which costs CPU and memory you
|
||||
don't need unless you work on the features they power. Start them only
|
||||
when you need them.
|
||||
|
||||
| Agent | Feature | Make command | Setting in `env.d/development/common` |
|
||||
|---|---|---|---|
|
||||
| `metadata-collector-dev` | Recording metadata (used to identify speakers in transcripts) | `make run-agent-metadata-collector` | `METADATA_COLLECTOR_ENABLED=True` |
|
||||
| `multi-user-transcriber-dev` | Live subtitles | `make run-agent-multi-user-transcriber` | `ROOM_SUBTITLE_ENABLED=True` |
|
||||
|
||||
To start both at once:
|
||||
```shellscript
|
||||
$ make run-agents
|
||||
```
|
||||
|
||||
Then set the matching settings to `True` and restart the backend so it
|
||||
picks them up:
|
||||
```shellscript
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
The multi-user transcriber also needs a speech-to-text provider. Configure
|
||||
`STT_PROVIDER` and its credentials in
|
||||
`env.d/development/multi_user_transcriber`.
|
||||
|
||||
Keep the settings and the agents in sync: if a setting is `True` while its
|
||||
agent is stopped, the backend still dispatches jobs to it and the feature
|
||||
fails silently.
|
||||
|
||||
---
|
||||
|
||||
## Adding Content
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# 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": "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](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`, `livekit` |
|
||||
| `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](#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 : the LiveKit SIP bridge (`roomkit`), the summary service (`summary`), the LiveKit server reporting on a recording (`livekit`) | 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` |
|
||||
| `recording.start` | A room owner or administrator starts a recording, or the attempt fails: conflict with a recording in progress, worker error | `lasuite.target` = recording, or the room when none was created, `lasuite.details.collect_metadata` |
|
||||
| `recording.stop` | A room owner or administrator stops the recording in progress, or the attempt fails | `lasuite.target` = recording, or the room when none is active |
|
||||
| `recording.end` | LiveKit reports a recording ended (`egress_ended` webhook): `success` when its media file is available, `failure` when it was aborted or failed | `lasuite.actor.name` = `livekit`, `lasuite.target` = recording, as it was before the report is processed, `lasuite.details.worker_event` (`completed`, `limit reached`, `aborted`, `failed`), `error_code` |
|
||||
| `recording.delete` | A recording is deleted, or the attempt fails | `lasuite.target` = recording |
|
||||
| `recording.transcript.request` | The backend sends a recording to the summary service to be transcribed, or fails to | `lasuite.actor.type` = `system`, `lasuite.target` = recording, `lasuite.details.summary_requested`: whether a summary is to be made of the transcript, `job_id` |
|
||||
| `recording.transcript.report` | The summary service reports on a transcript (`external-process-hook`), or a call to the hook is refused | `lasuite.actor.name` = `summary`, `lasuite.target` = recording, absent for an unknown job, `lasuite.details.job_id`, `status`. A reported `failure` is a `failure` |
|
||||
| `recording.summary.report` | The summary service reports on a summary | As `recording.transcript.report` |
|
||||
| `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 | |
|
||||
|
||||
### Recordings, transcripts and summaries
|
||||
|
||||
A recording's target carries what was recorded: `mode` is how the media was captured (`screen_recording`, a video;
|
||||
`transcript`, an audio track), `requested_mode` what the user asked for, and `is_transcribed` whether it is sent to
|
||||
the summary service. They differ for a transcript started with a screen capture: `mode` is `screen_recording`,
|
||||
`requested_mode` `transcript`. A transcript and a summary are not recordings but what the summary service derives
|
||||
from one, audited under `recording.transcript.*` and `recording.summary.*` with the recording as their target.
|
||||
|
||||
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:
|
||||
|
||||
```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`**, 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:
|
||||
|
||||
```python
|
||||
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](#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`.
|
||||
@@ -104,11 +104,10 @@ ROOM_TELEPHONY_ENABLED=True
|
||||
# ROOMKIT_ENABLED = True
|
||||
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
||||
|
||||
# LiveKit agents (opt-in, start them with `make run-agents`)
|
||||
# Metadata (requires the metadata-collector agent)
|
||||
METADATA_COLLECTOR_ENABLED=False
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
# Subtitle (requires the multi-user-transcriber agent)
|
||||
# Subtitle
|
||||
ROOM_SUBTITLE_ENABLED=False
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.7.0",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
@@ -40,7 +40,7 @@ from rest_framework import (
|
||||
)
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from core import analytics, enums, models, utils
|
||||
from core import analytics, audit, auditing, enums, models, utils
|
||||
from core.api import throttling
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
@@ -168,6 +168,7 @@ class UserViewSet(
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -312,6 +313,7 @@ class RoomViewSet(
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
audit_action=auditing.RECORDING_START,
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -349,6 +351,11 @@ class RoomViewSet(
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
self.audit_target = recording
|
||||
self.audit_details = {
|
||||
"collect_metadata": bool(recording.options.get("collect_metadata")),
|
||||
}
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
# IntegrityError covers the race where two concurrent requests both
|
||||
@@ -393,6 +400,7 @@ class RoomViewSet(
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
audit_action=auditing.RECORDING_STOP,
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -409,6 +417,8 @@ class RoomViewSet(
|
||||
"No active recording found for this room."
|
||||
) from e
|
||||
|
||||
self.audit_target = recording
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
|
||||
@@ -942,6 +952,7 @@ class ResourceAccessViewSet(
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
@@ -956,6 +967,23 @@ class RecordingViewSet(
|
||||
queryset = models.Recording.objects.all()
|
||||
serializer_class = serializers.RecordingSerializer
|
||||
|
||||
audit_actions = {"destroy": auditing.RECORDING_DELETE}
|
||||
# What the summary service reports on, once its event is validated
|
||||
audit_process_type = None
|
||||
|
||||
def get_audit_action(self):
|
||||
"""Audit a summary apart from the transcript it is made from."""
|
||||
if self.audit_process_type == "summary":
|
||||
return auditing.RECORDING_SUMMARY_REPORT
|
||||
return super().get_audit_action()
|
||||
|
||||
def get_audit_fields(self, status_code, error=None):
|
||||
"""Report a failure of the summary service as one, though it is acknowledged."""
|
||||
fields = super().get_audit_fields(status_code, error)
|
||||
if status_code < 400 and (self.audit_details or {}).get("status") == "failure":
|
||||
fields["outcome"] = audit.Outcome.FAILURE
|
||||
return fields
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict recordings to the user's ones."""
|
||||
user = self.request.user
|
||||
@@ -971,6 +999,7 @@ class RecordingViewSet(
|
||||
url_path="external-process-hook",
|
||||
authentication_classes=[RecordingProcessWebhookAuthentication],
|
||||
serializer_class=serializers.ExternalProcessEventSerializer,
|
||||
audit_action=auditing.RECORDING_TRANSCRIPT_REPORT,
|
||||
)
|
||||
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Handle incoming external process events for recordings."""
|
||||
@@ -984,12 +1013,16 @@ class RecordingViewSet(
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
job_id = validated_data["job_id"]
|
||||
self.audit_process_type = validated_data.get("type")
|
||||
self.audit_details = {"job_id": job_id, "status": validated_data.get("status")}
|
||||
try:
|
||||
recording = models.Recording.objects.get(external_process_id=job_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
logger.warning("No recording found for job_id %s: %s", job_id, e)
|
||||
return ok_response
|
||||
|
||||
self.audit_target = recording
|
||||
|
||||
if validated_data.get("type") == "transcript":
|
||||
if validated_data.get("status") == "success":
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Structured audit logging."""
|
||||
|
||||
from .actions import Action
|
||||
from .actor import email_domain
|
||||
from .drf import AuditViewMixin
|
||||
from .emitter import AUDIT_LOGGER_NAME, log
|
||||
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
|
||||
from .formatter import AuditJsonFormatter
|
||||
from .registry import AlreadyRegistered, register, register_auth_method
|
||||
from .request import current_request
|
||||
from .signals import LOGIN_ACTION, LOGOUT_ACTION, connect_auth_signals
|
||||
|
||||
__all__ = [
|
||||
"AUDIT_LOGGER_NAME",
|
||||
"LOGIN_ACTION",
|
||||
"LOGOUT_ACTION",
|
||||
"Action",
|
||||
"ActorType",
|
||||
"AlreadyRegistered",
|
||||
"AuditJsonFormatter",
|
||||
"AuditViewMixin",
|
||||
"EventCategory",
|
||||
"EventType",
|
||||
"Outcome",
|
||||
"Reason",
|
||||
"connect_auth_signals",
|
||||
"current_request",
|
||||
"email_domain",
|
||||
"log",
|
||||
"register",
|
||||
"register_auth_method",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Specs of the actions audit events are emitted for."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .enums import EventCategory, EventType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""An audited action: its dotted name and its ECS classification.
|
||||
|
||||
``category`` and ``types`` are the defaults of every event of the action:
|
||||
a ``category`` or ``types`` given to ``log`` wins over them.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: EventCategory | None = None
|
||||
types: tuple[EventType, ...] = ()
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate the classification, so a bad one fails at import."""
|
||||
if self.category is not None:
|
||||
object.__setattr__(self, "category", EventCategory(self.category))
|
||||
object.__setattr__(self, "types", tuple(EventType(t) for t in self.types))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Resolve who is acting: actor type, identifiers, auth method and tenant.
|
||||
|
||||
Personal data is kept to a minimum on purpose: a person is identified by its
|
||||
primary key, its OIDC ``sub`` when it has one and the domain of its email
|
||||
address. The address itself is never recorded.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from lasuite.tools.email import get_domain_from_email
|
||||
|
||||
from .enums import ActorType
|
||||
from .registry import auth_methods, dotted_path
|
||||
|
||||
AUTH_METHOD_NONE = "none"
|
||||
AUTH_METHOD_SESSION = "session"
|
||||
AUTH_METHOD_UNKNOWN = "unknown"
|
||||
|
||||
DEFAULT_AUTH_METHODS = {
|
||||
"rest_framework.authentication.SessionAuthentication": AUTH_METHOD_SESSION,
|
||||
"rest_framework.authentication.BasicAuthentication": "basic",
|
||||
"rest_framework.authentication.TokenAuthentication": "token",
|
||||
"django.contrib.auth.backends.ModelBackend": "password",
|
||||
}
|
||||
|
||||
|
||||
def _auth_methods() -> dict[str, str]:
|
||||
return {**DEFAULT_AUTH_METHODS, **auth_methods()}
|
||||
|
||||
|
||||
def auth_method_for(authenticator) -> str:
|
||||
"""Return the auth method name for a DRF authenticator instance."""
|
||||
if authenticator is None:
|
||||
return AUTH_METHOD_NONE
|
||||
methods = _auth_methods()
|
||||
for klass in type(authenticator).__mro__:
|
||||
name = methods.get(dotted_path(klass))
|
||||
if name:
|
||||
return name
|
||||
return AUTH_METHOD_UNKNOWN
|
||||
|
||||
|
||||
def auth_method_for_backend(backend: str | None) -> str:
|
||||
"""Return the auth method name for the dotted path of a login backend."""
|
||||
return _auth_methods().get(backend or "", AUTH_METHOD_UNKNOWN)
|
||||
|
||||
|
||||
def request_auth_method(request) -> str:
|
||||
"""Return how ``request`` was authenticated.
|
||||
|
||||
A DRF request names its authenticator. A plain Django request, as served
|
||||
by the admin or the logout view, can only be authenticated by its session.
|
||||
"""
|
||||
if hasattr(request, "successful_authenticator"):
|
||||
return auth_method_for(request.successful_authenticator)
|
||||
if _is_authenticated(getattr(request, "user", None)):
|
||||
return AUTH_METHOD_SESSION
|
||||
return AUTH_METHOD_NONE
|
||||
|
||||
|
||||
def email_domain(email) -> str | None:
|
||||
"""Return the lower-cased domain part of an email address, if any.
|
||||
|
||||
It is parsed as for ``Application.can_delegate_email``, so an audited
|
||||
domain is the one a delegation was checked against.
|
||||
"""
|
||||
domain = get_domain_from_email(str(email)) if email else None
|
||||
return domain.lower() if domain else None
|
||||
|
||||
|
||||
def client_id_from_auth(auth) -> str | None:
|
||||
"""Extract an application client id from a token payload."""
|
||||
if isinstance(auth, Mapping):
|
||||
value = auth.get("client_id")
|
||||
return str(value) if value else None
|
||||
return None
|
||||
|
||||
|
||||
def _is_authenticated(user) -> bool:
|
||||
return bool(user is not None and getattr(user, "is_authenticated", False))
|
||||
|
||||
|
||||
def _is_account(user) -> bool:
|
||||
"""Tell whether ``user`` is a user account.
|
||||
|
||||
It stays one once deleted, when Django clears its primary key.
|
||||
"""
|
||||
return isinstance(user, get_user_model())
|
||||
|
||||
|
||||
def _is_service(user) -> bool:
|
||||
"""Tell whether ``user`` authenticated without an account, as a machine user."""
|
||||
return _is_authenticated(user) and not _is_account(user)
|
||||
|
||||
|
||||
def _default_actor_type(request, user, client_id) -> ActorType:
|
||||
if client_id:
|
||||
return ActorType.APPLICATION
|
||||
if request is None and user is None:
|
||||
return ActorType.SYSTEM
|
||||
if _is_service(user):
|
||||
return ActorType.SERVICE
|
||||
if _is_account(user):
|
||||
return ActorType.USER
|
||||
return ActorType.ANONYMOUS
|
||||
|
||||
|
||||
def describe_user(user) -> dict[str, Any]:
|
||||
"""Return the fields identifying a person: id, OIDC sub and email domain.
|
||||
|
||||
The sub is missing for accounts that never signed in, such as provisional
|
||||
users, and the id for accounts that were deleted.
|
||||
"""
|
||||
return {
|
||||
"id": str(user.pk) if user.pk is not None else None,
|
||||
"sub": getattr(user, "sub", None) or None,
|
||||
"domain": email_domain(getattr(user, "email", None)),
|
||||
}
|
||||
|
||||
|
||||
def describe_actor(
|
||||
request,
|
||||
*,
|
||||
actor=None,
|
||||
actor_type: ActorType | str | None = None,
|
||||
client_id: str | None = None,
|
||||
auth_method: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the ECS ``user`` and ``organization`` fields and the ``lasuite`` ones.
|
||||
|
||||
Everything is read from ``request`` unless overridden. Without a request
|
||||
or an actor, the actor is the system. ``user`` is the account whose
|
||||
authority the action used, see ``ActorType``: None for a service, the
|
||||
system or an anonymous caller.
|
||||
"""
|
||||
user = actor if actor is not None else getattr(request, "user", None)
|
||||
client_id = client_id or client_id_from_auth(getattr(request, "auth", None))
|
||||
actor_type = actor_type or _default_actor_type(request, user, client_id)
|
||||
|
||||
lasuite: dict[str, Any] = {
|
||||
"actor": {
|
||||
"type": str(ActorType(actor_type)),
|
||||
"name": user.get_username() if _is_service(user) else None,
|
||||
},
|
||||
"auth": {"method": auth_method or request_auth_method(request)},
|
||||
"application": {"client_id": client_id},
|
||||
}
|
||||
is_account = _is_account(user)
|
||||
tenant = client_id or (
|
||||
email_domain(getattr(user, "email", None)) if is_account else None
|
||||
)
|
||||
return {
|
||||
"user": describe_user(user) if is_account else None,
|
||||
"organization": {"id": tenant},
|
||||
"lasuite": lasuite,
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Audit the writes performed through the Django admin.
|
||||
|
||||
Every ``ModelAdmin`` registered on :class:`AuditedAdminSite` emits an audit
|
||||
event when an object is created, changed or deleted, and when a bulk action
|
||||
runs. Django's own ``LogEntry`` keeps being written exactly as before: this
|
||||
stream is additive.
|
||||
|
||||
Actions are named ``admin.<target>.<verb>`` where ``<target>`` is the model
|
||||
name, for instance ``admin.room.update`` or ``admin.user.delete``.
|
||||
Unlike the rest of the catalogue this family is templated rather than
|
||||
enumerated: it follows whatever models are registered.
|
||||
|
||||
Only writes are audited. Browsing a change list or a change form emits
|
||||
nothing.
|
||||
|
||||
Which field values may be recorded, and the event category, are registered
|
||||
per model; see ``core.audit.registry``.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from enum import StrEnum
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.admin import ModelAdmin
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
|
||||
from .actions import Action
|
||||
from .emitter import log
|
||||
from .enums import EventCategory, EventType, Outcome, Reason
|
||||
from .registry import model_options
|
||||
from .utils import render_value
|
||||
|
||||
ADMIN_ACCESS_ACTION = Action("admin.access", category=EventCategory.IAM)
|
||||
DIFF_ATTRIBUTE = "audit_admin_diff"
|
||||
PENDING_DELETIONS_ATTRIBUTE = "audit_admin_pending_deletions"
|
||||
UNAUDITED_ACTIONS = frozenset({"delete_selected"})
|
||||
|
||||
SENSITIVE_FIELD_NAMES = frozenset(
|
||||
{"api_key", "client_secret", "pin_code", "secret", "sub", "token"}
|
||||
)
|
||||
SENSITIVE_FIELD_MARKERS = ("password", "secret", "token")
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminVerb(StrEnum):
|
||||
"""What was done to an object through the admin."""
|
||||
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
ACTION = "action"
|
||||
|
||||
|
||||
_VERB_TYPES: dict[AdminVerb, list[EventType]] = {
|
||||
AdminVerb.CREATE: [EventType.CREATION],
|
||||
AdminVerb.UPDATE: [EventType.CHANGE],
|
||||
AdminVerb.DELETE: [EventType.DELETION],
|
||||
AdminVerb.ACTION: [EventType.CHANGE],
|
||||
}
|
||||
|
||||
|
||||
def is_sensitive(field_name: str) -> bool:
|
||||
"""Tell whether the value of a field must never be recorded."""
|
||||
return field_name in SENSITIVE_FIELD_NAMES or any(
|
||||
marker in field_name for marker in SENSITIVE_FIELD_MARKERS
|
||||
)
|
||||
|
||||
|
||||
def value_fields_for(model: type) -> frozenset[str]:
|
||||
"""Return the fields of ``model`` whose before and after values may be recorded.
|
||||
|
||||
Anything that looks like a secret is dropped from the ``admin_values`` of
|
||||
the model here, so a mistake in the registration cannot leak one.
|
||||
"""
|
||||
names = model_options(model).admin_values
|
||||
return frozenset(name for name in names if not is_sensitive(name))
|
||||
|
||||
|
||||
def category_for(model: type) -> EventCategory:
|
||||
"""Return the registered category, else IAM for Django's auth models.
|
||||
|
||||
Anything granting access to the product is IAM, the rest configuration.
|
||||
"""
|
||||
if category := model_options(model).category:
|
||||
return category
|
||||
if model is get_user_model() or issubclass(model, (Group, Permission)):
|
||||
return EventCategory.IAM
|
||||
return EventCategory.CONFIGURATION
|
||||
|
||||
|
||||
def types_for(model: type, verb: AdminVerb) -> list[EventType]:
|
||||
"""Return the event types of ``verb`` on ``model``.
|
||||
|
||||
ECS expects ``user`` or ``group`` before the verb when one was the target.
|
||||
"""
|
||||
if issubclass(model, get_user_model()):
|
||||
return [EventType.USER, *_VERB_TYPES[verb]]
|
||||
if issubclass(model, Group):
|
||||
return [EventType.GROUP, *_VERB_TYPES[verb]]
|
||||
return _VERB_TYPES[verb]
|
||||
|
||||
|
||||
def action_name(model: type, verb: AdminVerb) -> str:
|
||||
"""Return the audit action for ``verb`` on ``model``."""
|
||||
return f"admin.{model._meta.model_name}.{verb}" # noqa: SLF001
|
||||
|
||||
|
||||
def form_diff(form, value_fields: frozenset[str]) -> dict[str, Any]:
|
||||
"""Return the names of the fields a form changed, and the allowed values.
|
||||
|
||||
Field names are always reported. Values are reported for allow-listed
|
||||
fields only, as ``{"from": ..., "to": ...}``.
|
||||
"""
|
||||
changed = sorted(form.changed_data)
|
||||
changes = {
|
||||
name: {
|
||||
"from": render_value(form.initial.get(name)),
|
||||
"to": render_value(form.cleaned_data.get(name)),
|
||||
}
|
||||
for name in changed
|
||||
if name in value_fields
|
||||
}
|
||||
return {"changed_fields": changed, "changes": changes}
|
||||
|
||||
|
||||
def related_diffs(formsets) -> list[tuple[Any, AdminVerb, dict[str, Any] | None]]:
|
||||
"""Return one ``(object, verb, diff)`` triple per inline object touched.
|
||||
|
||||
Called after ``save_related``, so the formsets already carry what they
|
||||
saved. The objects they list are the very instances their forms bound, so
|
||||
the matching form, and with it the before and after values, is found by
|
||||
identity.
|
||||
"""
|
||||
touched = []
|
||||
for formset in formsets or ():
|
||||
forms = {id(form.instance): form for form in formset.forms}
|
||||
value_fields = value_fields_for(formset.model)
|
||||
|
||||
def diff_of(obj, forms=forms, value_fields=value_fields):
|
||||
form = forms.get(id(obj))
|
||||
return form_diff(form, value_fields) if form is not None else None
|
||||
|
||||
for obj in getattr(formset, "new_objects", ()):
|
||||
touched.append((obj, AdminVerb.CREATE, diff_of(obj)))
|
||||
for obj, _fields in getattr(formset, "changed_objects", ()):
|
||||
touched.append((obj, AdminVerb.UPDATE, diff_of(obj)))
|
||||
for obj in getattr(formset, "deleted_objects", ()):
|
||||
# A deleted inline has no meaningful diff
|
||||
touched.append((obj, AdminVerb.DELETE, None))
|
||||
return touched
|
||||
|
||||
|
||||
class AuditedModelAdminMixin:
|
||||
"""Emit an audit event for every write made through this ModelAdmin."""
|
||||
|
||||
def construct_change_message(self, request, form, formsets, add=False):
|
||||
"""Stash the structured diff for the ``log_*`` hook that follows."""
|
||||
message = super().construct_change_message(request, form, formsets, add)
|
||||
try:
|
||||
diff = {
|
||||
"own": form_diff(form, value_fields_for(self.model)),
|
||||
"related": related_diffs(formsets),
|
||||
}
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception("Admin audit diff could not be built")
|
||||
diff = None
|
||||
setattr(request, DIFF_ATTRIBUTE, diff)
|
||||
return message
|
||||
|
||||
def log_addition(self, request, obj, message):
|
||||
"""Record the creation, and that of any inline object saved with it."""
|
||||
entry = super().log_addition(request, obj, message)
|
||||
self.audit_form_write(request, AdminVerb.CREATE, obj)
|
||||
return entry
|
||||
|
||||
def log_change(self, request, obj, message):
|
||||
"""Record the change, and that of any inline object saved with it."""
|
||||
entry = super().log_change(request, obj, message)
|
||||
self.audit_form_write(request, AdminVerb.UPDATE, obj)
|
||||
return entry
|
||||
|
||||
def log_deletions(self, request, queryset):
|
||||
"""Note the objects about to be deleted.
|
||||
|
||||
Django calls this before ``delete_model`` and ``delete_queryset``, in
|
||||
both the single and the bulk path. Those emit the events, once the
|
||||
deletion has succeeded or failed. Copies are kept because deleting an
|
||||
instance clears its primary key.
|
||||
"""
|
||||
targets = list(queryset)
|
||||
entries = super().log_deletions(request, targets)
|
||||
setattr(
|
||||
request, PENDING_DELETIONS_ATTRIBUTE, [copy.copy(obj) for obj in targets]
|
||||
)
|
||||
return entries
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
"""Delete the object, then record one deletion."""
|
||||
with self.auditing_deletions(request, lambda: [copy.copy(obj)]):
|
||||
super().delete_model(request, obj)
|
||||
|
||||
def delete_queryset(self, request, queryset):
|
||||
"""Delete the objects, then record one deletion per object."""
|
||||
with self.auditing_deletions(request, lambda: list(queryset)):
|
||||
super().delete_queryset(request, queryset)
|
||||
|
||||
@contextmanager
|
||||
def auditing_deletions(self, request, default_targets):
|
||||
"""Record the deletions noted by ``log_deletions`` with their outcome.
|
||||
|
||||
``default_targets`` lists the objects when ``log_deletions`` did not
|
||||
run, as when a custom action deletes through these methods directly.
|
||||
"""
|
||||
targets = getattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
|
||||
setattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
|
||||
if targets is None:
|
||||
targets = default_targets()
|
||||
try:
|
||||
yield
|
||||
except Exception as error:
|
||||
for obj in targets:
|
||||
self.audit_write(request, AdminVerb.DELETE, obj, error=error)
|
||||
raise
|
||||
for obj in targets:
|
||||
self.audit_write(request, AdminVerb.DELETE, obj)
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Return the available actions, each wrapped so that running it is audited."""
|
||||
return {
|
||||
name: (self.audited_action(func, name), name, description)
|
||||
for name, (func, _name, description) in super().get_actions(request).items()
|
||||
}
|
||||
|
||||
def audited_action(self, func, name):
|
||||
"""Wrap an admin action so every run emits an event, success or not."""
|
||||
if name in UNAUDITED_ACTIONS:
|
||||
return func
|
||||
|
||||
@wraps(func)
|
||||
def run(modeladmin, request, queryset):
|
||||
count = queryset.count()
|
||||
try:
|
||||
response = func(modeladmin, request, queryset)
|
||||
except Exception as error:
|
||||
modeladmin.audit_action(request, name, count, error=error)
|
||||
raise
|
||||
modeladmin.audit_action(request, name, count)
|
||||
return response
|
||||
|
||||
return run
|
||||
|
||||
def audit_form_write(self, request, verb, obj):
|
||||
"""Emit the event for a form write and for the inlines saved with it."""
|
||||
diff = getattr(request, DIFF_ATTRIBUTE, None) or {}
|
||||
setattr(request, DIFF_ATTRIBUTE, None)
|
||||
self.audit_write(request, verb, obj, diff.get("own"))
|
||||
for related_obj, related_verb, related_diff in diff.get("related", ()):
|
||||
self.audit_write(request, related_verb, related_obj, related_diff)
|
||||
|
||||
def audit_write(self, request, verb, obj, diff=None, *, error=None): # pylint: disable=too-many-arguments
|
||||
"""Emit one event for a write on ``obj``, a failed one if ``error`` is set."""
|
||||
model = obj.__class__
|
||||
log(
|
||||
action_name(model, verb),
|
||||
request=request,
|
||||
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
|
||||
reason=None if error is None else Reason.INTERNAL_ERROR,
|
||||
error=error,
|
||||
category=category_for(model),
|
||||
types=types_for(model, verb),
|
||||
target=obj,
|
||||
user_target=obj if isinstance(obj, get_user_model()) else None,
|
||||
**(diff or {}),
|
||||
)
|
||||
|
||||
def audit_action(self, request, name, count, error=None):
|
||||
"""Emit one event for a bulk action run on ``count`` objects."""
|
||||
log(
|
||||
action_name(self.model, AdminVerb.ACTION),
|
||||
request=request,
|
||||
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
|
||||
reason=None if error is None else Reason.INTERNAL_ERROR,
|
||||
category=category_for(self.model),
|
||||
types=types_for(self.model, AdminVerb.ACTION),
|
||||
error=error,
|
||||
admin_action=name,
|
||||
count=count,
|
||||
)
|
||||
|
||||
|
||||
def audited(admin_class: type) -> type:
|
||||
"""Return ``admin_class`` with the audit mixin."""
|
||||
if issubclass(admin_class, AuditedModelAdminMixin):
|
||||
return admin_class
|
||||
return type(
|
||||
f"Audited{admin_class.__name__}",
|
||||
(AuditedModelAdminMixin, admin_class),
|
||||
{"__module__": admin_class.__module__, "__doc__": admin_class.__doc__},
|
||||
)
|
||||
|
||||
|
||||
class AuditedAdminSite(AdminSite):
|
||||
"""Admin site whose model admins all emit audit events.
|
||||
|
||||
Installed through ``AdminConfig.default_site`` so that admin classes
|
||||
declared by Django itself, or by a third-party app, are covered as well as
|
||||
the project's own.
|
||||
"""
|
||||
|
||||
def register(self, model_or_iterable, admin_class=None, **options):
|
||||
"""Register the audited flavour of the given admin class."""
|
||||
super().register(
|
||||
model_or_iterable, audited(admin_class or ModelAdmin), **options
|
||||
)
|
||||
|
||||
def admin_view(self, view, cacheable=False):
|
||||
"""Record when a signed-in account without staff access tries an admin view.
|
||||
|
||||
Django asks ``has_permission`` several times per request, the login
|
||||
page included, so the refusal is recorded here instead: once per
|
||||
refused view. The answer is taken before the view runs, which may log
|
||||
the user out.
|
||||
"""
|
||||
guarded = super().admin_view(view, cacheable)
|
||||
|
||||
@wraps(guarded)
|
||||
def inner(request, *args, **kwargs):
|
||||
refused = getattr(
|
||||
request.user, "is_authenticated", False
|
||||
) and not self.has_permission(request)
|
||||
response = guarded(request, *args, **kwargs)
|
||||
if refused:
|
||||
log(
|
||||
ADMIN_ACCESS_ACTION,
|
||||
outcome=Outcome.DENIED,
|
||||
reason=Reason.PERMISSION_DENIED,
|
||||
request=request,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return response
|
||||
|
||||
return inner
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Application configurations of the audit facility."""
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.contrib.admin.apps import AdminConfig
|
||||
from django.utils.module_loading import autodiscover_modules
|
||||
|
||||
from .signals import connect_auth_signals
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
"""Audit Django's authentication signals and load the project's declarations."""
|
||||
|
||||
name = "core.audit"
|
||||
label = "audit"
|
||||
|
||||
def ready(self):
|
||||
"""Connect the login, failed login and logout receivers.
|
||||
|
||||
Then import the ``auditing`` module of every installed app, where the
|
||||
project registers its models and authentication classes.
|
||||
"""
|
||||
connect_auth_signals()
|
||||
autodiscover_modules("auditing")
|
||||
|
||||
|
||||
class AuditedAdminConfig(AdminConfig):
|
||||
"""Serve the admin from the site that audits every write."""
|
||||
|
||||
default_site = "core.audit.admin.AuditedAdminSite"
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Django REST framework integration
|
||||
|
||||
``AuditViewMixin`` turns every response of an audited action into one audit
|
||||
event, from DRF's ``finalize_response`` hook, which runs for successes and for
|
||||
handled errors alike. An exception DRF does not handle is audited as an
|
||||
internal error from ``handle_exception`` before it propagates.
|
||||
|
||||
The CRUD actions a viewset audits are mapped in ``audit_actions``.
|
||||
Extra action names require a decorator::
|
||||
|
||||
class RoomViewSet(audit.AuditViewMixin, viewsets.ModelViewSet):
|
||||
audit_actions = {"create": ROOM_CREATE, "retrieve": ROOM_RETRIEVE}
|
||||
|
||||
@action(detail=True, methods=["post"], audit_action=ROOM_INVITE)
|
||||
def invite(self, request, pk=None): ...
|
||||
|
||||
A refusal is recorded under the action that was attempted, with its outcome
|
||||
and reason derived from the response status.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from .actions import Action
|
||||
from .emitter import EVENT_FIELDS, log
|
||||
from .enums import EventCategory, EventType, Outcome, Reason
|
||||
from .utils import exception_type
|
||||
|
||||
ACTION_TYPES = {
|
||||
"create": EventType.CREATION,
|
||||
"update": EventType.CHANGE,
|
||||
"partial_update": EventType.CHANGE,
|
||||
"destroy": EventType.DELETION,
|
||||
"retrieve": EventType.ACCESS,
|
||||
"list": EventType.ACCESS,
|
||||
}
|
||||
STATUS_REASONS = {
|
||||
400: Reason.VALIDATION_ERROR,
|
||||
401: Reason.AUTHENTICATION_FAILED,
|
||||
403: Reason.PERMISSION_DENIED,
|
||||
404: Reason.NOT_FOUND,
|
||||
409: Reason.CONFLICT,
|
||||
429: Reason.RATE_LIMITED,
|
||||
}
|
||||
DENIED_STATUSES = frozenset({401, 403, 429})
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def error_message(response) -> Any:
|
||||
"""Return the message of an error response, as DRF or the view wrote it."""
|
||||
data = getattr(response, "data", None)
|
||||
if isinstance(data, Mapping):
|
||||
return data.get("detail") or data.get("error")
|
||||
return None
|
||||
|
||||
|
||||
class AuditViewMixin:
|
||||
"""Emit one audit event per response of an audited action.
|
||||
|
||||
``audit_actions`` maps the CRUD actions only. An extra action is audited
|
||||
by passing ``audit_action`` to its ``@action`` decorator.
|
||||
|
||||
While handling a request, a view may *assign* ``audit_target``,
|
||||
``audit_actor`` and ``audit_details``; ``check_object_permissions`` sets
|
||||
the target on its own, before a refusal can happen. A detail named after
|
||||
an event field, as ``outcome`` or ``request``, is dropped: overriding one
|
||||
is done in ``get_audit_fields``.
|
||||
"""
|
||||
|
||||
audit_actions: Mapping[str, Action | str] = {}
|
||||
# Only declared so the router may pass the ``@action`` keyword arguments
|
||||
# to ``as_view``; the action is read from the handler of the request.
|
||||
audit_action: Action | str | None = None
|
||||
audit_target: Any = None
|
||||
audit_actor: Any = None
|
||||
audit_details: Mapping[str, Any] | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
"""Refuse extra actions in ``audit_actions``, keyed by a method name."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if extra := sorted(set(cls.audit_actions) - set(ACTION_TYPES)):
|
||||
raise TypeError(
|
||||
f"{cls.__qualname__}.audit_actions only maps CRUD actions: "
|
||||
f"audit {', '.join(extra)} with @action(audit_action=...)"
|
||||
)
|
||||
|
||||
def check_object_permissions(self, request, obj):
|
||||
"""Remember the object as the target."""
|
||||
self.audit_target = obj
|
||||
super().check_object_permissions(request, obj)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Keep a copy of the target, since deleting an instance clears its pk."""
|
||||
self.audit_target = copy.copy(instance)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
"""Audit the response once DRF has built it."""
|
||||
response = super().finalize_response(request, response, *args, **kwargs)
|
||||
self.emit_audit_event(request, response.status_code, error_message(response))
|
||||
return response
|
||||
|
||||
def handle_exception(self, exc):
|
||||
"""Audit an exception DRF cannot turn into a response, then let it propagate.
|
||||
|
||||
Only its class is recorded since its message could carry personal data.
|
||||
"""
|
||||
try:
|
||||
return super().handle_exception(exc)
|
||||
except Exception as error:
|
||||
self.emit_audit_event(self.request, 500, error_type=exception_type(error))
|
||||
raise
|
||||
|
||||
def get_audit_action(self) -> Action | str | None:
|
||||
"""Return what the current request audits, if anything.
|
||||
|
||||
An extra action is read from its handler, so a request that reaches
|
||||
none, as an OPTIONS request or a refused method, audits nothing.
|
||||
"""
|
||||
name = getattr(self, "action", None)
|
||||
if name in ACTION_TYPES:
|
||||
return self.audit_actions.get(name)
|
||||
handler = getattr(self, name, None) if name else None
|
||||
return getattr(handler, "kwargs", {}).get("audit_action")
|
||||
|
||||
def emit_audit_event(self, request, status_code, error=None, error_type=None):
|
||||
"""Emit the event of the current action, if it is audited.
|
||||
|
||||
Never raises: a response must not fail because it could not be audited.
|
||||
"""
|
||||
try:
|
||||
action = self.get_audit_action()
|
||||
if action is not None:
|
||||
fields = self.get_audit_fields(status_code, error)
|
||||
log(action, request=request, error_type=error_type, **fields)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception("Audit event of %s could not be emitted", request.path)
|
||||
|
||||
def get_audit_fields(self, status_code, error=None) -> dict[str, Any]:
|
||||
"""Return the fields of the event for a response of ``status_code``.
|
||||
|
||||
The category and types of the ``Action`` win over those derived from
|
||||
the DRF action.
|
||||
"""
|
||||
action = self.get_audit_action()
|
||||
category, types = None, []
|
||||
if isinstance(action, Action):
|
||||
category, types = action.category, list(action.types)
|
||||
details = {
|
||||
key: value
|
||||
for key, value in (self.audit_details or {}).items()
|
||||
if key not in EVENT_FIELDS
|
||||
}
|
||||
fields = {
|
||||
**details,
|
||||
"category": category or EventCategory.API,
|
||||
"types": types
|
||||
or [ACTION_TYPES.get(getattr(self, "action", None), EventType.INFO)],
|
||||
"target": self.audit_target,
|
||||
"actor": self.audit_actor,
|
||||
}
|
||||
if status_code >= 400:
|
||||
fields |= {
|
||||
"outcome": (
|
||||
Outcome.DENIED
|
||||
if status_code in DENIED_STATUSES
|
||||
else Outcome.FAILURE
|
||||
),
|
||||
"reason": STATUS_REASONS.get(
|
||||
status_code, Reason.INTERNAL_ERROR if status_code >= 500 else None
|
||||
),
|
||||
"status_code": status_code,
|
||||
"error": error,
|
||||
}
|
||||
if status_code == 401:
|
||||
fields["category"] = EventCategory.AUTHENTICATION
|
||||
return fields
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Build ECS audit documents and emit them on the ``audit`` logger."""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .actions import Action
|
||||
from .actor import describe_actor, describe_user
|
||||
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
|
||||
from .request import current_request, current_request_id, resolve_client_ip
|
||||
from .targets import describe_target
|
||||
from .utils import prune_empty, render_value
|
||||
|
||||
AUDIT_LOGGER_NAME = "audit"
|
||||
ECS_VERSION = "9.5.0"
|
||||
LOG_TYPE = "audit"
|
||||
|
||||
_audit_logger = logging.getLogger(AUDIT_LOGGER_NAME)
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log(action: Action | str, **fields: Any) -> None:
|
||||
"""Emit one audit event."""
|
||||
try:
|
||||
document = build_document(action, **fields)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception("Audit event %r could not be built", action)
|
||||
return
|
||||
|
||||
_audit_logger.log(
|
||||
level_for(document["lasuite"]["outcome"], document["event"].get("reason")),
|
||||
str(action),
|
||||
extra={"audit": document},
|
||||
)
|
||||
|
||||
|
||||
def level_for(outcome: Outcome | str, reason: Reason | str | None) -> int:
|
||||
"""Derive the logging level so call sites never choose one."""
|
||||
if Outcome(outcome) == Outcome.SUCCESS:
|
||||
return logging.INFO
|
||||
if reason is not None and Reason(reason) == Reason.INTERNAL_ERROR:
|
||||
return logging.ERROR
|
||||
return logging.WARNING
|
||||
|
||||
|
||||
def build_document( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-locals
|
||||
action: Action | str,
|
||||
*,
|
||||
request: Any = None,
|
||||
outcome: Outcome | str = Outcome.SUCCESS,
|
||||
reason: Reason | str | None = None,
|
||||
category: EventCategory | str | None = None,
|
||||
types: list[EventType | str] | None = None,
|
||||
target: Any = None,
|
||||
user_target: Any = None,
|
||||
actor: Any = None,
|
||||
actor_type: ActorType | str | None = None,
|
||||
auth_method: str | None = None,
|
||||
client_id: str | None = None,
|
||||
status_code: int | None = None,
|
||||
error: Any = None,
|
||||
error_type: str | None = None,
|
||||
message: str | None = None,
|
||||
**details: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the ECS document of an event, pruned of empty values.
|
||||
|
||||
``action`` is what was attempted: an ``Action``, whose category and types
|
||||
apply unless given here, or a bare dotted name (``room.create``).
|
||||
The actor, auth method and network fields are read from ``request``, by
|
||||
default the request being served.
|
||||
``actor``, ``actor_type``, ``auth_method`` and ``client_id`` override them.
|
||||
``target`` is the resource acted on and ``user_target`` the account an IAM
|
||||
action was performed on, reported as ``user.target``. Any other keyword
|
||||
argument lands under ``lasuite.details``.
|
||||
"""
|
||||
if request is None:
|
||||
request = current_request()
|
||||
outcome = Outcome(outcome)
|
||||
reason = Reason(reason) if reason is not None else None
|
||||
if isinstance(action, Action):
|
||||
category = category or action.category
|
||||
types = types or list(action.types)
|
||||
client_ip = resolve_client_ip(request) if request is not None else None
|
||||
actor_fields = describe_actor(
|
||||
request,
|
||||
actor=actor,
|
||||
actor_type=actor_type,
|
||||
client_id=client_id,
|
||||
auth_method=auth_method,
|
||||
)
|
||||
|
||||
return prune_empty(
|
||||
{
|
||||
"@timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||
"ecs": {"version": ECS_VERSION},
|
||||
"log_type": LOG_TYPE,
|
||||
"message": message,
|
||||
"service": {
|
||||
"name": getattr(settings, "AUDIT_LOG_SERVICE_NAME", None),
|
||||
"environment": getattr(settings, "ENVIRONMENT", None),
|
||||
},
|
||||
"event": _event_fields(action, outcome, reason, category, types),
|
||||
"trace": {"id": current_request_id()},
|
||||
"client": {"ip": client_ip},
|
||||
"source": {"ip": client_ip},
|
||||
"http": {
|
||||
"request": {"method": getattr(request, "method", None)},
|
||||
"response": {"status_code": status_code},
|
||||
},
|
||||
"url": {"path": getattr(request, "path", None) or None},
|
||||
"user": {
|
||||
**(actor_fields["user"] or {}),
|
||||
"target": describe_user(user_target) if user_target else None,
|
||||
},
|
||||
"organization": actor_fields["organization"],
|
||||
"lasuite": {
|
||||
**actor_fields["lasuite"],
|
||||
"outcome": str(outcome),
|
||||
"target": describe_target(target) if target is not None else None,
|
||||
"details": render_value(details),
|
||||
},
|
||||
"error": {
|
||||
"message": str(error) if error is not None else None,
|
||||
"type": error_type,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# The keyword arguments of ``log`` that fill an event field rather than a detail
|
||||
EVENT_FIELDS = frozenset(
|
||||
name
|
||||
for name, parameter in inspect.signature(build_document).parameters.items()
|
||||
if parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
)
|
||||
|
||||
|
||||
def _event_fields(action, outcome, reason, category, types) -> dict[str, Any]:
|
||||
type_list = [str(EventType(item)) for item in (types or [])]
|
||||
if not type_list:
|
||||
type_list = [str(_default_type(outcome))]
|
||||
if outcome == Outcome.DENIED and str(EventType.DENIED) not in type_list:
|
||||
type_list.append(str(EventType.DENIED))
|
||||
|
||||
return {
|
||||
"kind": "event",
|
||||
"action": str(action),
|
||||
"category": [str(EventCategory(category or EventCategory.WEB))],
|
||||
"type": type_list,
|
||||
"outcome": "success" if outcome == Outcome.SUCCESS else "failure",
|
||||
"reason": str(reason) if reason is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def _default_type(outcome: Outcome) -> EventType:
|
||||
if outcome == Outcome.SUCCESS:
|
||||
return EventType.INFO
|
||||
if outcome == Outcome.DENIED:
|
||||
return EventType.DENIED
|
||||
return EventType.ERROR
|
||||
@@ -0,0 +1,74 @@
|
||||
"""ECS enums shared by every audit event."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class Outcome(StrEnum):
|
||||
"""Whether the audited action succeeded, failed, or was refused."""
|
||||
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
DENIED = "denied"
|
||||
|
||||
|
||||
class Reason(StrEnum):
|
||||
"""Why an action did not succeed."""
|
||||
|
||||
AUTHENTICATION_FAILED = "authentication_failed"
|
||||
PERMISSION_DENIED = "permission_denied"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
VALIDATION_ERROR = "validation_error"
|
||||
NOT_FOUND = "not_found"
|
||||
CONFLICT = "conflict"
|
||||
INTERNAL_ERROR = "internal_error"
|
||||
|
||||
|
||||
class ActorType(StrEnum):
|
||||
"""Kind of principal behind an action.
|
||||
|
||||
``user.*`` is the account whose authority the action used: the actor for
|
||||
``user``, the delegating user for ``application``, absent otherwise.
|
||||
"""
|
||||
|
||||
# A person's account acting for itself.
|
||||
USER = "user"
|
||||
# A client application acting on behalf of a user, named by its client id.
|
||||
APPLICATION = "application"
|
||||
# An internal peer of the deployment acting on its own behalf with a
|
||||
# shared secret, named by ``lasuite.actor.name``. Never an account.
|
||||
SERVICE = "service"
|
||||
# The backend itself, with no inbound request.
|
||||
SYSTEM = "system"
|
||||
# A caller that did not authenticate, or failed to.
|
||||
ANONYMOUS = "anonymous"
|
||||
|
||||
|
||||
class EventCategory(StrEnum):
|
||||
"""Subset of the ECS ``event.category`` ."""
|
||||
|
||||
API = "api"
|
||||
AUTHENTICATION = "authentication"
|
||||
CONFIGURATION = "configuration"
|
||||
EMAIL = "email"
|
||||
FILE = "file"
|
||||
IAM = "iam"
|
||||
SESSION = "session"
|
||||
WEB = "web"
|
||||
|
||||
|
||||
class EventType(StrEnum):
|
||||
"""Subset of the ECS ``event.type``."""
|
||||
|
||||
ACCESS = "access"
|
||||
ADMIN = "admin"
|
||||
ALLOWED = "allowed"
|
||||
CHANGE = "change"
|
||||
CREATION = "creation"
|
||||
DELETION = "deletion"
|
||||
DENIED = "denied"
|
||||
END = "end"
|
||||
ERROR = "error"
|
||||
GROUP = "group"
|
||||
INFO = "info"
|
||||
START = "start"
|
||||
USER = "user"
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Render audit records as single-line ECS JSON format."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AuditJsonFormatter(logging.Formatter):
|
||||
"""Serialise the document attached to the record under ``audit`` in ECS format."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
document = getattr(record, "audit", None)
|
||||
if not isinstance(document, dict):
|
||||
document = {
|
||||
"@timestamp": datetime.fromtimestamp(
|
||||
record.created, tz=timezone.utc
|
||||
).isoformat(timespec="milliseconds"),
|
||||
"log_type": "audit",
|
||||
"message": record.getMessage(),
|
||||
"event": {"action": record.getMessage()},
|
||||
}
|
||||
|
||||
document = {
|
||||
**document,
|
||||
"log": {"level": record.levelname.lower(), "logger": record.name},
|
||||
}
|
||||
if record.exc_info:
|
||||
error: dict[str, Any] = dict(document.get("error") or {})
|
||||
error["stack_trace"] = self.formatException(record.exc_info)
|
||||
document["error"] = error
|
||||
|
||||
return json.dumps(
|
||||
document, ensure_ascii=False, default=str, separators=(",", ":")
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Declare what audit events may say about models and authentication classes.
|
||||
|
||||
The project registers them from an ``auditing`` module in one of its apps,
|
||||
imported once the audit app is ready::
|
||||
|
||||
audit.register(
|
||||
Room,
|
||||
fields=("slug", "access_level"), # describe the target
|
||||
admin_values=("name", "access_level"), # values diffed in the admin
|
||||
category=audit.EventCategory.CONFIGURATION, # ECS category of admin writes
|
||||
)
|
||||
audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
|
||||
|
||||
A target is always identified by its model name and primary key, so a model
|
||||
that is not registered is still identifiable, just less detailed.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
from .enums import EventCategory
|
||||
|
||||
|
||||
class AlreadyRegistered(Exception):
|
||||
"""A model or an authentication class that was registered twice."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelOptions:
|
||||
"""What audit events may say about a model.
|
||||
|
||||
``fields`` describe the model when it is the target of an event.
|
||||
``admin_values`` are the fields whose before and after values may be
|
||||
recorded when they change in the Django admin. ``category`` is the ECS
|
||||
category of admin writes: ``iam`` for anything granting access to the
|
||||
product, ``configuration`` by default.
|
||||
"""
|
||||
|
||||
fields: tuple[str, ...] = ()
|
||||
admin_values: tuple[str, ...] = ()
|
||||
category: EventCategory | None = None
|
||||
|
||||
|
||||
_models: dict[type[Model], ModelOptions] = {}
|
||||
_auth_methods: dict[str, str] = {}
|
||||
|
||||
|
||||
def register(
|
||||
model: type[Model],
|
||||
*,
|
||||
fields=(),
|
||||
admin_values=(),
|
||||
category: EventCategory | str | None = None,
|
||||
) -> None:
|
||||
"""Declare what audit events may say about ``model``."""
|
||||
if model in _models:
|
||||
raise AlreadyRegistered(f"{model._meta.label} is already registered") # noqa: SLF001
|
||||
_models[model] = ModelOptions(
|
||||
fields=tuple(fields),
|
||||
admin_values=tuple(admin_values),
|
||||
category=EventCategory(category) if category is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def unregister(model: type[Model]) -> ModelOptions | None:
|
||||
"""Forget ``model`` and return what was registered for it, if anything."""
|
||||
return _models.pop(model, None)
|
||||
|
||||
|
||||
def model_options(model: type[Model]) -> ModelOptions:
|
||||
"""Return what is registered for a model, or for its concrete model."""
|
||||
for klass in (model, model._meta.concrete_model): # noqa: SLF001
|
||||
if (options := _models.get(klass)) is not None:
|
||||
return options
|
||||
return ModelOptions()
|
||||
|
||||
|
||||
def dotted_path(klass: type) -> str:
|
||||
"""Return the dotted path Django and DRF name a class by."""
|
||||
return f"{klass.__module__}.{klass.__qualname__}"
|
||||
|
||||
|
||||
def register_auth_method(klass: type, name: str) -> None:
|
||||
"""Name the ``lasuite.auth.method`` of a DRF authentication class or a login backend.
|
||||
|
||||
A DRF class is also the default of its subclasses. A login backend must be
|
||||
registered itself: custom backends often subclass ``ModelBackend`` only for
|
||||
its permission checks, and must not pass for password logins.
|
||||
"""
|
||||
path = dotted_path(klass)
|
||||
if path in _auth_methods:
|
||||
raise AlreadyRegistered(f"{path} is already registered")
|
||||
_auth_methods[path] = name
|
||||
|
||||
|
||||
def auth_methods() -> dict[str, str]:
|
||||
"""Return the registered auth methods, keyed by dotted path."""
|
||||
return dict(_auth_methods)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Read the network fields and the request id behind an audit event."""
|
||||
|
||||
import uuid
|
||||
from contextvars import ContextVar, Token
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from dockerflow.logging import request_id_context
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
_current_request: ContextVar = ContextVar("audit_request", default=None)
|
||||
|
||||
|
||||
def current_request():
|
||||
"""Return the request being served, if any, as set by ``AuditLogMiddleware``.
|
||||
|
||||
It is the Django request: DRF copies its user and auth onto it, but not its
|
||||
authenticator, so the auth method of a DRF view is not known from it.
|
||||
"""
|
||||
return _current_request.get()
|
||||
|
||||
|
||||
def set_current_request(request) -> Token:
|
||||
"""Make ``request`` the request being served, until the token is reset."""
|
||||
return _current_request.set(request)
|
||||
|
||||
|
||||
def reset_current_request(token: Token) -> None:
|
||||
"""Restore the request that was current before ``set_current_request``."""
|
||||
_current_request.reset(token)
|
||||
|
||||
|
||||
def current_request_id() -> str | None:
|
||||
"""Return the id of the request being served, if any."""
|
||||
return request_id_context.get(None)
|
||||
|
||||
|
||||
def resolve_client_ip(request) -> str | None:
|
||||
"""Return the address of the real client, never the one of a proxy.
|
||||
|
||||
It reuses DRF's throttles to identify the client.
|
||||
"""
|
||||
return BaseThrottle().get_ident(request) or request.META.get("REMOTE_ADDR")
|
||||
|
||||
|
||||
class AuditLogMiddleware:
|
||||
"""Settle the request id and the current request, then echo the id.
|
||||
|
||||
It must come right after ``DockerflowMiddleware``, which sets the id from
|
||||
the inbound ``DOCKERFLOW_REQUEST_ID_HEADER_NAME`` header. Unless
|
||||
``REQUEST_ID_TRUST_HEADER`` says the ingress overwrites that header, the id
|
||||
is replaced by a fresh one before anything logs, so that a client, the web
|
||||
server access log and the audit events of a request can be joined on an id
|
||||
the client did not choose.
|
||||
|
||||
The request is kept as the current one while it is served, so that an
|
||||
audit event emitted far from the view, as from a service, still reads its
|
||||
actor and network fields from it.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not settings.REQUEST_ID_TRUST_HEADER:
|
||||
request_id_context.set(str(uuid.uuid4()))
|
||||
|
||||
token = set_current_request(request)
|
||||
try:
|
||||
response = self.get_response(request)
|
||||
finally:
|
||||
reset_current_request(token)
|
||||
header = settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME
|
||||
if not response.has_header(header):
|
||||
response[header] = current_request_id()
|
||||
return response
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Audit Django's authentication signals: login, failed login, logout."""
|
||||
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY
|
||||
from django.contrib.auth.signals import (
|
||||
user_logged_in,
|
||||
user_logged_out,
|
||||
user_login_failed,
|
||||
)
|
||||
|
||||
from .actions import Action
|
||||
from .actor import AUTH_METHOD_UNKNOWN, auth_method_for_backend
|
||||
from .emitter import log
|
||||
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
|
||||
|
||||
LOGIN_ACTION = Action(
|
||||
"user.login", category=EventCategory.AUTHENTICATION, types=(EventType.START,)
|
||||
)
|
||||
LOGOUT_ACTION = Action(
|
||||
"user.logout", category=EventCategory.AUTHENTICATION, types=(EventType.END,)
|
||||
)
|
||||
|
||||
|
||||
def get_login_backend(request, user) -> str | None:
|
||||
"""Return the dotted path of the backend a login went through."""
|
||||
session = getattr(request, "session", None)
|
||||
from_session = session.get(BACKEND_SESSION_KEY) if session is not None else None
|
||||
return from_session or getattr(user, "backend", None)
|
||||
|
||||
|
||||
def auth_method_from_credentials(credentials) -> str:
|
||||
"""Name the mechanism of a failed login from the credentials it submitted."""
|
||||
if "password" in credentials:
|
||||
return "password"
|
||||
if "nonce" in credentials:
|
||||
return "oidc"
|
||||
return AUTH_METHOD_UNKNOWN
|
||||
|
||||
|
||||
def on_user_logged_in(sender, request, user, **kwargs): # pylint: disable=unused-argument
|
||||
"""Record a successful login."""
|
||||
backend = get_login_backend(request, user)
|
||||
log(
|
||||
LOGIN_ACTION,
|
||||
request=request,
|
||||
actor=user,
|
||||
auth_method=auth_method_for_backend(backend),
|
||||
auth_backend=backend,
|
||||
)
|
||||
|
||||
|
||||
def on_user_login_failed(sender, credentials, request, **kwargs): # pylint: disable=unused-argument
|
||||
"""Record a failed login.
|
||||
|
||||
It is a refusal, like a 401 on the API, whether the credentials were
|
||||
rejected or a backend raised ``PermissionDenied``. Its actor is anonymous
|
||||
even without a request, as when ``authenticate`` is called without one.
|
||||
"""
|
||||
log(
|
||||
LOGIN_ACTION,
|
||||
outcome=Outcome.DENIED,
|
||||
reason=Reason.AUTHENTICATION_FAILED,
|
||||
request=request,
|
||||
actor_type=ActorType.ANONYMOUS,
|
||||
auth_method=auth_method_from_credentials(credentials),
|
||||
)
|
||||
|
||||
|
||||
def on_user_logged_out(sender, request, user, **kwargs): # pylint: disable=unused-argument
|
||||
"""Record a logout, unless no one was signed in."""
|
||||
if user is None:
|
||||
return
|
||||
log(
|
||||
LOGOUT_ACTION,
|
||||
request=request,
|
||||
actor=user,
|
||||
)
|
||||
|
||||
|
||||
def connect_auth_signals() -> None:
|
||||
"""Connect the receivers to authentication signal."""
|
||||
user_logged_in.connect(on_user_logged_in, dispatch_uid="audit.user_logged_in")
|
||||
user_login_failed.connect(
|
||||
on_user_login_failed, dispatch_uid="audit.user_login_failed"
|
||||
)
|
||||
user_logged_out.connect(on_user_logged_out, dispatch_uid="audit.user_logged_out")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Describe the resource an audit event is about.
|
||||
|
||||
The fields describing each model are those registered for it, see
|
||||
``core.audit.registry``. A target is always identified by its model name and
|
||||
primary key, so a model that is not registered is still identifiable, just
|
||||
less detailed.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Model
|
||||
|
||||
from .actor import describe_user
|
||||
from .registry import model_options
|
||||
from .utils import render_value
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def describe_target(obj: Any) -> dict[str, Any]:
|
||||
"""Return ``{"type": ..., "id": ..., **fields}`` for a target.
|
||||
|
||||
A user will carries its OIDC sub and its email domain.
|
||||
A registered field that cannot be read is left out and simply reported.
|
||||
"""
|
||||
if isinstance(obj, Mapping):
|
||||
return dict(obj)
|
||||
if not isinstance(obj, Model):
|
||||
return {"type": obj.__class__.__name__.lower(), "id": str(obj)}
|
||||
|
||||
meta = obj._meta # noqa: SLF001
|
||||
document: dict[str, Any] = {
|
||||
"type": meta.model_name,
|
||||
"id": str(obj.pk) if obj.pk is not None else None,
|
||||
}
|
||||
for name in model_options(meta.model).fields:
|
||||
try:
|
||||
document[name] = render_value(getattr(obj, name))
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception(
|
||||
"Audit field %r of %s could not be read", name, meta.label
|
||||
)
|
||||
if isinstance(obj, get_user_model()):
|
||||
document |= describe_user(obj)
|
||||
return document
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Helpers for asserting on audit events in tests."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from . import registry
|
||||
from .actions import Action
|
||||
from .emitter import AUDIT_LOGGER_NAME
|
||||
|
||||
|
||||
class _CollectingHandler(logging.Handler):
|
||||
"""Keep the documents attached to the records it receives."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(level=logging.DEBUG)
|
||||
self.documents: list[dict[str, Any]] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
document = getattr(record, "audit", None)
|
||||
if not isinstance(document, dict):
|
||||
document = {"message": record.getMessage()}
|
||||
self.documents.append({**document, "log": {"level": record.levelname.lower()}})
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_audit() -> Iterator[list[dict[str, Any]]]:
|
||||
"""Collect the audit documents emitted inside the block"""
|
||||
logger = logging.getLogger(AUDIT_LOGGER_NAME)
|
||||
handler = _CollectingHandler()
|
||||
previous_level = logger.level
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
try:
|
||||
yield handler.documents
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
logger.setLevel(previous_level)
|
||||
|
||||
|
||||
def find_events(
|
||||
events: list[dict[str, Any]], action: Action | str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the captured events whose ``event.action`` is ``action``."""
|
||||
return [
|
||||
event for event in events if event.get("event", {}).get("action") == str(action)
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def override_registration(model, **options) -> Iterator[None]:
|
||||
"""Register ``model`` with ``options`` inside the block, whatever it was before."""
|
||||
previous = registry.unregister(model)
|
||||
registry.register(model, **options)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
registry.unregister(model)
|
||||
if previous is not None:
|
||||
registry.register(model, **asdict(previous))
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Value helpers used to assemble audit logs."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Model, QuerySet
|
||||
|
||||
|
||||
def render_value(value: Any) -> Any:
|
||||
"""Render a value as something stable and JSON-friendly.
|
||||
|
||||
Model instances are reduced to their primary key, enums to their value.
|
||||
"""
|
||||
if isinstance(value, Model):
|
||||
return str(value.pk)
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): render_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (QuerySet, list, tuple, set, frozenset)):
|
||||
return [render_value(item) for item in value]
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def exception_type(error: BaseException) -> str:
|
||||
"""Return the dotted name of an exception's class.
|
||||
|
||||
Audit events record it rather than the message, which may carry personal
|
||||
data.
|
||||
"""
|
||||
error_class = type(error)
|
||||
return f"{error_class.__module__}.{error_class.__qualname__}"
|
||||
|
||||
|
||||
def prune_empty(value: Any) -> Any:
|
||||
"""Drop ``None`` values and empty mappings, recursively."""
|
||||
if not isinstance(value, Mapping):
|
||||
return value
|
||||
pruned = {}
|
||||
for key, item in value.items():
|
||||
cleaned = prune_empty(item)
|
||||
if cleaned is None or (isinstance(cleaned, dict) and not cleaned):
|
||||
continue
|
||||
pruned[key] = cleaned
|
||||
return pruned
|
||||
@@ -0,0 +1,104 @@
|
||||
"""What Meet audits, and what its audit events may say.
|
||||
|
||||
Imported by the audit app once it is ready, see ``core.audit.apps``.
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
from core import audit, models
|
||||
from core.audit import EventCategory, EventType
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.livekit import LiveKitTokenAuthentication
|
||||
from core.external_api.authentication import (
|
||||
AddonsJWTAuthentication,
|
||||
ApplicationJWTAuthentication,
|
||||
)
|
||||
from core.recording.event.authentication import HeaderBasedAuthentication
|
||||
from core.roomkit.authentication import ServerToServerAuthentication
|
||||
|
||||
# Actions
|
||||
|
||||
APPLICATION_TOKEN_ISSUE = audit.Action(
|
||||
"application.token.issue",
|
||||
category=EventCategory.AUTHENTICATION,
|
||||
types=(EventType.START,),
|
||||
)
|
||||
USER_PROVISION = audit.Action(
|
||||
"user.provision",
|
||||
category=EventCategory.IAM,
|
||||
types=(EventType.USER, EventType.CREATION),
|
||||
)
|
||||
ROOM_CREATE = audit.Action("room.create")
|
||||
ROOM_LIST = audit.Action("room.list")
|
||||
ROOM_RETRIEVE = audit.Action("room.retrieve")
|
||||
ROOM_UPDATE = audit.Action("room.update")
|
||||
RECORDING_START = audit.Action("recording.start", types=(EventType.START,))
|
||||
RECORDING_STOP = audit.Action("recording.stop", types=(EventType.END,))
|
||||
RECORDING_END = audit.Action("recording.end", types=(EventType.END,))
|
||||
RECORDING_DELETE = audit.Action("recording.delete", types=(EventType.DELETION,))
|
||||
RECORDING_TRANSCRIPT_REQUEST = audit.Action(
|
||||
"recording.transcript.request", types=(EventType.START,)
|
||||
)
|
||||
RECORDING_TRANSCRIPT_REPORT = audit.Action(
|
||||
"recording.transcript.report", types=(EventType.END,)
|
||||
)
|
||||
RECORDING_SUMMARY_REPORT = audit.Action(
|
||||
"recording.summary.report", types=(EventType.END,)
|
||||
)
|
||||
|
||||
# Models
|
||||
|
||||
audit.register(
|
||||
models.User,
|
||||
category=EventCategory.IAM,
|
||||
admin_values=(
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
),
|
||||
)
|
||||
audit.register(Group, category=EventCategory.IAM, admin_values=("name", "permissions"))
|
||||
audit.register(
|
||||
models.Application,
|
||||
category=EventCategory.IAM,
|
||||
fields=("client_id", "name", "is_active", "scopes"),
|
||||
admin_values=("name", "is_active", "scopes"),
|
||||
)
|
||||
audit.register(
|
||||
models.ApplicationDomain, category=EventCategory.IAM, admin_values=("domain",)
|
||||
)
|
||||
audit.register(
|
||||
models.ResourceAccess,
|
||||
category=EventCategory.IAM,
|
||||
fields=("resource_id", "user_id", "role"),
|
||||
admin_values=("role",),
|
||||
)
|
||||
audit.register(
|
||||
models.RecordingAccess, category=EventCategory.IAM, admin_values=("role",)
|
||||
)
|
||||
audit.register(
|
||||
models.Room,
|
||||
fields=("slug", "name", "access_level"),
|
||||
admin_values=("name", "slug", "access_level", "configuration"),
|
||||
)
|
||||
audit.register(
|
||||
models.Recording,
|
||||
fields=("room_id", "status", "mode", "requested_mode", "is_transcribed"),
|
||||
admin_values=("status", "mode"),
|
||||
)
|
||||
audit.register(models.File, admin_values=("title", "upload_state"))
|
||||
|
||||
# Authentication classes and login backends -> ``lasuite.auth.method``
|
||||
|
||||
audit.register_auth_method(OIDCAuthenticationBackend, "oidc")
|
||||
audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
|
||||
audit.register_auth_method(AddonsJWTAuthentication, "addons_jwt")
|
||||
audit.register_auth_method(ResourceServerAuthentication, "resource_server")
|
||||
audit.register_auth_method(LiveKitTokenAuthentication, "livekit_token")
|
||||
audit.register_auth_method(HeaderBasedAuthentication, "shared_secret")
|
||||
audit.register_auth_method(ServerToServerAuthentication, "shared_secret")
|
||||
@@ -1,7 +1,6 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
import copy
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
@@ -23,7 +22,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import analytics, api, models
|
||||
from core import analytics, api, audit, auditing, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.room_management import RoomManagement
|
||||
@@ -35,18 +34,19 @@ from ..services.provisional_user_service import (
|
||||
)
|
||||
from . import authentication, permissions, serializers
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationViewSet(viewsets.ViewSet):
|
||||
class ApplicationViewSet(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
"""API endpoints for application authentication and token generation."""
|
||||
|
||||
audit_client_id = None
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
||||
audit_action=auditing.APPLICATION_TOKEN_ISSUE,
|
||||
)
|
||||
@FeatureFlag.require("application")
|
||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||
@@ -68,6 +68,10 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
|
||||
client_id = serializer.validated_data["client_id"]
|
||||
client_secret = serializer.validated_data["client_secret"]
|
||||
email = serializer.validated_data["scope"]
|
||||
|
||||
self.audit_client_id = client_id
|
||||
self.audit_details = {"requested_domain": audit.email_domain(email)}
|
||||
|
||||
try:
|
||||
application = models.Application.objects.get(client_id=client_id)
|
||||
@@ -80,7 +84,8 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
if not application.is_active:
|
||||
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
||||
|
||||
email = serializer.validated_data["scope"]
|
||||
self.audit_target = application
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
@@ -92,11 +97,6 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
)
|
||||
|
||||
if not application.can_delegate_email(email):
|
||||
logger.warning(
|
||||
"Application %s denied delegation for %s",
|
||||
application.client_id,
|
||||
email,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "This application is not authorized for this email domain.",
|
||||
@@ -105,7 +105,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user, _ = ProvisionalUserService().get_or_create(email, client_id)
|
||||
user, created = ProvisionalUserService().get_or_create(email, client_id)
|
||||
except ProvisionalUserCreationDisabledError as not_found_error:
|
||||
raise drf_exceptions.NotFound("User not found.") from not_found_error
|
||||
except ProvisionalUserIntegrityError:
|
||||
@@ -134,13 +134,42 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
},
|
||||
)
|
||||
|
||||
self.audit_actor = user
|
||||
self.audit_details = {
|
||||
"scopes": list(application.scopes or []),
|
||||
"user_provisioned": created,
|
||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
}
|
||||
|
||||
return drf_response.Response(
|
||||
data,
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def get_audit_fields(self, status_code, error=None):
|
||||
"""Report the application as the actor once its credentials are verified.
|
||||
|
||||
Until then the submitted client id is only a claim: it is kept apart so
|
||||
that it never names the application or the tenant of the event.
|
||||
"""
|
||||
application = self.audit_target
|
||||
fields = {
|
||||
**super().get_audit_fields(status_code, error),
|
||||
"auth_method": "client_credentials",
|
||||
"actor_type": audit.ActorType.ANONYMOUS,
|
||||
}
|
||||
if application:
|
||||
fields |= {
|
||||
"actor_type": audit.ActorType.APPLICATION,
|
||||
"client_id": application.client_id,
|
||||
}
|
||||
else:
|
||||
fields["claimed_client_id"] = self.audit_client_id
|
||||
return fields
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
@@ -163,6 +192,13 @@ class RoomViewSet(
|
||||
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
|
||||
audit_actions = {
|
||||
"list": auditing.ROOM_LIST,
|
||||
"retrieve": auditing.ROOM_RETRIEVE,
|
||||
"create": auditing.ROOM_CREATE,
|
||||
"partial_update": auditing.ROOM_UPDATE,
|
||||
}
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
@@ -191,29 +227,22 @@ class RoomViewSet(
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
self.audit_details = {"total": self.paginator.page.paginator.count}
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
self.audit_details = {"total": len(serializer.data)}
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def _track_room_event(self, room, event, **extra_properties):
|
||||
"""Log a room operation for auditing and forward it to analytics."""
|
||||
"""Add a room operation to the audit event and forward it to analytics."""
|
||||
|
||||
self.audit_target = room
|
||||
self.audit_details = extra_properties
|
||||
|
||||
auth_method = type(self.request.successful_authenticator).__name__
|
||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
# Log for auditing
|
||||
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
|
||||
logger.info(
|
||||
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
|
||||
event.removeprefix("room_"),
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
details,
|
||||
)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
event,
|
||||
|
||||
@@ -702,6 +702,22 @@ class Recording(BaseModel):
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status in RecordingStatusChoices.saved_statuses()
|
||||
|
||||
@property
|
||||
def requested_mode(self) -> str:
|
||||
"""Return the mode the user asked for.
|
||||
|
||||
A transcript started with a screen capture is stored as a screen
|
||||
recording, which keeps the transcript as its original mode.
|
||||
"""
|
||||
return (self.options or {}).get("original_mode") or self.mode
|
||||
|
||||
@property
|
||||
def is_transcribed(self) -> bool:
|
||||
"""Tell whether the recording is sent to the summary service."""
|
||||
return self.mode == RecordingModeChoices.TRANSCRIPT or bool(
|
||||
(self.options or {}).get("transcribe", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
"""Get recording extension based on its mode."""
|
||||
|
||||
@@ -40,6 +40,8 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
REALM = ""
|
||||
# Names the service in the audit log
|
||||
MACHINE_USER_NAME = "machine_user"
|
||||
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||
|
||||
@@ -74,7 +76,7 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
||||
)
|
||||
raise AuthenticationFailed("Invalid token")
|
||||
|
||||
return MachineUser(), token
|
||||
return MachineUser(self.MACHINE_USER_NAME), token
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
@@ -88,4 +90,5 @@ class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||
"""
|
||||
|
||||
REALM = "External process webhook API"
|
||||
MACHINE_USER_NAME = "summary"
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
|
||||
|
||||
@@ -17,7 +17,7 @@ import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core import audit, auditing, models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
@@ -52,7 +52,7 @@ class NotificationService:
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
summary_success = True
|
||||
if recording.options.get("transcribe", False):
|
||||
if recording.is_transcribed:
|
||||
summary_success = self._notify_summary_service(recording)
|
||||
|
||||
email_success = self._notify_user_by_email(recording)
|
||||
@@ -213,14 +213,30 @@ class NotificationService:
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
return NotificationService._notify_summary_service_v1(recording)
|
||||
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
return NotificationService._notify_summary_service_v2(recording)
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
succeeded = False
|
||||
audit_details = {}
|
||||
try:
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
succeeded = NotificationService._notify_summary_service_v1(recording)
|
||||
elif settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
succeeded = NotificationService._notify_summary_service_v2(
|
||||
recording, audit_details
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Unknown summary service version: "
|
||||
f"{settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
return succeeded
|
||||
finally:
|
||||
audit.log(
|
||||
auditing.RECORDING_TRANSCRIPT_REQUEST,
|
||||
actor_type=audit.ActorType.SYSTEM,
|
||||
target=recording,
|
||||
outcome=audit.Outcome.SUCCESS if succeeded else audit.Outcome.FAILURE,
|
||||
job_id=recording.external_process_id,
|
||||
**audit_details,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
@@ -298,8 +314,11 @@ class NotificationService:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v2(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
def _notify_summary_service_v2(recording: models.Recording, audit_details=None):
|
||||
"""Notify summary service about a new recording.
|
||||
|
||||
Whether a summary is asked for is added to ``audit_details``.
|
||||
"""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
@@ -349,6 +368,12 @@ class NotificationService:
|
||||
"ended_at": ended_at.isoformat(),
|
||||
}
|
||||
|
||||
summary_requested = is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
if audit_details is not None:
|
||||
audit_details["summary_requested"] = summary_requested
|
||||
|
||||
payload = {
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
@@ -372,9 +397,7 @@ class NotificationService:
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
"auto_create_summary": is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
),
|
||||
"auto_create_summary": summary_requested,
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class RecordingEventsService:
|
||||
@staticmethod
|
||||
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Notify the room's participants that a recording ended on the given event."""
|
||||
recording_mode = recording.options.get("original_mode", None) or recording.mode
|
||||
recording_mode = recording.requested_mode
|
||||
|
||||
notification_type = get_notification_type(recording_mode, event)
|
||||
if not notification_type:
|
||||
|
||||
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
|
||||
finally:
|
||||
recording.save()
|
||||
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
mode = recording.requested_mode
|
||||
|
||||
try:
|
||||
RoomManagement.update_metadata(
|
||||
|
||||
@@ -12,8 +12,9 @@ from django.utils import timezone
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core import audit, auditing, models
|
||||
from core.recording.enums import UNSUCCESSFUL_EVENTS, RecordingWorkerEvent
|
||||
from core.recording.event.authentication import MachineUser
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
@@ -219,6 +220,7 @@ class LiveKitEventsService:
|
||||
) from err
|
||||
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
self._audit_recording_end(recording, event, data.egress_info.error_code)
|
||||
|
||||
# Log if/why the recording failed
|
||||
self.recording_events.log_worker_error(
|
||||
@@ -254,6 +256,23 @@ class LiveKitEventsService:
|
||||
|
||||
self.recording_events.handle_terminal_event(recording, event)
|
||||
|
||||
@staticmethod
|
||||
def _audit_recording_end(recording, event, error_code=None):
|
||||
"""Audit the end of a recording, as LiveKit reports it."""
|
||||
audit.log(
|
||||
auditing.RECORDING_END,
|
||||
actor=MachineUser("livekit"),
|
||||
auth_method="shared_secret",
|
||||
target=recording,
|
||||
outcome=(
|
||||
audit.Outcome.FAILURE
|
||||
if event in UNSUCCESSFUL_EVENTS
|
||||
else audit.Outcome.SUCCESS
|
||||
),
|
||||
worker_event=event.value if event is not None else None,
|
||||
error_code=error_code or None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"""Service for provisional user creation."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
from core import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from core import audit, auditing, models
|
||||
|
||||
|
||||
class ProvisionalUserError(Exception):
|
||||
@@ -54,6 +50,20 @@ class ProvisionalUserService:
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
|
||||
def _audit(self, client_id: str, **fields) -> None:
|
||||
"""Emit a ``user.provision`` event on behalf of the application.
|
||||
|
||||
The user is identified by its id since it has no `sub` yet, therefore
|
||||
the user id is what correlate this event to the ones of its later sign-ins.
|
||||
"""
|
||||
audit.log(
|
||||
auditing.USER_PROVISION,
|
||||
actor_type=audit.ActorType.APPLICATION,
|
||||
auth_method="client_credentials",
|
||||
client_id=client_id,
|
||||
**fields,
|
||||
)
|
||||
|
||||
def get_or_create(
|
||||
self, email: str, client_id: str
|
||||
) -> tuple[models.User | None, bool]:
|
||||
@@ -86,21 +96,16 @@ class ProvisionalUserService:
|
||||
user = models.User(sub=None, email=email)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
logger.info(
|
||||
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
|
||||
user.id,
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
return user, True
|
||||
except (IntegrityError, ValidationError) as e:
|
||||
logger.warning(
|
||||
"Race condition on provisional user creation, fetching existing: "
|
||||
"email=%s, client_id=%s",
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
# Race condition on provisional user creation
|
||||
user = self._get_by_email(email)
|
||||
self._audit(
|
||||
client_id,
|
||||
outcome=audit.Outcome.FAILURE,
|
||||
reason=audit.Reason.CONFLICT,
|
||||
target=user,
|
||||
error_type=type(e).__name__,
|
||||
)
|
||||
|
||||
if user:
|
||||
return user, False
|
||||
@@ -108,3 +113,6 @@ class ProvisionalUserService:
|
||||
raise ProvisionalUserIntegrityError(
|
||||
"Failed to create or retrieve provisional user."
|
||||
) from e
|
||||
|
||||
self._audit(client_id, target=user)
|
||||
return user, True
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the audit logging facility."""
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Tests for the audit of writes made through the Django admin."""
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core import models
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import (
|
||||
FileFactory,
|
||||
RecordingFactory,
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# Admin pages render static files: serve them without a manifest.
|
||||
plain_storages = override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="staff_client")
|
||||
def staff_client_fixture(client):
|
||||
"""A client signed in as a superuser, able to reach every admin page."""
|
||||
client.force_login(UserFactory(is_staff=True, is_superuser=True))
|
||||
return client
|
||||
|
||||
|
||||
def room_payload(room=None, accesses=0, **overrides):
|
||||
"""Return what the room change form expects, inline management included."""
|
||||
payload = {
|
||||
"name": room.name if room else "Weekly sync",
|
||||
"slug": room.slug if room else "weekly-sync",
|
||||
"access_level": room.access_level if room else models.RoomAccessLevel.PUBLIC,
|
||||
"configuration": "{}",
|
||||
# ``configuration`` has a callable default, so the form renders a hidden
|
||||
# ``initial-`` input. Without it the field always looks changed.
|
||||
"initial-configuration": "{}",
|
||||
"pin_code": (room.pin_code if room else None) or "",
|
||||
"accesses-TOTAL_FORMS": str(accesses),
|
||||
"accesses-INITIAL_FORMS": "0",
|
||||
"accesses-MIN_NUM_FORMS": "0",
|
||||
"accesses-MAX_NUM_FORMS": "1000",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_room_creation_is_audited(audit_events, staff_client):
|
||||
"""Adding a room through the admin records a creation on the room."""
|
||||
response = staff_client.post("/admin/core/room/add/", room_payload())
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.room.create")
|
||||
|
||||
assert event["event"]["category"] == ["configuration"]
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["lasuite"]["target"]["type"] == "room"
|
||||
assert event["lasuite"]["target"]["slug"] == "weekly-sync"
|
||||
assert event["lasuite"]["details"]["changes"]["name"] == {"to": "Weekly sync"}
|
||||
|
||||
|
||||
def test_room_change_records_field_names_and_allowed_values(audit_events, staff_client):
|
||||
"""A change reports the raw field names, and the values of allowed fields."""
|
||||
room = RoomFactory(access_level=models.RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/room/{room.pk}/change/",
|
||||
room_payload(room, access_level=models.RoomAccessLevel.RESTRICTED),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.room.update")
|
||||
|
||||
assert event["event"]["type"] == ["change"]
|
||||
assert event["lasuite"]["details"]["changed_fields"] == ["access_level"]
|
||||
assert event["lasuite"]["details"]["changes"] == {
|
||||
"access_level": {"from": "public", "to": "restricted"}
|
||||
}
|
||||
|
||||
|
||||
def test_room_configuration_change_records_both_versions(audit_events, staff_client):
|
||||
"""The configuration is allow-listed, and kept as JSON rather than stringified."""
|
||||
room = RoomFactory(configuration={"a": 1})
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/room/{room.pk}/change/",
|
||||
room_payload(
|
||||
room,
|
||||
configuration='{"a": 2, "b": "new"}',
|
||||
**{"initial-configuration": '{"a": 1}'},
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.room.update")
|
||||
|
||||
assert event["lasuite"]["details"]["changed_fields"] == ["configuration"]
|
||||
assert event["lasuite"]["details"]["changes"]["configuration"] == {
|
||||
"from": {"a": 1},
|
||||
"to": {"a": 2, "b": "new"},
|
||||
}
|
||||
|
||||
|
||||
def test_room_deletion_is_audited(audit_events, staff_client):
|
||||
"""Deleting a room from its own page records a deletion."""
|
||||
room = RoomFactory()
|
||||
|
||||
response = staff_client.post(f"/admin/core/room/{room.pk}/delete/", {"post": "yes"})
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.room.delete")
|
||||
|
||||
assert event["event"]["type"] == ["deletion"]
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
|
||||
|
||||
def test_inline_access_grant_emits_its_own_iam_event(audit_events, staff_client):
|
||||
"""A role granted through the inline is an IAM event of its own."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/room/{room.pk}/change/",
|
||||
room_payload(
|
||||
room,
|
||||
accesses=1,
|
||||
**{
|
||||
"accesses-0-user": str(user.pk),
|
||||
"accesses-0-role": models.RoleChoices.OWNER,
|
||||
"accesses-0-id": "",
|
||||
"accesses-0-resource": str(room.pk),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[room_event] = find_events(audit_events, "admin.room.update")
|
||||
[access_event] = find_events(audit_events, "admin.resourceaccess.create")
|
||||
|
||||
assert access_event["event"]["category"] == ["iam"]
|
||||
assert access_event["event"]["type"] == ["creation"]
|
||||
assert access_event["lasuite"]["target"]["role"] == "owner"
|
||||
assert access_event["lasuite"]["details"]["changes"]["role"] == {"to": "owner"}
|
||||
# The grant and the room change belong to the same request.
|
||||
assert access_event["trace"]["id"] == room_event["trace"]["id"]
|
||||
|
||||
|
||||
def test_user_change_targets_the_user_and_never_leaks_the_password(
|
||||
audit_events, staff_client
|
||||
):
|
||||
"""Promoting a user is an IAM event naming the account, never its secret."""
|
||||
user = UserFactory(email="promoted@example.com", is_staff=False)
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/user/{user.pk}/password/",
|
||||
{
|
||||
"usable_password": "true",
|
||||
"password1": "sup3r-s3cret-value",
|
||||
"password2": "sup3r-s3cret-value",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.user.update")
|
||||
|
||||
assert event["event"]["category"] == ["iam"]
|
||||
assert event["event"]["type"] == ["user", "change"]
|
||||
assert event["user"]["target"] == {
|
||||
"id": str(user.pk),
|
||||
"sub": user.sub,
|
||||
"domain": "example.com",
|
||||
}
|
||||
assert event["lasuite"]["details"]["changed_fields"] == ["password"]
|
||||
assert "changes" not in event["lasuite"]["details"]
|
||||
assert "sup3r-s3cret-value" not in json.dumps(event)
|
||||
|
||||
|
||||
def test_user_permission_change_records_the_flag_values(audit_events, staff_client):
|
||||
"""Staff and superuser flags are allow-listed, so their values are kept."""
|
||||
user = UserFactory(is_staff=False, is_superuser=False)
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/user/{user.pk}/change/",
|
||||
{
|
||||
"admin_email": "",
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_active": "on",
|
||||
"is_staff": "on",
|
||||
"files_created-TOTAL_FORMS": "0",
|
||||
"files_created-INITIAL_FORMS": "0",
|
||||
"files_created-MIN_NUM_FORMS": "0",
|
||||
"files_created-MAX_NUM_FORMS": "1000",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.user.update")
|
||||
|
||||
assert event["lasuite"]["details"]["changes"]["is_staff"] == {
|
||||
"from": False,
|
||||
"to": True,
|
||||
}
|
||||
assert event["user"]["target"]["id"] == str(user.pk)
|
||||
|
||||
|
||||
def test_bulk_delete_audits_each_object_but_not_the_action(audit_events, staff_client):
|
||||
"""``delete_selected`` reports its objects, and nothing about itself."""
|
||||
recordings = RecordingFactory.create_batch(2)
|
||||
|
||||
response = staff_client.post(
|
||||
"/admin/core/recording/",
|
||||
{
|
||||
"action": "delete_selected",
|
||||
"_selected_action": [str(recording.pk) for recording in recordings],
|
||||
"post": "yes",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
events = find_events(audit_events, "admin.recording.delete")
|
||||
|
||||
assert {event["lasuite"]["target"]["id"] for event in events} == {
|
||||
str(recording.pk) for recording in recordings
|
||||
}
|
||||
assert find_events(audit_events, "admin.recording.action") == []
|
||||
|
||||
|
||||
def test_custom_action_is_audited(audit_events, staff_client):
|
||||
"""A custom admin action reports its name and how many objects it ran on."""
|
||||
recordings = RecordingFactory.create_batch(2)
|
||||
|
||||
response = staff_client.post(
|
||||
"/admin/core/recording/",
|
||||
{
|
||||
"action": "mark_as_failed_to_stop",
|
||||
"_selected_action": [str(recording.pk) for recording in recordings],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.recording.action")
|
||||
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["lasuite"]["details"] == {
|
||||
"admin_action": "mark_as_failed_to_stop",
|
||||
"count": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_hard_deleted_file_is_audited_once(audit_events, staff_client):
|
||||
"""``FileAdmin`` hard deletes without going through ``Model.delete``."""
|
||||
file = FileFactory()
|
||||
|
||||
response = staff_client.post(f"/admin/core/file/{file.pk}/delete/", {"post": "yes"})
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.file.delete")
|
||||
|
||||
assert event["lasuite"]["target"]["id"] == str(file.pk)
|
||||
|
||||
|
||||
def test_failed_deletion_is_audited_as_a_failure(audit_events, staff_client):
|
||||
"""A deletion that raises is recorded as failed, never as done."""
|
||||
file = FileFactory()
|
||||
|
||||
with (
|
||||
mock.patch("core.admin.hard_delete_file", side_effect=RuntimeError("S3 down")),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
staff_client.post(f"/admin/core/file/{file.pk}/delete/", {"post": "yes"})
|
||||
|
||||
[event] = find_events(audit_events, "admin.file.delete")
|
||||
|
||||
assert event["event"]["type"] == ["deletion"]
|
||||
assert event["event"]["reason"] == "internal_error"
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["target"]["id"] == str(file.pk)
|
||||
assert event["error"] == {"message": "S3 down"}
|
||||
assert event["log"]["level"] == "error"
|
||||
|
||||
|
||||
def test_failed_bulk_deletion_is_audited_as_a_failure(audit_events, staff_client):
|
||||
"""A bulk deletion that raises reports every selected object as failed."""
|
||||
files = FileFactory.create_batch(2)
|
||||
|
||||
with (
|
||||
mock.patch("core.admin.hard_delete_file", side_effect=RuntimeError("S3 down")),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
staff_client.post(
|
||||
"/admin/core/file/",
|
||||
{
|
||||
"action": "delete_selected",
|
||||
"_selected_action": [str(file.pk) for file in files],
|
||||
"post": "yes",
|
||||
},
|
||||
)
|
||||
|
||||
events = find_events(audit_events, "admin.file.delete")
|
||||
|
||||
assert {event["lasuite"]["target"]["id"] for event in events} == {
|
||||
str(file.pk) for file in files
|
||||
}
|
||||
assert {event["lasuite"]["outcome"] for event in events} == {"failure"}
|
||||
|
||||
|
||||
@plain_storages
|
||||
def test_reading_the_admin_emits_nothing(audit_events, staff_client):
|
||||
"""Browsing is not audited: only writes are."""
|
||||
room = RoomFactory()
|
||||
UserResourceAccessFactory(resource=room, user=UserFactory())
|
||||
|
||||
assert staff_client.get("/admin/").status_code == 200
|
||||
assert staff_client.get("/admin/core/room/").status_code == 200
|
||||
assert staff_client.get(f"/admin/core/room/{room.pk}/change/").status_code == 200
|
||||
assert staff_client.get(f"/admin/core/room/{room.pk}/history/").status_code == 200
|
||||
|
||||
assert [
|
||||
event["event"]["action"]
|
||||
for event in audit_events
|
||||
if event["event"]["action"].startswith("admin.")
|
||||
] == []
|
||||
|
||||
|
||||
def test_non_staff_user_reaching_the_admin_is_recorded(audit_events, client):
|
||||
"""A signed-in account without staff access trying the admin is a denial."""
|
||||
client.force_login(UserFactory(is_staff=False))
|
||||
|
||||
response = client.get("/admin/core/room/")
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.access")
|
||||
|
||||
assert event["event"]["category"] == ["iam"]
|
||||
assert event["event"]["reason"] == "permission_denied"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["auth"] == {"method": "session"}
|
||||
assert event["http"]["response"] == {"status_code": 302}
|
||||
assert event["log"]["level"] == "warning"
|
||||
|
||||
|
||||
@plain_storages
|
||||
def test_non_staff_user_is_recorded_once_per_refused_view(audit_events, client):
|
||||
"""Landing on the login page after the refusal records nothing more."""
|
||||
client.force_login(UserFactory(is_staff=False))
|
||||
|
||||
response = client.get("/admin/", follow=True)
|
||||
|
||||
assert response.redirect_chain[-1][0].startswith("/admin/login/")
|
||||
assert response.status_code == 200
|
||||
assert len(find_events(audit_events, "admin.access")) == 1
|
||||
|
||||
|
||||
def test_anonymous_visitor_is_not_recorded(audit_events, client):
|
||||
"""An anonymous hit is a redirect to the login page, not a denial worth keeping."""
|
||||
assert client.get("/admin/").status_code == 302
|
||||
|
||||
assert find_events(audit_events, "admin.access") == []
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Tests for the audit of DRF views through ``AuditViewMixin``."""
|
||||
|
||||
# pylint: disable=missing-function-docstring,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
|
||||
|
||||
import pytest
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
mixins,
|
||||
permissions,
|
||||
routers,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from core import audit, models
|
||||
from core.audit.testing import find_events
|
||||
from core.authentication.backends import SessionAuthenticationWith401
|
||||
from core.factories import RoomFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class ThingViewSet(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
"""A viewset auditing ``list`` and ``create`` but not ``destroy``."""
|
||||
|
||||
authentication_classes = [SessionAuthenticationWith401]
|
||||
permission_classes = []
|
||||
audit_actions = {"list": "thing.list", "create": "thing.create"}
|
||||
error = None
|
||||
|
||||
def list(self, request):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
self.audit_details = {"total": 3}
|
||||
return Response([])
|
||||
|
||||
def create(self, request):
|
||||
return Response({"error": "Already exists."}, status=409)
|
||||
|
||||
def destroy(self, request, pk=None):
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
audit.AuditViewMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
"""A viewset whose target comes from ``get_object``."""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
queryset = models.Room.objects.all()
|
||||
audit_actions = {"retrieve": "room.retrieve"}
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return type("Serializer", (), {"data": {}})()
|
||||
|
||||
|
||||
GRANT = audit.Action(
|
||||
"thing.grant",
|
||||
category=audit.EventCategory.IAM,
|
||||
types=(audit.EventType.CREATION,),
|
||||
)
|
||||
|
||||
|
||||
class GrantViewSet(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
"""A viewset whose extra action declares its audit on the route."""
|
||||
|
||||
authentication_classes = [SessionAuthenticationWith401]
|
||||
permission_classes = []
|
||||
error = None
|
||||
|
||||
@decorators.action(detail=False, methods=["post"], audit_action=GRANT)
|
||||
def grant(self, request):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return Response({})
|
||||
|
||||
@decorators.action(detail=False, methods=["post"])
|
||||
def ping(self, request):
|
||||
return Response({})
|
||||
|
||||
|
||||
class ListingViewSet(ThingViewSet):
|
||||
"""A ``ThingViewSet`` keeping the details it was built with."""
|
||||
|
||||
def list(self, request):
|
||||
return Response([])
|
||||
|
||||
|
||||
class DenyObjects(permissions.BasePermission):
|
||||
"""Refuse every object, whatever the request."""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return False
|
||||
|
||||
|
||||
def test_success_is_audited_with_the_view_details(audit_events):
|
||||
"""A successful action is recorded with its type and the view's details."""
|
||||
view = ThingViewSet.as_view({"get": "list"})
|
||||
|
||||
response = view(APIRequestFactory().get("/things/", REMOTE_ADDR="1.2.3.4"))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
|
||||
assert event["event"]["category"] == ["api"]
|
||||
assert event["event"]["type"] == ["access"]
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["lasuite"]["details"] == {"total": 3}
|
||||
assert event["client"] == {"ip": "1.2.3.4"}
|
||||
assert event["url"] == {"path": "/things/"}
|
||||
assert event["http"] == {"request": {"method": "GET"}}
|
||||
|
||||
|
||||
def test_missing_credentials_are_audited_as_authentication_denial(audit_events):
|
||||
"""A 401 is a denial in the authentication category."""
|
||||
view = ThingViewSet.as_view({"get": "list"}, error=exceptions.NotAuthenticated())
|
||||
|
||||
response = view(APIRequestFactory().get("/things/"))
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["access", "denied"]
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert event["http"]["response"] == {"status_code": 401}
|
||||
assert event["error"]["message"] == "Authentication credentials were not provided."
|
||||
assert event["log"]["level"] == "warning"
|
||||
assert "details" not in event["lasuite"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error,status_code,outcome,reason",
|
||||
[
|
||||
(
|
||||
exceptions.AuthenticationFailed("bad"),
|
||||
401,
|
||||
"denied",
|
||||
"authentication_failed",
|
||||
),
|
||||
(exceptions.PermissionDenied("scope"), 403, "denied", "permission_denied"),
|
||||
(DjangoPermissionDenied("nope"), 403, "denied", "permission_denied"),
|
||||
(exceptions.Throttled(wait=10), 429, "denied", "rate_limited"),
|
||||
(
|
||||
exceptions.ValidationError({"name": ["x"]}),
|
||||
400,
|
||||
"failure",
|
||||
"validation_error",
|
||||
),
|
||||
(exceptions.NotFound(), 404, "failure", "not_found"),
|
||||
(exceptions.MethodNotAllowed("PUT"), 405, "failure", None),
|
||||
],
|
||||
)
|
||||
def test_errors_are_audited_from_the_status_code(
|
||||
audit_events, error, status_code, outcome, reason
|
||||
):
|
||||
"""The outcome and the reason are derived from the response status."""
|
||||
view = ThingViewSet.as_view({"get": "list"}, error=error)
|
||||
|
||||
response = view(APIRequestFactory().get("/things/"))
|
||||
|
||||
assert response.status_code == status_code
|
||||
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["event"].get("reason") == reason
|
||||
assert event["http"]["response"] == {"status_code": status_code}
|
||||
|
||||
|
||||
def test_error_message_of_a_view_response(audit_events):
|
||||
"""An error response built by the view reports its ``error`` message."""
|
||||
view = ThingViewSet.as_view({"post": "create"})
|
||||
|
||||
response = view(APIRequestFactory().post("/things/"))
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
[event] = find_events(audit_events, "thing.create")
|
||||
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
assert event["event"]["reason"] == "conflict"
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["error"] == {"message": "Already exists."}
|
||||
|
||||
|
||||
def test_actions_missing_from_the_map_are_not_audited(audit_events):
|
||||
"""Only the actions listed in ``audit_actions`` emit events."""
|
||||
view = ThingViewSet.as_view({"delete": "destroy"})
|
||||
|
||||
response = view(APIRequestFactory().delete("/things/1/"), pk="1")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert audit_events == []
|
||||
|
||||
|
||||
def test_unhandled_exception_is_audited_as_internal_error(audit_events):
|
||||
"""An exception DRF does not handle is recorded, by class only, then raised."""
|
||||
view = ThingViewSet.as_view(
|
||||
{"get": "list"}, error=RuntimeError("user@example.com is broken")
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
view(APIRequestFactory().get("/things/"))
|
||||
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
|
||||
assert event["event"]["type"] == ["access"]
|
||||
assert event["event"]["reason"] == "internal_error"
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["http"]["response"] == {"status_code": 500}
|
||||
assert event["error"] == {"type": "builtins.RuntimeError"}
|
||||
assert event["log"]["level"] == "error"
|
||||
assert "user@example.com" not in str(event)
|
||||
|
||||
|
||||
def test_object_permission_denial_keeps_the_target(audit_events):
|
||||
"""A refusal on a detail route names the object that was refused."""
|
||||
room = RoomFactory()
|
||||
view = RoomViewSet.as_view({"get": "retrieve"}, permission_classes=[DenyObjects])
|
||||
|
||||
response = view(APIRequestFactory().get(f"/rooms/{room.pk}/"), pk=str(room.pk))
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
[event] = find_events(audit_events, "room.retrieve")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
|
||||
|
||||
def test_object_of_a_detail_route_is_the_target(audit_events):
|
||||
"""The object of a detail route becomes the target of the event."""
|
||||
room = RoomFactory()
|
||||
view = RoomViewSet.as_view({"get": "retrieve"})
|
||||
|
||||
response = view(APIRequestFactory().get(f"/rooms/{room.pk}/"), pk=str(room.pk))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "room.retrieve")
|
||||
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
assert event["lasuite"]["target"]["type"] == "room"
|
||||
|
||||
|
||||
def test_extra_actions_cannot_be_mapped_by_method_name():
|
||||
"""Renaming a method must not silently stop auditing it."""
|
||||
with pytest.raises(TypeError, match="grant"):
|
||||
|
||||
class MappedViewSet(audit.AuditViewMixin, viewsets.ViewSet): # pylint: disable=unused-variable
|
||||
"""Maps an extra action in ``audit_actions``."""
|
||||
|
||||
audit_actions = {"list": "thing.list", "grant": "thing.grant"}
|
||||
|
||||
|
||||
def test_extra_action_is_audited_from_its_route(audit_events):
|
||||
"""The ``audit_action`` of a routed ``@action`` names the event."""
|
||||
router = routers.SimpleRouter()
|
||||
router.register("things", GrantViewSet, basename="thing")
|
||||
[route] = [url for url in router.urls if url.name == "thing-grant"]
|
||||
|
||||
response = route.callback(APIRequestFactory().post("/things/grant/"))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, GRANT)
|
||||
|
||||
assert event["event"]["category"] == ["iam"]
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
|
||||
|
||||
def test_extra_action_is_audited_without_a_router(audit_events):
|
||||
"""A view built by hand reads ``audit_action`` from its handler."""
|
||||
view = GrantViewSet.as_view({"post": "grant"})
|
||||
|
||||
response = view(APIRequestFactory().post("/things/grant/"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(find_events(audit_events, GRANT)) == 1
|
||||
|
||||
|
||||
def test_extra_action_without_audit_action_is_not_audited(audit_events):
|
||||
"""An ``@action`` that does not name an audit action emits nothing."""
|
||||
view = GrantViewSet.as_view({"post": "ping"})
|
||||
|
||||
response = view(APIRequestFactory().post("/things/ping/"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert audit_events == []
|
||||
|
||||
|
||||
def test_unauthenticated_extra_action_is_an_authentication_denial(audit_events):
|
||||
"""A 401 still files the event under ``authentication``, whatever the spec."""
|
||||
view = GrantViewSet.as_view({"post": "grant"}, error=exceptions.NotAuthenticated())
|
||||
|
||||
response = view(APIRequestFactory().post("/things/grant/"))
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(audit_events, GRANT)
|
||||
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["creation", "denied"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["options", "get"])
|
||||
def test_requests_reaching_no_extra_action_are_not_audited(audit_events, method):
|
||||
"""An OPTIONS request, or a method the route refuses, audits nothing.
|
||||
|
||||
The router hands the route's ``audit_action`` to every view it builds, the
|
||||
ones answering those requests included.
|
||||
"""
|
||||
router = routers.SimpleRouter()
|
||||
router.register("things", GrantViewSet, basename="thing")
|
||||
[route] = [url for url in router.urls if url.name == "thing-grant"]
|
||||
|
||||
response = route.callback(getattr(APIRequestFactory(), method)("/things/grant/"))
|
||||
|
||||
assert response.status_code == (200 if method == "options" else 405)
|
||||
assert audit_events == []
|
||||
|
||||
|
||||
def test_details_never_override_event_fields(audit_events):
|
||||
"""A detail named after an event field is dropped, never raising."""
|
||||
view = ListingViewSet.as_view(
|
||||
{"get": "list"},
|
||||
audit_details={"request": None, "outcome": "failure", "total": 3},
|
||||
)
|
||||
|
||||
response = view(APIRequestFactory().get("/things/"))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["url"] == {"path": "/things/"}
|
||||
assert event["lasuite"]["details"] == {"total": 3}
|
||||
|
||||
|
||||
def test_failing_audit_never_fails_the_response(audit_events):
|
||||
"""An error assembling the event is logged, and the response is kept."""
|
||||
view = ThingViewSet.as_view({"get": "list"})
|
||||
|
||||
with mock.patch.object(
|
||||
ThingViewSet, "get_audit_fields", side_effect=RuntimeError("boom")
|
||||
):
|
||||
response = view(APIRequestFactory().get("/things/"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert audit_events == []
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Tests for building and emitting audit events."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
from dockerflow.logging import request_id_context
|
||||
|
||||
from core import audit
|
||||
from core.audit import request as audit_request
|
||||
from core.audit.formatter import AuditJsonFormatter
|
||||
from core.audit.testing import find_events, override_registration
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import Room
|
||||
from core.recording.event.authentication import MachineUser
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_audit_log_emits_ecs_document(audit_events):
|
||||
"""A minimal call produces a complete, pruned ECS document."""
|
||||
audit.log("room.create", target={"type": "room", "id": "1"}, extra="x")
|
||||
|
||||
assert len(audit_events) == 1
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["log_type"] == "audit"
|
||||
assert event["ecs"] == {"version": "9.5.0"}
|
||||
assert event["service"] == {"name": "meet", "environment": "test"}
|
||||
assert event["event"] == {
|
||||
"kind": "event",
|
||||
"action": "room.create",
|
||||
"category": ["web"],
|
||||
"type": ["info"],
|
||||
"outcome": "success",
|
||||
}
|
||||
assert event["lasuite"] == {
|
||||
"actor": {"type": "system"},
|
||||
"auth": {"method": "none"},
|
||||
"outcome": "success",
|
||||
"target": {"type": "room", "id": "1"},
|
||||
"details": {"extra": "x"},
|
||||
}
|
||||
assert event["log"]["level"] == "info"
|
||||
assert "user" not in event
|
||||
assert "organization" not in event
|
||||
|
||||
|
||||
def test_audit_log_timestamp_is_utc_with_explicit_offset(audit_events):
|
||||
"""Timestamps are ISO 8601, millisecond precision, UTC with offset."""
|
||||
audit.log("something")
|
||||
|
||||
timestamp = audit_events[0]["@timestamp"]
|
||||
parsed = datetime.fromisoformat(timestamp)
|
||||
|
||||
assert timestamp.endswith("+00:00")
|
||||
assert parsed.utcoffset().total_seconds() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outcome,reason,expected",
|
||||
[
|
||||
("success", None, ("info", "success", ["info"])),
|
||||
("failure", "validation_error", ("warning", "failure", ["error"])),
|
||||
("denied", "permission_denied", ("warning", "failure", ["denied"])),
|
||||
("failure", "internal_error", ("error", "failure", ["error"])),
|
||||
],
|
||||
)
|
||||
def test_audit_log_outcome_reason_and_level(audit_events, outcome, reason, expected):
|
||||
"""The level is derived from the outcome."""
|
||||
level, wire_outcome, types = expected
|
||||
|
||||
audit.log("something", outcome=outcome, reason=reason)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["event"]["outcome"] == wire_outcome
|
||||
assert event["event"].get("reason") == reason
|
||||
assert event["event"]["type"] == types
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["log"]["level"] == level
|
||||
|
||||
|
||||
def test_audit_log_denied_adds_denied_type_to_explicit_types(audit_events):
|
||||
"""A denial always carries the ``denied`` ECS type."""
|
||||
audit.log("something", outcome="denied", reason="rate_limited", types=["access"])
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["event"]["type"] == ["access", "denied"]
|
||||
|
||||
|
||||
def test_audit_log_accepts_categories_and_types(audit_events):
|
||||
"""Category and types are validated against the ECS subset."""
|
||||
audit.log(
|
||||
"user.login",
|
||||
category=audit.EventCategory.AUTHENTICATION,
|
||||
types=[audit.EventType.START],
|
||||
)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["start"]
|
||||
|
||||
|
||||
def test_audit_log_classifies_an_action_by_its_spec(audit_events):
|
||||
"""An ``Action`` brings its category and types, and names the event."""
|
||||
action = audit.Action(
|
||||
"thing.grant",
|
||||
category=audit.EventCategory.IAM,
|
||||
types=(audit.EventType.CREATION,),
|
||||
)
|
||||
|
||||
audit.log(action)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["event"]["action"] == "thing.grant"
|
||||
assert event["event"]["category"] == ["iam"]
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
|
||||
|
||||
def test_audit_log_arguments_win_over_the_spec(audit_events):
|
||||
"""A category or types given to ``log`` override those of the ``Action``."""
|
||||
action = audit.Action(
|
||||
"thing.grant",
|
||||
category=audit.EventCategory.IAM,
|
||||
types=(audit.EventType.CREATION,),
|
||||
)
|
||||
|
||||
audit.log(
|
||||
action,
|
||||
category=audit.EventCategory.CONFIGURATION,
|
||||
types=[audit.EventType.CHANGE],
|
||||
)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["event"]["category"] == ["configuration"]
|
||||
assert event["event"]["type"] == ["change"]
|
||||
|
||||
|
||||
def test_action_spec_validates_its_classification():
|
||||
"""A category or type outside the ECS subset fails where it is declared."""
|
||||
with pytest.raises(ValueError):
|
||||
audit.Action("thing.grant", category="nonsense")
|
||||
with pytest.raises(ValueError):
|
||||
audit.Action("thing.grant", types=("nonsense",))
|
||||
|
||||
|
||||
def test_audit_log_records_the_error_type(audit_events):
|
||||
"""The class of an error lands in ``error.type``, next to its message."""
|
||||
audit.log(
|
||||
"anything",
|
||||
outcome="failure",
|
||||
reason="internal_error",
|
||||
error="boom",
|
||||
error_type="builtins.RuntimeError",
|
||||
)
|
||||
|
||||
assert audit_events[0]["error"] == {
|
||||
"message": "boom",
|
||||
"type": "builtins.RuntimeError",
|
||||
}
|
||||
|
||||
|
||||
def test_audit_log_fails_open_on_invalid_input(audit_events, caplog):
|
||||
"""A bad call never raises: it is reported on the application logger."""
|
||||
with caplog.at_level(logging.ERROR, logger="core.audit.emitter"):
|
||||
audit.log("something", outcome="maybe")
|
||||
|
||||
assert audit_events == []
|
||||
assert "could not be built" in caplog.text
|
||||
|
||||
|
||||
def test_audit_log_skips_a_target_field_that_cannot_be_read(audit_events, caplog):
|
||||
"""A broken registration costs the field, not the event."""
|
||||
room = RoomFactory()
|
||||
|
||||
with (
|
||||
override_registration(Room, fields=("no_such_field", "slug")),
|
||||
caplog.at_level(logging.ERROR, logger="core.audit.targets"),
|
||||
):
|
||||
audit.log("anything", target=room)
|
||||
|
||||
[event] = audit_events
|
||||
|
||||
assert event["lasuite"]["target"] == {
|
||||
"type": "room",
|
||||
"id": str(room.pk),
|
||||
"slug": room.slug,
|
||||
}
|
||||
assert "no_such_field" in caplog.text
|
||||
|
||||
|
||||
def test_audit_log_describes_registered_targets(audit_events):
|
||||
"""Describe rooms with the fields registered in ``core.auditing``."""
|
||||
room = RoomFactory(name="Daily standup")
|
||||
|
||||
audit.log("room.create", target=room)
|
||||
|
||||
assert audit_events[0]["lasuite"]["target"] == {
|
||||
"type": "room",
|
||||
"id": str(room.pk),
|
||||
"slug": room.slug,
|
||||
"name": "Daily standup",
|
||||
"access_level": room.access_level,
|
||||
}
|
||||
|
||||
|
||||
def test_audit_log_normalises_details(audit_events):
|
||||
"""Nested details are rendered: enums, models as keys, lists, no ``None``."""
|
||||
room = RoomFactory()
|
||||
|
||||
audit.log(
|
||||
"something",
|
||||
rooms=[room],
|
||||
nested={"outcome": audit.Outcome.DENIED},
|
||||
empty=None,
|
||||
)
|
||||
|
||||
details = audit_events[0]["lasuite"]["details"]
|
||||
|
||||
assert details["rooms"] == [str(room.pk)]
|
||||
assert details["nested"] == {"outcome": "denied"}
|
||||
assert "empty" not in details
|
||||
|
||||
|
||||
def test_audit_log_reads_request_fields(audit_events):
|
||||
"""Should read the HTTP fields from the request, the trace id from dockerflow."""
|
||||
token = request_id_context.set("trace-1")
|
||||
request = RequestFactory().post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
data="{}",
|
||||
content_type="application/json",
|
||||
REMOTE_ADDR="1.2.3.4",
|
||||
)
|
||||
|
||||
try:
|
||||
audit.log("anything", request=request)
|
||||
finally:
|
||||
request_id_context.reset(token)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["http"] == {"request": {"method": "POST"}}
|
||||
assert event["url"] == {"path": "/external-api/v1.0/rooms/"}
|
||||
assert event["client"] == {"ip": "1.2.3.4"}
|
||||
assert event["trace"] == {"id": "trace-1"}
|
||||
|
||||
|
||||
def test_audit_log_defaults_to_the_current_request(audit_events):
|
||||
"""Should read the request fields from the request being served."""
|
||||
request = RequestFactory().post("/rooms/", REMOTE_ADDR="1.2.3.4")
|
||||
request.user = AnonymousUser()
|
||||
token = audit_request.set_current_request(request)
|
||||
|
||||
try:
|
||||
audit.log("anything")
|
||||
finally:
|
||||
audit_request.reset_current_request(token)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["http"] == {"request": {"method": "POST"}}
|
||||
assert event["url"] == {"path": "/rooms/"}
|
||||
assert event["client"] == {"ip": "1.2.3.4"}
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
|
||||
|
||||
def test_audit_log_explicit_request_wins_over_the_current_one(audit_events):
|
||||
"""Should prefer the request passed to the one being served."""
|
||||
token = audit_request.set_current_request(RequestFactory().get("/current/"))
|
||||
|
||||
try:
|
||||
audit.log("anything", request=RequestFactory().get("/explicit/"))
|
||||
finally:
|
||||
audit_request.reset_current_request(token)
|
||||
|
||||
assert audit_events[0]["url"] == {"path": "/explicit/"}
|
||||
|
||||
|
||||
def test_audit_log_reports_the_client_not_the_proxy(audit_events):
|
||||
"""Should report the forwarded client address, not the one of the proxy."""
|
||||
request = RequestFactory().get(
|
||||
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="5.6.7.8"
|
||||
)
|
||||
audit.log("something", request=request)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["client"]["ip"] == "5.6.7.8"
|
||||
assert event["source"] == {"ip": "5.6.7.8"}
|
||||
|
||||
|
||||
def test_audit_log_actor_user_is_id_sub_and_domain_only(audit_events):
|
||||
"""A human actor is identified without email or name."""
|
||||
user = UserFactory(email="john.doe@example.com", full_name="John Doe")
|
||||
request = RequestFactory().get("/")
|
||||
request.user = user
|
||||
|
||||
audit.log("anything", request=request)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["user"] == {
|
||||
"id": str(user.pk),
|
||||
"sub": user.sub,
|
||||
"domain": "example.com",
|
||||
}
|
||||
assert event["lasuite"]["actor"] == {"type": "user"}
|
||||
assert event["lasuite"]["auth"] == {"method": "session"}
|
||||
assert event["organization"] == {"id": "example.com"}
|
||||
assert "John" not in json.dumps(event)
|
||||
assert "john.doe" not in json.dumps(event)
|
||||
|
||||
|
||||
def test_audit_log_anonymous_plain_request_has_no_auth_method(audit_events):
|
||||
"""A plain Django request without a signed-in user is not authenticated."""
|
||||
request = RequestFactory().get("/")
|
||||
request.user = AnonymousUser()
|
||||
|
||||
audit.log("anything", request=request)
|
||||
|
||||
assert audit_events[0]["lasuite"]["auth"] == {"method": "none"}
|
||||
|
||||
|
||||
def test_audit_log_actor_application_with_delegated_user(audit_events):
|
||||
"""A client id in the token payload makes the actor an application."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
request = RequestFactory().get("/")
|
||||
request.user = user
|
||||
request.auth = {"client_id": "app-1", "delegated": True}
|
||||
|
||||
audit.log("something", request=request)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["lasuite"]["application"] == {"client_id": "app-1"}
|
||||
assert event["user"] == {
|
||||
"id": str(user.pk),
|
||||
"sub": user.sub,
|
||||
"domain": "example.com",
|
||||
}
|
||||
assert event["organization"] == {"id": "app-1"}
|
||||
|
||||
|
||||
def test_audit_log_actor_service(audit_events):
|
||||
"""Machine users are services identified by name."""
|
||||
request = RequestFactory().get("/")
|
||||
request.user = MachineUser("roomkit")
|
||||
|
||||
audit.log("something", request=request)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "service", "name": "roomkit"}
|
||||
assert "user" not in event
|
||||
assert "organization" not in event
|
||||
|
||||
|
||||
def test_audit_log_actor_deleted_user_is_not_a_service(audit_events):
|
||||
"""A deleted account has no primary key left, yet it is still a user.
|
||||
|
||||
A service is named by its username, which for an account is an email address.
|
||||
"""
|
||||
user = UserFactory(email="john.doe@example.com", admin_email="admin@example.com")
|
||||
user.delete()
|
||||
request = RequestFactory().get("/")
|
||||
request.user = user
|
||||
|
||||
audit.log("anything", request=request)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "user"}
|
||||
assert event["user"] == {"sub": user.sub, "domain": "example.com"}
|
||||
assert "admin@example.com" not in json.dumps(event)
|
||||
|
||||
|
||||
def test_audit_log_explicit_overrides(audit_events):
|
||||
"""Actor, actor type, auth method and client id can be forced."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
audit.log(
|
||||
"something",
|
||||
actor=user,
|
||||
actor_type="system",
|
||||
auth_method="oidc",
|
||||
client_id="app-2",
|
||||
)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "system"}
|
||||
assert event["lasuite"]["auth"] == {"method": "oidc"}
|
||||
assert event["lasuite"]["application"] == {"client_id": "app-2"}
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
assert event["organization"] == {"id": "app-2"}
|
||||
|
||||
|
||||
def test_audit_log_status_code_error_and_message(audit_events):
|
||||
"""Response status, error message and free text have their ECS slots."""
|
||||
audit.log(
|
||||
"something",
|
||||
outcome="denied",
|
||||
reason="permission_denied",
|
||||
status_code=403,
|
||||
error="Insufficient permissions.",
|
||||
message="scope missing",
|
||||
)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["http"] == {"response": {"status_code": 403}}
|
||||
assert event["error"] == {"message": "Insufficient permissions."}
|
||||
assert event["message"] == "scope missing"
|
||||
|
||||
|
||||
def test_audit_json_formatter_renders_one_line_of_json():
|
||||
"""The formatter emits compact, single-line, non-ASCII friendly JSON."""
|
||||
record = logging.makeLogRecord(
|
||||
{
|
||||
"name": "audit",
|
||||
"levelname": "INFO",
|
||||
"msg": "anything",
|
||||
"audit": {"event": {"action": "anything"}, "note": "multi\nline wörld"},
|
||||
}
|
||||
)
|
||||
|
||||
rendered = AuditJsonFormatter().format(record)
|
||||
|
||||
assert "\n" not in rendered
|
||||
assert "wörld" in rendered
|
||||
assert json.loads(rendered) == {
|
||||
"event": {"action": "anything"},
|
||||
"note": "multi\nline wörld",
|
||||
"log": {"level": "info", "logger": "audit"},
|
||||
}
|
||||
|
||||
|
||||
def test_audit_json_formatter_wraps_plain_records():
|
||||
"""A plain record on the audit logger still renders as JSON."""
|
||||
record = logging.makeLogRecord(
|
||||
{"name": "audit", "levelname": "WARNING", "msg": "log %s", "args": ("x",)}
|
||||
)
|
||||
|
||||
rendered = json.loads(AuditJsonFormatter().format(record))
|
||||
|
||||
assert rendered["log_type"] == "audit"
|
||||
assert rendered["event"] == {"action": "log x"}
|
||||
assert rendered["message"] == "log x"
|
||||
assert rendered["@timestamp"].endswith("+00:00")
|
||||
|
||||
|
||||
def test_audit_json_formatter_adds_stack_trace():
|
||||
"""An attached traceback lands under ``error.stack_trace``."""
|
||||
try:
|
||||
raise ValueError("boom")
|
||||
except ValueError:
|
||||
record = logging.makeLogRecord(
|
||||
{"name": "audit", "levelname": "ERROR", "msg": "x", "audit": {}}
|
||||
)
|
||||
record.exc_info = sys.exc_info()
|
||||
|
||||
rendered = json.loads(AuditJsonFormatter().format(record))
|
||||
|
||||
assert "ValueError: boom" in rendered["error"]["stack_trace"]
|
||||
|
||||
|
||||
def test_find_events_filters_by_action(audit_events):
|
||||
"""The test helper narrows captured events by action."""
|
||||
audit.log("first")
|
||||
audit.log("second")
|
||||
|
||||
found = find_events(audit_events, "second")
|
||||
|
||||
assert [event["event"]["action"] for event in found] == ["second"]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests for the registry of audited models and authentication classes."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
import pytest
|
||||
|
||||
from core import audit
|
||||
from core.audit.actor import auth_method_for, auth_method_for_backend
|
||||
from core.audit.registry import (
|
||||
ModelOptions,
|
||||
auth_methods,
|
||||
dotted_path,
|
||||
model_options,
|
||||
unregister,
|
||||
)
|
||||
from core.audit.testing import override_registration
|
||||
from core.external_api.authentication import ApplicationJWTAuthentication
|
||||
from core.models import Resource, Room
|
||||
|
||||
|
||||
def test_register_twice_is_refused():
|
||||
"""A model is registered once, like in the admin."""
|
||||
audit.register(Resource, fields=("id",))
|
||||
try:
|
||||
with pytest.raises(audit.AlreadyRegistered):
|
||||
audit.register(Resource)
|
||||
finally:
|
||||
unregister(Resource)
|
||||
|
||||
|
||||
def test_register_refuses_unknown_options():
|
||||
"""A misspelled option is an error, not silently ignored."""
|
||||
with pytest.raises(TypeError):
|
||||
audit.register(Resource, field=("name",)) # pylint: disable=unexpected-keyword-arg
|
||||
|
||||
assert model_options(Resource) == ModelOptions()
|
||||
|
||||
|
||||
def test_register_refuses_unknown_categories():
|
||||
"""A category outside the ECS subset fails where it is registered."""
|
||||
with pytest.raises(ValueError):
|
||||
audit.register(Resource, category="nonsense")
|
||||
|
||||
assert model_options(Resource) == ModelOptions()
|
||||
|
||||
|
||||
def test_model_options_falls_back_to_the_concrete_model():
|
||||
"""A proxy model is described as the model it proxies."""
|
||||
proxy = type("ProxyRoom", (), {"_meta": SimpleNamespace(concrete_model=Room)})
|
||||
|
||||
with override_registration(Room, fields=("slug",)):
|
||||
assert model_options(proxy).fields == ("slug",)
|
||||
|
||||
|
||||
def test_override_registration_restores_the_previous_one():
|
||||
"""The test helper puts back what the project registered."""
|
||||
registered = model_options(Room)
|
||||
|
||||
with override_registration(Room, fields=("slug",)):
|
||||
assert model_options(Room).fields == ("slug",)
|
||||
|
||||
assert model_options(Room) == registered
|
||||
|
||||
|
||||
def test_project_declarations_are_discovered():
|
||||
"""``core.auditing`` is imported when the audit app is ready."""
|
||||
assert model_options(Room).fields == ("slug", "name", "access_level")
|
||||
assert model_options(Group).category == audit.EventCategory.IAM
|
||||
assert auth_methods()[dotted_path(ApplicationJWTAuthentication)] == (
|
||||
"application_jwt"
|
||||
)
|
||||
assert (
|
||||
auth_method_for_backend(
|
||||
"core.authentication.backends.OIDCAuthenticationBackend"
|
||||
)
|
||||
== "oidc"
|
||||
)
|
||||
|
||||
|
||||
def test_register_auth_method_twice_is_refused():
|
||||
"""An authentication class is named once."""
|
||||
with pytest.raises(audit.AlreadyRegistered):
|
||||
audit.register_auth_method(ApplicationJWTAuthentication, "other")
|
||||
|
||||
|
||||
def test_auth_method_is_inherited_by_subclasses():
|
||||
"""A DRF class takes the name of its closest registered base."""
|
||||
|
||||
class CustomAuthentication(ApplicationJWTAuthentication):
|
||||
"""A project subclass nobody registered."""
|
||||
|
||||
authenticator = object.__new__(CustomAuthentication)
|
||||
|
||||
assert auth_method_for(authenticator) == "application_jwt"
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests for the network fields and the request id of audit events."""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
from dockerflow.logging import request_id_context
|
||||
from faker import Faker
|
||||
|
||||
from core.api.throttling import CreationCallbackAnonRateThrottle
|
||||
from core.audit import request as audit_request
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
def _set_num_proxies(settings, count):
|
||||
"""Trust ``count`` proxies, as DRF's ``NUM_PROXIES`` setting."""
|
||||
settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": count}
|
||||
|
||||
|
||||
@pytest.fixture(name="dockerflow_request_id")
|
||||
def fixture_dockerflow_request_id():
|
||||
"""Simulate the dockerflow middleware having assigned a request id."""
|
||||
request_id = fake.uuid4()
|
||||
token = request_id_context.set(request_id)
|
||||
try:
|
||||
yield request_id
|
||||
finally:
|
||||
request_id_context.reset(token)
|
||||
|
||||
|
||||
def test_resolve_client_ip_without_forwarded_header():
|
||||
"""Should use the peer address when no proxy header is present."""
|
||||
peer_ip = fake.ipv4()
|
||||
request = RequestFactory().get("/", REMOTE_ADDR=peer_ip)
|
||||
|
||||
assert audit_request.resolve_client_ip(request) == peer_ip
|
||||
|
||||
|
||||
def test_resolve_client_ip_prefers_the_client_over_the_proxy():
|
||||
"""Should return the client the trusted proxy saw, not the proxy address."""
|
||||
request = RequestFactory().get(
|
||||
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="4.5.6.7, 10.0.0.1"
|
||||
)
|
||||
|
||||
assert audit_request.resolve_client_ip(request) == "10.0.0.1"
|
||||
|
||||
|
||||
def test_resolve_client_ip_skips_trusted_proxies(settings):
|
||||
"""Should skip the load balancer entry when two proxies are trusted."""
|
||||
_set_num_proxies(settings, 2)
|
||||
request = RequestFactory().get(
|
||||
"/", HTTP_X_FORWARDED_FOR="1.1.1.1, 2.2.2.2, 8.8.8.8"
|
||||
)
|
||||
|
||||
assert audit_request.resolve_client_ip(request) == "2.2.2.2"
|
||||
|
||||
|
||||
def test_resolve_client_ip_clamps_when_fewer_addresses_than_proxies(settings):
|
||||
"""Should never index out of range on a short chain."""
|
||||
_set_num_proxies(settings, 5)
|
||||
request = RequestFactory().get("/", HTTP_X_FORWARDED_FOR="1.2.3.4")
|
||||
|
||||
assert audit_request.resolve_client_ip(request) == "1.2.3.4"
|
||||
|
||||
|
||||
def test_resolve_client_ip_ignores_an_empty_forwarded_header():
|
||||
"""Should fall back to the peer address when the header is blank."""
|
||||
peer_ip = fake.ipv4()
|
||||
request = RequestFactory().get("/", REMOTE_ADDR=peer_ip, HTTP_X_FORWARDED_FOR=" , ")
|
||||
|
||||
assert audit_request.resolve_client_ip(request) == peer_ip
|
||||
|
||||
|
||||
def test_resolve_client_ip_without_trusted_proxy(settings):
|
||||
"""Should ignore the header entirely when no proxy is trusted."""
|
||||
_set_num_proxies(settings, 0)
|
||||
request = RequestFactory().get(
|
||||
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="4.5.6.7"
|
||||
)
|
||||
|
||||
assert audit_request.resolve_client_ip(request) == "1.2.3.4"
|
||||
|
||||
|
||||
def test_resolve_client_ip_is_the_throttle_identity(settings):
|
||||
"""Should identify the client exactly as Meet's throttles do."""
|
||||
_set_num_proxies(settings, 2)
|
||||
request = RequestFactory().get(
|
||||
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="6.6.6.6, 5.6.7.8, 10.0.0.1"
|
||||
)
|
||||
|
||||
assert audit_request.resolve_client_ip(request) == "5.6.7.8"
|
||||
assert CreationCallbackAnonRateThrottle().get_ident(request) == "5.6.7.8"
|
||||
|
||||
|
||||
def test_resolve_client_ip_tolerates_bare_requests():
|
||||
"""Should accept requests built by hand, which have an empty META."""
|
||||
request = RequestFactory().get("/")
|
||||
request.META = {}
|
||||
|
||||
assert audit_request.resolve_client_ip(request) is None
|
||||
|
||||
|
||||
def test_current_request_id_is_dockerflow_request_id(dockerflow_request_id):
|
||||
"""Should reuse the dockerflow request id as the trace id."""
|
||||
assert audit_request.current_request_id() == dockerflow_request_id
|
||||
|
||||
|
||||
def test_current_request_id_outside_a_request():
|
||||
"""Should have no id when dockerflow did not assign one."""
|
||||
assert audit_request.current_request_id() is None
|
||||
|
||||
|
||||
def test_middleware_replaces_an_untrusted_request_id(dockerflow_request_id):
|
||||
"""Should not reuse an inbound id unless the ingress is trusted to set it."""
|
||||
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
|
||||
|
||||
response = middleware(RequestFactory().get("/"))
|
||||
|
||||
request_id = response["X-Request-ID"]
|
||||
|
||||
assert request_id != dockerflow_request_id
|
||||
assert str(uuid.UUID(request_id)) == request_id
|
||||
assert audit_request.current_request_id() == request_id
|
||||
|
||||
|
||||
def test_middleware_echoes_a_trusted_request_id(settings, dockerflow_request_id):
|
||||
"""Should keep and echo the inbound id when the ingress is trusted."""
|
||||
settings.REQUEST_ID_TRUST_HEADER = True
|
||||
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
|
||||
|
||||
response = middleware(RequestFactory().get("/"))
|
||||
|
||||
assert response["X-Request-ID"] == dockerflow_request_id
|
||||
|
||||
|
||||
def test_middleware_echoes_on_the_configured_header(settings, dockerflow_request_id):
|
||||
"""Should echo the id on the header dockerflow reads it from."""
|
||||
settings.REQUEST_ID_TRUST_HEADER = True
|
||||
settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME = "X-Trace-ID"
|
||||
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
|
||||
|
||||
response = middleware(RequestFactory().get("/"))
|
||||
|
||||
assert response["X-Trace-ID"] == dockerflow_request_id
|
||||
assert not response.has_header("X-Request-ID")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("dockerflow_request_id")
|
||||
def test_middleware_keeps_an_existing_response_header():
|
||||
"""Should leave an X-Request-ID set by the view untouched."""
|
||||
|
||||
def view(request): # pylint: disable=unused-argument
|
||||
response = HttpResponse()
|
||||
response["X-Request-ID"] = "from-the-view"
|
||||
return response
|
||||
|
||||
response = audit_request.AuditLogMiddleware(view)(RequestFactory().get("/"))
|
||||
|
||||
assert response["X-Request-ID"] == "from-the-view"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_request_id_from_the_client_is_replaced_by_default(client):
|
||||
"""Should answer with an id of its own, not the one the client sent."""
|
||||
response = client.get("/api/v1.0/config/", HTTP_X_REQUEST_ID="abc-123")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response["X-Request-ID"] != "abc-123"
|
||||
assert uuid.UUID(response["X-Request-ID"])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_request_id_flows_through_the_test_client_when_trusted(client, settings):
|
||||
"""Should echo the id dockerflow read when the ingress is trusted."""
|
||||
settings.REQUEST_ID_TRUST_HEADER = True
|
||||
|
||||
response = client.get("/api/v1.0/config/", HTTP_X_REQUEST_ID="abc-123")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response["X-Request-ID"] == "abc-123"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_request_id_is_echoed_on_responses_of_outer_middleware(client):
|
||||
"""Should reach responses that never get to the view, as slash redirects."""
|
||||
response = client.get("/api/v1.0/config")
|
||||
|
||||
assert response.status_code == 301
|
||||
assert uuid.UUID(response["X-Request-ID"])
|
||||
|
||||
|
||||
def test_current_request_outside_a_request():
|
||||
"""Should have no current request when none is being served."""
|
||||
assert audit_request.current_request() is None
|
||||
|
||||
|
||||
def test_middleware_sets_the_current_request_while_serving():
|
||||
"""Should expose the request to the code serving it, then forget it."""
|
||||
seen = []
|
||||
|
||||
def view(request): # pylint: disable=unused-argument
|
||||
seen.append(audit_request.current_request())
|
||||
return HttpResponse()
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
audit_request.AuditLogMiddleware(view)(request)
|
||||
|
||||
assert seen == [request]
|
||||
assert audit_request.current_request() is None
|
||||
|
||||
|
||||
def test_middleware_forgets_the_current_request_when_the_view_raises():
|
||||
"""Should not leak the request to the next one when the view raises."""
|
||||
|
||||
def view(request):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
audit_request.AuditLogMiddleware(view)(RequestFactory().get("/"))
|
||||
|
||||
assert audit_request.current_request() is None
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests for the audit of Django's authentication signals."""
|
||||
|
||||
import json
|
||||
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
|
||||
from core import audit
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _request_with_session(method="get"):
|
||||
request = getattr(RequestFactory(), method)("/", REMOTE_ADDR="1.2.3.4")
|
||||
SessionMiddleware(lambda req: HttpResponse())(request)
|
||||
return request
|
||||
|
||||
|
||||
def test_login_is_audited(audit_events, client):
|
||||
"""A login records the user, the mechanism and the backend."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
[event] = find_events(audit_events, "user.login")
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["start"]
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["user"] == {
|
||||
"id": str(user.pk),
|
||||
"sub": user.sub,
|
||||
"domain": "example.com",
|
||||
}
|
||||
assert event["lasuite"]["actor"] == {"type": "user"}
|
||||
assert event["lasuite"]["auth"] == {"method": "password"}
|
||||
assert event["lasuite"]["details"]["auth_backend"].endswith("ModelBackend")
|
||||
|
||||
|
||||
def test_login_through_oidc_backend_is_named_oidc(audit_events):
|
||||
"""The OIDC backend is reported as the ``oidc`` auth method."""
|
||||
user = UserFactory()
|
||||
user.backend = "core.authentication.backends.OIDCAuthenticationBackend"
|
||||
request = _request_with_session()
|
||||
|
||||
login(request, user)
|
||||
|
||||
[event] = find_events(audit_events, "user.login")
|
||||
assert event["lasuite"]["auth"] == {"method": "oidc"}
|
||||
assert event["client"] == {"ip": "1.2.3.4"}
|
||||
assert event["lasuite"]["details"]["auth_backend"] == user.backend
|
||||
|
||||
|
||||
def test_login_given_its_backend_is_named_after_it(audit_events):
|
||||
"""A backend passed to ``login`` rather than set by ``authenticate`` counts."""
|
||||
backend = "core.authentication.backends.OIDCAuthenticationBackend"
|
||||
|
||||
login(_request_with_session(), UserFactory(), backend=backend)
|
||||
|
||||
[event] = find_events(audit_events, "user.login")
|
||||
assert event["lasuite"]["auth"] == {"method": "oidc"}
|
||||
assert event["lasuite"]["details"]["auth_backend"] == backend
|
||||
|
||||
|
||||
def test_login_through_an_unlisted_backend_is_unknown(audit_events):
|
||||
"""A backend missing from the setting is unknown, even a ModelBackend subclass."""
|
||||
user = UserFactory()
|
||||
user.backend = "django.contrib.auth.backends.RemoteUserBackend"
|
||||
|
||||
login(_request_with_session(), user)
|
||||
|
||||
[event] = find_events(audit_events, "user.login")
|
||||
assert event["lasuite"]["auth"] == {"method": "unknown"}
|
||||
assert event["lasuite"]["details"]["auth_backend"] == user.backend
|
||||
|
||||
|
||||
def test_failed_login_is_audited_without_credentials(audit_events):
|
||||
"""A failed login is a warning that never contains the credentials."""
|
||||
request = _request_with_session("post")
|
||||
|
||||
assert authenticate(request=request, username="nobody", password="s3cret") is None
|
||||
|
||||
[event] = find_events(audit_events, "user.login")
|
||||
assert event["event"]["outcome"] == "failure"
|
||||
assert event["event"]["type"] == ["start", "denied"]
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert event["lasuite"]["auth"] == {"method": "password"}
|
||||
assert event["log"]["level"] == "warning"
|
||||
assert "s3cret" not in json.dumps(event)
|
||||
assert "nobody" not in json.dumps(event)
|
||||
|
||||
|
||||
def test_failed_login_without_request_is_anonymous(audit_events):
|
||||
"""A failed login is anonymous even when no request is at hand."""
|
||||
assert authenticate(username="nobody", password="s3cret") is None
|
||||
|
||||
[event] = find_events(audit_events, "user.login")
|
||||
assert event["event"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert event["lasuite"]["auth"] == {"method": "password"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"credentials,method",
|
||||
[
|
||||
# What the OIDC callback hands to ``authenticate``.
|
||||
({"nonce": "n-0nce", "code_verifier": "v3rifier"}, "oidc"),
|
||||
({"token": "t0ken"}, "unknown"),
|
||||
],
|
||||
)
|
||||
def test_failed_login_is_named_after_its_credentials(audit_events, credentials, method):
|
||||
"""A failed attempt is named after what it submitted, never recording it."""
|
||||
request = _request_with_session()
|
||||
|
||||
assert authenticate(request=request, **credentials) is None
|
||||
|
||||
[event] = find_events(audit_events, "user.login")
|
||||
assert event["event"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["auth"] == {"method": method}
|
||||
for value in credentials.values():
|
||||
assert value not in json.dumps(event)
|
||||
|
||||
|
||||
def test_logout_is_audited(audit_events, client):
|
||||
"""A logout records the user who left."""
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
client.logout()
|
||||
|
||||
[event] = find_events(audit_events, "user.logout")
|
||||
assert event["event"]["type"] == ["end"]
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
|
||||
|
||||
def test_logout_without_a_signed_in_user_is_not_audited(audit_events, client):
|
||||
"""Django signals a logout without a user when no one was signed in."""
|
||||
client.logout()
|
||||
|
||||
assert find_events(audit_events, "user.logout") == []
|
||||
|
||||
|
||||
def test_connect_auth_signals_is_idempotent(audit_events, client):
|
||||
"""Connecting twice does not duplicate events."""
|
||||
audit.connect_auth_signals()
|
||||
audit.connect_auth_signals()
|
||||
user = UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
assert len(find_events(audit_events, "user.login")) == 1
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for the description of audit targets."""
|
||||
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
|
||||
import pytest
|
||||
|
||||
from core.audit.registry import ModelOptions, model_options
|
||||
from core.audit.targets import describe_target
|
||||
from core.audit.testing import override_registration
|
||||
from core.factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from core.models import Recording, Resource, Room
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_describe_target_reads_the_registered_fields():
|
||||
"""A model is described by its name, its key and its registered fields."""
|
||||
room = RoomFactory()
|
||||
|
||||
with override_registration(Room, fields=("slug", "access_level")):
|
||||
described = describe_target(room)
|
||||
|
||||
assert described == {
|
||||
"type": "room",
|
||||
"id": str(room.pk),
|
||||
"slug": room.slug,
|
||||
"access_level": room.access_level,
|
||||
}
|
||||
|
||||
|
||||
def test_describe_target_renders_values():
|
||||
"""Foreign keys, enums and other values are rendered for JSON."""
|
||||
recording = RecordingFactory()
|
||||
|
||||
with override_registration(Recording, fields=("room_id", "room")):
|
||||
described = describe_target(recording)
|
||||
|
||||
assert described == {
|
||||
"type": "recording",
|
||||
"id": str(recording.pk),
|
||||
"room_id": str(recording.room_id),
|
||||
"room": str(recording.room_id),
|
||||
}
|
||||
|
||||
|
||||
def test_describe_target_without_fields():
|
||||
"""A model registered without fields stays identifiable."""
|
||||
room = RoomFactory()
|
||||
|
||||
with override_registration(Room):
|
||||
described = describe_target(room)
|
||||
|
||||
assert described == {"type": "room", "id": str(room.pk)}
|
||||
|
||||
|
||||
def test_describe_target_identifies_users_without_their_email():
|
||||
"""A user is identified by its key, OIDC sub and email domain."""
|
||||
user = UserFactory(email="jane@Example.org", sub="oidc-sub-1")
|
||||
|
||||
assert describe_target(user) == {
|
||||
"type": "user",
|
||||
"id": str(user.pk),
|
||||
"sub": "oidc-sub-1",
|
||||
"domain": "example.org",
|
||||
}
|
||||
|
||||
|
||||
def test_describe_target_of_a_user_without_sub():
|
||||
"""A user who never signed in, such as a provisional one, has no sub.
|
||||
|
||||
The empty value is pruned when the event is built.
|
||||
"""
|
||||
user = UserFactory(email="jane@example.org", sub=None)
|
||||
|
||||
assert describe_target(user) == {
|
||||
"type": "user",
|
||||
"id": str(user.pk),
|
||||
"sub": None,
|
||||
"domain": "example.org",
|
||||
}
|
||||
|
||||
|
||||
def test_describe_target_sees_through_lazy_objects():
|
||||
"""A lazy proxy is described as the object it wraps."""
|
||||
room = RoomFactory()
|
||||
|
||||
with override_registration(Room, fields=("slug",)):
|
||||
described = describe_target(SimpleLazyObject(lambda: room))
|
||||
|
||||
assert described == {
|
||||
"type": "room",
|
||||
"id": str(room.pk),
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
|
||||
def test_describe_target_mapping_passes_through():
|
||||
"""A ready-made dict is used verbatim."""
|
||||
assert describe_target({"type": "x", "id": "1"}) == {"type": "x", "id": "1"}
|
||||
|
||||
|
||||
def test_describe_target_of_a_plain_object():
|
||||
"""Anything else is identified by its class and string form."""
|
||||
|
||||
class Thing: # pylint: disable=missing-class-docstring
|
||||
def __str__(self):
|
||||
return "thing-1"
|
||||
|
||||
assert describe_target(Thing()) == {"type": "thing", "id": "thing-1"}
|
||||
|
||||
|
||||
def test_model_options_by_model():
|
||||
"""Options are looked up by model, and default to nothing."""
|
||||
with override_registration(Room, fields=("slug",)):
|
||||
assert model_options(Room) == ModelOptions(fields=("slug",))
|
||||
assert model_options(Resource) == ModelOptions()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Test audit.utils.prune_empty
|
||||
"""
|
||||
|
||||
from core.audit.utils import prune_empty
|
||||
|
||||
|
||||
def test_prune_empty_drops_none_and_empty_mappings():
|
||||
"""Should drop None and emptied mappings but keep falsy values."""
|
||||
document = {
|
||||
"none": None,
|
||||
"emptied": {"inner": None, "deeper": {"again": None}},
|
||||
"kept": {"zero": 0, "false": False, "blank": "", "none": None},
|
||||
"list": [],
|
||||
}
|
||||
|
||||
assert prune_empty(document) == {
|
||||
"kept": {"zero": 0, "false": False, "blank": ""},
|
||||
"list": [],
|
||||
}
|
||||
|
||||
|
||||
def test_prune_empty_leaves_non_mappings_untouched():
|
||||
"""Should return anything that is not a mapping as it is."""
|
||||
assert prune_empty([None, {}]) == [None, {}]
|
||||
assert prune_empty("text") == "text"
|
||||
assert prune_empty(None) is None
|
||||
@@ -3,6 +3,9 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from dockerflow.logging import request_id_context
|
||||
|
||||
from core.audit.testing import capture_audit
|
||||
|
||||
USER = "user"
|
||||
TEAM = "team"
|
||||
@@ -14,3 +17,23 @@ def mock_user_get_teams():
|
||||
"""Mock for the "get_teams" method on the User model."""
|
||||
with mock.patch("core.models.User.get_teams") as mock_get_teams:
|
||||
yield mock_get_teams
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audit_events():
|
||||
"""Collect the audit events emitted during the test, as dicts."""
|
||||
with capture_audit() as events:
|
||||
yield events
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_request_id():
|
||||
"""Keep dockerflow's request id from leaking from one test to the next.
|
||||
|
||||
Its middleware sets the context variable on every request the test client
|
||||
makes and never clears it, which would make the trace id of a later test
|
||||
depend on the order tests ran in.
|
||||
"""
|
||||
token = request_id_context.set(None)
|
||||
yield
|
||||
request_id_context.reset(token)
|
||||
|
||||
@@ -24,6 +24,8 @@ def test_successful_authentication(settings):
|
||||
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
assert token == "valid-test-token"
|
||||
assert isinstance(user, MachineUser)
|
||||
# Names the summary service in the audit log
|
||||
assert user.get_username() == "summary"
|
||||
|
||||
|
||||
def test_authentication_fails_when_token_not_configured(settings):
|
||||
|
||||
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.analytics import UserFeatureFlag
|
||||
from core.audit.testing import find_events
|
||||
from core.recording.event.notification import NotificationService, notification_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -479,3 +480,63 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
||||
assert isinstance(title, str)
|
||||
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||
json.dumps(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("summary_requested", [True, False])
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
def test_notify_summary_service_is_audited( # noqa: PLR0913, PLR0917
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
summary_requested,
|
||||
settings,
|
||||
audit_events,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Sending a recording to be transcribed says whether a summary is asked for."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
|
||||
recording = factories.RecordingFactory(mode="transcript")
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
mock_get_recording_timestamps.return_value = (None, None)
|
||||
mock_generate_download_s3_url.return_value = "https://storage.test/recording.ogg"
|
||||
mock_post.return_value.json.return_value = {"job_id": "job-7"}
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.is_user_feature_flag_enabled",
|
||||
return_value=summary_requested,
|
||||
):
|
||||
assert NotificationService._notify_summary_service(recording) is True
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.request")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["lasuite"]["actor"] == {"type": "system"}
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["details"] == {
|
||||
"job_id": "job-7",
|
||||
"summary_requested": summary_requested,
|
||||
}
|
||||
|
||||
|
||||
def test_notify_summary_service_failure_is_audited(settings, audit_events):
|
||||
"""A recording the summary service never received is a failure."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = None
|
||||
|
||||
recording = factories.RecordingFactory(mode="transcript")
|
||||
|
||||
assert NotificationService._notify_summary_service(recording) is False
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.request")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ Test recordings API endpoints in the Meet core app: delete.
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import capture_audit, find_events
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
from ...models import Recording
|
||||
|
||||
@@ -112,3 +113,24 @@ def test_api_recordings_delete_final(role):
|
||||
|
||||
assert response.status_code == 204
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_api_recordings_delete_is_audited():
|
||||
"""A deleted recording is still identified by the event."""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(status="saved", mode="transcript")
|
||||
UserRecordingAccessFactory(role="owner", user=user, recording=recording)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with capture_audit() as events:
|
||||
response = client.delete(f"/api/v1.0/recordings/{recording.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
[event] = find_events(events, "recording.delete")
|
||||
|
||||
assert event["event"]["type"] == ["deletion"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["target"]["mode"] == "transcript"
|
||||
|
||||
@@ -6,6 +6,7 @@ Test recordings API endpoints: external process hook.
|
||||
|
||||
import pytest
|
||||
|
||||
from ...audit.testing import find_events
|
||||
from ...factories import RecordingFactory
|
||||
from ...models import RecordingStatusChoices
|
||||
|
||||
@@ -132,3 +133,82 @@ def test_external_process_event_non_transcript_event_does_not_change_status(
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"process_type,action",
|
||||
[
|
||||
("transcript", "recording.transcript.report"),
|
||||
("summary", "recording.summary.report"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"process_status,outcome", [("success", "success"), ("failure", "failure")]
|
||||
)
|
||||
def test_external_process_event_is_audited( # noqa: PLR0913, PLR0917
|
||||
external_process_settings,
|
||||
client,
|
||||
audit_events,
|
||||
process_type,
|
||||
action,
|
||||
process_status,
|
||||
outcome,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""A transcript and a summary are audited apart, as reported by the service."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED, external_process_id="job-123"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-123", "type": process_type, "status": process_status},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, action)
|
||||
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["lasuite"]["actor"] == {"type": "service", "name": "summary"}
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["details"] == {
|
||||
"job_id": "job-123",
|
||||
"status": process_status,
|
||||
}
|
||||
|
||||
|
||||
def test_external_process_event_unknown_recording_is_audited(
|
||||
external_process_settings, client, audit_events
|
||||
):
|
||||
"""An event about an unknown job keeps its job id, with no target."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-unknown", "type": "summary", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "recording.summary.report")
|
||||
|
||||
assert "target" not in event["lasuite"]
|
||||
assert event["lasuite"]["details"]["job_id"] == "job-unknown"
|
||||
|
||||
|
||||
def test_external_process_event_refused_is_audited(
|
||||
external_process_settings, client, audit_events
|
||||
):
|
||||
"""A caller without the token is denied under the transcript action."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-1", "type": "summary", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer wrongToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.report")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert not find_events(audit_events, "recording.summary.report")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: start recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||
# pylint: disable=redefined-outer-name,unused-argument,no-member,too-many-lines
|
||||
|
||||
from unittest import mock
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from livekit import api as livekit_api
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import capture_audit, find_events
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording
|
||||
from ...recording.worker.exceptions import RecordingStartError
|
||||
@@ -879,3 +880,142 @@ def test_start_recording_options_original_mode_invalid(settings, value):
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_start_recording_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A started screen recording names the recording and its mode."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
recording = Recording.objects.get(room=room)
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["event"]["type"] == ["start"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
assert event["lasuite"]["target"] == {
|
||||
"type": "recording",
|
||||
"id": str(recording.pk),
|
||||
"room_id": str(room.pk),
|
||||
"status": "initiated",
|
||||
"mode": "screen_recording",
|
||||
"requested_mode": "screen_recording",
|
||||
"is_transcribed": False,
|
||||
}
|
||||
assert event["lasuite"]["details"] == {"collect_metadata": False}
|
||||
|
||||
|
||||
def test_start_recording_transcript_with_screen_capture_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A transcript recorded with the screen is told apart from a screen recording."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{
|
||||
"mode": "screen_recording",
|
||||
"options": {"transcribe": True, "original_mode": "transcript"},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
target = event["lasuite"]["target"]
|
||||
|
||||
assert target["mode"] == "screen_recording"
|
||||
assert target["requested_mode"] == "transcript"
|
||||
assert target["is_transcribed"] is True
|
||||
|
||||
|
||||
def test_start_recording_conflict_is_audited_on_the_room(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A conflicting start is a failure aimed at the room, no recording existing."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
Recording.objects.create(room=room, mode="screen_recording", status="active")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "transcript"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["event"]["reason"] == "conflict"
|
||||
assert event["lasuite"]["target"]["type"] == "room"
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
|
||||
|
||||
def test_start_recording_worker_error_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A recording the worker could not start is a failure on that recording."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
mock_worker_manager.start = mock.Mock(side_effect=RecordingStartError("boom"))
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "transcript"},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["http"]["response"]["status_code"] == 502
|
||||
assert event["lasuite"]["target"]["id"] == str(Recording.objects.get().pk)
|
||||
assert event["lasuite"]["target"]["mode"] == "transcript"
|
||||
assert event["lasuite"]["target"]["is_transcribed"] is True
|
||||
|
||||
|
||||
def test_start_recording_anonymous_is_audited():
|
||||
"""An anonymous attempt is denied."""
|
||||
room = RoomFactory()
|
||||
|
||||
with capture_audit() as events:
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"]["type"] == "anonymous"
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest import mock
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import find_events
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.worker.exceptions import RecordingStopError
|
||||
@@ -181,3 +182,48 @@ def test_stop_recording_success(
|
||||
|
||||
# Verify the recording still exists
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A stopped recording is the target of the event."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
recording = RecordingFactory(
|
||||
room=room, mode="transcript", status=RecordingStatusChoices.ACTIVE
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "recording.stop")
|
||||
|
||||
assert event["event"]["type"] == ["end"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.pk)
|
||||
assert event["lasuite"]["target"]["mode"] == "transcript"
|
||||
|
||||
|
||||
def test_stop_recording_without_active_recording_is_audited(settings, audit_events):
|
||||
"""Stopping a room that records nothing fails on the room."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
[event] = find_events(audit_events, "recording.stop")
|
||||
|
||||
assert event["event"]["reason"] == "not_found"
|
||||
assert event["lasuite"]["target"]["type"] == "room"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
# pylint: disable=W0621,W0613, W0212, E0611, too-many-lines
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
@@ -13,6 +13,7 @@ from django.utils import timezone
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.models import Room
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
@@ -994,3 +995,54 @@ def test_participant_left_without_identity_is_ignored(mock_delete, service, sett
|
||||
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "worker_event", "outcome"),
|
||||
(
|
||||
(EgressStatus.EGRESS_COMPLETE, "completed", "success"),
|
||||
(EgressStatus.EGRESS_LIMIT_REACHED, "limit reached", "success"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted", "failure"),
|
||||
(EgressStatus.EGRESS_FAILED, "failed", "failure"),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_is_audited( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
worker_event,
|
||||
outcome,
|
||||
service,
|
||||
audit_events,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""The end of a recording is audited as LiveKit reports it."""
|
||||
|
||||
recording = RecordingFactory(
|
||||
worker_id="worker-1",
|
||||
status="active",
|
||||
mode="screen_recording",
|
||||
options={"transcribe": True},
|
||||
)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.error_code = 0
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
[event] = find_events(audit_events, "recording.end")
|
||||
|
||||
assert event["event"]["type"] == ["end"]
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["lasuite"]["actor"] == {"type": "service", "name": "livekit"}
|
||||
assert event["lasuite"]["auth"] == {"method": "shared_secret"}
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["target"]["is_transcribed"] is True
|
||||
assert event["lasuite"]["details"] == {"worker_event": worker_event}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Test provisional user service."""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.db import IntegrityError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import UserFactory
|
||||
from core.models import User
|
||||
from core.services.provisional_user_service import (
|
||||
ProvisionalUserIntegrityError,
|
||||
ProvisionalUserService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def allow_provisioning(settings):
|
||||
"""Enable provisional user creation."""
|
||||
settings.APPLICATION_ALLOW_USER_CREATION = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
|
||||
|
||||
def test_get_or_create_existing_user_is_not_audited(audit_events):
|
||||
"""Finding an existing user provisions nothing, so it emits no event."""
|
||||
user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
assert ProvisionalUserService().get_or_create(
|
||||
"John.Doe@example.com", "my-client"
|
||||
) == (user, False)
|
||||
assert not find_events(audit_events, "user.provision")
|
||||
|
||||
|
||||
def test_get_or_create_creation_is_audited(audit_events):
|
||||
"""A created user is identified by its id, without sub nor email."""
|
||||
user, created = ProvisionalUserService().get_or_create(
|
||||
"john.doe@example.com", "my-client"
|
||||
)
|
||||
|
||||
assert created is True
|
||||
assert user.sub is None
|
||||
|
||||
[event] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert event["log"] == {"level": "info"}
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["event"]["type"] == ["user", "creation"]
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["lasuite"]["auth"] == {"method": "client_credentials"}
|
||||
assert event["lasuite"]["application"] == {"client_id": "my-client"}
|
||||
assert event["lasuite"]["target"] == {
|
||||
"type": "user",
|
||||
"id": str(user.pk),
|
||||
"domain": "example.com",
|
||||
}
|
||||
assert "john.doe" not in json.dumps(event)
|
||||
|
||||
|
||||
@mock.patch.object(ProvisionalUserService, "_get_by_email")
|
||||
def test_get_or_create_lost_race_is_audited_with_the_existing_user(
|
||||
mock_get_by_email, audit_events
|
||||
):
|
||||
"""A lost race reports the user the concurrent request created."""
|
||||
existing_user = UserFactory(sub=None, email="john.doe@example.com")
|
||||
mock_get_by_email.side_effect = [None, existing_user]
|
||||
|
||||
assert ProvisionalUserService().get_or_create(
|
||||
"john.doe@example.com", "my-client"
|
||||
) == (existing_user, False)
|
||||
assert User.objects.filter(email="john.doe@example.com").count() == 1
|
||||
|
||||
[event] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert event["log"] == {"level": "warning"}
|
||||
assert event["event"]["outcome"] == "failure"
|
||||
assert event["event"]["reason"] == "conflict"
|
||||
assert event["error"] == {"type": "ValidationError"}
|
||||
assert event["lasuite"]["application"] == {"client_id": "my-client"}
|
||||
assert event["lasuite"]["target"] == {
|
||||
"type": "user",
|
||||
"id": str(existing_user.pk),
|
||||
"domain": "example.com",
|
||||
}
|
||||
assert "john.doe" not in json.dumps(event)
|
||||
|
||||
|
||||
@mock.patch.object(User, "save", side_effect=IntegrityError)
|
||||
@mock.patch.object(ProvisionalUserService, "_get_by_email", return_value=None)
|
||||
def test_get_or_create_unrecoverable_conflict_is_audited(
|
||||
mock_get_by_email, mock_save, audit_events
|
||||
):
|
||||
"""A conflict without any user to fall back on is audited without target."""
|
||||
with pytest.raises(ProvisionalUserIntegrityError):
|
||||
ProvisionalUserService().get_or_create("john.doe@example.com", "my-client")
|
||||
|
||||
[event] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert event["event"]["outcome"] == "failure"
|
||||
assert event["event"]["reason"] == "conflict"
|
||||
assert event["error"] == {"type": "IntegrityError"}
|
||||
assert "target" not in event["lasuite"]
|
||||
@@ -17,6 +17,7 @@ from lasuite.oidc_resource_server.authentication import ResourceServerAuthentica
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.analytics import AnalyticsEvent
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.models import (
|
||||
Application,
|
||||
@@ -2375,3 +2376,278 @@ def test_api_rooms_addons_disabled_does_not_break_application_auth(settings):
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_create_is_audited(audit_events):
|
||||
"""Creating a room records the application, the delegated user and the room."""
|
||||
user = UserFactory(email="jean-neige@winterfell.com")
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
application = Application.objects.get()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/", {}, format="json", REMOTE_ADDR="1.2.3.4"
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
[event] = find_events(audit_events, "room.create")
|
||||
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["lasuite"]["auth"] == {"method": "application_jwt"}
|
||||
assert event["lasuite"]["application"] == {"client_id": str(application.client_id)}
|
||||
assert event["user"] == {
|
||||
"id": str(user.pk),
|
||||
"sub": user.sub,
|
||||
"domain": "winterfell.com",
|
||||
}
|
||||
assert event["organization"] == {"id": str(application.client_id)}
|
||||
assert event["lasuite"]["target"] == {
|
||||
"type": "room",
|
||||
"id": str(room.pk),
|
||||
"slug": room.slug,
|
||||
"name": room.name,
|
||||
"access_level": "trusted",
|
||||
}
|
||||
assert event["client"]["ip"] == "1.2.3.4"
|
||||
assert event["http"]["request"]["method"] == "POST"
|
||||
assert event["url"]["path"] == "/external-api/v1.0/rooms/"
|
||||
assert event["trace"]["id"] == response["X-Request-ID"]
|
||||
assert "jean-neige@winterfell.com" not in str(event)
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_is_audited(mock_update_metadata, audit_events):
|
||||
"""Updating a room records what changed and the previous access level."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={},
|
||||
)
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
mock_update_metadata.assert_called_once()
|
||||
[event] = find_events(audit_events, "room.update")
|
||||
|
||||
assert event["event"]["type"] == ["change"]
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
assert event["lasuite"]["target"]["access_level"] == "restricted"
|
||||
assert event["lasuite"]["details"] == {
|
||||
"updated_fields": ["access_level"],
|
||||
"previous_access_level": "trusted",
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_update_refused_is_audited_with_its_target(audit_events):
|
||||
"""A refused update names the room it was aimed at."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
[event] = find_events(audit_events, "room.update")
|
||||
|
||||
assert event["event"]["reason"] == "permission_denied"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
RoomManagement, "sync_room_metadata", side_effect=RuntimeError("LiveKit down")
|
||||
)
|
||||
def test_api_rooms_update_crashing_is_audited(mock_sync_room_metadata, audit_events):
|
||||
"""An update saved but not synced to LiveKit is recorded as a failure."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
|
||||
)
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
with pytest.raises(RuntimeError):
|
||||
client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
mock_sync_room_metadata.assert_called_once()
|
||||
[event] = find_events(audit_events, "room.update")
|
||||
|
||||
assert event["event"]["reason"] == "internal_error"
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
assert event["http"]["response"] == {"status_code": 500}
|
||||
assert event["error"] == {"type": "builtins.RuntimeError"}
|
||||
|
||||
|
||||
def test_api_rooms_list_is_audited(audit_events):
|
||||
"""Listing records how many rooms were visible to the user."""
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
RoomFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "room.list")
|
||||
|
||||
assert event["event"]["type"] == ["access"]
|
||||
assert event["lasuite"]["details"] == {"total": 2}
|
||||
assert "target" not in event["lasuite"]
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_is_audited(audit_events):
|
||||
"""Reading a room is recorded as an access to that room."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "room.retrieve")
|
||||
|
||||
assert event["event"]["type"] == ["access"]
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
assert event["lasuite"]["target"]["slug"] == room.slug
|
||||
|
||||
|
||||
def test_api_rooms_missing_token_is_audited_as_denial(audit_events):
|
||||
"""An unauthenticated call is recorded under the action it attempted."""
|
||||
response = APIClient().get("/external-api/v1.0/rooms/", REMOTE_ADDR="1.2.3.4")
|
||||
|
||||
assert response.status_code == 401
|
||||
[event] = find_events(audit_events, "room.list")
|
||||
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["access", "denied"]
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert "details" not in event["lasuite"]
|
||||
assert event["http"]["response"] == {"status_code": 401}
|
||||
assert event["client"]["ip"] == "1.2.3.4"
|
||||
assert event["url"]["path"] == "/external-api/v1.0/rooms/"
|
||||
|
||||
|
||||
def test_api_rooms_missing_scope_is_audited_as_denial(audit_events):
|
||||
"""A token without the required scope is a permission denial by the application."""
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
application = Application.objects.get()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
[event] = find_events(audit_events, "room.create")
|
||||
|
||||
assert event["event"]["type"] == ["creation", "denied"]
|
||||
assert event["event"]["reason"] == "permission_denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["lasuite"]["auth"] == {"method": "application_jwt"}
|
||||
assert event["lasuite"]["application"] == {"client_id": str(application.client_id)}
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
assert event["http"]["response"] == {"status_code": 403}
|
||||
assert "Required scope" in event["error"]["message"]
|
||||
assert "target" not in event["lasuite"]
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_is_audited_as_user(audit_events):
|
||||
"""An add-on token has no application: the actor is the user."""
|
||||
user = UserFactory(email="jean-neige@winterfell.com")
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
[event] = find_events(audit_events, "room.list")
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "user"}
|
||||
assert event["lasuite"]["auth"] == {"method": "addons_jwt"}
|
||||
assert "application" not in event["lasuite"]
|
||||
assert event["organization"] == {"id": "winterfell.com"}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_resource_server_is_audited_as_application(audit_events, settings):
|
||||
"""A La Suite application calling through the resource server acts for the user.
|
||||
|
||||
The application is the client the introspected token was issued to.
|
||||
"""
|
||||
user = UserFactory(sub="very-specific-sub")
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
[event] = find_events(audit_events, "room.list")
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["lasuite"]["auth"] == {"method": "resource_server"}
|
||||
assert event["lasuite"]["application"] == {"client_id": "some_service_provider"}
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
|
||||
@@ -4,6 +4,7 @@ Tests for external API /token endpoint
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -12,6 +13,7 @@ import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import (
|
||||
ApplicationDomainFactory,
|
||||
ApplicationFactory,
|
||||
@@ -674,3 +676,225 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
|
||||
|
||||
assert response.status_code == 409
|
||||
assert mock_get_or_create.call_count == 1
|
||||
|
||||
|
||||
def _application(**kwargs):
|
||||
"""Create an application whose plain secret is ``test-secret-123``."""
|
||||
kwargs.setdefault("is_active", True)
|
||||
application = ApplicationFactory(**kwargs)
|
||||
application.client_secret = "test-secret-123"
|
||||
application.save()
|
||||
return application
|
||||
|
||||
|
||||
def _post_token(client_id, client_secret, scope, **extra):
|
||||
"""Post a client-credentials token request."""
|
||||
return APIClient().post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": scope,
|
||||
},
|
||||
format="json",
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_success_is_audited(audit_events, settings):
|
||||
"""An issued token records the application, the delegated user and scopes."""
|
||||
user = UserFactory(email="jean-neige@winterfell.com")
|
||||
application = _application(scopes=[ApplicationScope.ROOMS_LIST])
|
||||
|
||||
response = _post_token(
|
||||
application.client_id,
|
||||
"test-secret-123",
|
||||
"jean-neige@winterfell.com",
|
||||
REMOTE_ADDR="1.2.3.4",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["start"]
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["lasuite"]["auth"] == {"method": "client_credentials"}
|
||||
assert event["lasuite"]["application"] == {"client_id": application.client_id}
|
||||
assert event["user"] == {
|
||||
"id": str(user.pk),
|
||||
"sub": user.sub,
|
||||
"domain": "winterfell.com",
|
||||
}
|
||||
assert event["organization"] == {"id": application.client_id}
|
||||
assert event["lasuite"]["target"] == {
|
||||
"type": "application",
|
||||
"id": str(application.pk),
|
||||
"client_id": application.client_id,
|
||||
"name": application.name,
|
||||
"is_active": True,
|
||||
"scopes": ["rooms:list"],
|
||||
}
|
||||
assert event["lasuite"]["details"] == {
|
||||
"scopes": ["rooms:list"],
|
||||
"user_provisioned": False,
|
||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
}
|
||||
assert event["client"]["ip"] == "1.2.3.4"
|
||||
assert event["url"]["path"] == "/external-api/v1.0/application/token/"
|
||||
assert event["trace"]["id"] == response["X-Request-ID"]
|
||||
assert "jean-neige@winterfell.com" not in json.dumps(event)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_wrong_secret_is_audited(audit_events):
|
||||
"""A wrong secret is a denial: the submitted client id is only a claim."""
|
||||
UserFactory(email="jean-neige@winterfell.com")
|
||||
application = _application()
|
||||
|
||||
response = _post_token(application.client_id, "wrong-secret", "user@example.com")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["start", "denied"]
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert event["lasuite"]["auth"] == {"method": "client_credentials"}
|
||||
assert "application" not in event["lasuite"]
|
||||
assert "organization" not in event
|
||||
assert event["lasuite"]["details"] == {
|
||||
"requested_domain": "example.com",
|
||||
"claimed_client_id": application.client_id,
|
||||
}
|
||||
assert "target" not in event["lasuite"]
|
||||
assert event["http"]["response"] == {"status_code": 401}
|
||||
assert event["error"] == {"message": "Invalid credentials"}
|
||||
assert event["log"]["level"] == "warning"
|
||||
assert "jean-neige@winterfell.com" not in json.dumps(event)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_unknown_client_is_audited(audit_events):
|
||||
"""An unknown client id is still recorded, so brute force is visible."""
|
||||
response = _post_token("does-not-exist", "whatever", "jean-neige@winterfell.com")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["lasuite"]["details"]["claimed_client_id"] == "does-not-exist"
|
||||
assert "application" not in event["lasuite"]
|
||||
assert "organization" not in event
|
||||
|
||||
|
||||
def test_api_applications_generate_token_inactive_application_is_audited(
|
||||
audit_events,
|
||||
):
|
||||
"""A disabled application is refused with an explicit message."""
|
||||
UserFactory(email="jean-neige@winterfell.com")
|
||||
application = _application(is_active=False)
|
||||
|
||||
response = _post_token(
|
||||
application.client_id, "test-secret-123", "jean-neige@winterfell.com"
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["error"] == {"message": "Application is inactive"}
|
||||
|
||||
|
||||
def test_api_applications_generate_token_domain_denied_is_audited(audit_events):
|
||||
"""Delegating outside the allowed domains is a permission denial."""
|
||||
UserFactory(email="user@random.com")
|
||||
application = _application()
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
|
||||
response = _post_token(application.client_id, "test-secret-123", "user@random.com")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert event["event"]["reason"] == "permission_denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["lasuite"]["target"]["id"] == str(application.pk)
|
||||
assert event["lasuite"]["details"] == {"requested_domain": "random.com"}
|
||||
assert event["http"]["response"] == {"status_code": 403}
|
||||
|
||||
|
||||
def test_api_applications_generate_token_invalid_email_is_audited(audit_events):
|
||||
"""An invalid scope is a validation failure by an authenticated application."""
|
||||
application = _application()
|
||||
|
||||
response = _post_token(application.client_id, "test-secret-123", "not-an-email")
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert event["event"]["reason"] == "validation_error"
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["http"]["response"] == {"status_code": 400}
|
||||
assert "details" not in event["lasuite"]
|
||||
|
||||
|
||||
def test_api_applications_generate_token_unknown_user_is_audited(audit_events):
|
||||
"""An unknown user with provisioning disabled is a not-found denial."""
|
||||
application = _application()
|
||||
|
||||
response = _post_token(
|
||||
application.client_id, "test-secret-123", "nobody@example.com"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert event["event"]["reason"] == "not_found"
|
||||
assert event["lasuite"]["details"] == {"requested_domain": "example.com"}
|
||||
assert event["http"]["response"] == {"status_code": 404}
|
||||
|
||||
|
||||
def test_api_applications_generate_token_provisioning_is_audited(
|
||||
audit_events, settings
|
||||
):
|
||||
"""Provisioning a user is its own event, correlated with the token issue."""
|
||||
settings.APPLICATION_ALLOW_USER_CREATION = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
application = _application(scopes=[ApplicationScope.ROOMS_LIST])
|
||||
|
||||
response = _post_token(
|
||||
application.client_id, "test-secret-123", "new.user@example.com"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
user = User.objects.get(email="new.user@example.com")
|
||||
[provision] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert provision["event"]["category"] == ["iam"]
|
||||
assert provision["event"]["type"] == ["user", "creation"]
|
||||
assert provision["lasuite"]["actor"] == {"type": "application"}
|
||||
# Same mechanism as the token issue it belongs to
|
||||
assert provision["lasuite"]["auth"] == {"method": "client_credentials"}
|
||||
assert provision["lasuite"]["application"] == {"client_id": application.client_id}
|
||||
assert provision["lasuite"]["target"] == {
|
||||
"type": "user",
|
||||
"id": str(user.pk),
|
||||
"domain": "example.com",
|
||||
}
|
||||
assert provision["trace"]["id"] == response["X-Request-ID"]
|
||||
assert provision["url"]["path"] == "/external-api/v1.0/application/token/"
|
||||
assert "new.user@example.com" not in json.dumps(provision)
|
||||
|
||||
[issue] = find_events(audit_events, "application.token.issue")
|
||||
assert issue["lasuite"]["details"]["user_provisioned"] is True
|
||||
assert issue["user"]["id"] == str(user.pk)
|
||||
|
||||
@@ -311,6 +311,7 @@ class Base(Configuration):
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
"core.audit.request.AuditLogMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
@@ -331,6 +332,7 @@ class Base(Configuration):
|
||||
INSTALLED_APPS = [
|
||||
# Meet
|
||||
"core",
|
||||
"core.audit.apps.AuditConfig",
|
||||
"demo",
|
||||
"drf_spectacular",
|
||||
# Third party apps
|
||||
@@ -340,7 +342,8 @@ class Base(Configuration):
|
||||
"parler",
|
||||
"easy_thumbnails",
|
||||
# Django
|
||||
"django.contrib.admin",
|
||||
# The admin is served by a site that audits every write it performs.
|
||||
"core.audit.apps.AuditedAdminConfig",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.postgres",
|
||||
@@ -383,6 +386,13 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
# Trusted proxies appending to X-Forwarded-For in front of the backend.
|
||||
# Throttles and audit events identify the client as the entry that many
|
||||
# positions from the right; unset, DRF would use the raw header, which a
|
||||
# client can vary to escape its throttle.
|
||||
"NUM_PROXIES": values.IntegerValue(
|
||||
1, environ_name="NUM_PROXIES", environ_prefix=None
|
||||
),
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"room_creation": values.Value(
|
||||
default="50/minute",
|
||||
@@ -1210,6 +1220,28 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
AUDIT_LOG_LEVEL = values.Value(
|
||||
"INFO", environ_name="AUDIT_LOG_LEVEL", environ_prefix=None
|
||||
)
|
||||
AUDIT_LOG_STREAM = values.Value(
|
||||
"ext://sys.stdout", environ_name="AUDIT_LOG_STREAM", environ_prefix=None
|
||||
)
|
||||
AUDIT_LOG_SERVICE_NAME = values.Value(
|
||||
"meet", environ_name="AUDIT_LOG_SERVICE_NAME", environ_prefix=None
|
||||
)
|
||||
# Reuse the inbound request id as the trace id
|
||||
# Only enable it when the ingress overwrites the header
|
||||
# When off, the backend generates the id.
|
||||
REQUEST_ID_TRUST_HEADER = values.BooleanValue(
|
||||
False, environ_name="REQUEST_ID_TRUST_HEADER", environ_prefix=None
|
||||
)
|
||||
|
||||
DOCKERFLOW_REQUEST_ID_HEADER_NAME = values.Value(
|
||||
"X-Request-ID",
|
||||
environ_name="DOCKERFLOW_REQUEST_ID_HEADER_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
LOGGING_SILENCED_401_PATHS = values.ListValue(
|
||||
default=["/api/v1.0/users/me/"],
|
||||
environ_name="LOGGING_SILENCED_401_PATHS",
|
||||
@@ -1227,6 +1259,9 @@ class Base(Configuration):
|
||||
"format": "{asctime} {name} {levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"audit_json": {
|
||||
"()": "core.audit.formatter.AuditJsonFormatter",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"silence_expected_401": {
|
||||
@@ -1239,6 +1274,11 @@ class Base(Configuration):
|
||||
"formatter": "simple",
|
||||
"filters": ["silence_expected_401"],
|
||||
},
|
||||
"audit_console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": AUDIT_LOG_STREAM,
|
||||
"formatter": "audit_json",
|
||||
},
|
||||
},
|
||||
# Override root logger to send it to console
|
||||
"root": {
|
||||
@@ -1271,6 +1311,11 @@ class Base(Configuration):
|
||||
),
|
||||
"propagate": False,
|
||||
},
|
||||
"audit": {
|
||||
"handlers": ["audit_console"],
|
||||
"level": AUDIT_LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1451,6 +1496,8 @@ class Base(Configuration):
|
||||
|
||||
# Ignore the logs added by the DockerflowMiddleware
|
||||
ignore_logger("request.summary")
|
||||
# Audit events are a data stream, not errors to report
|
||||
ignore_logger("audit")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
@@ -1502,16 +1549,31 @@ class Test(Base):
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"audit_json": {
|
||||
"()": "core.audit.formatter.AuditJsonFormatter",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
"audit_console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stdout",
|
||||
"formatter": "audit_json",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"meet": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
"audit": {
|
||||
"handlers": ["audit_console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1297,7 +1297,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
|
||||
Generated
+1
-1
@@ -1484,7 +1484,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "summary"
|
||||
version = "1.34.0"
|
||||
version = "1.33.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
Reference in New Issue
Block a user