mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-08 14:32:32 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc93637da2 | |||
| 49cda567db | |||
| 82f028623f | |||
| 4ca1731df0 | |||
| 22a05207dc |
+6
-15
@@ -10,20 +10,6 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(backend) hash application secrets with SHA-256
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) prevent editing client id and secret in Django admin
|
||||
|
||||
## [1.34.0] - 2026-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) import environment variables from Secrets and ConfigMaps
|
||||
- 🔒(backend) throttle meeting link generation
|
||||
- 🔒️(backend) add a daily cap on room creation
|
||||
@@ -32,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
|
||||
|
||||
@@ -42,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
|
||||
@@ -109,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,304 @@
|
||||
# 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": "9.5.0"},
|
||||
"data_stream": {"type": "logs", "dataset": "meet.audit", "namespace": "default"},
|
||||
"service": {"name": "meet", "environment": "production", "version": "1.34.0", "node": {"name": "meet-backend-6d8f7b9c4-x2x7q"}},
|
||||
"event": {
|
||||
"kind": "event",
|
||||
"id": "0b3f6a0e-5d1c-4f43-9a8e-2b7c1d0e4f5a",
|
||||
"action": "room.create",
|
||||
"category": ["api"],
|
||||
"type": ["creation"],
|
||||
"outcome": "success",
|
||||
"dataset": "meet.audit"
|
||||
},
|
||||
"client": {"ip": "1.2.3.4"},
|
||||
"source": {"ip": "1.2.3.4"},
|
||||
"http": {"request": {"id": "6f1c0d0e-2a8b-4c1d-9e7f-0a1b2c3d4e5f", "method": "POST"}, "response": {"status_code": 201}},
|
||||
"url": {"path": "/external-api/v1.0/rooms/"},
|
||||
"user_agent": {"original": "calendar-app/2.3"},
|
||||
"user": {"id": "beecd833-4be4-4675-b139-a196b07144a9", "domain": "gouv.fr"},
|
||||
"organization": {"id": "calendar-app"},
|
||||
"entity": {
|
||||
"target": {
|
||||
"id": "9ae54744-ae64-44f4-b094-71e137556b66",
|
||||
"sub_type": "room",
|
||||
"name": "Daily standup",
|
||||
"raw": {"slug": "daily-standup", "access_level": "trusted"}
|
||||
}
|
||||
},
|
||||
"lasuite": {
|
||||
"actor": {"type": "application", "sub": "0edebfa3-1355-4891-ae63-daf9fd37ac04"},
|
||||
"auth": {"method": "application_jwt"},
|
||||
"application": {"client_id": "calendar-app"},
|
||||
"outcome": "success"
|
||||
},
|
||||
"log": {"level": "info", "logger": "audit"}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Events follow the [Elastic Common Schema](https://www.elastic.co/docs/reference/ecs) 9.5.0. Only two namespaces
|
||||
hold what ECS does not define: `lasuite.*`, and `entity.target.raw.*` for the fields of a target.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `@timestamp` | ISO 8601 with millisecond precision in UTC timezone |
|
||||
| `data_stream.*`, `event.dataset` | `logs`, `<AUDIT_LOG_SERVICE_NAME>.audit` and `AUDIT_LOG_DATA_STREAM_NAMESPACE`: what tells the audit stream apart from the application logs |
|
||||
| `service.name`, `service.environment`, `service.version`, `service.node.name` | `AUDIT_LOG_SERVICE_NAME`, current environment, release and host name (the pod on Kubernetes): the emitter, never the caller |
|
||||
| `service.origin.name` | The internal peer that called the backend, when the actor is a `service`: `roomkit`, `summary`, `livekit` |
|
||||
| `service.target.name` | The peer service the backend called, as `summary` for `recording.transcript.request` |
|
||||
| `event.id` | Unique id of the event, so that a shipper retrying it cannot duplicate it |
|
||||
| `event.action` | What was attempted, from the catalogue below |
|
||||
| `event.category`, `event.type` | ECS classification (`api`, `authentication`, `iam`... / `creation`, `change`, `access`, `denied`, `user`...). Always a combination ECS expects, see [Classification](#classification) |
|
||||
| `event.outcome` | ECS `success`, `failure`, or `unknown` when the result was reported in terms the backend does not recognise |
|
||||
| `event.reason` | Why it did not succeed: `authentication_failed`, `permission_denied`, `rate_limited`, `validation_error`, `not_found`, `conflict`, `internal_error` |
|
||||
| `lasuite.outcome` | `success`, `failure`, `denied` or `unknown` |
|
||||
| `lasuite.actor.type` | `user`, `application`, `service`, `system` or `anonymous`, see [Actors](#actors) |
|
||||
| `lasuite.actor.sub` | OIDC sub of the account in `user.*`, when it has one: the identity it shares with the other La Suite products |
|
||||
| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `oidc`, `password`, `none`, or `unknown` for 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.domain` | The account whose authority the action used, see [Actors](#actors): primary key and email domain. The email address is never recorded |
|
||||
| `user.roles` | Its privileges at the time of the event: `superuser`, `staff` |
|
||||
| `user.target.id`, `user.target.domain` | The account the action was about: the target when it is a user, or the user an access grants a role to. `user.*` stays the actor |
|
||||
| `organization.id` | Tenant: the application client id when present, else the user's email domain |
|
||||
| `entity.target.*` | The resource acted on, see [Targets](#targets) |
|
||||
| `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 |
|
||||
| `user_agent.original` | The client's `User-Agent`, cut at 1024 characters |
|
||||
| `http.request.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 |
|
||||
| `http.response.status_code` | Status of the response, for API calls and refusals |
|
||||
| `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, denials and unknown outcomes, `error` for internal errors |
|
||||
|
||||
### Targets
|
||||
|
||||
The resource an action was performed on is described as an ECS `entity.target`, the field set ECS 9.3 introduced for
|
||||
"the targeted entity of an action taken":
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `entity.target.id` | Its primary key |
|
||||
| `entity.target.sub_type` | Its model: `room`, `recording`, `user`, `application`, `resourceaccess`... |
|
||||
| `entity.target.type` | Its ECS entity type, when one fits: `user` for an account, `application` for an application |
|
||||
| `entity.target.name` | Its name, when its model registers one |
|
||||
| `entity.target.raw.*` | The other fields its model registers, as a room's `slug` and `access_level`, and the OIDC `sub` of a user |
|
||||
|
||||
ECS still flags the `entity` fields as beta.
|
||||
|
||||
### Classification
|
||||
|
||||
`event.category` and `event.type` only ever combine as the ECS 9.5.0 `expected_event_types` allow:
|
||||
|
||||
- An action declares its category, `api` by default, and its types. A type the category does not expect fails at import.
|
||||
- A refusal adds `denied` only to a category expecting it: an API call is `["creation", "denied"]`, a failed login
|
||||
stays `["start"]`, its refusal told by `event.outcome`, `event.reason` and `lasuite.outcome`.
|
||||
- A 401 is also filed under `authentication`, as in `["api", "authentication"]`, so that it counts as a failed
|
||||
authentication.
|
||||
|
||||
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, named by `service.origin.name`: 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, `entity.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 | `user.target` and `entity.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 | `entity.target` = room |
|
||||
| `room.update` | A room is updated through the external API, or the attempt fails | `entity.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 | `entity.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 | `entity.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 | `entity.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, `unknown` for a status the backend does not map | `service.origin.name` = `livekit`, `entity.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 | `entity.target` = recording |
|
||||
| `recording.transcript.request` | The backend sends a recording to the summary service to be transcribed, or fails to | `lasuite.actor.type` = `system`, `service.target.name` = `summary`, `entity.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 | `service.origin.name` = `summary`, `entity.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.category` = `web`, `event.type` = `access`, `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 `http.request.id`: granting a role
|
||||
on a room produces both `admin.room.update` and `admin.resourceaccess.create`, whose `user.target` is the account
|
||||
granted the role.
|
||||
|
||||
What is 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`, `None` recording no account, 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, the path and the user agent. `http.request.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=`. An explicit `actor_type=` keeps the account the request is signed in as; `actor=None` records none, as
|
||||
for a caller failing to authenticate, an application acting before it is given a delegated user, or the system.
|
||||
|
||||
- **Targets are described** by their model name, primary key and the `fields` their model is registered with, see
|
||||
[Targets](#targets). A model that is not registered is still identified. The account an action is about,
|
||||
`user.target`, is found from the target unless given as `user_target=`. A peer service the backend calls is named with
|
||||
`target_service=`. 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`, and the dataset `<name>.audit`, with any `-` turned into `_` |
|
||||
| `AUDIT_LOG_DATA_STREAM_NAMESPACE` | `default` | `data_stream.namespace`: the data stream events are routed to is `logs-meet.audit-<namespace>`. It must not contain `-` |
|
||||
| `NUM_PROXIES` | `1` | DRF's number of trusted proxies appending to `X-Forwarded-For`, shared with the throttles. The client is the entry that many positions from the right; anything a client injects lands further left and is ignored. `1` matches ingress-nginx defaults; use `2` behind a load balancer that also appends |
|
||||
| `REQUEST_ID_TRUST_HEADER` | `False` | Reuse the inbound request id as `http.request.id`, so 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=..., entity_type=..., user_target=...)`: the `fields`
|
||||
describing a model as a target, the `admin_values` whose before and after values may be recorded in the admin, the
|
||||
`category` of its admin writes, its ECS `entity_type` when one of the allowed values fits (`application`, `user`...),
|
||||
and the attribute holding the account an event on it is about, as `user` for an access. A proxy model falls back to
|
||||
its concrete model. Registering a model twice raises `AlreadyRegistered`, an entity type ECS does not allow
|
||||
`ValueError`.
|
||||
- `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" },
|
||||
|
||||
@@ -483,11 +483,6 @@ class ApplicationAdminForm(forms.ModelForm):
|
||||
if self.instance.pk and self.instance.scopes:
|
||||
self.fields["scopes"].initial = self.instance.scopes
|
||||
|
||||
# On creation: display generated credentials without allowing edits
|
||||
for name in ("client_id", "client_secret"):
|
||||
if name in self.fields:
|
||||
self.fields[name].widget.attrs["readonly"] = True
|
||||
|
||||
|
||||
@admin.register(models.Application)
|
||||
class ApplicationAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -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,34 @@
|
||||
"""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
|
||||
from .utils import exception_type
|
||||
|
||||
__all__ = [
|
||||
"AUDIT_LOGGER_NAME",
|
||||
"LOGIN_ACTION",
|
||||
"LOGOUT_ACTION",
|
||||
"Action",
|
||||
"ActorType",
|
||||
"AlreadyRegistered",
|
||||
"AuditJsonFormatter",
|
||||
"AuditViewMixin",
|
||||
"EventCategory",
|
||||
"EventType",
|
||||
"Outcome",
|
||||
"Reason",
|
||||
"connect_auth_signals",
|
||||
"current_request",
|
||||
"email_domain",
|
||||
"exception_type",
|
||||
"log",
|
||||
"register",
|
||||
"register_auth_method",
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Specs of the actions audit events are emitted for."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .ecs import DEFAULT_CATEGORY, check_classification
|
||||
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. Without a
|
||||
category, the action is an API call.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: EventCategory | None = None
|
||||
types: tuple[EventType, ...] = ()
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate the classification against ECS, 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))
|
||||
check_classification([self.category or DEFAULT_CATEGORY], self.types)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
@@ -0,0 +1,187 @@
|
||||
"""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 enum import Enum
|
||||
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"
|
||||
|
||||
USER_ROLE_FLAGS = (("is_superuser", "superuser"), ("is_staff", "staff"))
|
||||
|
||||
|
||||
class _ActorDefault(Enum):
|
||||
"""Sentinel telling an omitted ``actor`` apart from ``None``(no account)."""
|
||||
|
||||
FROM_REQUEST = "from_request"
|
||||
|
||||
|
||||
FROM_REQUEST = _ActorDefault.FROM_REQUEST
|
||||
|
||||
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 ECS fields identifying a person: id and email domain.
|
||||
|
||||
The id is missing for accounts that were deleted.
|
||||
"""
|
||||
return {
|
||||
"id": str(user.pk) if user.pk is not None else None,
|
||||
"domain": email_domain(getattr(user, "email", None)),
|
||||
}
|
||||
|
||||
|
||||
def user_sub(user) -> str | None:
|
||||
"""Return the OIDC sub of an account, missing for one that never signed in."""
|
||||
return getattr(user, "sub", None) or None
|
||||
|
||||
|
||||
def user_roles(user) -> list[str] | None:
|
||||
"""Return the privileges of an account at the time of the event, if any."""
|
||||
roles = [role for flag, role in USER_ROLE_FLAGS if getattr(user, flag, False)]
|
||||
return roles or None
|
||||
|
||||
|
||||
def describe_actor(
|
||||
request,
|
||||
*,
|
||||
actor: Any = FROM_REQUEST,
|
||||
actor_type: ActorType | str | None = None,
|
||||
client_id: str | None = None,
|
||||
auth_method: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the fields describing the actor.
|
||||
|
||||
That is the ECS ``user`` and ``organization`` fields, the name of the
|
||||
service acting, if any, reported as ``service.origin.name``, and the
|
||||
``lasuite`` ones. Everything is read from ``request`` unless overridden.
|
||||
``actor=None`` means no account, whoever the request is signed in as:
|
||||
an explicit ``actor_type`` alone does not discard it.
|
||||
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 = getattr(request, "user", None) if actor is FROM_REQUEST else actor
|
||||
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)
|
||||
is_account = _is_account(user)
|
||||
|
||||
lasuite: dict[str, Any] = {
|
||||
"actor": {
|
||||
"type": str(ActorType(actor_type)),
|
||||
"sub": user_sub(user) if is_account else None,
|
||||
},
|
||||
"auth": {"method": auth_method or request_auth_method(request)},
|
||||
"application": {"client_id": client_id},
|
||||
}
|
||||
tenant = client_id or (
|
||||
email_domain(getattr(user, "email", None)) if is_account else None
|
||||
)
|
||||
return {
|
||||
"user": (
|
||||
{**describe_user(user), "roles": user_roles(user)} if is_account else None
|
||||
),
|
||||
"organization": {"id": tenant},
|
||||
"service": user.get_username() if _is_service(user) else None,
|
||||
"lasuite": lasuite,
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"""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.WEB, types=(EventType.ACCESS,)
|
||||
)
|
||||
DIFF_ATTRIBUTE = "audit_admin_diff"
|
||||
PENDING_DELETIONS_ATTRIBUTE = "audit_admin_pending_deletions"
|
||||
DELETED_COPIES_ATTRIBUTE = "audit_admin_deleted_copies"
|
||||
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. Deleted objects are reported through the copies ``save_formset``
|
||||
took, as deleting an instance clears its primary key.
|
||||
"""
|
||||
touched = []
|
||||
for formset in formsets or ():
|
||||
forms = {id(form.instance): form for form in formset.forms}
|
||||
deleted_copies = getattr(formset, DELETED_COPIES_ATTRIBUTE, {})
|
||||
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((deleted_copies.get(id(obj), 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 save_formset(self, request, form, formset, change):
|
||||
"""Save the inline objects, keeping copies of those about to be deleted."""
|
||||
setattr(
|
||||
formset,
|
||||
DELETED_COPIES_ATTRIBUTE,
|
||||
{id(f.instance): copy.copy(f.instance) for f in formset.deleted_forms},
|
||||
)
|
||||
super().save_formset(request, form, formset, change)
|
||||
|
||||
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,
|
||||
**(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,187 @@
|
||||
"""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 .actor import FROM_REQUEST
|
||||
from .ecs import DEFAULT_CATEGORY
|
||||
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. The actor is the
|
||||
account of the request unless ``audit_actor`` is assigned, ``None``
|
||||
recording no account. 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 = FROM_REQUEST
|
||||
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. A 401 is also filed under ``authentication``, so that
|
||||
it counts as a failed authentication.
|
||||
"""
|
||||
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
|
||||
}
|
||||
category = category or DEFAULT_CATEGORY
|
||||
fields = {
|
||||
**details,
|
||||
"category": (
|
||||
[category, EventCategory.AUTHENTICATION]
|
||||
if status_code == 401
|
||||
else category
|
||||
),
|
||||
"types": types
|
||||
or [ACTION_TYPES.get(getattr(self, "action", None), EventType.INFO)],
|
||||
"target": self.audit_target,
|
||||
"actor": self.audit_actor,
|
||||
"status_code": status_code,
|
||||
}
|
||||
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
|
||||
),
|
||||
"error": error,
|
||||
}
|
||||
return fields
|
||||
@@ -0,0 +1,134 @@
|
||||
"""What the Elastic Common Schema says about audit events.
|
||||
|
||||
Reference:
|
||||
- https://github.com/elastic/ecs/blob/v9.5.0/schemas/event.yml
|
||||
- https://github.com/elastic/ecs/blob/v9.5.0/schemas/entity.yml
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .enums import EventCategory, EventType
|
||||
|
||||
ECS_VERSION = "9.5.0"
|
||||
|
||||
# The category of an event that names none, as an API call
|
||||
DEFAULT_CATEGORY = EventCategory.API
|
||||
|
||||
# ``expected_event_types`` of the categories in ``EventCategory``
|
||||
EXPECTED_EVENT_TYPES: dict[EventCategory, frozenset[EventType]] = {
|
||||
EventCategory.API: frozenset(
|
||||
{
|
||||
EventType.ACCESS,
|
||||
EventType.ADMIN,
|
||||
EventType.ALLOWED,
|
||||
EventType.CHANGE,
|
||||
EventType.CREATION,
|
||||
EventType.DELETION,
|
||||
EventType.DENIED,
|
||||
EventType.END,
|
||||
EventType.INFO,
|
||||
EventType.START,
|
||||
EventType.USER,
|
||||
}
|
||||
),
|
||||
EventCategory.AUTHENTICATION: frozenset(
|
||||
{EventType.START, EventType.END, EventType.INFO}
|
||||
),
|
||||
EventCategory.CONFIGURATION: frozenset(
|
||||
{
|
||||
EventType.ACCESS,
|
||||
EventType.CHANGE,
|
||||
EventType.CREATION,
|
||||
EventType.DELETION,
|
||||
EventType.INFO,
|
||||
}
|
||||
),
|
||||
EventCategory.EMAIL: frozenset({EventType.INFO}),
|
||||
EventCategory.FILE: frozenset(
|
||||
{
|
||||
EventType.ACCESS,
|
||||
EventType.CHANGE,
|
||||
EventType.CREATION,
|
||||
EventType.DELETION,
|
||||
EventType.INFO,
|
||||
}
|
||||
),
|
||||
EventCategory.IAM: frozenset(
|
||||
{
|
||||
EventType.ADMIN,
|
||||
EventType.CHANGE,
|
||||
EventType.CREATION,
|
||||
EventType.DELETION,
|
||||
EventType.GROUP,
|
||||
EventType.INFO,
|
||||
EventType.USER,
|
||||
}
|
||||
),
|
||||
EventCategory.SESSION: frozenset({EventType.START, EventType.END, EventType.INFO}),
|
||||
EventCategory.WEB: frozenset({EventType.ACCESS, EventType.ERROR, EventType.INFO}),
|
||||
}
|
||||
|
||||
# Allowed values of ``entity.type``
|
||||
ENTITY_TYPES = frozenset(
|
||||
{
|
||||
"application",
|
||||
"bucket",
|
||||
"cloud",
|
||||
"container",
|
||||
"database",
|
||||
"function",
|
||||
"host",
|
||||
"orchestrator",
|
||||
"queue",
|
||||
"service",
|
||||
"session",
|
||||
"user",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_expected(categories: Iterable[EventCategory], event_type: EventType) -> bool:
|
||||
"""Tell whether one of ``categories`` expects ``event_type``."""
|
||||
return any(event_type in EXPECTED_EVENT_TYPES[category] for category in categories)
|
||||
|
||||
|
||||
def check_classification(
|
||||
categories: Iterable[EventCategory | str], types: Iterable[EventType | str]
|
||||
) -> None:
|
||||
"""Raise ``ValueError`` unless every type is expected by one of the categories."""
|
||||
categories = [EventCategory(category) for category in categories]
|
||||
unexpected = [
|
||||
str(event_type)
|
||||
for event_type in (EventType(item) for item in types)
|
||||
if not is_expected(categories, event_type)
|
||||
]
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
f"ECS {ECS_VERSION} does not expect event type {', '.join(unexpected)} "
|
||||
f"in category {', '.join(str(category) for category in categories)}"
|
||||
)
|
||||
|
||||
|
||||
def dataset() -> str:
|
||||
"""Return the dataset of audit events, ``<service>.audit``.
|
||||
|
||||
Data stream names forbid ``-``, so it is replaced by ``_``.
|
||||
"""
|
||||
service_name = getattr(settings, "AUDIT_LOG_SERVICE_NAME", None) or "meet"
|
||||
return f"{service_name}.audit".lower().replace("-", "_")
|
||||
|
||||
|
||||
def stream_fields() -> dict[str, dict[str, str]]:
|
||||
"""Return the ``data_stream`` and ``event.dataset`` fields of audit events."""
|
||||
name = dataset()
|
||||
return {
|
||||
"data_stream": {
|
||||
"type": "logs",
|
||||
"dataset": name,
|
||||
"namespace": getattr(settings, "AUDIT_LOG_DATA_STREAM_NAMESPACE", None)
|
||||
or "default",
|
||||
},
|
||||
"event": {"dataset": name},
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Build ECS audit documents and emit them on the ``audit`` logger."""
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import socket
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .actions import Action
|
||||
from .actor import FROM_REQUEST, describe_actor, describe_user
|
||||
from .ecs import (
|
||||
DEFAULT_CATEGORY,
|
||||
ECS_VERSION,
|
||||
check_classification,
|
||||
is_expected,
|
||||
stream_fields,
|
||||
)
|
||||
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
|
||||
from .request import current_request, current_request_id, resolve_client_ip
|
||||
from .targets import describe_target, user_target_of
|
||||
from .utils import prune_empty, render_value
|
||||
|
||||
AUDIT_LOGGER_NAME = "audit"
|
||||
|
||||
USER_AGENT_MAX_LENGTH = 1024
|
||||
|
||||
_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
|
||||
|
||||
|
||||
@functools.cache
|
||||
def service_version() -> str | None:
|
||||
"""Return the release of the backend."""
|
||||
return getattr(settings, "RELEASE", None)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def service_node_name() -> str:
|
||||
"""Return the name of the node serving, its pod name on Kubernetes."""
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
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 | Iterable[EventCategory | str] | None = None,
|
||||
types: list[EventType | str] | None = None,
|
||||
target: Any = None,
|
||||
user_target: Any = None,
|
||||
actor: Any = FROM_REQUEST,
|
||||
actor_type: ActorType | str | None = None,
|
||||
auth_method: str | None = None,
|
||||
client_id: str | None = None,
|
||||
target_service: 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 fields.
|
||||
|
||||
``action`` is what was attempted: an ``Action``, whose category and types
|
||||
apply unless given here, or a bare dotted name (``room.create``).
|
||||
``category`` may be a list, for an event filed under several.
|
||||
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;
|
||||
``actor=None`` records no account even when the request is signed in.
|
||||
``target`` is the resource acted on, reported as ``entity.target``, and
|
||||
``user_target`` the account the action was about, reported as
|
||||
``user.target``: by default the target if it is a user, or the account
|
||||
registered for its model. ``target_service`` names the peer service the
|
||||
backend called, reported as ``service.target.name``. Any other keyword
|
||||
argument lands under ``lasuite.details``, unless ``None`` or an empty
|
||||
mapping. The value of a detail is data and is kept whole: a ``None`` or an
|
||||
empty mapping inside it, as in the ``from`` and ``to`` of a change, stays.
|
||||
"""
|
||||
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)
|
||||
if user_target is None and target is not None:
|
||||
user_target = user_target_of(target)
|
||||
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,
|
||||
)
|
||||
stream = stream_fields()
|
||||
|
||||
document = prune_empty(
|
||||
{
|
||||
"@timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||
"ecs": {"version": ECS_VERSION},
|
||||
"data_stream": stream["data_stream"],
|
||||
"message": message,
|
||||
"service": {
|
||||
"name": getattr(settings, "AUDIT_LOG_SERVICE_NAME", None),
|
||||
"environment": getattr(settings, "ENVIRONMENT", None),
|
||||
"version": service_version(),
|
||||
"node": {"name": service_node_name()},
|
||||
"origin": {"name": actor_fields["service"]},
|
||||
"target": {"name": target_service},
|
||||
},
|
||||
"event": _event_fields(action, outcome, reason, category, types)
|
||||
| stream["event"],
|
||||
"client": {"ip": client_ip},
|
||||
"source": {"ip": client_ip},
|
||||
"http": {
|
||||
"request": {
|
||||
"id": current_request_id(),
|
||||
"method": getattr(request, "method", None),
|
||||
},
|
||||
"response": {"status_code": status_code},
|
||||
},
|
||||
"url": {"path": getattr(request, "path", None) or None},
|
||||
"user_agent": {"original": _user_agent(request)},
|
||||
"user": {
|
||||
**(actor_fields["user"] or {}),
|
||||
"target": describe_user(user_target) if user_target else None,
|
||||
},
|
||||
"organization": actor_fields["organization"],
|
||||
"entity": {
|
||||
"target": describe_target(target) if target is not None else None
|
||||
},
|
||||
"lasuite": {
|
||||
**actor_fields["lasuite"],
|
||||
"outcome": str(outcome),
|
||||
},
|
||||
"error": {
|
||||
"message": str(error) if error is not None else None,
|
||||
"type": error_type,
|
||||
},
|
||||
}
|
||||
)
|
||||
# Pruned apart and one level deep only: what a detail holds is data
|
||||
if details := prune_empty(render_value(details), depth=1):
|
||||
document["lasuite"]["details"] = details
|
||||
return document
|
||||
|
||||
|
||||
# The allowed kwargs of the``log`` function that fill an event field
|
||||
EVENT_FIELDS = frozenset(
|
||||
name
|
||||
for name, parameter in inspect.signature(build_document).parameters.items()
|
||||
if parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
)
|
||||
|
||||
|
||||
def _user_agent(request) -> str | None:
|
||||
meta = getattr(request, "META", None) or {}
|
||||
return meta.get("HTTP_USER_AGENT", "")[:USER_AGENT_MAX_LENGTH] or None
|
||||
|
||||
|
||||
def _categories(category) -> list[EventCategory]:
|
||||
if category is None:
|
||||
return [DEFAULT_CATEGORY]
|
||||
if isinstance(category, str):
|
||||
return [EventCategory(category)]
|
||||
return list(dict.fromkeys(EventCategory(item) for item in category)) or [
|
||||
DEFAULT_CATEGORY
|
||||
]
|
||||
|
||||
|
||||
def _event_fields(action, outcome, reason, category, types) -> dict[str, Any]:
|
||||
"""Return the ECS ``event`` fields of the event."""
|
||||
categories = _categories(category)
|
||||
type_list = [EventType(item) for item in (types or [])]
|
||||
if not type_list:
|
||||
type_list = [_default_type(outcome, categories)]
|
||||
if (
|
||||
outcome == Outcome.DENIED
|
||||
and EventType.DENIED not in type_list
|
||||
and is_expected(categories, EventType.DENIED)
|
||||
):
|
||||
type_list.append(EventType.DENIED)
|
||||
try:
|
||||
check_classification(categories, type_list)
|
||||
except ValueError:
|
||||
_logger.exception("Audit event %r is misclassified", str(action))
|
||||
|
||||
return {
|
||||
"kind": "event",
|
||||
"id": str(uuid.uuid4()),
|
||||
"action": str(action),
|
||||
"category": [str(item) for item in categories],
|
||||
"type": [str(item) for item in type_list],
|
||||
"outcome": str(Outcome.FAILURE if outcome == Outcome.DENIED else outcome),
|
||||
"reason": str(reason) if reason is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def _default_type(outcome: Outcome, categories: list[EventCategory]) -> EventType:
|
||||
"""Return the type of an event whose action names none.
|
||||
|
||||
``denied`` or ``error`` when one of the categories expects it, else ``info``.
|
||||
"""
|
||||
preferred = {Outcome.DENIED: EventType.DENIED, Outcome.FAILURE: EventType.ERROR}
|
||||
event_type = preferred.get(outcome)
|
||||
if event_type is not None and is_expected(categories, event_type):
|
||||
return event_type
|
||||
return EventType.INFO
|
||||
@@ -0,0 +1,79 @@
|
||||
"""ECS enums shared by every audit event."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class Outcome(StrEnum):
|
||||
"""Whether the audited action succeeded, failed, or was refused.
|
||||
|
||||
``unknown`` is for an action whose result was reported in terms the
|
||||
backend does not recognise.
|
||||
"""
|
||||
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
DENIED = "denied"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
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 ``service.origin.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,39 @@
|
||||
"""Render audit records as single-line ECS JSON format."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .ecs import ECS_VERSION, stream_fields
|
||||
|
||||
|
||||
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):
|
||||
stream = stream_fields()
|
||||
document = {
|
||||
"@timestamp": datetime.fromtimestamp(
|
||||
record.created, tz=timezone.utc
|
||||
).isoformat(timespec="milliseconds"),
|
||||
"ecs": {"version": ECS_VERSION},
|
||||
"data_stream": stream["data_stream"],
|
||||
"message": record.getMessage(),
|
||||
"event": {**stream["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,116 @@
|
||||
"""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(Application, entity_type="application") # ECS ``entity.type``
|
||||
audit.register(ResourceAccess, user_target="user") # the account it is about
|
||||
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 .ecs import ENTITY_TYPES
|
||||
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, under
|
||||
``entity.target``. ``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. ``entity_type`` is the ECS
|
||||
``entity.type`` of the model, when one of its allowed values fits.
|
||||
``user_target`` names the attribute holding the account an event on the
|
||||
model is about, reported as ``user.target``.
|
||||
"""
|
||||
|
||||
fields: tuple[str, ...] = ()
|
||||
admin_values: tuple[str, ...] = ()
|
||||
category: EventCategory | None = None
|
||||
entity_type: str | None = None
|
||||
user_target: str | None = None
|
||||
|
||||
|
||||
_models: dict[type[Model], ModelOptions] = {}
|
||||
_auth_methods: dict[str, str] = {}
|
||||
|
||||
|
||||
def register( # noqa: PLR0913 # pylint: disable=too-many-arguments
|
||||
model: type[Model],
|
||||
*,
|
||||
fields=(),
|
||||
admin_values=(),
|
||||
category: EventCategory | str | None = None,
|
||||
entity_type: str | None = None,
|
||||
user_target: 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
|
||||
if entity_type is not None and entity_type not in ENTITY_TYPES:
|
||||
raise ValueError(
|
||||
f"{entity_type!r} is not an ECS entity type: "
|
||||
f"use one of {', '.join(sorted(ENTITY_TYPES))}"
|
||||
)
|
||||
_models[model] = ModelOptions(
|
||||
fields=tuple(fields),
|
||||
admin_values=tuple(admin_values),
|
||||
category=EventCategory(category) if category is not None else None,
|
||||
entity_type=entity_type,
|
||||
user_target=user_target,
|
||||
)
|
||||
|
||||
|
||||
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,87 @@
|
||||
"""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,
|
||||
and never the account the session is already signed in as.
|
||||
"""
|
||||
log(
|
||||
LOGIN_ACTION,
|
||||
outcome=Outcome.DENIED,
|
||||
reason=Reason.AUTHENTICATION_FAILED,
|
||||
request=request,
|
||||
actor=None,
|
||||
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,90 @@
|
||||
"""Describe the resource an audit event is about, as an ECS ``entity.target``.
|
||||
|
||||
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 user_sub
|
||||
from .registry import model_options
|
||||
from .utils import prune_empty, render_value
|
||||
|
||||
# A registered field ECS defines on ``entity``
|
||||
NAME_FIELD = "name"
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_user(obj: Any) -> bool:
|
||||
return isinstance(obj, get_user_model())
|
||||
|
||||
|
||||
def describe_target(obj: Any) -> dict[str, Any]:
|
||||
"""Return the ``entity.target`` fields of a target.
|
||||
|
||||
``id`` is its primary key and ``sub_type`` its model name. ``type`` is the
|
||||
ECS entity type registered for its model, ``user`` for a user. A
|
||||
registered field called ``name`` is reported as ``name``, the others
|
||||
under ``raw``, as the OIDC sub of a user. Empty values are left out, as is
|
||||
a registered field that cannot be read, which is reported.
|
||||
A mapping is taken as already described.
|
||||
"""
|
||||
if isinstance(obj, Mapping):
|
||||
return dict(obj)
|
||||
if not isinstance(obj, Model):
|
||||
return {"id": str(obj), "sub_type": obj.__class__.__name__.lower()}
|
||||
|
||||
meta = obj._meta # noqa: SLF001
|
||||
options = model_options(meta.model)
|
||||
entity_type = options.entity_type or ("user" if _is_user(obj) else None)
|
||||
raw: dict[str, Any] = {}
|
||||
for name in options.fields:
|
||||
try:
|
||||
raw[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 _is_user(obj):
|
||||
raw["sub"] = user_sub(obj)
|
||||
return prune_empty(
|
||||
{
|
||||
"id": str(obj.pk) if obj.pk is not None else None,
|
||||
"type": [entity_type] if entity_type else None,
|
||||
"sub_type": meta.model_name,
|
||||
"name": raw.pop(NAME_FIELD, None),
|
||||
"raw": raw,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def user_target_of(obj: Any) -> Any:
|
||||
"""Return the account an event on ``obj`` is about, if any.
|
||||
|
||||
It is ``obj`` itself for a user, else the attribute registered as the
|
||||
``user_target`` of its model, as the user an access is granted to.
|
||||
"""
|
||||
if _is_user(obj):
|
||||
return obj
|
||||
if not isinstance(obj, Model):
|
||||
return None
|
||||
meta = obj._meta # noqa: SLF001
|
||||
attribute = model_options(meta.model).user_target
|
||||
if attribute is None:
|
||||
return None
|
||||
try:
|
||||
user = getattr(obj, attribute)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception(
|
||||
"Audit user target %r of %s could not be read", attribute, meta.label
|
||||
)
|
||||
return None
|
||||
return user if _is_user(user) else None
|
||||
@@ -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,52 @@
|
||||
"""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, depth: int | None = None) -> Any:
|
||||
"""Drop ``None`` values and empty mappings, recursively.
|
||||
|
||||
With ``depth``, only that many levels are pruned: the values below are
|
||||
kept whole, ``None`` and empty mappings included.
|
||||
"""
|
||||
if not isinstance(value, Mapping) or depth == 0:
|
||||
return value
|
||||
pruned = {}
|
||||
for key, item in value.items():
|
||||
cleaned = prune_empty(item, None if depth is None else depth - 1)
|
||||
if cleaned is None or (isinstance(cleaned, Mapping) and not cleaned):
|
||||
continue
|
||||
pruned[key] = cleaned
|
||||
return pruned
|
||||
@@ -0,0 +1,109 @@
|
||||
"""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,
|
||||
entity_type="application",
|
||||
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",),
|
||||
user_target="user",
|
||||
)
|
||||
audit.register(
|
||||
models.RecordingAccess,
|
||||
category=EventCategory.IAM,
|
||||
admin_values=("role",),
|
||||
user_target="user",
|
||||
)
|
||||
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,9 +1,9 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
import copy
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
@@ -22,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
|
||||
@@ -34,18 +34,20 @@ 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_actor = None
|
||||
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):
|
||||
@@ -67,19 +69,24 @@ 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)
|
||||
except models.Application.DoesNotExist as e:
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
||||
|
||||
if not application.check_client_secret(client_secret):
|
||||
if not check_password(client_secret, application.client_secret):
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
||||
|
||||
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:
|
||||
@@ -91,11 +98,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.",
|
||||
@@ -104,7 +106,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:
|
||||
@@ -133,13 +135,43 @@ 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. Either
|
||||
way, a session the request carries never makes its account the actor.
|
||||
"""
|
||||
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,
|
||||
@@ -162,6 +194,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,
|
||||
@@ -190,29 +229,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,
|
||||
@@ -230,6 +262,7 @@ class RoomViewSet(
|
||||
def perform_create(self, serializer: serializers.RoomSerializer):
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
room = serializer.save()
|
||||
self.audit_target = room
|
||||
models.ResourceAccess.objects.create(
|
||||
resource=room,
|
||||
user=self.request.user,
|
||||
|
||||
@@ -7,8 +7,6 @@ from logging import getLogger
|
||||
from django.contrib.auth.hashers import identify_hasher, make_password
|
||||
from django.db import models
|
||||
|
||||
from .hashers import CLIENT_SECRET_HASH_PATTERN
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@@ -26,14 +24,6 @@ class SecretField(models.CharField):
|
||||
|
||||
secret = getattr(model_instance, self.attname)
|
||||
|
||||
if CLIENT_SECRET_HASH_PATTERN.fullmatch(secret):
|
||||
logger.debug(
|
||||
"%s: %s is already hashed with sha256.",
|
||||
model_instance,
|
||||
self.attname,
|
||||
)
|
||||
return secret
|
||||
|
||||
try:
|
||||
hasher = identify_hasher(secret)
|
||||
logger.debug(
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Application secrets only: keep fast hashing out of PASSWORD_HASHERS.
|
||||
|
||||
Secrets must be securely randomly generated, not human-chosen.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.utils.crypto import constant_time_compare
|
||||
from django.utils.encoding import force_bytes
|
||||
|
||||
CLIENT_SECRET_HASH_ALGORITHM = "sha256" # noqa: S105
|
||||
CLIENT_SECRET_HASH_VERSION = "v0" # noqa: S105
|
||||
CLIENT_SECRET_HASH_PREFIX = (
|
||||
f"{CLIENT_SECRET_HASH_ALGORITHM}${CLIENT_SECRET_HASH_VERSION}$"
|
||||
)
|
||||
|
||||
# Accept only the versioned format: sha256$v0$<digest>.
|
||||
CLIENT_SECRET_HASH_PATTERN = re.compile(
|
||||
rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}(?P<digest>[0-9a-f]{{64}})"
|
||||
)
|
||||
|
||||
|
||||
def _digest(raw_secret):
|
||||
"""Return the hex SHA-256 digest of a raw secret."""
|
||||
return hashlib.sha256(force_bytes(raw_secret)).hexdigest()
|
||||
|
||||
|
||||
def hash_client_secret(raw_secret):
|
||||
"""Hash a machine-generated application secret without key stretching."""
|
||||
return f"{CLIENT_SECRET_HASH_PREFIX}{_digest(raw_secret)}"
|
||||
|
||||
|
||||
def verify_client_secret(raw_secret, encoded):
|
||||
"""Verify the versioned application format or a legacy Django password hash."""
|
||||
if raw_secret is None:
|
||||
return False
|
||||
|
||||
match = CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)
|
||||
|
||||
# Legacy path
|
||||
if not match:
|
||||
return check_password(raw_secret, encoded)
|
||||
|
||||
return constant_time_compare(match["digest"], _digest(raw_secret))
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Add a separate fast hash while preserving legacy credentials for rollback."""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0024_room_last_started_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="application",
|
||||
name="client_secret_sha256",
|
||||
field=models.CharField(
|
||||
max_length=255, null=True, blank=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-10-07 18:45
|
||||
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
from django.db.models.functions import Upper
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('core', '0025_application_client_secret_sha256'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
Upper("email"),
|
||||
name="user_email_upper_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
+17
-65
@@ -14,7 +14,6 @@ from typing import List, Optional
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.auth.hashers import identify_hasher
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
@@ -26,7 +25,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from lasuite.tools.email import get_domain_from_email
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from . import fields, hashers, utils
|
||||
from . import fields, utils
|
||||
from .recording.enums import FileExtension
|
||||
from .validators import sub_validator
|
||||
|
||||
@@ -246,9 +245,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
name="unique_email_when_sub_is_null",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
@@ -706,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."""
|
||||
@@ -828,9 +840,6 @@ class Application(BaseModel):
|
||||
default=utils.generate_client_secret,
|
||||
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
|
||||
)
|
||||
client_secret_sha256 = models.CharField(
|
||||
max_length=255, null=True, blank=True, editable=False
|
||||
)
|
||||
scopes = ArrayField(
|
||||
models.CharField(max_length=50, choices=ApplicationScope.choices),
|
||||
default=list,
|
||||
@@ -846,63 +855,6 @@ class Application(BaseModel):
|
||||
def __str__(self):
|
||||
return f"{self.name!s}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Populate the fast hash on creation when the raw secret is available."""
|
||||
if self._state.adding:
|
||||
# Prevent hashing an existing hash instead of the original secret
|
||||
try:
|
||||
if not hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(self.client_secret):
|
||||
identify_hasher(self.client_secret)
|
||||
except ValueError:
|
||||
# SecretField.pre_save hashes the legacy field after this method
|
||||
self.client_secret_sha256 = hashers.hash_client_secret(
|
||||
self.client_secret
|
||||
)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def rotate_client_secret(self):
|
||||
"""Persist a new generated secret and return its raw value to the caller.
|
||||
|
||||
This is the only supported rotation path while both credential fields coexist.
|
||||
Direct writes may leave a stale fast hash that still accepts the revoked secret,
|
||||
while saving a stale instance may restore previous credentials.
|
||||
|
||||
This transitional risk is accepted until the legacy field is removed
|
||||
and rotation writes only the fast hash.
|
||||
"""
|
||||
secret = utils.generate_client_secret()
|
||||
self.client_secret = secret
|
||||
self.client_secret_sha256 = hashers.hash_client_secret(secret)
|
||||
self.save(update_fields=["client_secret", "client_secret_sha256"])
|
||||
return secret
|
||||
|
||||
def check_client_secret(self, raw_secret):
|
||||
"""Verify the secret and lazily populate its fast hash for future logins."""
|
||||
if self.client_secret_sha256 is not None:
|
||||
return hashers.verify_client_secret(raw_secret, self.client_secret_sha256)
|
||||
|
||||
original_hash = self.client_secret
|
||||
if not hashers.verify_client_secret(raw_secret, original_hash):
|
||||
return False
|
||||
|
||||
encoded = hashers.hash_client_secret(raw_secret)
|
||||
updated = Application.objects.filter(
|
||||
pk=self.pk, client_secret=original_hash, client_secret_sha256__isnull=True
|
||||
).update(client_secret_sha256=encoded)
|
||||
|
||||
if updated:
|
||||
self.client_secret_sha256 = encoded
|
||||
return True
|
||||
|
||||
try:
|
||||
self.refresh_from_db()
|
||||
except Application.DoesNotExist:
|
||||
return False
|
||||
|
||||
current_hash = self.client_secret_sha256 or self.client_secret
|
||||
return hashers.verify_client_secret(raw_secret, current_hash)
|
||||
|
||||
def can_delegate_email(self, email):
|
||||
"""Check if this application can delegate the given email."""
|
||||
|
||||
|
||||
@@ -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,32 @@ 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=None,
|
||||
actor_type=audit.ActorType.SYSTEM,
|
||||
target=recording,
|
||||
target_service="summary",
|
||||
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 +316,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 +370,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 +399,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,28 @@ 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.
|
||||
|
||||
An egress status the backend does not map has an unknown outcome.
|
||||
"""
|
||||
if event is None:
|
||||
outcome = audit.Outcome.UNKNOWN
|
||||
elif event in UNSUCCESSFUL_EVENTS:
|
||||
outcome = audit.Outcome.FAILURE
|
||||
else:
|
||||
outcome = audit.Outcome.SUCCESS
|
||||
audit.log(
|
||||
auditing.RECORDING_END,
|
||||
actor=MachineUser("livekit"),
|
||||
auth_method="shared_secret",
|
||||
target=recording,
|
||||
outcome=outcome,
|
||||
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,21 @@ 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=None,
|
||||
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 +97,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=audit.exception_type(e),
|
||||
)
|
||||
|
||||
if user:
|
||||
return user, False
|
||||
@@ -108,3 +114,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,447 @@
|
||||
"""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["entity"]["target"]["sub_type"] == "room"
|
||||
assert event["entity"]["target"]["raw"]["slug"] == "weekly-sync"
|
||||
assert event["lasuite"]["details"]["changes"]["name"] == {
|
||||
"from": None,
|
||||
"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_configuration_change_keeps_nulls_and_empty_objects(
|
||||
audit_events, staff_client
|
||||
):
|
||||
"""A null or an empty object in the configuration is a value, recorded as is."""
|
||||
room = RoomFactory(configuration={"a": None})
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/room/{room.pk}/change/",
|
||||
room_payload(
|
||||
room,
|
||||
configuration='{"a": {}}',
|
||||
**{"initial-configuration": '{"a": null}'},
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
[event] = find_events(audit_events, "admin.room.update")
|
||||
|
||||
assert event["lasuite"]["details"]["changes"] == {
|
||||
"configuration": {"from": {"a": None}, "to": {"a": {}}}
|
||||
}
|
||||
|
||||
|
||||
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["entity"]["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["entity"]["target"]["raw"]["role"] == "owner"
|
||||
assert access_event["lasuite"]["details"]["changes"]["role"] == {
|
||||
"from": None,
|
||||
"to": "owner",
|
||||
}
|
||||
# The account granted the role is the target user
|
||||
assert access_event["user"]["target"]["id"] == str(user.pk)
|
||||
# The grant and the room change belong to the same request.
|
||||
assert access_event["http"]["request"]["id"] == room_event["http"]["request"]["id"]
|
||||
|
||||
|
||||
def test_inline_access_removal_identifies_the_removed_access(
|
||||
audit_events, staff_client
|
||||
):
|
||||
"""An access removed through the inline is identified by its primary key."""
|
||||
room = RoomFactory()
|
||||
owner = UserResourceAccessFactory(resource=room, role=models.RoleChoices.OWNER)
|
||||
member = UserResourceAccessFactory(resource=room, role=models.RoleChoices.MEMBER)
|
||||
inline = {"accesses-INITIAL_FORMS": "2"}
|
||||
for index, access in enumerate((owner, member)):
|
||||
inline.update(
|
||||
{
|
||||
f"accesses-{index}-id": str(access.pk),
|
||||
f"accesses-{index}-resource": str(room.pk),
|
||||
f"accesses-{index}-user": str(access.user.pk),
|
||||
f"accesses-{index}-role": access.role,
|
||||
}
|
||||
)
|
||||
inline["accesses-1-DELETE"] = "on"
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/room/{room.pk}/change/",
|
||||
room_payload(room, accesses=2, **inline),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert not models.ResourceAccess.objects.filter(pk=member.pk).exists()
|
||||
|
||||
[event] = find_events(audit_events, "admin.resourceaccess.delete")
|
||||
|
||||
assert event["event"]["type"] == ["deletion"]
|
||||
assert event["entity"]["target"]["id"] == str(member.pk)
|
||||
|
||||
|
||||
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), "domain": "example.com"}
|
||||
assert event["entity"]["target"]["raw"] == {"sub": user.sub}
|
||||
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["entity"]["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["entity"]["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["entity"]["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["entity"]["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"] == ["web"]
|
||||
assert event["event"]["type"] == ["access"]
|
||||
assert event["event"]["outcome"] == "failure"
|
||||
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,371 @@
|
||||
"""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"},
|
||||
"response": {"status_code": 200},
|
||||
}
|
||||
|
||||
|
||||
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"] == ["api", "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["entity"]["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["entity"]["target"]["id"] == str(room.pk)
|
||||
assert event["entity"]["target"]["sub_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 also files the event under ``authentication``, whatever the spec.
|
||||
|
||||
Neither category expects the ``denied`` type, so it is not added.
|
||||
"""
|
||||
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"] == ["iam", "authentication"]
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
assert event["event"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["outcome"] == "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,171 @@
|
||||
"""Tests holding audit events to the Elastic Common Schema 9.5.0."""
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
|
||||
from core import audit, auditing
|
||||
from core.audit import ecs
|
||||
from core.audit.admin import ADMIN_ACCESS_ACTION, AdminVerb, category_for, types_for
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.recording.event.authentication import MachineUser
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# The ECS 9.5.0 fields audit events may carry, from
|
||||
# https://github.com/elastic/ecs/blob/v9.5.0/generated/ecs/ecs_flat.yml
|
||||
ECS_FIELDS = frozenset(
|
||||
{
|
||||
"@timestamp",
|
||||
"client.ip",
|
||||
"data_stream.dataset",
|
||||
"data_stream.namespace",
|
||||
"data_stream.type",
|
||||
"ecs.version",
|
||||
"entity.target.id",
|
||||
"entity.target.name",
|
||||
"entity.target.sub_type",
|
||||
"entity.target.type",
|
||||
"error.message",
|
||||
"error.stack_trace",
|
||||
"error.type",
|
||||
"event.action",
|
||||
"event.category",
|
||||
"event.dataset",
|
||||
"event.id",
|
||||
"event.kind",
|
||||
"event.outcome",
|
||||
"event.reason",
|
||||
"event.type",
|
||||
"http.request.id",
|
||||
"http.request.method",
|
||||
"http.response.status_code",
|
||||
"log.level",
|
||||
"log.logger",
|
||||
"message",
|
||||
"organization.id",
|
||||
"service.environment",
|
||||
"service.name",
|
||||
"service.node.name",
|
||||
"service.origin.name",
|
||||
"service.target.name",
|
||||
"service.version",
|
||||
"source.ip",
|
||||
"url.path",
|
||||
"user.domain",
|
||||
"user.id",
|
||||
"user.roles",
|
||||
"user.target.domain",
|
||||
"user.target.id",
|
||||
"user_agent.original",
|
||||
}
|
||||
)
|
||||
# What is not ECS lives in these namespaces: ``raw`` holds a target's own fields
|
||||
CUSTOM_NAMESPACES = ("lasuite.", "entity.target.raw.")
|
||||
|
||||
|
||||
def leaf_paths(document: Mapping[str, Any], prefix: str = "") -> Iterator[str]:
|
||||
"""Yield the dotted path of every value of a document."""
|
||||
for key, value in document.items():
|
||||
path = f"{prefix}{key}"
|
||||
if isinstance(value, Mapping):
|
||||
yield from leaf_paths(value, f"{path}.")
|
||||
else:
|
||||
yield path
|
||||
|
||||
|
||||
def declared_actions() -> list[audit.Action]:
|
||||
"""Return every action the project and the facility declare."""
|
||||
actions = [
|
||||
value for value in vars(auditing).values() if isinstance(value, audit.Action)
|
||||
]
|
||||
return [*actions, audit.LOGIN_ACTION, audit.LOGOUT_ACTION, ADMIN_ACCESS_ACTION]
|
||||
|
||||
|
||||
def test_check_classification_accepts_expected_types():
|
||||
"""A type is valid when one of the categories expects it."""
|
||||
ecs.check_classification([audit.EventCategory.IAM], [audit.EventType.USER])
|
||||
ecs.check_classification(
|
||||
[audit.EventCategory.API, audit.EventCategory.AUTHENTICATION],
|
||||
[audit.EventType.CREATION, audit.EventType.DENIED],
|
||||
)
|
||||
|
||||
|
||||
def test_check_classification_refuses_unexpected_types():
|
||||
"""ECS expects no ``denied`` for an authentication, nor ``end`` on the web."""
|
||||
with pytest.raises(ValueError, match="denied"):
|
||||
ecs.check_classification(
|
||||
[audit.EventCategory.AUTHENTICATION], [audit.EventType.DENIED]
|
||||
)
|
||||
with pytest.raises(ValueError, match="end"):
|
||||
ecs.check_classification([audit.EventCategory.WEB], [audit.EventType.END])
|
||||
|
||||
|
||||
def test_every_category_has_its_expected_types():
|
||||
"""The subset of categories the facility uses is fully transcribed."""
|
||||
assert set(ecs.EXPECTED_EVENT_TYPES) == set(audit.EventCategory)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", declared_actions(), ids=str)
|
||||
def test_declared_actions_are_classified_as_ecs_expects(action):
|
||||
"""Every action of the catalogue is classified as ECS expects.
|
||||
|
||||
``Action`` refuses anything else when it is declared: this lists them.
|
||||
"""
|
||||
ecs.check_classification([action.category or ecs.DEFAULT_CATEGORY], action.types)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verb", list(AdminVerb))
|
||||
def test_admin_writes_are_classified_as_ecs_expects(verb):
|
||||
"""Writes made through the admin are classified as ECS expects, for any model."""
|
||||
for model in apps.get_models():
|
||||
ecs.check_classification([category_for(model)], types_for(model, verb))
|
||||
|
||||
|
||||
def _documents(audit_events) -> list[dict[str, Any]]:
|
||||
"""Emit the events covering every field the facility fills."""
|
||||
user = UserFactory(is_staff=True)
|
||||
request = RequestFactory().post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
REMOTE_ADDR="1.2.3.4",
|
||||
HTTP_USER_AGENT="Mozilla/5.0",
|
||||
)
|
||||
request.user = user
|
||||
request.auth = {"client_id": "app-1"}
|
||||
|
||||
audit.log(
|
||||
auditing.ROOM_UPDATE,
|
||||
request=request,
|
||||
target=RoomFactory(),
|
||||
status_code=500,
|
||||
outcome=audit.Outcome.FAILURE,
|
||||
reason=audit.Reason.INTERNAL_ERROR,
|
||||
error="boom",
|
||||
error_type="builtins.RuntimeError",
|
||||
message="anything",
|
||||
updated_fields=["name"],
|
||||
)
|
||||
audit.log(auditing.USER_PROVISION, target=user)
|
||||
audit.log(auditing.APPLICATION_TOKEN_ISSUE, target=ApplicationFactory())
|
||||
audit.log(
|
||||
auditing.RECORDING_TRANSCRIPT_REQUEST,
|
||||
actor=MachineUser("livekit"),
|
||||
target_service="summary",
|
||||
)
|
||||
return audit_events
|
||||
|
||||
|
||||
def test_documents_only_carry_ecs_fields_or_custom_namespaces(audit_events):
|
||||
"""No field outside ECS 9.5.0 lands anywhere but in a custom namespace."""
|
||||
for document in _documents(audit_events):
|
||||
stray = {
|
||||
path
|
||||
for path in leaf_paths(document)
|
||||
if path not in ECS_FIELDS and not path.startswith(CUSTOM_NAMESPACES)
|
||||
}
|
||||
|
||||
assert not stray, f"{document['event']['action']}: {sorted(stray)}"
|
||||
@@ -0,0 +1,744 @@
|
||||
"""Tests for building and emitting audit events."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
|
||||
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 emitter
|
||||
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 ApplicationFactory, 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."""
|
||||
with (
|
||||
mock.patch.object(emitter, "service_version", return_value="1.2.3"),
|
||||
mock.patch.object(emitter, "service_node_name", return_value="node-1"),
|
||||
):
|
||||
audit.log("room.create", target={"sub_type": "room", "id": "1"}, extra="x")
|
||||
|
||||
assert len(audit_events) == 1
|
||||
|
||||
event = audit_events[0]
|
||||
event_id = event["event"].pop("id")
|
||||
|
||||
assert uuid.UUID(event_id).version == 4
|
||||
assert event["ecs"] == {"version": "9.5.0"}
|
||||
assert event["data_stream"] == {
|
||||
"type": "logs",
|
||||
"dataset": "meet.audit",
|
||||
"namespace": "default",
|
||||
}
|
||||
assert event["service"] == {
|
||||
"name": "meet",
|
||||
"environment": "test",
|
||||
"version": "1.2.3",
|
||||
"node": {"name": "node-1"},
|
||||
}
|
||||
assert event["event"] == {
|
||||
"kind": "event",
|
||||
"dataset": "meet.audit",
|
||||
"action": "room.create",
|
||||
"category": ["api"],
|
||||
"type": ["info"],
|
||||
"outcome": "success",
|
||||
}
|
||||
assert event["entity"] == {"target": {"sub_type": "room", "id": "1"}}
|
||||
assert event["lasuite"] == {
|
||||
"actor": {"type": "system"},
|
||||
"auth": {"method": "none"},
|
||||
"outcome": "success",
|
||||
"details": {"extra": "x"},
|
||||
}
|
||||
assert event["log"]["level"] == "info"
|
||||
assert "user" not in event
|
||||
assert "organization" not in event
|
||||
|
||||
|
||||
def test_audit_log_identifies_each_event(audit_events):
|
||||
"""Every event has its own id, so a shipper retrying it cannot duplicate it."""
|
||||
audit.log("something")
|
||||
audit.log("something")
|
||||
|
||||
assert audit_events[0]["event"]["id"] != audit_events[1]["event"]["id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_name,namespace,dataset",
|
||||
[
|
||||
("meet", "default", "meet.audit"),
|
||||
("La-Suite-Meet", "production", "la_suite_meet.audit"),
|
||||
],
|
||||
)
|
||||
def test_audit_log_routes_to_its_data_stream(
|
||||
audit_events, settings, service_name, namespace, dataset
|
||||
):
|
||||
"""The dataset follows the service name, minus what data stream names forbid."""
|
||||
settings.AUDIT_LOG_SERVICE_NAME = service_name
|
||||
settings.AUDIT_LOG_DATA_STREAM_NAMESPACE = namespace
|
||||
|
||||
audit.log("something")
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["data_stream"] == {
|
||||
"type": "logs",
|
||||
"dataset": dataset,
|
||||
"namespace": namespace,
|
||||
}
|
||||
assert event["event"]["dataset"] == dataset
|
||||
|
||||
|
||||
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", ["info"])),
|
||||
("denied", "permission_denied", ("warning", "failure", ["denied"])),
|
||||
("failure", "internal_error", ("error", "failure", ["info"])),
|
||||
("unknown", None, ("warning", "unknown", ["info"])),
|
||||
],
|
||||
)
|
||||
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 carries the ``denied`` ECS type in a category expecting it."""
|
||||
audit.log("something", outcome="denied", reason="rate_limited", types=["access"])
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["event"]["type"] == ["access", "denied"]
|
||||
|
||||
|
||||
def test_audit_log_denied_type_only_where_ecs_expects_it(audit_events):
|
||||
"""ECS expects no ``denied`` type for an authentication, so none is added."""
|
||||
audit.log(
|
||||
"user.login",
|
||||
outcome="denied",
|
||||
reason="authentication_failed",
|
||||
category=audit.EventCategory.AUTHENTICATION,
|
||||
types=[audit.EventType.START],
|
||||
)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["event"]["type"] == ["start"]
|
||||
assert event["event"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
|
||||
|
||||
def test_audit_log_failure_type_is_error_where_ecs_expects_it(audit_events):
|
||||
"""A failure without types is an ``error`` in a category expecting one."""
|
||||
audit.log("something", outcome="failure", category=audit.EventCategory.WEB)
|
||||
|
||||
assert audit_events[0]["event"]["type"] == ["error"]
|
||||
|
||||
|
||||
def test_audit_log_accepts_several_categories(audit_events):
|
||||
"""An event may be filed under several categories, each listed once."""
|
||||
audit.log(
|
||||
"room.create",
|
||||
category=[
|
||||
audit.EventCategory.API,
|
||||
audit.EventCategory.AUTHENTICATION,
|
||||
audit.EventCategory.API,
|
||||
],
|
||||
types=[audit.EventType.CREATION],
|
||||
)
|
||||
|
||||
assert audit_events[0]["event"]["category"] == ["api", "authentication"]
|
||||
|
||||
|
||||
def test_audit_log_reports_a_misclassified_event_and_still_emits_it(
|
||||
audit_events, caplog
|
||||
):
|
||||
"""A classification ECS does not expect is an error, yet the event is kept."""
|
||||
with caplog.at_level(logging.ERROR, logger="core.audit.emitter"):
|
||||
audit.log(
|
||||
"something",
|
||||
category=audit.EventCategory.AUTHENTICATION,
|
||||
types=[audit.EventType.CREATION],
|
||||
)
|
||||
|
||||
assert audit_events[0]["event"]["type"] == ["creation"]
|
||||
assert "misclassified" in caplog.text
|
||||
|
||||
|
||||
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_action_spec_validates_its_classification_against_ecs():
|
||||
"""A type the category does not expect in ECS fails where it is declared."""
|
||||
with pytest.raises(ValueError, match="does not expect event type end"):
|
||||
audit.Action(
|
||||
"thing.grant",
|
||||
category=audit.EventCategory.WEB,
|
||||
types=(audit.EventType.END,),
|
||||
)
|
||||
with pytest.raises(ValueError, match="in category api"):
|
||||
audit.Action("thing.grant", types=(audit.EventType.ERROR,))
|
||||
|
||||
|
||||
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["entity"]["target"] == {
|
||||
"id": str(room.pk),
|
||||
"sub_type": "room",
|
||||
"raw": {"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]["entity"]["target"] == {
|
||||
"id": str(room.pk),
|
||||
"sub_type": "room",
|
||||
"name": "Daily standup",
|
||||
"raw": {"slug": room.slug, "access_level": room.access_level},
|
||||
}
|
||||
assert "user" not in audit_events[0]
|
||||
|
||||
|
||||
def test_audit_log_reports_a_user_target_as_user_target(audit_events):
|
||||
"""A user acted on is the ``user.target``, its sub only in ``entity.target``."""
|
||||
user = UserFactory(email="jane@example.org")
|
||||
|
||||
audit.log("user.provision", target=user)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["user"] == {"target": {"id": str(user.pk), "domain": "example.org"}}
|
||||
assert event["entity"]["target"] == {
|
||||
"id": str(user.pk),
|
||||
"type": ["user"],
|
||||
"sub_type": "user",
|
||||
"raw": {"sub": user.sub},
|
||||
}
|
||||
|
||||
|
||||
def test_audit_log_reports_the_registered_user_target(audit_events):
|
||||
"""An access names the user it grants a role to as the ``user.target``."""
|
||||
room = RoomFactory(users=[(UserFactory(email="jane@example.org"), "member")])
|
||||
access = room.accesses.get()
|
||||
|
||||
audit.log("thing.grant", target=access)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["user"] == {
|
||||
"target": {"id": str(access.user_id), "domain": "example.org"}
|
||||
}
|
||||
assert event["entity"]["target"]["sub_type"] == "resourceaccess"
|
||||
|
||||
|
||||
def test_audit_log_explicit_user_target_wins(audit_events):
|
||||
"""A user target given to ``log`` wins over the one of the target."""
|
||||
room = RoomFactory(users=[(UserFactory(), "member")])
|
||||
other = UserFactory(email="other@example.net")
|
||||
|
||||
audit.log("thing.grant", target=room.accesses.get(), user_target=other)
|
||||
|
||||
assert audit_events[0]["user"]["target"]["id"] == str(other.pk)
|
||||
|
||||
|
||||
def test_audit_log_registered_entity_type(audit_events):
|
||||
"""A model registered with an ECS entity type reports it."""
|
||||
application = ApplicationFactory()
|
||||
|
||||
audit.log("thing.grant", target=application)
|
||||
|
||||
target = audit_events[0]["entity"]["target"]
|
||||
|
||||
assert target["type"] == ["application"]
|
||||
assert target["sub_type"] == "application"
|
||||
assert target["name"] == application.name
|
||||
|
||||
|
||||
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 request id from dockerflow."""
|
||||
token = request_id_context.set("request-1")
|
||||
request = RequestFactory().post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
data="{}",
|
||||
content_type="application/json",
|
||||
REMOTE_ADDR="1.2.3.4",
|
||||
HTTP_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64)",
|
||||
)
|
||||
|
||||
try:
|
||||
audit.log("anything", request=request)
|
||||
finally:
|
||||
request_id_context.reset(token)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["http"] == {"request": {"id": "request-1", "method": "POST"}}
|
||||
assert event["url"] == {"path": "/external-api/v1.0/rooms/"}
|
||||
assert event["client"] == {"ip": "1.2.3.4"}
|
||||
assert event["user_agent"] == {"original": "Mozilla/5.0 (X11; Linux x86_64)"}
|
||||
assert "trace" not in event
|
||||
|
||||
|
||||
def test_audit_log_truncates_the_user_agent(audit_events):
|
||||
"""A user agent is cut where ECS stops indexing it."""
|
||||
request = RequestFactory().get("/", HTTP_USER_AGENT="x" * 5000)
|
||||
|
||||
audit.log("anything", request=request)
|
||||
|
||||
assert audit_events[0]["user_agent"]["original"] == "x" * 1024
|
||||
|
||||
|
||||
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), "domain": "example.com"}
|
||||
assert event["lasuite"]["actor"] == {"type": "user", "sub": user.sub}
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flags,roles",
|
||||
[
|
||||
({}, None),
|
||||
({"is_staff": True}, ["staff"]),
|
||||
({"is_staff": True, "is_superuser": True}, ["superuser", "staff"]),
|
||||
],
|
||||
)
|
||||
def test_audit_log_actor_roles(audit_events, flags, roles):
|
||||
"""The privileges of the actor at the time of the event are its roles."""
|
||||
request = RequestFactory().get("/")
|
||||
request.user = UserFactory(**flags)
|
||||
|
||||
audit.log("anything", request=request)
|
||||
|
||||
assert audit_events[0]["user"].get("roles") == roles
|
||||
|
||||
|
||||
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", "sub": user.sub}
|
||||
assert event["lasuite"]["application"] == {"client_id": "app-1"}
|
||||
assert event["user"] == {"id": str(user.pk), "domain": "example.com"}
|
||||
assert event["organization"] == {"id": "app-1"}
|
||||
|
||||
|
||||
def test_audit_log_actor_service(audit_events):
|
||||
"""Machine users are services, named as the origin of the request."""
|
||||
request = RequestFactory().get("/")
|
||||
request.user = MachineUser("roomkit")
|
||||
|
||||
audit.log("something", request=request)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "service"}
|
||||
assert event["service"]["origin"] == {"name": "roomkit"}
|
||||
assert "user" not in event
|
||||
assert "organization" not in event
|
||||
|
||||
|
||||
def test_audit_log_target_service(audit_events):
|
||||
"""A peer service the backend called is the target service."""
|
||||
audit.log("something", target_service="summary")
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["service"]["target"] == {"name": "summary"}
|
||||
assert "origin" not in event["service"]
|
||||
assert "target_service" not in event.get("lasuite", {}).get("details", {})
|
||||
|
||||
|
||||
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", "sub": user.sub}
|
||||
assert event["user"] == {"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", "sub": user.sub}
|
||||
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_explicit_no_actor_ignores_the_signed_in_account(audit_events):
|
||||
"""``actor=None`` records no account, whoever the request is signed in as."""
|
||||
request = RequestFactory().get("/")
|
||||
request.user = UserFactory(email="user@example.com")
|
||||
|
||||
audit.log("something", request=request, actor=None)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert "user" not in event
|
||||
assert "organization" not in event
|
||||
assert "example.com" not in json.dumps(event)
|
||||
|
||||
|
||||
def test_audit_log_explicit_actor_type_alone_keeps_the_signed_in_account(
|
||||
audit_events,
|
||||
):
|
||||
"""Forcing the actor type does not discard the account of the request."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
request = RequestFactory().get("/")
|
||||
request.user = user
|
||||
|
||||
audit.log("something", request=request, actor_type="anonymous")
|
||||
|
||||
assert audit_events[0]["user"]["id"] == str(user.pk)
|
||||
|
||||
|
||||
def test_audit_log_application_without_an_account(audit_events):
|
||||
"""An application acting for nobody keeps its tenant but no user."""
|
||||
request = RequestFactory().get("/")
|
||||
request.user = UserFactory(email="user@example.com")
|
||||
|
||||
audit.log(
|
||||
"something",
|
||||
request=request,
|
||||
actor=None,
|
||||
actor_type="application",
|
||||
client_id="app-1",
|
||||
)
|
||||
|
||||
event = audit_events[0]
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["lasuite"]["application"] == {"client_id": "app-1"}
|
||||
assert "user" not in event
|
||||
assert event["organization"] == {"id": "app-1"}
|
||||
|
||||
|
||||
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["ecs"] == {"version": "9.5.0"}
|
||||
assert rendered["data_stream"]["dataset"] == "meet.audit"
|
||||
assert rendered["event"] == {"dataset": "meet.audit", "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,106 @@
|
||||
"""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 Application, Resource, ResourceAccess, 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_register_refuses_unknown_entity_types():
|
||||
"""An entity type ECS does not allow fails where it is registered."""
|
||||
with pytest.raises(ValueError, match="not an ECS entity type"):
|
||||
audit.register(Resource, entity_type="room")
|
||||
|
||||
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 model_options(Application).entity_type == "application"
|
||||
assert model_options(ResourceAccess).user_target == "user"
|
||||
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,179 @@
|
||||
"""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), "domain": "example.com"}
|
||||
assert event["lasuite"]["actor"] == {"type": "user", "sub": user.sub}
|
||||
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"]
|
||||
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_in_a_signed_in_session_is_anonymous(audit_events):
|
||||
"""A failed login never records the account the session is signed in as."""
|
||||
request = _request_with_session("post")
|
||||
request.user = UserFactory(email="signed-in@example.com")
|
||||
|
||||
assert authenticate(request=request, username="nobody", password="s3cret") is None
|
||||
|
||||
[event] = find_events(audit_events, "user.login")
|
||||
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert "user" not in event
|
||||
assert "organization" not in event
|
||||
assert "example.com" 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,197 @@
|
||||
"""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, user_target_of
|
||||
from core.audit.testing import override_registration
|
||||
from core.factories import (
|
||||
ApplicationFactory,
|
||||
RecordingFactory,
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserRecordingAccessFactory,
|
||||
)
|
||||
from core.models import Application, Recording, Resource, ResourceAccess, Room
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_describe_target_reads_the_registered_fields():
|
||||
"""A model is an ECS entity: its key, its model name and its registered fields."""
|
||||
room = RoomFactory()
|
||||
|
||||
with override_registration(Room, fields=("slug", "access_level")):
|
||||
described = describe_target(room)
|
||||
|
||||
assert described == {
|
||||
"id": str(room.pk),
|
||||
"sub_type": "room",
|
||||
"raw": {"slug": room.slug, "access_level": room.access_level},
|
||||
}
|
||||
|
||||
|
||||
def test_describe_target_promotes_its_name():
|
||||
"""A registered ``name`` is the ECS ``entity.name``, not a raw field."""
|
||||
room = RoomFactory(name="Daily standup")
|
||||
|
||||
with override_registration(Room, fields=("name", "slug")):
|
||||
described = describe_target(room)
|
||||
|
||||
assert described == {
|
||||
"id": str(room.pk),
|
||||
"sub_type": "room",
|
||||
"name": "Daily standup",
|
||||
"raw": {"slug": room.slug},
|
||||
}
|
||||
|
||||
|
||||
def test_describe_target_reports_the_registered_entity_type():
|
||||
"""A model registered with an ECS entity type is of that type."""
|
||||
application = ApplicationFactory()
|
||||
|
||||
with override_registration(Application, entity_type="application"):
|
||||
described = describe_target(application)
|
||||
|
||||
assert described == {
|
||||
"id": str(application.pk),
|
||||
"type": ["application"],
|
||||
"sub_type": "application",
|
||||
}
|
||||
|
||||
|
||||
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 == {
|
||||
"id": str(recording.pk),
|
||||
"sub_type": "recording",
|
||||
"raw": {"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 == {"id": str(room.pk), "sub_type": "room"}
|
||||
|
||||
|
||||
def test_describe_target_identifies_users_without_their_email():
|
||||
"""A user is an ECS user entity, identified by its key and OIDC sub."""
|
||||
user = UserFactory(email="jane@Example.org", sub="oidc-sub-1")
|
||||
|
||||
described = describe_target(user)
|
||||
|
||||
assert described == {
|
||||
"id": str(user.pk),
|
||||
"type": ["user"],
|
||||
"sub_type": "user",
|
||||
"raw": {"sub": "oidc-sub-1"},
|
||||
}
|
||||
assert "example.org" not in str(described).lower()
|
||||
|
||||
|
||||
def test_describe_target_of_a_user_without_sub():
|
||||
"""A user who never signed in, such as a provisional one, has no sub."""
|
||||
user = UserFactory(email="jane@example.org", sub=None)
|
||||
|
||||
assert describe_target(user) == {
|
||||
"id": str(user.pk),
|
||||
"type": ["user"],
|
||||
"sub_type": "user",
|
||||
}
|
||||
|
||||
|
||||
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 == {
|
||||
"id": str(room.pk),
|
||||
"sub_type": "room",
|
||||
"raw": {"slug": room.slug},
|
||||
}
|
||||
|
||||
|
||||
def test_describe_target_mapping_passes_through():
|
||||
"""A ready-made dict is used verbatim."""
|
||||
assert describe_target({"sub_type": "x", "id": "1"}) == {
|
||||
"sub_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()) == {"id": "thing-1", "sub_type": "thing"}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def test_user_target_of_a_user_is_itself():
|
||||
"""An event on an account is about that account."""
|
||||
user = UserFactory()
|
||||
|
||||
assert user_target_of(user) == user
|
||||
|
||||
|
||||
def test_user_target_of_reads_the_registered_attribute():
|
||||
"""An event on an access is about the user it grants a role to."""
|
||||
user = UserFactory()
|
||||
access = RoomFactory(users=[(user, "member")]).accesses.get()
|
||||
|
||||
assert user_target_of(access) == user
|
||||
|
||||
|
||||
def test_user_target_of_a_recording_access():
|
||||
"""``core.auditing`` registers the user of a recording access."""
|
||||
access = UserRecordingAccessFactory()
|
||||
|
||||
assert user_target_of(access) == access.user
|
||||
|
||||
|
||||
def test_user_target_of_an_unregistered_model_is_none():
|
||||
"""A model registered without a user target is about no account."""
|
||||
assert user_target_of(RoomFactory()) is None
|
||||
assert user_target_of("something") is None
|
||||
|
||||
|
||||
def test_user_target_of_a_broken_registration_is_none(caplog):
|
||||
"""An attribute that cannot be read costs the user target, not the event."""
|
||||
access = RoomFactory(users=[(UserFactory(), "member")]).accesses.get()
|
||||
|
||||
with override_registration(ResourceAccess, user_target="no_such_attribute"):
|
||||
assert user_target_of(access) is None
|
||||
|
||||
assert "no_such_attribute" in caplog.text
|
||||
|
||||
|
||||
def test_user_target_of_an_attribute_that_is_not_a_user_is_none():
|
||||
"""A registered attribute holding anything but an account is ignored."""
|
||||
access = RoomFactory(users=[(UserFactory(), "member")]).accesses.get()
|
||||
|
||||
with override_registration(ResourceAccess, user_target="role"):
|
||||
assert user_target_of(access) is None
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def test_prune_empty_keeps_values_below_depth_whole():
|
||||
"""Should prune only ``depth`` levels and keep anything deeper as it is."""
|
||||
document = {
|
||||
"none": None,
|
||||
"empty": {},
|
||||
"change": {"from": None, "to": {}},
|
||||
"deeper": {"inner": {"none": None}},
|
||||
}
|
||||
|
||||
assert prune_empty(document, depth=1) == {
|
||||
"change": {"from": None, "to": {}},
|
||||
"deeper": {"inner": {"none": None}},
|
||||
}
|
||||
assert prune_empty(document, depth=2) == {"deeper": {"inner": {"none": None}}}
|
||||
assert prune_empty(document, depth=0) is document
|
||||
@@ -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,64 @@ 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["entity"]["target"]["id"] == str(recording.id)
|
||||
assert event["service"]["target"] == {"name": "summary"}
|
||||
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["entity"]["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["entity"]["target"]["id"] == str(recording.id)
|
||||
assert event["entity"]["target"]["raw"]["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,83 @@ 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"}
|
||||
assert event["service"]["origin"] == {"name": "summary"}
|
||||
assert event["entity"]["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,144 @@ 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["entity"]["target"] == {
|
||||
"id": str(recording.pk),
|
||||
"sub_type": "recording",
|
||||
"raw": {
|
||||
"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["entity"]["target"]["raw"]
|
||||
|
||||
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["entity"]["target"]["sub_type"] == "room"
|
||||
assert event["entity"]["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["entity"]["target"]["id"] == str(Recording.objects.get().pk)
|
||||
assert event["entity"]["target"]["raw"]["mode"] == "transcript"
|
||||
assert event["entity"]["target"]["raw"]["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["entity"]["target"]["id"] == str(recording.pk)
|
||||
assert event["entity"]["target"]["raw"]["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["entity"]["target"]["sub_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,76 @@ 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"}
|
||||
assert event["service"]["origin"] == {"name": "livekit"}
|
||||
assert event["lasuite"]["auth"] == {"method": "shared_secret"}
|
||||
assert event["entity"]["target"]["id"] == str(recording.id)
|
||||
assert event["entity"]["target"]["raw"]["is_transcribed"] is True
|
||||
assert event["lasuite"]["details"] == {"worker_event": worker_event}
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_with_an_unmapped_status_is_audited_as_unknown(
|
||||
mock_update_metadata, service, audit_events
|
||||
):
|
||||
"""An egress status the backend does not map has an unknown outcome."""
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = 999
|
||||
mock_data.egress_info.error_code = 0
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
[event] = find_events(audit_events, "recording.end")
|
||||
|
||||
assert event["event"]["outcome"] == "unknown"
|
||||
assert event["lasuite"]["outcome"] == "unknown"
|
||||
assert event["log"]["level"] == "warning"
|
||||
assert "details" not in event["lasuite"]
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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"}
|
||||
# A provisional user has no sub yet
|
||||
assert event["entity"]["target"] == {
|
||||
"id": str(user.pk),
|
||||
"type": ["user"],
|
||||
"sub_type": "user",
|
||||
}
|
||||
assert event["user"] == {"target": {"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": "django.core.exceptions.ValidationError"}
|
||||
assert event["lasuite"]["application"] == {"client_id": "my-client"}
|
||||
assert event["entity"]["target"] == {
|
||||
"id": str(existing_user.pk),
|
||||
"type": ["user"],
|
||||
"sub_type": "user",
|
||||
}
|
||||
assert event["user"] == {
|
||||
"target": {"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": "django.db.utils.IntegrityError"}
|
||||
assert "target" not in event["lasuite"]
|
||||
@@ -1,350 +0,0 @@
|
||||
"""Application hashing and migration of existing credentials."""
|
||||
|
||||
import hashlib
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.hashers import check_password, identify_hasher, make_password
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import hashers
|
||||
from core.factories import ApplicationFactory, UserFactory
|
||||
from core.models import Application
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secret", ["short", "a" * 128, b"byte-secret"])
|
||||
def test_application_hash(secret):
|
||||
"""Application hashes verify correctly but are not accepted for user passwords."""
|
||||
encoded = hashers.hash_client_secret(secret)
|
||||
raw = secret.encode() if isinstance(secret, str) else secret
|
||||
algorithm, version, digest = encoded.split("$")
|
||||
assert algorithm == "sha256"
|
||||
assert version == "v0"
|
||||
assert digest == hashlib.sha256(raw).hexdigest()
|
||||
assert hashers.hash_client_secret(secret) == encoded
|
||||
assert hashers.verify_client_secret(secret, encoded)
|
||||
assert not hashers.verify_client_secret("wrong", encoded)
|
||||
assert not hashers.verify_client_secret(None, encoded)
|
||||
assert not hashers.verify_client_secret(secret, "sha256$invalid")
|
||||
assert not hashers.verify_client_secret(secret, "sha256$v1$" + digest)
|
||||
assert not check_password(secret, encoded)
|
||||
with pytest.raises(ValueError):
|
||||
identify_hasher(encoded)
|
||||
assert not make_password(raw.decode()).startswith("sha256$")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("algorithm", ["pbkdf2_sha256", "md5"])
|
||||
def test_token_migrates_legacy_secret_once(algorithm):
|
||||
"""The same client secret works before and after migration, with no later writes."""
|
||||
secret = get_random_string(128)
|
||||
user = UserFactory()
|
||||
legacy = make_password(secret, hasher=algorithm)
|
||||
app = ApplicationFactory(client_secret=legacy)
|
||||
app.refresh_from_db()
|
||||
|
||||
assert app.client_secret == legacy
|
||||
assert app.client_secret_sha256 is None
|
||||
payload = {
|
||||
"client_id": app.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
app.refresh_from_db()
|
||||
migrated = app.client_secret_sha256
|
||||
assert check_password(secret, app.client_secret)
|
||||
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated)["digest"]
|
||||
assert hashers.verify_client_secret(secret, migrated)
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret_sha256 == migrated
|
||||
assert app.client_secret == legacy
|
||||
|
||||
|
||||
def test_wrong_secret_does_not_migrate():
|
||||
"""Failed authentication leaves a production PBKDF2 hash untouched."""
|
||||
user = UserFactory()
|
||||
legacy = make_password(get_random_string(128), hasher="pbkdf2_sha256")
|
||||
app = ApplicationFactory(client_secret=legacy)
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": app.client_id,
|
||||
"client_secret": "wrong",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == legacy
|
||||
assert app.client_secret_sha256 is None
|
||||
|
||||
|
||||
def test_migration_preserves_concurrent_rotation():
|
||||
"""Migration must not restore a secret rotated after verification."""
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(
|
||||
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||
)
|
||||
replacement = make_password(get_random_string(128), hasher="pbkdf2_sha256")
|
||||
|
||||
def verify_then_rotate(raw, encoded):
|
||||
verified = check_password(raw, encoded)
|
||||
Application.objects.filter(pk=app.pk).update(client_secret=replacement)
|
||||
return verified
|
||||
|
||||
with mock.patch.object(hashers, "check_password", side_effect=verify_then_rotate):
|
||||
assert app.check_client_secret(secret) is False
|
||||
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == replacement
|
||||
assert app.client_secret_sha256 is None
|
||||
|
||||
|
||||
def test_migration_preserves_concurrent_migration():
|
||||
"""Authentication succeeds when another request migrates the same secret."""
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(
|
||||
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||
)
|
||||
migrated = hashers.hash_client_secret(secret)
|
||||
|
||||
def verify_then_migrate(raw, encoded):
|
||||
verified = check_password(raw, encoded)
|
||||
Application.objects.filter(pk=app.pk).update(client_secret_sha256=migrated)
|
||||
return verified
|
||||
|
||||
with mock.patch.object(hashers, "check_password", side_effect=verify_then_migrate):
|
||||
assert app.check_client_secret(secret) is True
|
||||
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret_sha256 == migrated
|
||||
|
||||
|
||||
def test_migration_preserves_concurrent_deletion():
|
||||
"""Authentication fails when the application is deleted after verification."""
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(
|
||||
client_secret=make_password(secret, hasher="pbkdf2_sha256")
|
||||
)
|
||||
|
||||
def verify_then_delete(raw, encoded):
|
||||
verified = check_password(raw, encoded)
|
||||
Application.objects.filter(pk=app.pk).delete()
|
||||
return verified
|
||||
|
||||
with mock.patch.object(hashers, "check_password", side_effect=verify_then_delete):
|
||||
assert app.check_client_secret(secret) is False
|
||||
|
||||
assert not Application.objects.filter(pk=app.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secret",
|
||||
[
|
||||
"sha256$my-secret",
|
||||
"sha256$" + "a" * 63,
|
||||
"sha256$" + "a" * 64,
|
||||
"sha256$" + "g" * 64,
|
||||
"sha256$" + "a" * 64 + "\n",
|
||||
"sha256$$" + "a" * 64,
|
||||
"sha256$short$" + "a" * 64,
|
||||
"sha256$" + "b" * 22 + "$" + "g" * 64,
|
||||
"sha256$" + "b" * 22 + "$" + "a" * 64,
|
||||
"sha256$v0$" + "g" * 64,
|
||||
"sha256$v0$" + "a" * 63,
|
||||
"sha256$v1$" + "a" * 64,
|
||||
],
|
||||
)
|
||||
def test_prefixed_plaintext_is_hashed(secret):
|
||||
"""A prefix alone must not cause a raw secret to bypass hashing."""
|
||||
assert not hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(secret)
|
||||
app = ApplicationFactory(client_secret=secret)
|
||||
app.refresh_from_db()
|
||||
encoded = app.client_secret_sha256
|
||||
assert encoded != secret
|
||||
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)["digest"]
|
||||
assert app.check_client_secret(secret)
|
||||
app.name = "Updated application"
|
||||
app.save()
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret_sha256 == encoded
|
||||
|
||||
|
||||
def test_unsalted_secret_is_rejected():
|
||||
"""Only salted SHA-256 hashes are accepted."""
|
||||
secret = get_random_string(128)
|
||||
encoded = f"sha256${hashlib.sha256(secret.encode()).hexdigest()}"
|
||||
|
||||
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded) is None
|
||||
assert not hashers.verify_client_secret(secret, encoded)
|
||||
|
||||
|
||||
def test_new_application_supports_legacy_verification(settings):
|
||||
"""A rollback can authenticate applications created by the new release."""
|
||||
settings.PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||
]
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(client_secret=secret)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret.startswith("pbkdf2_sha256$")
|
||||
assert check_password(secret, app.client_secret)
|
||||
assert hashers.verify_client_secret(secret, app.client_secret_sha256)
|
||||
with mock.patch.object(hashers, "check_password", side_effect=AssertionError):
|
||||
assert app.check_client_secret(secret)
|
||||
assert not app.check_client_secret("wrong")
|
||||
|
||||
|
||||
def test_unrelated_save_preserves_both_hashes():
|
||||
"""Saving an application's metadata does not change either credential hash."""
|
||||
app = ApplicationFactory()
|
||||
original = (app.client_secret, app.client_secret_sha256)
|
||||
app.name = "Renamed"
|
||||
app.save()
|
||||
app.refresh_from_db()
|
||||
assert (app.client_secret, app.client_secret_sha256) == original
|
||||
|
||||
|
||||
def test_creation_with_legacy_hash_defers_fast_hash_until_login():
|
||||
"""An imported Django hash is preserved, never treated as the raw secret."""
|
||||
secret = get_random_string(128)
|
||||
legacy = make_password(secret, hasher="pbkdf2_sha256")
|
||||
app = ApplicationFactory(client_secret=legacy)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == legacy
|
||||
assert app.client_secret_sha256 is None
|
||||
assert not app.check_client_secret(legacy)
|
||||
assert app.check_client_secret(secret)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == legacy
|
||||
assert hashers.verify_client_secret(secret, app.client_secret_sha256)
|
||||
|
||||
|
||||
def test_metadata_only_save_does_not_rotate_secret():
|
||||
"""A secret excluded from update_fields must not change either stored hash."""
|
||||
app = ApplicationFactory()
|
||||
original = (app.client_secret, app.client_secret_sha256)
|
||||
app.client_secret = get_random_string(128)
|
||||
app.name = "Renamed"
|
||||
app.save(update_fields=["name"])
|
||||
app.refresh_from_db()
|
||||
assert (app.client_secret, app.client_secret_sha256) == original
|
||||
|
||||
|
||||
def test_empty_update_fields_does_not_rotate_secret():
|
||||
"""Django's explicit no-op save must not update either credential field."""
|
||||
app = ApplicationFactory()
|
||||
original = (app.client_secret, app.client_secret_sha256)
|
||||
app.client_secret = get_random_string(128)
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
app.save(update_fields=[])
|
||||
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
|
||||
app.refresh_from_db()
|
||||
assert (app.client_secret, app.client_secret_sha256) == original
|
||||
|
||||
|
||||
def test_creation_with_salted_hash_skips_fast_hash():
|
||||
"""An existing salted hash must not be hashed again as plaintext."""
|
||||
encoded = hashers.hash_client_secret(get_random_string(128))
|
||||
app = ApplicationFactory(client_secret=encoded)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret == encoded
|
||||
assert app.client_secret_sha256 is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_only", [False, True])
|
||||
def test_rotate_client_secret_updates_both_hashes(legacy_only, settings):
|
||||
"""Rotation revokes the old secret for both current and rollback releases."""
|
||||
settings.PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||
]
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(
|
||||
client_secret=make_password(secret) if legacy_only else secret
|
||||
)
|
||||
|
||||
replacement = app.rotate_client_secret()
|
||||
|
||||
assert replacement != secret
|
||||
assert len(replacement) == settings.APPLICATION_CLIENT_SECRET_LENGTH
|
||||
assert app.check_client_secret(replacement)
|
||||
assert not app.check_client_secret(secret)
|
||||
app.refresh_from_db()
|
||||
assert app.client_secret.startswith("pbkdf2_sha256$")
|
||||
assert check_password(replacement, app.client_secret)
|
||||
assert not check_password(secret, app.client_secret)
|
||||
assert hashers.verify_client_secret(replacement, app.client_secret_sha256)
|
||||
assert not app.check_client_secret(secret)
|
||||
assert app.client_secret != replacement
|
||||
assert app.client_secret_sha256 != replacement
|
||||
|
||||
|
||||
def test_rotate_client_secret_preserves_metadata():
|
||||
"""Rotation persists only the credential fields, not other pending changes."""
|
||||
app = ApplicationFactory()
|
||||
original_name = app.name
|
||||
original_client_id = app.client_id
|
||||
app.name = "Unsaved metadata"
|
||||
|
||||
app.rotate_client_secret()
|
||||
|
||||
app.refresh_from_db()
|
||||
assert app.name == original_name
|
||||
assert app.client_id == original_client_id
|
||||
|
||||
|
||||
def test_rotate_client_secret_repeatedly_revokes_previous_secrets():
|
||||
"""Only the latest generated secret remains valid after successive rotations."""
|
||||
original = get_random_string(128)
|
||||
app = ApplicationFactory(client_secret=original)
|
||||
first = app.rotate_client_secret()
|
||||
second = app.rotate_client_secret()
|
||||
|
||||
app.refresh_from_db()
|
||||
assert len({original, first, second}) == 3
|
||||
assert app.check_client_secret(second)
|
||||
assert check_password(second, app.client_secret)
|
||||
for revoked in (original, first):
|
||||
assert not app.check_client_secret(revoked)
|
||||
assert not check_password(revoked, app.client_secret)
|
||||
|
||||
|
||||
def test_token_endpoint_rejects_rotated_secret():
|
||||
"""New token requests reject the revoked secret and accept its replacement."""
|
||||
secret = get_random_string(128)
|
||||
app = ApplicationFactory(client_secret=secret)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
payload = {
|
||||
"client_id": app.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
endpoint = "/external-api/v1.0/application/token/"
|
||||
assert client.post(endpoint, payload, format="json").status_code == 200
|
||||
|
||||
replacement = app.rotate_client_secret()
|
||||
|
||||
assert client.post(endpoint, payload, format="json").status_code == 401
|
||||
payload["client_secret"] = replacement
|
||||
assert client.post(endpoint, payload, format="json").status_code == 200
|
||||
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
@@ -17,10 +18,12 @@ 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,
|
||||
ApplicationScope,
|
||||
ResourceAccess,
|
||||
RoleChoices,
|
||||
Room,
|
||||
RoomAccessLevel,
|
||||
@@ -2375,3 +2378,297 @@ 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", "sub": user.sub}
|
||||
assert event["lasuite"]["auth"] == {"method": "application_jwt"}
|
||||
assert event["lasuite"]["application"] == {"client_id": str(application.client_id)}
|
||||
assert event["user"] == {"id": str(user.pk), "domain": "winterfell.com"}
|
||||
assert event["organization"] == {"id": str(application.client_id)}
|
||||
assert event["entity"]["target"] == {
|
||||
"id": str(room.pk),
|
||||
"sub_type": "room",
|
||||
"name": room.name,
|
||||
"raw": {"slug": room.slug, "access_level": "trusted"},
|
||||
}
|
||||
assert event["client"]["ip"] == "1.2.3.4"
|
||||
assert event["http"]["request"]["method"] == "POST"
|
||||
assert event["http"]["response"] == {"status_code": 201}
|
||||
assert event["url"]["path"] == "/external-api/v1.0/rooms/"
|
||||
assert event["http"]["request"]["id"] == response["X-Request-ID"]
|
||||
assert "jean-neige@winterfell.com" not in str(event)
|
||||
|
||||
|
||||
@mock.patch.object(ResourceAccess.objects, "create", side_effect=DatabaseError("boom"))
|
||||
def test_api_rooms_create_crashing_is_audited(mock_create_access, audit_events):
|
||||
"""A room saved without its owner's access is recorded as a failure on it."""
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
with pytest.raises(DatabaseError):
|
||||
client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
mock_create_access.assert_called_once()
|
||||
room = Room.objects.get()
|
||||
[event] = find_events(audit_events, "room.create")
|
||||
|
||||
assert event["event"]["reason"] == "internal_error"
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["entity"]["target"]["id"] == str(room.pk)
|
||||
assert event["http"]["response"] == {"status_code": 500}
|
||||
assert event["error"] == {"type": "django.db.utils.DatabaseError"}
|
||||
|
||||
|
||||
@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["entity"]["target"]["id"] == str(room.pk)
|
||||
assert event["entity"]["target"]["raw"]["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["entity"]["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["entity"]["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 "entity" not in event
|
||||
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["entity"]["target"]["id"] == str(room.pk)
|
||||
assert event["entity"]["target"]["raw"]["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")
|
||||
|
||||
# Still an API call, also filed as a failed authentication
|
||||
assert event["event"]["category"] == ["api", "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", "sub": user.sub}
|
||||
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 "entity" not in event
|
||||
|
||||
|
||||
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", "sub": user.sub}
|
||||
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", "sub": user.sub}
|
||||
assert event["lasuite"]["auth"] == {"method": "resource_server"}
|
||||
assert event["lasuite"]["application"] == {"client_id": "some_service_provider"}
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
|
||||
@@ -4,23 +4,22 @@ Tests for external API /token endpoint
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.auth.hashers import check_password
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import hashers
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import (
|
||||
ApplicationDomainFactory,
|
||||
ApplicationFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import Application, ApplicationScope, User
|
||||
from core.models import ApplicationScope, User
|
||||
from core.services import provisional_user_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -31,13 +30,15 @@ def test_api_applications_generate_token_application_disabled(settings):
|
||||
settings.APPLICATION_ENABLED = False
|
||||
|
||||
user = UserFactory(email="user@example.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -56,13 +57,16 @@ def test_api_applications_generate_token_application_disabled(settings):
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
UserFactory(email="User.Family@example.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
# Store plain secret before it's hashed
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -93,13 +97,15 @@ def test_api_applications_generate_token_form_urlencoded(settings):
|
||||
token endpoints, so that standard OAuth 2.0 client libraries work
|
||||
out of the box."""
|
||||
UserFactory(email="user@example.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -166,8 +172,11 @@ def test_api_applications_generate_token_form_urlencoded_missing_fields():
|
||||
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
|
||||
"""An unsupported grant_type sent as form-urlencoded should return 400."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -191,13 +200,15 @@ def test_api_applications_generate_token_form_urlencoded_special_characters():
|
||||
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
|
||||
client_secret should survive form-urlencoded decoding."""
|
||||
UserFactory(email="user@example.com")
|
||||
plain_secret = "s3cr3t&with=special+chars%42"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
|
||||
plain_secret = "s3cr3t&with=special+chars%42"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -270,54 +281,14 @@ def test_api_applications_generate_token_invalid_client_secret():
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_token_unknown_client_id_with_valid_secret():
|
||||
"""A valid secret cannot authenticate an unknown client ID."""
|
||||
secret = "application-a-secret"
|
||||
ApplicationFactory(client_secret=secret)
|
||||
user = UserFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": "unknown-client-id",
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_token_rejects_secret_owned_by_another_application():
|
||||
"""Application A's secret cannot authenticate application B."""
|
||||
secret_a = "application-a-secret"
|
||||
ApplicationFactory(client_secret=secret_a)
|
||||
application_b = ApplicationFactory(client_secret="application-b-secret")
|
||||
user = UserFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application_b.client_id,
|
||||
"client_secret": secret_a,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_inactive_application():
|
||||
"""Inactive application should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(is_active=False)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=False)
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -359,8 +330,11 @@ def test_api_applications_generate_token_inactive_application_wrong_secret():
|
||||
|
||||
def test_api_applications_generate_token_invalid_email_format():
|
||||
"""Invalid email format should return 400."""
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -381,10 +355,13 @@ def test_api_applications_generate_token_invalid_email_format():
|
||||
def test_api_applications_generate_token_domain_not_authorized():
|
||||
"""Application without domain authorization should return 403."""
|
||||
user = UserFactory(email="user@denied.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||
application = ApplicationFactory(is_active=True)
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -404,14 +381,16 @@ def test_api_applications_generate_token_domain_not_authorized():
|
||||
def test_api_applications_generate_token_domain_authorized():
|
||||
"""Application with domain authorization should succeed."""
|
||||
user = UserFactory(email="user@allowed.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -430,8 +409,11 @@ def test_api_applications_generate_token_domain_authorized():
|
||||
|
||||
def test_api_applications_generate_token_user_not_found():
|
||||
"""Non-existent user should return 404."""
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -454,13 +436,15 @@ def test_api_applications_token_payload_structure(settings):
|
||||
"""Generated token should have correct payload structure."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -505,13 +489,15 @@ def test_api_applications_token_new_user(settings):
|
||||
|
||||
assert len(User.objects.all()) == 0
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -561,13 +547,15 @@ def test_api_applications_token_existing_user(settings):
|
||||
|
||||
assert len(User.objects.all()) == 1
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
@@ -612,10 +600,12 @@ def test_api_applications_token_new_user_race_condition(mock_get_by_email, setti
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
email = "john.doe@example.com"
|
||||
|
||||
@@ -665,10 +655,12 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(
|
||||
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -686,181 +678,225 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
|
||||
assert mock_get_or_create.call_count == 1
|
||||
|
||||
|
||||
def test_token_populates_fast_hash_and_stops_using_legacy_hash():
|
||||
"""First login migrates; subsequent logins use only the fast hash."""
|
||||
secret = "application-secret"
|
||||
|
||||
application = ApplicationFactory(client_secret=secret)
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
application.refresh_from_db()
|
||||
|
||||
original_hash = application.client_secret
|
||||
|
||||
user = UserFactory()
|
||||
payload = {
|
||||
"client_id": application.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
client = APIClient()
|
||||
|
||||
with mock.patch.object(
|
||||
hashers, "check_password", wraps=hashers.check_password
|
||||
) as legacy_verifier:
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
legacy_verifier.assert_called_once_with(secret, original_hash)
|
||||
|
||||
application.refresh_from_db()
|
||||
|
||||
migrated_hash = application.client_secret_sha256
|
||||
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated_hash)
|
||||
assert hashers.verify_client_secret(secret, migrated_hash)
|
||||
assert application.client_secret == original_hash
|
||||
|
||||
# Fail immediately if a subsequent login tries the legacy verifier.
|
||||
with mock.patch.object(
|
||||
hashers,
|
||||
"check_password",
|
||||
side_effect=AssertionError("Legacy hash must no longer be used"),
|
||||
):
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
application.refresh_from_db()
|
||||
assert application.client_secret_sha256 == migrated_hash
|
||||
assert application.client_secret == original_hash
|
||||
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 test_token_failed_login_leaves_legacy_credentials_untouched():
|
||||
"""An incorrect secret neither migrates nor changes the legacy hash."""
|
||||
|
||||
application = ApplicationFactory(client_secret="application-secret")
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
application.refresh_from_db()
|
||||
|
||||
original_hash = application.client_secret
|
||||
user = UserFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
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": application.client_id,
|
||||
"client_secret": "wrong-secret",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
"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", "sub": user.sub}
|
||||
assert event["lasuite"]["auth"] == {"method": "client_credentials"}
|
||||
assert event["lasuite"]["application"] == {"client_id": application.client_id}
|
||||
assert event["user"] == {"id": str(user.pk), "domain": "winterfell.com"}
|
||||
assert event["organization"] == {"id": application.client_id}
|
||||
assert event["entity"]["target"] == {
|
||||
"id": str(application.pk),
|
||||
"type": ["application"],
|
||||
"sub_type": "application",
|
||||
"name": application.name,
|
||||
"raw": {
|
||||
"client_id": application.client_id,
|
||||
"is_active": True,
|
||||
"scopes": ["rooms:list"],
|
||||
},
|
||||
}
|
||||
assert event["http"]["response"] == {"status_code": 200}
|
||||
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["http"]["request"]["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"]
|
||||
# ECS expects no ``denied`` type for an authentication
|
||||
assert event["event"]["type"] == ["start"]
|
||||
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
|
||||
application.refresh_from_db()
|
||||
assert application.client_secret == original_hash
|
||||
assert application.client_secret_sha256 is None
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["error"] == {"message": "Application is inactive"}
|
||||
|
||||
|
||||
def test_token_concurrent_successful_logins_preserve_first_migration():
|
||||
"""Both logins succeed; the later migration preserves the first hash."""
|
||||
secret = "application-secret"
|
||||
application = ApplicationFactory(client_secret=secret)
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
application.refresh_from_db()
|
||||
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")
|
||||
|
||||
original_hash = application.client_secret
|
||||
user = UserFactory()
|
||||
payload = {
|
||||
"client_id": application.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
legacy_verifier = hashers.check_password
|
||||
winning_hashes = []
|
||||
response = _post_token(application.client_id, "test-secret-123", "user@random.com")
|
||||
|
||||
def verify_then_complete_other_login(raw_secret, encoded):
|
||||
verified = legacy_verifier(raw_secret, encoded)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Complete another login before this request writes its migration.
|
||||
# Restore the real verifier to avoid recursively invoking this callback.
|
||||
with mock.patch.object(hashers, "check_password", new=legacy_verifier):
|
||||
other_response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
[event] = find_events(audit_events, "application.token.issue")
|
||||
|
||||
assert other_response.status_code == 200
|
||||
application.refresh_from_db()
|
||||
winning_hashes.append(application.client_secret_sha256)
|
||||
return verified
|
||||
|
||||
with mock.patch.object(
|
||||
hashers, "check_password", side_effect=verify_then_complete_other_login
|
||||
) as verifier:
|
||||
response = APIClient().post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
verifier.assert_called_once_with(secret, original_hash)
|
||||
application.refresh_from_db()
|
||||
assert application.client_secret_sha256 == winning_hashes[0]
|
||||
assert hashers.verify_client_secret(secret, application.client_secret_sha256)
|
||||
assert application.client_secret == original_hash
|
||||
assert event["event"]["reason"] == "permission_denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "application"}
|
||||
assert event["entity"]["target"]["id"] == str(application.pk)
|
||||
assert event["lasuite"]["details"] == {"requested_domain": "random.com"}
|
||||
assert event["http"]["response"] == {"status_code": 403}
|
||||
|
||||
|
||||
def test_token_authenticates_after_rollback():
|
||||
"""Legacy authentication still works after the fast hash is discarded."""
|
||||
secret = "application-secret"
|
||||
application = ApplicationFactory(client_secret=secret)
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
application.refresh_from_db()
|
||||
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()
|
||||
|
||||
original_hash = application.client_secret
|
||||
response = _post_token(application.client_id, "test-secret-123", "not-an-email")
|
||||
|
||||
user = UserFactory()
|
||||
payload = {
|
||||
"client_id": application.client_id,
|
||||
"client_secret": secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
client = APIClient()
|
||||
assert response.status_code == 400
|
||||
|
||||
# Authenticate with the new implementation and migrate the hash.
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
[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
|
||||
|
||||
application.refresh_from_db()
|
||||
user = User.objects.get(email="new.user@example.com")
|
||||
[provision] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert hashers.verify_client_secret(secret, application.client_secret_sha256)
|
||||
assert application.client_secret == original_hash
|
||||
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["entity"]["target"] == {
|
||||
"id": str(user.pk),
|
||||
"type": ["user"],
|
||||
"sub_type": "user",
|
||||
}
|
||||
assert provision["user"]["target"] == {"id": str(user.pk), "domain": "example.com"}
|
||||
assert provision["http"]["request"]["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)
|
||||
|
||||
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
|
||||
|
||||
def legacy_check(instance, raw_secret):
|
||||
return check_password(raw_secret, instance.client_secret)
|
||||
|
||||
# Simulate the old release's verification using only the legacy field.
|
||||
with mock.patch.object(
|
||||
Application,
|
||||
"check_client_secret",
|
||||
autospec=True,
|
||||
side_effect=legacy_check,
|
||||
) as verifier:
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/", payload, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
verifier.assert_called_once()
|
||||
application.refresh_from_db()
|
||||
assert application.client_secret == original_hash
|
||||
assert application.client_secret_sha256 is None
|
||||
[issue] = find_events(audit_events, "application.token.issue")
|
||||
assert issue["lasuite"]["details"]["user_provisioned"] is True
|
||||
assert issue["user"]["id"] == str(user.pk)
|
||||
|
||||
@@ -6,12 +6,12 @@ Unit tests for the Application and ApplicationDomain models
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import ApplicationDomainFactory, ApplicationFactory
|
||||
from core.hashers import verify_client_secret
|
||||
from core.models import Application, ApplicationDomain, ApplicationScope
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -98,8 +98,8 @@ def test_models_application_client_secret_hashed_on_save():
|
||||
|
||||
# Secret should be hashed, not plain
|
||||
assert application.client_secret != plain_secret
|
||||
# Should verify with the application credential policy
|
||||
assert verify_client_secret(plain_secret, application.client_secret) is True
|
||||
# Should verify with check_password
|
||||
assert check_password(plain_secret, application.client_secret) is True
|
||||
|
||||
|
||||
def test_models_application_client_secret_preserves_existing_hash():
|
||||
|
||||
@@ -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",
|
||||
@@ -1070,7 +1080,7 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue(
|
||||
50,
|
||||
128,
|
||||
environ_name="APPLICATION_CLIENT_SECRET_LENGTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -1210,6 +1220,33 @@ 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
|
||||
)
|
||||
# Namespace of the data stream audit events are routed to in Elasticsearch,
|
||||
# ``logs-<service>.audit-<namespace>``. It must not contain ``-``.
|
||||
AUDIT_LOG_DATA_STREAM_NAMESPACE = values.Value(
|
||||
"default", environ_name="AUDIT_LOG_DATA_STREAM_NAMESPACE", 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 +1264,9 @@ class Base(Configuration):
|
||||
"format": "{asctime} {name} {levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"audit_json": {
|
||||
"()": "core.audit.formatter.AuditJsonFormatter",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"silence_expected_401": {
|
||||
@@ -1239,6 +1279,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 +1316,11 @@ class Base(Configuration):
|
||||
),
|
||||
"propagate": False,
|
||||
},
|
||||
"audit": {
|
||||
"handlers": ["audit_console"],
|
||||
"level": AUDIT_LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1432,20 +1482,6 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Secrets use a 62-character alphanumeric charset (~5.95 bits/char).
|
||||
# 43 characters provide at least 256 bits of entropy; 42 provide ~250 bits.
|
||||
if cls.APPLICATION_CLIENT_SECRET_LENGTH < 43:
|
||||
warnings.warn(
|
||||
f"APPLICATION_CLIENT_SECRET_LENGTH={cls.APPLICATION_CLIENT_SECRET_LENGTH} "
|
||||
"is below the recommended 43 characters (256 bits of entropy). "
|
||||
"Application secrets use a fast hash and rely on high entropy to "
|
||||
"resist offline guessing if the database leaks. "
|
||||
"Please set APPLICATION_CLIENT_SECRET_LENGTH to at least 43.",
|
||||
# We use UserWarning to make sure it shows up in production deployment
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
@@ -1465,6 +1501,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):
|
||||
@@ -1516,22 +1554,36 @@ 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||
]
|
||||
USE_SWAGGER = True
|
||||
EXTERNAL_API_ENABLED = True
|
||||
|
||||
@@ -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