mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-10 15:24:35 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 284902c788 | |||
| 547c371dba | |||
| 7fad537aad | |||
| 5f1eced645 | |||
| 12f58dfc09 | |||
| 3af2f2f774 |
+6
-21
@@ -10,26 +10,6 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(backend) hash application secrets with SHA-256
|
||||
- ⚡️(backend) reduce domain queries on the application token endpoint
|
||||
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
|
||||
- ♻️(all) stop relying on cookies for the lobby flow
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) prevent editing client id and secret in Django admin
|
||||
- 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
|
||||
- 🔒️(backend) bind accepted lobby entries to the current username
|
||||
- 🔒️(backend) sign lobby participant id
|
||||
|
||||
## [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
|
||||
@@ -38,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
|
||||
|
||||
@@ -48,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
|
||||
@@ -115,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
|
||||
|
||||
@@ -58,7 +58,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -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. Without `request=`, the event reads the
|
||||
context of the request being served, `audit.request_context()`, which `AuditLogMiddleware` captures when the request
|
||||
comes in. Outside a request, as in a Celery task, it is empty 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):
|
||||
|
||||
@@ -17,7 +17,6 @@ class FeatureFlag:
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"roomkit": "ROOMKIT_ENABLED",
|
||||
"connection_test": "CONNECTION_TEST_ENABLED",
|
||||
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
@@ -357,26 +356,6 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
participant_id = serializers.CharField(
|
||||
required=False, allow_null=True, max_length=128
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sign_participant_id(participant_id):
|
||||
"""Sign with Django's SECRET_KEY and a lobby-specific namespace."""
|
||||
return signing.Signer(salt="core.lobby.participant").sign(participant_id)
|
||||
|
||||
def validate_participant_id(self, value):
|
||||
"""Require a valid server signature before looking up a participant."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
participant_id = signing.Signer(salt="core.lobby.participant").unsign(value)
|
||||
except signing.BadSignature as exc:
|
||||
raise serializers.ValidationError(
|
||||
"Invalid participant credential."
|
||||
) from exc
|
||||
return serializers.UUIDField().run_validation(participant_id)
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||
@@ -684,20 +663,3 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
# useless bad requests
|
||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
|
||||
class TransitCodeSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate the single-use transit code sent to the exchange endpoint."""
|
||||
|
||||
code = serializers.CharField(trim_whitespace=True)
|
||||
|
||||
def validate_code(self, value):
|
||||
"""Reject codes whose length cannot match a generated one."""
|
||||
|
||||
# Calculates urlsafe_b64encode length without padding
|
||||
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||
|
||||
if len(value) != expected_length:
|
||||
raise serializers.ValidationError("Invalid transit code format.")
|
||||
|
||||
return value
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Throttling modules for the API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
def sentry_monitoring_throttle_failure(message):
|
||||
"""Log when a failure occurs to detect rate limiting issues."""
|
||||
@@ -69,14 +69,13 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||
|
||||
Only throttle requests carrying a participant identifier. The
|
||||
identifier is returned by the first request-entry response and
|
||||
echoed back by the client from the second request onward, which is
|
||||
when throttling starts applying.
|
||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
||||
return None to skip throttling — the cookie will be set on the first
|
||||
response, and throttling will apply from the second request onward.
|
||||
|
||||
Keying on the identifier rather than the IP address prevents
|
||||
penalising multiple users behind the same NAT/proxy, and is
|
||||
consistent with how the lobby identifies participants.
|
||||
Keying on the cookie rather than the IP address prevents penalising
|
||||
multiple users behind the same NAT/proxy, and is consistent with how
|
||||
LobbyService identifies participants.
|
||||
|
||||
Note: as per DRF documentation, application-level throttling is not a
|
||||
security measure against brute-force or DoS attacks. This throttle exists
|
||||
@@ -86,14 +85,10 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
if request.user and request.user.is_authenticated:
|
||||
return None # Only throttle unauthenticated requests.
|
||||
|
||||
serializer = serializers.RequestEntrySerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return None
|
||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
||||
|
||||
participant_id = serializer.validated_data.get("participant_id")
|
||||
|
||||
if not participant_id:
|
||||
return None # No throttling for unidentified requests
|
||||
if participant_id is None:
|
||||
return None # No throttling for cookieless requests
|
||||
|
||||
return self.cache_format % {
|
||||
"scope": self.scope,
|
||||
@@ -129,14 +124,3 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous users requesting connection test tokens."""
|
||||
|
||||
scope = "connection_test"
|
||||
|
||||
|
||||
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous transit code exchange attempts.
|
||||
|
||||
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||
best-effort. The security of the exchange rests on the codes'
|
||||
entropy and single use.
|
||||
"""
|
||||
|
||||
scope = "exchange_access_token"
|
||||
|
||||
@@ -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
|
||||
@@ -62,7 +62,6 @@ from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.invitation import InvitationService
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
@@ -83,7 +82,6 @@ from core.services.room_roles import (
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.utils import generate_token
|
||||
@@ -168,100 +166,9 @@ class UserViewSet(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="exchange-access-token",
|
||||
permission_classes=[],
|
||||
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
|
||||
)
|
||||
@FeatureFlag.require("user_access_token")
|
||||
def exchange_access_token(self, request):
|
||||
"""Exchange a single-use transit code for a user access token.
|
||||
|
||||
The endpoint is unauthenticated: the transit code itself, an opaque
|
||||
random string obtained through the external API and delivered to
|
||||
the embedded frontend via a URL fragment, is the credential. Each
|
||||
code can be exchanged exactly once (consuming it deletes it from
|
||||
the cache); replaying a consumed code is denied and logged.
|
||||
|
||||
The issued JWT authenticates the user the code was minted for on
|
||||
the whole core API, exactly like a session cookie would (similar
|
||||
to lib-jitsi-meet's token authentication), and never appears in
|
||||
any URL. Role-based permissions apply unchanged.
|
||||
"""
|
||||
if request.user and request.user.is_authenticated:
|
||||
logger.warning(
|
||||
"Transit code exchange refused: request is already "
|
||||
"session-authenticated (user_id=%s)",
|
||||
request.user.id,
|
||||
)
|
||||
raise drf_exceptions.PermissionDenied("Already authenticated.")
|
||||
|
||||
serializer = serializers.TransitCodeSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
|
||||
|
||||
if code_data is None:
|
||||
logger.warning("Invalid, expired or already used transit code")
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"Invalid, expired or already used transit code."
|
||||
)
|
||||
|
||||
# Re-check the user at exchange time so that a deactivation after
|
||||
# the transit code was minted is taken into account.
|
||||
user_id = code_data["user_id"]
|
||||
try:
|
||||
user = models.User.objects.get(id=user_id, is_active=True)
|
||||
except models.User.DoesNotExist as e:
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"This account can no longer access the application."
|
||||
) from e
|
||||
|
||||
client_id = code_data.get("client_id")
|
||||
if not models.Application.has_active_scope(
|
||||
client_id, models.ApplicationScope.USERS_SESSION
|
||||
):
|
||||
logger.warning(
|
||||
"Transit code exchange refused: application '%s' no longer "
|
||||
"holds the '%s' grant",
|
||||
client_id,
|
||||
models.ApplicationScope.USERS_SESSION,
|
||||
)
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"This application can no longer create user sessions."
|
||||
)
|
||||
|
||||
token_service = JwtTokenService(
|
||||
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
data = token_service.generate_jwt(
|
||||
user,
|
||||
"user:access",
|
||||
{
|
||||
"client_id": client_id or "unknown",
|
||||
"token_type": settings.USER_ACCESS_TOKEN_TYPE_CLAIM,
|
||||
},
|
||||
)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"User access token issued from transit code: user_id=%s, client_id=%s",
|
||||
user_id,
|
||||
client_id,
|
||||
)
|
||||
|
||||
return drf_response.Response(data)
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -406,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
|
||||
@@ -443,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
|
||||
@@ -487,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
|
||||
@@ -503,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)
|
||||
|
||||
@@ -539,18 +455,13 @@ class RoomViewSet(
|
||||
|
||||
participant, livekit = lobby_service.request_entry(
|
||||
room=room,
|
||||
user=request.user,
|
||||
request=request,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
**participant.to_dict(),
|
||||
"id": serializers.RequestEntrySerializer.sign_participant_id(
|
||||
participant.id
|
||||
),
|
||||
"livekit": livekit,
|
||||
}
|
||||
)
|
||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
lobby_service.prepare_response(response, participant.id)
|
||||
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
@@ -1041,6 +952,7 @@ class ResourceAccessViewSet(
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
@@ -1055,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
|
||||
@@ -1070,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."""
|
||||
@@ -1083,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 request_context
|
||||
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",
|
||||
"email_domain",
|
||||
"exception_type",
|
||||
"log",
|
||||
"register",
|
||||
"register_auth_method",
|
||||
"request_context",
|
||||
]
|
||||
@@ -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,130 @@
|
||||
"""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``."""
|
||||
service_name = settings.AUDIT_LOG_SERVICE_NAME
|
||||
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": settings.AUDIT_LOG_DATA_STREAM_NAMESPACE,
|
||||
},
|
||||
"event": {"dataset": name},
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"""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 RequestContext, request_context
|
||||
from .targets import describe_target, user_target_of
|
||||
from .utils import prune_empty, render_value
|
||||
|
||||
AUDIT_LOGGER_NAME = "audit"
|
||||
|
||||
audit_logger = logging.getLogger(AUDIT_LOGGER_NAME)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log(action: Action | str, **fields: Any) -> None:
|
||||
"""Emit one audit event."""
|
||||
try:
|
||||
document = build_document(action, **fields)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("Audit event %r could not be built", action)
|
||||
return
|
||||
|
||||
audit_logger.log(
|
||||
level_for(document["lasuite"]["outcome"], document["event"].get("reason")),
|
||||
str(action),
|
||||
extra={"audit": document},
|
||||
)
|
||||
|
||||
|
||||
def level_for(outcome: Outcome | str, reason: Reason | str | None) -> int:
|
||||
"""Derive the logging level so call sites never choose one."""
|
||||
if Outcome(outcome) == Outcome.SUCCESS:
|
||||
return logging.INFO
|
||||
if reason is not None and Reason(reason) == Reason.INTERNAL_ERROR:
|
||||
return logging.ERROR
|
||||
return logging.WARNING
|
||||
|
||||
|
||||
@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 from the context of 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.
|
||||
"""
|
||||
context = (
|
||||
request_context() if request is None else RequestContext.from_request(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)
|
||||
actor_fields = describe_actor(
|
||||
context.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": settings.AUDIT_LOG_SERVICE_NAME,
|
||||
"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": context.client_ip},
|
||||
"source": {"ip": context.client_ip},
|
||||
"http": {
|
||||
"request": {"id": context.request_id, "method": context.method},
|
||||
"response": {"status_code": status_code},
|
||||
},
|
||||
"url": {"path": context.path},
|
||||
"user_agent": {"original": context.user_agent},
|
||||
"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 _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
|
||||
# named by ``service.origin.name``
|
||||
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,114 @@
|
||||
"""Read the network fields and the request id behind an audit event."""
|
||||
|
||||
import uuid
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from dockerflow.logging import request_id_context
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
USER_AGENT_MAX_LENGTH = 1024
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestContext:
|
||||
"""What an audit event reads from the request behind it.
|
||||
|
||||
The network fields are read once, when the request comes in. The request
|
||||
itself is kept for the actor, only known once it is authenticated: it is
|
||||
the Django request, onto which DRF copies its user and auth, but not its
|
||||
authenticator, so the auth method of a DRF view is not known from it.
|
||||
Outside a request, every field is ``None``.
|
||||
"""
|
||||
|
||||
request: Any = None
|
||||
request_id: str | None = None
|
||||
method: str | None = None
|
||||
path: str | None = None
|
||||
client_ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_request(cls, request) -> "RequestContext":
|
||||
"""Read the context of ``request``, its user agent cut where ECS stops."""
|
||||
meta = getattr(request, "META", None) or {}
|
||||
return cls(
|
||||
request=request,
|
||||
request_id=current_request_id(),
|
||||
method=getattr(request, "method", None),
|
||||
path=getattr(request, "path", None) or None,
|
||||
client_ip=resolve_client_ip(request),
|
||||
user_agent=meta.get("HTTP_USER_AGENT", "")[:USER_AGENT_MAX_LENGTH] or None,
|
||||
)
|
||||
|
||||
|
||||
_request_context: ContextVar[RequestContext | None] = ContextVar(
|
||||
"audit_request_context", default=None
|
||||
)
|
||||
|
||||
|
||||
def request_context() -> RequestContext:
|
||||
"""Return the context of the request being served, as set by ``AuditLogMiddleware``.
|
||||
|
||||
It is empty outside a request.
|
||||
"""
|
||||
return _request_context.get() or RequestContext()
|
||||
|
||||
|
||||
def set_request_context(context: RequestContext) -> Token:
|
||||
"""Make ``context`` the one of the request being served, until reset."""
|
||||
return _request_context.set(context)
|
||||
|
||||
|
||||
def reset_request_context(token: Token) -> None:
|
||||
"""Restore the context that was current before ``set_request_context``."""
|
||||
_request_context.reset(token)
|
||||
|
||||
|
||||
class AuditLogMiddleware:
|
||||
"""Settle the request id and the request context, 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 context of the request is kept 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_request_context(RequestContext.from_request(request))
|
||||
try:
|
||||
response = self.get_response(request)
|
||||
finally:
|
||||
reset_request_context(token)
|
||||
header = settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME
|
||||
if not response.has_header(header):
|
||||
response[header] = current_request_id()
|
||||
return response
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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 get_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."""
|
||||
log(
|
||||
LOGIN_ACTION,
|
||||
outcome=Outcome.DENIED,
|
||||
reason=Reason.AUTHENTICATION_FAILED,
|
||||
request=request,
|
||||
actor=None,
|
||||
actor_type=ActorType.ANONYMOUS,
|
||||
auth_method=get_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,89 @@
|
||||
"""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
|
||||
|
||||
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")
|
||||
@@ -9,8 +9,6 @@ from rest_framework import authentication, exceptions
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
|
||||
|
||||
|
||||
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||
@@ -22,14 +20,9 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
return None # No authentication attempted
|
||||
|
||||
parts = auth_header.split()
|
||||
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
|
||||
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
|
||||
# backend may recognize it.
|
||||
return None
|
||||
|
||||
if len(parts) != 2:
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise exceptions.AuthenticationFailed(
|
||||
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
|
||||
"Authorization header must be: Bearer <token>"
|
||||
)
|
||||
|
||||
token = parts[1]
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""User access JWT authentication for the Meet core API.
|
||||
|
||||
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
|
||||
session cookies are blocked) to authenticate requests on the core API with
|
||||
a JWT, obtained by exchanging a single-use transit code (see
|
||||
core.services.transit_code and the users exchange-access-token endpoint)
|
||||
and passed as a Bearer header. The JWT itself never appears in any URL.
|
||||
|
||||
Similar to lib-jitsi-meet's token authentication, the token is bound to a
|
||||
user, not to a resource: once authenticated, the request is treated
|
||||
exactly like a session-authenticated one, and the existing role-based
|
||||
permissions apply unchanged.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import exceptions
|
||||
|
||||
from core.external_api.authentication import BaseJWTAuthentication
|
||||
from core.models import Application, ApplicationScope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserAccessJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for user access tokens.
|
||||
|
||||
Validates user access tokens issued by the users exchange-access-token
|
||||
endpoint and authenticates the user they were issued for. A bearer
|
||||
token that does not verify against the user access token secret is
|
||||
deferred to the next authentication backend; a token that does verify
|
||||
but carries wrong claims is rejected.
|
||||
|
||||
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
|
||||
backend is entirely inert: `BaseJWTAuthentication.authenticate`
|
||||
returns None before reading the Authorization header, deferring every
|
||||
request to the next authentication backend.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the backend with user access token settings."""
|
||||
super().__init__(
|
||||
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
|
||||
)
|
||||
|
||||
def validate_payload(self, payload):
|
||||
"""Validate the token type and the issuance-audit claim.
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the token verified against the user
|
||||
access token secret but does not carry the expected
|
||||
claims, or if the issuing application lost its grant.
|
||||
"""
|
||||
|
||||
if payload.get("token_type") != settings.USER_ACCESS_TOKEN_TYPE_CLAIM:
|
||||
logger.warning("Wrong 'token_type' in user access token payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
client_id = payload.get("client_id")
|
||||
if not client_id:
|
||||
logger.warning("Missing 'client_id' in user access token payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
if not Application.has_active_scope(client_id, ApplicationScope.USERS_SESSION):
|
||||
logger.warning(
|
||||
"User access token refused: application '%s' no longer "
|
||||
"holds the '%s' grant",
|
||||
client_id,
|
||||
ApplicationScope.USERS_SESSION,
|
||||
)
|
||||
raise exceptions.AuthenticationFailed("Application access revoked.")
|
||||
@@ -48,7 +48,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
|
||||
self.is_enabled = is_enabled
|
||||
self._token_service = None
|
||||
self._token_type = token_type
|
||||
|
||||
if not self.is_enabled:
|
||||
return
|
||||
@@ -74,10 +73,7 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
|
||||
if (
|
||||
not auth_header
|
||||
or auth_header[0].lower() != self._token_type.lower().encode()
|
||||
):
|
||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||
# Defer to next authentication backend
|
||||
return None
|
||||
|
||||
@@ -163,7 +159,7 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return authentication scheme for WWW-Authenticate header."""
|
||||
return self._token_type
|
||||
return "Bearer"
|
||||
|
||||
def authenticate_credentials(self, token):
|
||||
"""Validate JWT token and return authenticated user.
|
||||
|
||||
@@ -20,60 +20,8 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
|
||||
scope_map: Dict[str, str] = {}
|
||||
|
||||
def get_required_scope(self, view):
|
||||
"""Return the scope required by the view's current action.
|
||||
|
||||
Returns:
|
||||
The required scope, or None for an unsupported method so
|
||||
DRF's router can answer 405.
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If the action is not in scope_map (deny by
|
||||
default).
|
||||
"""
|
||||
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||
action = getattr(view, "action", None)
|
||||
if not action:
|
||||
# DRF routers return a 405 for unsupported methods
|
||||
return None
|
||||
|
||||
required_scope = self.scope_map.get(action)
|
||||
if not required_scope:
|
||||
# Action not in scope_map, deny by default
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Insufficient permissions. Required scope: {required_scope}"
|
||||
)
|
||||
|
||||
return required_scope
|
||||
|
||||
def get_token_scopes(self, request):
|
||||
"""Extract and normalize the scopes claimed by the token."""
|
||||
token_scopes = (request.auth or {}).get("scope")
|
||||
|
||||
if not token_scopes:
|
||||
return []
|
||||
|
||||
# Ensure scopes is a list (handle both list and space-separated string)
|
||||
if isinstance(token_scopes, str):
|
||||
token_scopes = token_scopes.split()
|
||||
|
||||
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
||||
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
||||
|
||||
return self.strip_scope_prefix(token_scopes)
|
||||
|
||||
@staticmethod
|
||||
def strip_scope_prefix(token_scopes):
|
||||
"""Strip the OIDC resource server prefix, when configured."""
|
||||
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||
return [
|
||||
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||
for scope in token_scopes
|
||||
]
|
||||
return token_scopes
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the token claims the scope required by this action.
|
||||
"""Check if the JWT token contains the required scope for this action.
|
||||
|
||||
Args:
|
||||
request: DRF request object with authenticated user
|
||||
@@ -85,15 +33,38 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
Raises:
|
||||
PermissionDenied: If required scope is missing from token
|
||||
"""
|
||||
required_scope = self.get_required_scope(view)
|
||||
if required_scope is None:
|
||||
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||
action = getattr(view, "action", None)
|
||||
if not action:
|
||||
# DRF routers return a 405 for unsupported methods
|
||||
return True
|
||||
|
||||
token_scopes = self.get_token_scopes(request)
|
||||
required_scope = self.scope_map.get(action)
|
||||
if not required_scope:
|
||||
# Action not in scope_map, deny by default
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Insufficient permissions. Required scope: {required_scope}"
|
||||
)
|
||||
|
||||
token_payload = request.auth
|
||||
token_scopes = token_payload.get("scope")
|
||||
|
||||
if not token_scopes:
|
||||
raise exceptions.PermissionDenied("Insufficient permissions.")
|
||||
|
||||
# Ensure scopes is a list (handle both list and space-separated string)
|
||||
if isinstance(token_scopes, str):
|
||||
token_scopes = token_scopes.split()
|
||||
|
||||
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
||||
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
||||
|
||||
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||
token_scopes = [
|
||||
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||
for scope in token_scopes
|
||||
]
|
||||
|
||||
if required_scope not in token_scopes:
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Insufficient permissions. Required scope: {required_scope}"
|
||||
@@ -102,37 +73,6 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
return True
|
||||
|
||||
|
||||
class ApplicationScopePermission(BaseScopePermission):
|
||||
"""Scope-based permission for application-authenticated endpoints."""
|
||||
|
||||
@staticmethod
|
||||
def strip_scope_prefix(token_scopes):
|
||||
"""Compare application scopes verbatim."""
|
||||
return token_scopes
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check the scope claim, then the grant recorded in the database."""
|
||||
granted = super().has_permission(request, view)
|
||||
|
||||
required_scope = self.get_required_scope(view)
|
||||
|
||||
if granted and required_scope:
|
||||
client_id = (request.auth or {}).get("client_id")
|
||||
|
||||
if not models.Application.has_active_scope(client_id, required_scope):
|
||||
logger.warning(
|
||||
"Application '%s' presented scope '%s' without a matching "
|
||||
"grant in database",
|
||||
client_id,
|
||||
required_scope,
|
||||
)
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Application is not granted the required scope: {required_scope}"
|
||||
)
|
||||
|
||||
return granted
|
||||
|
||||
|
||||
class HasRequiredRoomScope(BaseScopePermission):
|
||||
"""Permission class for Room-related operations."""
|
||||
|
||||
@@ -146,14 +86,6 @@ class HasRequiredRoomScope(BaseScopePermission):
|
||||
}
|
||||
|
||||
|
||||
class HasRequiredUserScope(ApplicationScopePermission):
|
||||
"""Scope-based permissions for the external user endpoints."""
|
||||
|
||||
scope_map = {
|
||||
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
|
||||
}
|
||||
|
||||
|
||||
class RoomPermissions(permissions.BasePermission):
|
||||
"""Permissions applying to the room API endpoint."""
|
||||
|
||||
|
||||
@@ -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,11 +22,10 @@ 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
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -35,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):
|
||||
@@ -68,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:
|
||||
@@ -92,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.",
|
||||
@@ -105,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:
|
||||
@@ -134,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,
|
||||
@@ -163,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,
|
||||
@@ -191,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,
|
||||
@@ -231,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,
|
||||
@@ -265,63 +297,3 @@ class RoomViewSet(
|
||||
updated_fields=updated_fields,
|
||||
previous_access_level=previous_values["access_level"],
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
"""Application-delegated API for user operations.
|
||||
|
||||
Provides JWT-authenticated access to user operations for external
|
||||
applications acting on behalf of users. All operations are
|
||||
scope-based. Meant to grow with the other user actions exposed to
|
||||
third parties.
|
||||
|
||||
Supported operations:
|
||||
- transit-code: Mint a single-use transit code for the delegated user
|
||||
(requires 'users:session' scope)
|
||||
"""
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
|
||||
]
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="transit-code",
|
||||
url_name="transit-code",
|
||||
)
|
||||
@FeatureFlag.require("user_access_token")
|
||||
def generate_transit_code(self, request):
|
||||
"""Mint a transit code for the delegated user.
|
||||
|
||||
Returns a short-lived, single-use opaque code to pass to an embedded
|
||||
frontend (e.g. via a URL fragment when cookies are unavailable). The
|
||||
frontend exchanges it once on
|
||||
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
|
||||
equivalent to session-cookie authentication and never exposed in a URL.
|
||||
"""
|
||||
|
||||
if not request.auth or not request.auth.get("client_id"):
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid application token.")
|
||||
|
||||
client_id = request.auth["client_id"]
|
||||
|
||||
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Transit code issued: user_id=%s, client_id=%s",
|
||||
request.user.id,
|
||||
client_id,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"transit_code": code,
|
||||
"expires_in": settings.TRANSIT_CODE_TTL,
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -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,19 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-07-31 18:27
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0025_application_client_secret_sha256'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='application',
|
||||
name='scopes',
|
||||
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
|
||||
),
|
||||
]
|
||||
+19
-78
@@ -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
|
||||
|
||||
@@ -703,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."""
|
||||
@@ -800,7 +815,6 @@ class ApplicationScope(models.TextChoices):
|
||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
@@ -826,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,
|
||||
@@ -844,84 +855,14 @@ 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."""
|
||||
|
||||
allowed_domains = {d.domain for d in self.allowed_domains.all()}
|
||||
if not allowed_domains:
|
||||
if not self.allowed_domains.exists():
|
||||
return True # No domain restrictions
|
||||
|
||||
domain = get_domain_from_email(email)
|
||||
return bool(domain) and domain.lower() in allowed_domains
|
||||
|
||||
@classmethod
|
||||
def has_active_scope(cls, client_id, scope) -> bool:
|
||||
"""Check that an active application holds a scope."""
|
||||
if not client_id or not scope:
|
||||
return False
|
||||
|
||||
return cls.objects.filter(
|
||||
client_id=client_id,
|
||||
is_active=True,
|
||||
scopes__contains=[scope],
|
||||
).exists()
|
||||
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
||||
|
||||
|
||||
class ApplicationDomain(BaseModel):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import get_language, gettext, gettext_noop, override
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from kombu.exceptions import OperationalError as KombuOperationalError
|
||||
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.tasks.email import send_email
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -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)
|
||||
@@ -68,14 +68,10 @@ class NotificationService:
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Queue an email notification to recording owners when their recording is ready.
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
|
||||
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
|
||||
this only does one DB query and enqueues one message per owner.
|
||||
Returns True when every email was queued (or sent, if Celery is disabled).
|
||||
"""
|
||||
|
||||
owner_accesses = (
|
||||
@@ -92,6 +88,10 @@ class NotificationService:
|
||||
return False
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
@@ -104,22 +104,37 @@ class NotificationService:
|
||||
# 2. The number of recipients per recording is typically small (not thousands)
|
||||
for access in owner_accesses:
|
||||
user = access.user
|
||||
local_created_at = recording.created_at.astimezone(user.timezone)
|
||||
try:
|
||||
send_email.delay(
|
||||
template="screen_recording",
|
||||
subject=gettext_noop("Your recording is ready"),
|
||||
recipients=[user.email],
|
||||
language=user.language or get_language(),
|
||||
context={
|
||||
**context,
|
||||
"recording_date": local_created_at.strftime("%Y-%m-%d"),
|
||||
"recording_time": local_created_at.strftime("%H:%M"),
|
||||
},
|
||||
language = user.language or get_language()
|
||||
with override(language):
|
||||
personalized_context = {
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%H:%M"),
|
||||
**context,
|
||||
}
|
||||
msg_html = render_to_string(
|
||||
"mail/html/screen_recording.html", personalized_context
|
||||
)
|
||||
except (smtplib.SMTPException, KombuOperationalError) as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = gettext("Your recording is ready") # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[user.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
|
||||
return not has_failures
|
||||
|
||||
@@ -198,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):
|
||||
@@ -283,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
|
||||
@@ -334,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,
|
||||
@@ -357,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,
|
||||
@@ -31,6 +32,8 @@ from .sip_management import SIPException, SIPManagement
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
SERVICE_ORIGIN = "livekit"
|
||||
|
||||
|
||||
class LiveKitWebhookError(Exception):
|
||||
"""Base exception for LiveKit webhook processing errors."""
|
||||
@@ -219,6 +222,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 +258,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(SERVICE_ORIGIN),
|
||||
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."""
|
||||
|
||||
@@ -131,6 +131,23 @@ class LobbyService:
|
||||
if participant_ids:
|
||||
self._redis().srem(self._get_index_key(room_id), *participant_ids)
|
||||
|
||||
@staticmethod
|
||||
def _get_or_create_participant_id(request) -> str:
|
||||
"""Extract unique participant identifier from the request."""
|
||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
|
||||
|
||||
@staticmethod
|
||||
def prepare_response(response, participant_id):
|
||||
"""Set participant cookie if needed."""
|
||||
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
|
||||
response.set_cookie(
|
||||
key=settings.LOBBY_COOKIE_NAME,
|
||||
value=participant_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
@@ -161,9 +178,8 @@ class LobbyService:
|
||||
def request_entry(
|
||||
self,
|
||||
room: models.Room,
|
||||
user,
|
||||
request,
|
||||
username: str,
|
||||
participant_id: Optional[uuid.UUID] = None,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
"""Request entry to a room for a participant.
|
||||
|
||||
@@ -178,51 +194,52 @@ class LobbyService:
|
||||
5. If denied, do nothing.
|
||||
"""
|
||||
|
||||
participant = None
|
||||
if participant_id:
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
is_new_participant = participant is None
|
||||
if is_new_participant:
|
||||
participant = self._create_participant(username)
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
room_id = str(room.id)
|
||||
user_role = room.get_role(user)
|
||||
user_role = room.get_role(request.user)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=user, role=user_role):
|
||||
if not is_new_participant:
|
||||
self.clear_participant_cache(room.id, participant.id)
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
entered_at=timezone.now().isoformat(),
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=user,
|
||||
username=participant.username,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant.id,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if is_new_participant:
|
||||
self._save_participant(room.id, participant)
|
||||
self._notify_entry_request(room_id)
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant.id)
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=user,
|
||||
username=participant.username,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant.id,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
@@ -240,35 +257,24 @@ class LobbyService:
|
||||
)
|
||||
self._index_touch(room_id)
|
||||
|
||||
def _create_participant(self, username: str) -> LobbyParticipant:
|
||||
"""Create a new waiting participant without persisting it.
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby."""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
|
||||
Participant identifiers are minted here, server-side, exclusively.
|
||||
"""
|
||||
participant_id = str(uuid.uuid4())
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
entered_at=timezone.now().isoformat(),
|
||||
color=utils.generate_color(participant_id),
|
||||
)
|
||||
return participant
|
||||
|
||||
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
|
||||
"""Persist a participant in the room's lobby."""
|
||||
cache.set(
|
||||
self._get_cache_key(room_id, participant.id),
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
self._index_add(room_id, participant.id)
|
||||
|
||||
def _notify_entry_request(self, room_id: str):
|
||||
"""Notify room participants of a new entry request."""
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=room_id,
|
||||
room_name=str(room_id),
|
||||
notification_data={
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
},
|
||||
@@ -277,6 +283,16 @@ class LobbyService:
|
||||
# If room not created yet, there is no participants to notify
|
||||
logger.exception("Failed to notify room participants")
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.set(
|
||||
cache_key,
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
self._index_add(room_id, participant_id)
|
||||
|
||||
return participant
|
||||
|
||||
def _get_participant(
|
||||
self, room_id: UUID, participant_id: str
|
||||
) -> Optional[LobbyParticipant]:
|
||||
@@ -337,7 +353,7 @@ class LobbyService:
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
allow_entry: bool,
|
||||
) -> LobbyParticipant:
|
||||
) -> None:
|
||||
"""Handle decision on participant entry.
|
||||
|
||||
Updates participant status based on allow_entry:
|
||||
@@ -355,7 +371,7 @@ class LobbyService:
|
||||
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
||||
}
|
||||
|
||||
return self._update_participant_status(room_id, participant_id, **decision)
|
||||
self._update_participant_status(room_id, participant_id, **decision)
|
||||
|
||||
def _update_participant_status(
|
||||
self,
|
||||
@@ -363,7 +379,7 @@ class LobbyService:
|
||||
participant_id: str,
|
||||
status: LobbyParticipantStatus,
|
||||
timeout: int,
|
||||
) -> LobbyParticipant:
|
||||
) -> None:
|
||||
"""Update participant status with appropriate timeout."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
@@ -386,8 +402,6 @@ class LobbyService:
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
self._index_touch(room_id)
|
||||
|
||||
return participant
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Service handling the lifecycle of transit codes.
|
||||
|
||||
A transit code is an opaque, cryptographically random, single-use code
|
||||
handed to an embedded frontend (through a URL fragment) so it can obtain a
|
||||
user access token on the core API without a session cookie. The code
|
||||
carries no information by itself: everything it references (user, client)
|
||||
is stored server-side in the cache, and consumed atomically on exchange.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class TransitCodeService:
|
||||
"""Create and consume single-use transit codes."""
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(code):
|
||||
"""Build the cache key for a code.
|
||||
|
||||
The code is hashed so that a dump of the cache never reveals
|
||||
directly usable codes.
|
||||
"""
|
||||
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
|
||||
|
||||
def create_code(self, user, client_id):
|
||||
"""Generate a transit code for a user, and store it.
|
||||
|
||||
The code expires after TRANSIT_CODE_TTL seconds.
|
||||
|
||||
Returns:
|
||||
str: The opaque code to hand to the client.
|
||||
"""
|
||||
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
|
||||
# entropy: unguessable and safe to transit through a URL fragment.
|
||||
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||
|
||||
cache.set(
|
||||
self._cache_key(code),
|
||||
{
|
||||
"user_id": str(user.id),
|
||||
"client_id": client_id,
|
||||
},
|
||||
timeout=settings.TRANSIT_CODE_TTL,
|
||||
)
|
||||
|
||||
return code
|
||||
|
||||
def consume_code(self, code):
|
||||
"""Consume a transit code, enforcing single use.
|
||||
|
||||
The code is deleted from the cache upon consumption. `cache.delete`
|
||||
returns whether a key was actually deleted, so if two requests race
|
||||
on the same code, only one of them wins.
|
||||
|
||||
Returns:
|
||||
dict | None: The data stored at creation time ('user_id',
|
||||
'client_id'), or None if the code is unknown, expired or
|
||||
already consumed.
|
||||
"""
|
||||
if not code:
|
||||
return None
|
||||
|
||||
key = self._cache_key(code)
|
||||
data = cache.get(key)
|
||||
|
||||
if data is None or not cache.delete(key):
|
||||
return None
|
||||
|
||||
return data
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Celery tasks for the core app."""
|
||||
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.email import send_email
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
__all__ = (
|
||||
"delete_connection_test_room",
|
||||
"process_file_deletion",
|
||||
"send_email",
|
||||
)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Generic tasks to send transactional emails."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_base_email_context() -> dict:
|
||||
"""Context shared by every email template (branding, support, domain)."""
|
||||
return {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
}
|
||||
|
||||
|
||||
@task(
|
||||
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=600,
|
||||
retry_jitter=True,
|
||||
max_retries=5,
|
||||
)
|
||||
def send_email(
|
||||
*,
|
||||
template: str,
|
||||
subject: str,
|
||||
recipients: list[str],
|
||||
context: dict | None = None,
|
||||
language: str | None = None,
|
||||
):
|
||||
"""Render a mail template and send it.
|
||||
|
||||
Generic on purpose: any feature can reuse it with its own template and context.
|
||||
|
||||
Args:
|
||||
template: Template base name, e.g. "screen_recording". Both
|
||||
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
|
||||
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
|
||||
call site; it is translated here, in ``language``.
|
||||
recipients: Email addresses, all receiving the same rendered message.
|
||||
context: Template context. Must be JSON-serializable (str, int, list, dict…):
|
||||
pre-format dates, cast UUIDs to str. Merged over the base branding context.
|
||||
language: Language to render the email in. Defaults to the active language.
|
||||
|
||||
Raises:
|
||||
smtplib.SMTPException: after retries are exhausted (or immediately when
|
||||
Celery is disabled and the call runs synchronously).
|
||||
"""
|
||||
full_context = {**get_base_email_context(), **(context or {})}
|
||||
|
||||
with override(language or get_language()):
|
||||
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
|
||||
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
|
||||
translated_subject = gettext(subject)
|
||||
|
||||
send_mail(
|
||||
translated_subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
recipients,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
|
||||
@@ -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,748 @@
|
||||
"""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_request_context(audit_events):
|
||||
"""Should read the request fields from the context of the request being served."""
|
||||
request = RequestFactory().post("/rooms/", REMOTE_ADDR="1.2.3.4")
|
||||
request.user = AnonymousUser()
|
||||
token = audit_request.set_request_context(
|
||||
audit_request.RequestContext.from_request(request)
|
||||
)
|
||||
|
||||
try:
|
||||
audit.log("anything")
|
||||
finally:
|
||||
audit_request.reset_request_context(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_request_context(audit_events):
|
||||
"""Should prefer the request passed to the one being served."""
|
||||
token = audit_request.set_request_context(
|
||||
audit_request.RequestContext.from_request(RequestFactory().get("/current/"))
|
||||
)
|
||||
|
||||
try:
|
||||
audit.log("anything", request=RequestFactory().get("/explicit/"))
|
||||
finally:
|
||||
audit_request.reset_request_context(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,271 @@
|
||||
"""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_request_context_reads_the_request(dockerflow_request_id):
|
||||
"""Should read the network fields and the request id of a request."""
|
||||
request = RequestFactory().post(
|
||||
"/rooms/",
|
||||
REMOTE_ADDR="1.2.3.4",
|
||||
HTTP_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64)",
|
||||
)
|
||||
|
||||
context = audit_request.RequestContext.from_request(request)
|
||||
|
||||
assert context == audit_request.RequestContext(
|
||||
request=request,
|
||||
request_id=dockerflow_request_id,
|
||||
method="POST",
|
||||
path="/rooms/",
|
||||
client_ip="1.2.3.4",
|
||||
user_agent="Mozilla/5.0 (X11; Linux x86_64)",
|
||||
)
|
||||
|
||||
|
||||
def test_request_context_truncates_the_user_agent():
|
||||
"""A user agent is cut where ECS stops indexing it."""
|
||||
request = RequestFactory().get("/", HTTP_USER_AGENT="x" * 5000)
|
||||
|
||||
context = audit_request.RequestContext.from_request(request)
|
||||
|
||||
assert context.user_agent == "x" * audit_request.USER_AGENT_MAX_LENGTH
|
||||
|
||||
|
||||
def test_request_context_without_a_user_agent():
|
||||
"""Should have no user agent when the client sent none."""
|
||||
context = audit_request.RequestContext.from_request(RequestFactory().get("/"))
|
||||
|
||||
assert context.user_agent is None
|
||||
|
||||
|
||||
def test_request_context_outside_a_request():
|
||||
"""Should have an empty request context when no request is being served."""
|
||||
assert audit_request.request_context() == audit_request.RequestContext()
|
||||
|
||||
|
||||
def test_middleware_sets_the_request_context_while_serving():
|
||||
"""Should expose the request context to the code serving it, then forget it."""
|
||||
seen = []
|
||||
|
||||
def view(request): # pylint: disable=unused-argument
|
||||
seen.append(audit_request.request_context())
|
||||
return HttpResponse()
|
||||
|
||||
request = RequestFactory().get(
|
||||
"/rooms/", REMOTE_ADDR="1.2.3.4", HTTP_USER_AGENT="calendar-app/2.3"
|
||||
)
|
||||
response = audit_request.AuditLogMiddleware(view)(request)
|
||||
|
||||
assert seen == [
|
||||
audit_request.RequestContext(
|
||||
request=request,
|
||||
request_id=response["X-Request-ID"],
|
||||
method="GET",
|
||||
path="/rooms/",
|
||||
client_ip="1.2.3.4",
|
||||
user_agent="calendar-app/2.3",
|
||||
)
|
||||
]
|
||||
assert audit_request.request_context() == audit_request.RequestContext()
|
||||
|
||||
|
||||
def test_middleware_forgets_the_request_context_when_the_view_raises():
|
||||
"""Should not leak the request context 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.request_context() == audit_request.RequestContext()
|
||||
@@ -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):
|
||||
|
||||
@@ -12,10 +12,10 @@ from unittest import mock
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
import pytest
|
||||
from kombu.exceptions import OperationalError as KombuOperationalError
|
||||
|
||||
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
|
||||
@@ -143,7 +143,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch("core.tasks.email.send_mail") as mock_send_mail:
|
||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
@@ -238,7 +238,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch(
|
||||
"core.tasks.email.send_mail",
|
||||
"core.recording.event.notification.send_mail",
|
||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||
) as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
@@ -248,60 +248,6 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
|
||||
|
||||
def test_notify_user_by_email_queues_generic_task(settings):
|
||||
"""One generic send_email task is queued per owner, with serializable args."""
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
|
||||
settings.RECORDING_EXPIRATION_DAYS = 7
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
recording.created_at = datetime.datetime(
|
||||
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
user = factories.UserFactory(
|
||||
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=user
|
||||
)
|
||||
|
||||
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
mock_delay.assert_called_once_with(
|
||||
template="screen_recording",
|
||||
subject="Your recording is ready",
|
||||
recipients=["franc@test.com"],
|
||||
language="fr-fr",
|
||||
context={
|
||||
"room_name": "Conference Room A",
|
||||
"recording_expiration_days": 7,
|
||||
"link": f"https://acme.com/recordings/{recording.id}",
|
||||
"recording_date": "2023-05-15",
|
||||
"recording_time": "16:30",
|
||||
},
|
||||
)
|
||||
# Celery's JSON serializer must accept every argument
|
||||
json.dumps(mock_delay.call_args.kwargs)
|
||||
|
||||
|
||||
def test_notify_user_by_email_broker_unavailable(caplog):
|
||||
"""A broker outage is reported as a failure without crashing the caller."""
|
||||
recording = factories.RecordingFactory()
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.send_email.delay",
|
||||
side_effect=KombuOperationalError("broker down"),
|
||||
):
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert "notification could not be sent: broker down" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
@@ -534,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")
|
||||
|
||||
@@ -15,7 +15,6 @@ from ...api.throttling import (
|
||||
)
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Room, RoomAccessLevel
|
||||
from ..utils import generate_user_access_token
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -459,16 +458,3 @@ def test_api_rooms_create_daily_throttle_does_not_limit_other_actions(
|
||||
assert client.get("/api/v1.0/rooms/").status_code == 200
|
||||
response = client.patch(f"/api/v1.0/rooms/{room_id}/", {"name": "Renamed"})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_with_user_access_token():
|
||||
"""A user access token should create a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -10,7 +10,6 @@ from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ..utils import generate_user_access_token
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -157,18 +156,3 @@ def test_api_rooms_list_pagination_page_size():
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||
assert content["previous"] is None
|
||||
|
||||
|
||||
def test_api_rooms_list_authenticated_with_user_access_token():
|
||||
"""A user access token should list rooms exactly like a session would."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
RoomFactory() # another user's room, not listed
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
@@ -6,7 +6,6 @@ Test rooms API endpoints in the Meet core app: lobby functionality.
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.core import signing
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
@@ -16,16 +15,13 @@ from rest_framework.test import APIClient
|
||||
from ... import utils
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.lobby import LobbyService
|
||||
from ...services.lobby import (
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _lobby_signer():
|
||||
"""Use the polling credential's dedicated signing namespace."""
|
||||
return signing.Signer(salt="core.lobby.participant")
|
||||
|
||||
|
||||
# Tests for request_entry endpoint
|
||||
|
||||
|
||||
@@ -35,6 +31,7 @@ def test_request_entry_anonymous(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -52,10 +49,11 @@ def test_request_entry_anonymous(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -72,8 +70,7 @@ def test_request_entry_anonymous(settings):
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
# Verify participant data was correctly stored in cache
|
||||
raw_id = _lobby_signer().unsign(participant_id)
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@@ -85,6 +82,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -102,10 +100,11 @@ def test_request_entry_authenticated_user(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -122,8 +121,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
# Verify participant data was correctly stored in cache
|
||||
raw_id = _lobby_signer().unsign(participant_id)
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@@ -135,6 +133,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
client = APIClient()
|
||||
|
||||
# Configure test settings for cookies and cache
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add two participants already waiting in the lobby
|
||||
@@ -177,10 +176,11 @@ def test_request_entry_with_existing_participants(settings):
|
||||
# Verify successful response
|
||||
assert response.status_code == 200
|
||||
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
# Verify the lobby cookie was properly set for the new participant
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -197,8 +197,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
assert len(lobby_keys) == 3
|
||||
|
||||
# Verify the new participant data was correctly stored in cache
|
||||
raw_id = _lobby_signer().unsign(participant_id)
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@@ -208,6 +207,7 @@ def test_request_entry_public_room(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -216,9 +216,8 @@ def test_request_entry_public_room(settings):
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch(
|
||||
"core.services.lobby.uuid.uuid4",
|
||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
@@ -232,9 +231,14 @@ def test_request_entry_public_room(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "123"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
@@ -242,8 +246,9 @@ def test_request_entry_public_room(settings):
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not LobbyService()._index_members(room.id)
|
||||
# Verify lobby cache is still empty after the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
@@ -254,6 +259,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -262,8 +268,9 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch(
|
||||
"core.services.lobby.uuid.uuid4",
|
||||
mock.patch.object(
|
||||
LobbyService,
|
||||
"_get_or_create_participant_id",
|
||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
),
|
||||
mock.patch.object(
|
||||
@@ -278,9 +285,14 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
@@ -288,8 +300,9 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not LobbyService()._index_members(room.id)
|
||||
# Verify lobby cache is still empty after the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
@@ -298,6 +311,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add a waiting participant to the room's lobby cache
|
||||
@@ -312,9 +326,9 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
},
|
||||
)
|
||||
|
||||
LobbyService()._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
|
||||
# Simulate a browser with existing participant cookie
|
||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
||||
|
||||
# Simulate a returning participant echoing its identifier
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
@@ -323,19 +337,19 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{
|
||||
"username": "user1",
|
||||
"participant_id": _lobby_signer().sign(
|
||||
"2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
),
|
||||
},
|
||||
{"username": "user1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
@@ -343,8 +357,9 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not LobbyService()._index_members(room.id)
|
||||
# Verify participant remains in the lobby cache after acceptance
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
|
||||
def test_request_entry_invalid_data():
|
||||
@@ -647,14 +662,15 @@ def test_list_waiting_participants_empty(settings):
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_anonymous_unidentified(
|
||||
def test_request_entry_throttling_anonymous_without_cookie(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Requests without a participant identifier should not be throttled."""
|
||||
"""Anonymous users without a cookie should not be throttled."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||
|
||||
response = client.post(
|
||||
@@ -663,6 +679,9 @@ def test_request_entry_throttling_anonymous_unidentified(
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cookies.get("mocked-cookie") is not None
|
||||
|
||||
client.cookies.clear() # Simulate a new cookieless request
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
@@ -676,32 +695,34 @@ def test_request_entry_throttling_anonymous_unidentified(
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_anonymous_identified(
|
||||
def test_request_entry_throttling_anonymous_with_cookie(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Identified requests should be throttled after exceeding the rate limit."""
|
||||
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
participant_id = _lobby_signer().sign(str(uuid.uuid4()))
|
||||
participant_id = str(uuid.uuid4())
|
||||
client.cookies.load({"mocked-cookie": participant_id})
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
@@ -720,6 +741,7 @@ def test_request_entry_throttling_authenticated_user(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
response = client.post(
|
||||
@@ -740,237 +762,3 @@ def test_request_entry_throttling_authenticated_user(
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
def test_request_entry_with_participant_id(settings):
|
||||
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
# Echoing the identifier must be recognized as the same
|
||||
# participant: no duplicate in the lobby
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == participant_id
|
||||
assert response.json()["status"] == "waiting"
|
||||
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
|
||||
def test_request_entry_unknown_participant_id_not_seeded(settings):
|
||||
"""A valid signed credential with no cached record creates a new participant."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
forged_id = str(uuid.uuid4())
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{
|
||||
"username": "test_user",
|
||||
"participant_id": _lobby_signer().sign(forged_id),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _lobby_signer().unsign(response.json()["id"]) != forged_id
|
||||
|
||||
# Nothing was stored under the forged identifier
|
||||
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
|
||||
|
||||
|
||||
def test_request_entry_participant_id_bound_to_room(settings):
|
||||
"""An identifier minted for one room must not be honored in another."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] != participant_id
|
||||
|
||||
|
||||
def test_request_entry_legacy_cookie_ignored():
|
||||
"""The retired cookie channel must not be honored anymore."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
legacy_participant_id = str(uuid.uuid4())
|
||||
client.cookies["lobbyParticipantId"] = legacy_participant_id
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
returned_id = response.json()["id"]
|
||||
assert returned_id != legacy_participant_id
|
||||
uuid.UUID(_lobby_signer().unsign(returned_id))
|
||||
|
||||
|
||||
def test_request_entry_malformed_participant_id(settings):
|
||||
"""A malformed polling credential is rejected with a 400."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": "../../../evil-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "participant_id" in response.json()
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(utils, "generate_livekit_config")
|
||||
def test_request_entry_rejects_unsigned_id(generate_config, _notify):
|
||||
"""Knowing the public UUID must not grant admission."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
public_id = _lobby_signer().unsign(response.json()["id"])
|
||||
LobbyService().handle_participant_entry(room.id, public_id, True)
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "Impersonator", "participant_id": public_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "participant_id" in response.json()
|
||||
generate_config.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(utils, "generate_livekit_config")
|
||||
def test_request_entry_rejects_tampered_credential(generate_config, _notify):
|
||||
"""Modifying a signed credential must invalidate it."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
credential = response.json()["id"]
|
||||
public_id = _lobby_signer().unsign(credential)
|
||||
LobbyService().handle_participant_entry(room.id, public_id, True)
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "Impersonator", "participant_id": credential + "x"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "participant_id" in response.json()
|
||||
generate_config.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(utils, "generate_livekit_config")
|
||||
def test_request_entry_rejects_wrong_signing_secret(generate_config, _notify):
|
||||
"""A credential signed with another secret must not grant admission."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
public_id = _lobby_signer().unsign(response.json()["id"])
|
||||
LobbyService().handle_participant_entry(room.id, public_id, True)
|
||||
|
||||
forged = signing.Signer(
|
||||
key="incorrect-test-signing-secret",
|
||||
salt="core.lobby.participant",
|
||||
fallback_keys=[],
|
||||
).sign(public_id)
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "Impersonator", "participant_id": forged},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "participant_id" in response.json()
|
||||
generate_config.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_accepts_signed_credential(generate_config, _notify):
|
||||
"""The signed credential grants admission using the public LiveKit UUID."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
credential = response.json()["id"]
|
||||
public_id = _lobby_signer().unsign(credential)
|
||||
assert credential != public_id
|
||||
LobbyService().handle_participant_entry(room.id, public_id, True)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "Guest", "participant_id": credential},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "accepted"
|
||||
assert response.json()["id"] == credential
|
||||
assert response.json()["livekit"] == {"token": "test-token"}
|
||||
generate_config.assert_called_once()
|
||||
assert generate_config.call_args.kwargs["participant_id"] == public_id
|
||||
|
||||
@@ -19,13 +19,8 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import utils
|
||||
from core.factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from core.services.lobby import LobbyParticipant, LobbyParticipantStatus, LobbyService
|
||||
from core.tests.utils import generate_user_access_token
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -92,7 +87,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -118,7 +113,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -158,7 +153,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -305,7 +300,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -335,7 +330,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -366,7 +361,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -386,7 +381,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -417,7 +412,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -445,7 +440,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -474,7 +469,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -854,16 +849,7 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
|
||||
participant_identity = str(uuid4())
|
||||
|
||||
# Create participant in lobby cache first
|
||||
LobbyService()._save_participant(
|
||||
room.id,
|
||||
LobbyParticipant(
|
||||
id=participant_identity,
|
||||
username="John doe",
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
)
|
||||
LobbyService().enter(room.id, participant_identity, "John doe")
|
||||
|
||||
# Accept participant
|
||||
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
||||
@@ -1034,119 +1020,3 @@ def test_remove_participant_not_found(mock_livekit_client):
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_bearer_scheme_defers_to_next_authentication(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should defer a "Bearer" header to the next authentication backend.
|
||||
|
||||
The LiveKit backend only claims the "X-LiveKit-Token" scheme. Any other
|
||||
scheme must be left untouched so the backends declared after it get a
|
||||
chance to authenticate the request.
|
||||
"""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.get_participant.assert_not_called()
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_bearer_scheme_defers_role_permissions_still_apply(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should still enforce room privileges once another backend authenticated."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
user = UserFactory() # no UserResourceAccess for this room
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_unknown_scheme_defers_and_stays_anonymous(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should leave the request unauthenticated when no backend claims the scheme."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_scheme_is_case_insensitive(mock_livekit_client):
|
||||
"""Should claim the LiveKit scheme whatever its casing, and not defer it."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
token = utils.generate_token(str(room.id), AnonymousUser())
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_scheme_malformed_header_is_rejected(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should reject a malformed header once the LiveKit scheme is claimed."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
token = utils.generate_token(str(room.id), AnonymousUser())
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||
}
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
@@ -17,12 +17,7 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import utils
|
||||
from core.factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from core.tests.utils import generate_user_access_token
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -74,10 +69,7 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -92,10 +84,7 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": False},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -112,10 +101,7 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -131,10 +117,7 @@ def test_toggle_hand_identity_derived_from_token(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -145,9 +128,7 @@ def test_toggle_hand_missing_raised_field(room, token):
|
||||
"""Test toggle hand with missing raised field returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||
)
|
||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "raised" in response.data
|
||||
@@ -161,7 +142,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
|
||||
url,
|
||||
{"raised": "not-a-boolean"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -185,10 +166,7 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -203,10 +181,7 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
@@ -225,7 +200,7 @@ def test_toggle_hand_raise_success_anonymous(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -245,7 +220,7 @@ def test_toggle_hand_lower_success_anonymous(
|
||||
url,
|
||||
{"raised": False},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -265,7 +240,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -282,10 +257,7 @@ def test_rename_participant_success(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -300,10 +272,7 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"name": "Jane Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -317,10 +286,7 @@ def test_rename_participant_uses_identity_from_token(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -332,7 +298,7 @@ def test_rename_participant_empty_name(room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -343,9 +309,7 @@ def test_rename_participant_missing_name(room, token):
|
||||
"""Test rename with missing name field returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||
)
|
||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "name" in response.data
|
||||
@@ -356,10 +320,7 @@ def test_rename_participant_name_too_long(room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "a" * 256},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -387,7 +348,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -402,10 +363,7 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
@@ -427,10 +385,7 @@ def test_rename_participant_forbidden_when_display_name_edit_disabled(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": name},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -449,10 +404,7 @@ def test_rename_participant_allowed_when_display_name_edit_enabled(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -474,7 +426,7 @@ def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -491,7 +443,7 @@ def test_rename_participant_success_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -511,7 +463,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -528,7 +480,7 @@ def test_rename_participant_sets_correct_name_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -545,7 +497,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -571,7 +523,7 @@ def test_toggle_hand_expired_token(room, expired_token):
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -585,7 +537,7 @@ def test_rename_participant_expired_token(room, expired_token):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -599,7 +551,7 @@ def test_toggle_hand_malformed_token(room):
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -613,10 +565,7 @@ def test_toggle_hand_room_not_found(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -631,10 +580,7 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -651,7 +597,7 @@ def test_rename_participant_malformed_token(room):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -665,10 +611,7 @@ def test_rename_participant_room_not_found(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -683,188 +626,10 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_access_token(user):
|
||||
"""Generate a valid user access JWT, sent with the "X-LiveKit-Token" scheme."""
|
||||
return generate_user_access_token(user)
|
||||
|
||||
|
||||
def test_toggle_hand_bearer_scheme_defers_to_next_authentication(
|
||||
mock_livekit_client, room, user, user_access_token
|
||||
):
|
||||
"""Test toggle hand defers a "Bearer" header instead of failing on it."""
|
||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_participant_bearer_scheme_defers_to_next_authentication(
|
||||
mock_livekit_client, room, user, user_access_token
|
||||
):
|
||||
"""Test rename defers a "Bearer" header instead of failing on it."""
|
||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_toggle_hand_unknown_scheme_defers(mock_livekit_client, room):
|
||||
"""Test toggle hand defers a scheme no backend recognizes."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_participant_unknown_scheme_defers(mock_livekit_client, room):
|
||||
"""Test rename defers a scheme no backend recognizes."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_toggle_hand_session_authentication_is_not_accepted(
|
||||
mock_livekit_client, room, user
|
||||
):
|
||||
"""Test toggle hand is not granted by a session, whatever the user's room role."""
|
||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(url, {"raised": True}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_participant_session_authentication_is_not_accepted(
|
||||
mock_livekit_client, room, user
|
||||
):
|
||||
"""Test rename is not granted by a session, whatever the user's room role."""
|
||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(url, {"name": "John Doe"}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_participant_livekit_scheme_is_case_insensitive(
|
||||
mock_livekit_client, room, token
|
||||
):
|
||||
"""Test rename claims the LiveKit scheme whatever its casing."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
|
||||
def test_toggle_hand_livekit_scheme_malformed_header_is_rejected(
|
||||
mock_livekit_client, room, token
|
||||
):
|
||||
"""Test toggle hand rejects a malformed header once the LiveKit scheme is claimed."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||
}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_participant_livekit_scheme_malformed_header_is_rejected(
|
||||
mock_livekit_client, room, token
|
||||
):
|
||||
"""Test rename rejects a malformed header once the LiveKit scheme is claimed."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||
}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
@@ -7,18 +7,13 @@ from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone as dj_timezone
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
from ..utils import generate_user_access_token
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -519,7 +514,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
@pytest.mark.parametrize("role", [None, *RoleChoices])
|
||||
def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
|
||||
"""Should not expose when the room was last started, whoever the requester is."""
|
||||
room = RoomFactory(access_level=access_level, last_started_at=dj_timezone.now())
|
||||
room = RoomFactory(access_level=access_level, last_started_at=timezone.now())
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
if role is not None:
|
||||
@@ -530,18 +525,3 @@ def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "last_started_at" not in response.json()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||
"""A user access token should retrieve a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == str(room.id)
|
||||
assert response.data["pin_code"] == room.pin_code
|
||||
assert "accesses" in response.data
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -12,8 +12,7 @@ import pytest
|
||||
from livekit.api import AccessToken, TwirpError, VideoGrants
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.tests.utils import generate_user_access_token
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -111,7 +110,7 @@ def test_start_subtitle_invalid_token():
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token",
|
||||
HTTP_AUTHORIZATION="Bearer invalid-token",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -131,7 +130,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -151,7 +150,7 @@ def test_start_subtitle_valid_token(
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -181,7 +180,7 @@ def test_start_subtitle_twirp_error(
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
@@ -201,7 +200,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -222,114 +221,10 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Invalid LiveKit token: Signature verification failed"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_access_token():
|
||||
"""Generate a valid user access JWT, sent with the "Bearer" scheme."""
|
||||
return generate_user_access_token(UserFactory())
|
||||
|
||||
|
||||
def test_start_subtitle_bearer_scheme_defers_to_next_authentication(
|
||||
settings, mock_livekit_client, user_access_token
|
||||
):
|
||||
"""Test that a "Bearer" header is deferred instead of failing on the LiveKit backend.
|
||||
|
||||
The action declares LiveKitTokenAuthentication as its only backend, so a
|
||||
scheme it does not own must be left to the next one. None follows, so the
|
||||
request ends up unauthenticated: the body reports missing credentials
|
||||
rather than an invalid LiveKit token.
|
||||
"""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||
|
||||
|
||||
def test_start_subtitle_unknown_scheme_defers(settings, mock_livekit_client):
|
||||
"""Test that a scheme no backend recognizes is deferred, not rejected."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||
|
||||
|
||||
def test_start_subtitle_scheme_is_case_insensitive(
|
||||
settings, mock_livekit_client, mock_livekit_token, mock_room_id
|
||||
):
|
||||
"""Test that the LiveKit scheme is claimed whatever its casing."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory(id=mock_room_id)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"x-livekit-token {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
|
||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
|
||||
|
||||
|
||||
def test_start_subtitle_malformed_header_is_rejected(
|
||||
settings, mock_livekit_client, mock_livekit_token
|
||||
):
|
||||
"""Test that a malformed header is rejected once the LiveKit scheme is claimed."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token} extra-part",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||
}
|
||||
|
||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||
|
||||
@@ -18,7 +18,6 @@ from ...services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from ..utils import generate_user_access_token
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -447,24 +446,3 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("privileged_role", ["administrator", "owner"])
|
||||
def test_api_rooms_update_authenticated_with_user_access_token(privileged_role):
|
||||
"""Role-based permissions apply unchanged with a user access token."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "member")])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
# A simple member cannot update the room
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 403
|
||||
|
||||
# An administrator or an owner can
|
||||
room.accesses.filter(user=user).update(role=privileged_role)
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "new name"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
Test lobby service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613, W0212, R0913, C0302, R0917
|
||||
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
||||
# ruff: noqa: PLR0913, PLR0917
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core import utils
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
@@ -151,10 +151,63 @@ def test_get_cache_key(lobby_service, participant_id):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
||||
|
||||
expected_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
||||
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
||||
assert cache_key == expected_key
|
||||
|
||||
|
||||
def test_get_or_create_participant_id_from_cookie(lobby_service):
|
||||
"""Test extracting participant ID from cookie."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
|
||||
|
||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
||||
|
||||
assert participant_id == "existing-id"
|
||||
|
||||
|
||||
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
|
||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
||||
"""Test creating new participant ID when cookie is missing."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {}
|
||||
|
||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
||||
|
||||
assert participant_id == "generated-id"
|
||||
mock_uuid4.assert_called_once()
|
||||
|
||||
|
||||
def test_prepare_response_existing_cookie(lobby_service, participant_id):
|
||||
"""Test response preparation with existing cookie."""
|
||||
response = HttpResponse()
|
||||
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
|
||||
|
||||
lobby_service.prepare_response(response, participant_id)
|
||||
|
||||
# Verify cookie wasn't set again
|
||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
||||
assert cookie.value == "existing-cookie"
|
||||
assert cookie.value != participant_id
|
||||
|
||||
|
||||
def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
"""Test response preparation with new cookie."""
|
||||
response = HttpResponse()
|
||||
|
||||
lobby_service.prepare_response(response, participant_id)
|
||||
|
||||
# Verify cookie was set
|
||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
||||
assert cookie is not None
|
||||
assert cookie.value == participant_id
|
||||
assert cookie["httponly"] is True
|
||||
assert cookie["secure"] is True
|
||||
assert cookie["samesite"] == "Lax"
|
||||
|
||||
# It's a session cookies (no max_age specified):
|
||||
assert not cookie["max-age"]
|
||||
|
||||
|
||||
def test_can_bypass_lobby_public_room(lobby_service):
|
||||
"""Should return True for public rooms regardless of user auth and role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -218,99 +271,94 @@ def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_public_room(
|
||||
mock_generate_config, lobby_service, participant_id, username, settings
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry to a public room."""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
user = AnonymousUser()
|
||||
request = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||
{
|
||||
"id": participant_id,
|
||||
"username": username,
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.UNKNOWN,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=user,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_trusted_room(
|
||||
mock_generate_config, lobby_service, participant_id, username, settings
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
user = UserFactory()
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||
{
|
||||
"id": participant_id,
|
||||
"username": username,
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.UNKNOWN,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=user,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
|
||||
@mock.patch("core.services.lobby.LobbyService._create_participant")
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService.enter")
|
||||
def test_request_entry_new_participant(
|
||||
mock_create, mock_notify, lobby_service, participant_id, username
|
||||
mock_enter, lobby_service, participant_id, username
|
||||
):
|
||||
"""A new participant gets a server-minted identifier - any provided
|
||||
one is unknown to the lobby and therefore discarded - and the room is
|
||||
notified of the entry request."""
|
||||
|
||||
user = AnonymousUser()
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=None)
|
||||
|
||||
participant_data = LobbyParticipant(
|
||||
@@ -320,20 +368,14 @@ def test_request_entry_new_participant(
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
mock_create.return_value = participant_data
|
||||
mock_enter.return_value = participant_data
|
||||
|
||||
forged_id = str(uuid.uuid4())
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=forged_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
# The provided identifier was looked up, found unknown, and replaced
|
||||
# by a freshly minted participant
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
|
||||
mock_create.assert_called_once_with(username)
|
||||
mock_notify.assert_called_once_with(str(room.id))
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
||||
@@ -341,7 +383,9 @@ def test_request_entry_waiting_participant(
|
||||
mock_refresh, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
user = AnonymousUser()
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -352,11 +396,10 @@ def test_request_entry_waiting_participant(
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert livekit_config is None
|
||||
@@ -366,122 +409,82 @@ def test_request_entry_waiting_participant(
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_accepted_participant(
|
||||
mock_generate_config, lobby_service, participant_id, username, settings
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
user = AnonymousUser()
|
||||
request = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||
{
|
||||
"id": participant_id,
|
||||
"username": username,
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=user,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_accepted_participant_username_is_bound(
|
||||
mock_generate_config, lobby_service, participant_id, settings
|
||||
):
|
||||
"""An accepted identifier must join under the username the host accepted.
|
||||
|
||||
The participant identifier is a bearer value: a stolen or replayed
|
||||
identifier must not be able to enter the room under a different
|
||||
display name than the one the acceptance decision was made on.
|
||||
"""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
lobby_service._save_participant(
|
||||
room.id,
|
||||
LobbyParticipant(
|
||||
id=participant_id,
|
||||
username="accepted-name",
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, "spoofed-name", participant_id=participant_id
|
||||
)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
assert mock_generate_config.call_args.kwargs["username"] == "accepted-name"
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_participant_with_role(
|
||||
mock_generate_config, lobby_service, participant_id, username, settings
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a participant with a role on the room."""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
user = UserFactory()
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
UserResourceAccessFactory(resource=room, user=user, role="administrator")
|
||||
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||
{
|
||||
"id": participant_id,
|
||||
"username": username,
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=user,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role="administrator",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@@ -492,70 +495,87 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||
mock_cache.touch.assert_called_once_with(
|
||||
"mocked_cache_key", django_settings.LOBBY_WAITING_TIMEOUT
|
||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_create_participant_not_persisted_until_saved(
|
||||
def test_enter_success(
|
||||
mock_index_add,
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""Creation is in-memory; explicitly saving persists and indexes the participant."""
|
||||
"""Test successful participant entry."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant = lobby_service._create_participant(username)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
|
||||
# The identifier is minted server-side
|
||||
uuid.UUID(participant.id)
|
||||
mock_generate_color.assert_called_once_with(participant.id)
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
assert participant.id == participant_id
|
||||
assert participant.color == "#123456"
|
||||
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
mock_cache.set.assert_not_called()
|
||||
mock_index_add.assert_not_called()
|
||||
lobby_service._save_participant(room.id, participant)
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=django_settings.LOBBY_WAITING_TIMEOUT,
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant.id)
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||
def test_enter_with_notification_error(
|
||||
mock_index_add,
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""A notification error must not break the entry request flow."""
|
||||
"""Test participant entry with notification error."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
lobby_service._notify_entry_request("room-id")
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name="room-id", notification_data={"type": "participantWaiting"}
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@@ -607,9 +627,7 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants(
|
||||
mock_cache, lobby_service, participant_dict, settings
|
||||
):
|
||||
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
@@ -631,8 +649,8 @@ def test_list_waiting_participants(
|
||||
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"""Test listing multiple waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
participant1 = {
|
||||
"status": "waiting",
|
||||
@@ -676,7 +694,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service, settings):
|
||||
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
@@ -694,8 +712,8 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service, set
|
||||
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with one valid and one corrupted entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
valid_participant = {
|
||||
"status": "waiting",
|
||||
@@ -735,8 +753,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||
"""Test listing only waiting participants (not accepted/denied)."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
participant1 = {
|
||||
"status": "waiting",
|
||||
@@ -779,7 +797,7 @@ def test_handle_participant_entry_allow(mock_update, lobby_service, participant_
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=django_settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@@ -793,7 +811,7 @@ def test_handle_participant_entry_deny(mock_update, lobby_service, participant_i
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.DENIED,
|
||||
timeout=django_settings.LOBBY_DENIED_TIMEOUT,
|
||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@@ -940,12 +958,12 @@ def test_clear_room_empty(settings, lobby_service):
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
|
||||
|
||||
def test_clear_participant_cache(lobby_service, settings):
|
||||
def test_clear_participant_cache(lobby_service):
|
||||
"""Test clearing a specific participant entry from cache."""
|
||||
room_id = uuid.uuid4()
|
||||
participant_id = "test-participant-id"
|
||||
|
||||
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
participant_data = {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
@@ -968,7 +986,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
room_id = uuid.uuid4()
|
||||
participant_id = "nonexistent-participant"
|
||||
|
||||
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
@@ -976,7 +994,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
|
||||
def test_index_add_members_remove_roundtrip(lobby_service, settings):
|
||||
def test_index_add_members_remove_roundtrip(lobby_service):
|
||||
"""The room index records, lists and forgets participant ids."""
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
@@ -1005,10 +1023,9 @@ def test_enter_registers_participant_in_room_index(
|
||||
"""Entering the lobby must index the participant id for the room."""
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
participant = lobby_service._create_participant(username)
|
||||
lobby_service._save_participant(room_id, participant)
|
||||
lobby_service.enter(room_id, participant_id, username)
|
||||
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant.id])
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||
|
||||
|
||||
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
|
||||
@@ -1057,70 +1074,3 @@ def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant
|
||||
|
||||
assert redis_client.ttl(index_key) > 10
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cached_status",
|
||||
[None, LobbyParticipantStatus.WAITING, LobbyParticipantStatus.ACCEPTED],
|
||||
)
|
||||
def test_bypass_clears_lobby_admission_before_room_becomes_restricted(
|
||||
lobby_service, cached_status
|
||||
):
|
||||
"""Bypass writes no admission; later restricted entry requires approval."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = AnonymousUser()
|
||||
participant_id = None
|
||||
if cached_status is not None:
|
||||
participant_id = str(uuid.uuid4())
|
||||
participant = LobbyParticipant(
|
||||
status=cached_status,
|
||||
username="Guest",
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._save_participant(room.id, participant)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test"}
|
||||
) as generate_config,
|
||||
mock.patch.object(lobby_service, "_notify_entry_request") as notify,
|
||||
mock.patch.object(
|
||||
lobby_service, "_save_participant", wraps=lobby_service._save_participant
|
||||
) as save,
|
||||
mock.patch.object(
|
||||
lobby_service,
|
||||
"clear_participant_cache",
|
||||
wraps=lobby_service.clear_participant_cache,
|
||||
) as clear,
|
||||
):
|
||||
admitted, config = lobby_service.request_entry(
|
||||
room, user, "Guest", participant_id=participant_id
|
||||
)
|
||||
assert admitted.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert config == {"token": "test"}
|
||||
assert lobby_service._get_participant(room.id, admitted.id) is None
|
||||
assert admitted.id not in lobby_service._index_members(room.id)
|
||||
notify.assert_not_called()
|
||||
save.assert_not_called()
|
||||
if cached_status is None:
|
||||
clear.assert_not_called()
|
||||
else:
|
||||
clear.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
room.access_level = RoomAccessLevel.RESTRICTED
|
||||
generate_config.reset_mock()
|
||||
waiting, config = lobby_service.request_entry(
|
||||
room, user, "Guest", participant_id=admitted.id
|
||||
)
|
||||
|
||||
assert waiting.status == LobbyParticipantStatus.WAITING
|
||||
assert config is None
|
||||
generate_config.assert_not_called()
|
||||
notify.assert_called_once_with(str(room.id))
|
||||
save.assert_called_once_with(room.id, waiting)
|
||||
cached = lobby_service._get_participant(room.id, waiting.id)
|
||||
assert cached is not None
|
||||
assert cached.status == LobbyParticipantStatus.WAITING
|
||||
assert waiting.id in lobby_service._index_members(room.id)
|
||||
|
||||
@@ -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,58 +0,0 @@
|
||||
"""
|
||||
Unit tests for the TransitCodeService.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_code_returns_unique_opaque_codes():
|
||||
"""Each created code should be a distinct high-entropy string."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
|
||||
codes = {service.create_code(user, "my-app") for _ in range(5)}
|
||||
|
||||
assert len(codes) == 5
|
||||
for code in codes:
|
||||
assert len(code) >= 43
|
||||
|
||||
|
||||
def test_consume_code_returns_stored_data_once():
|
||||
"""Consuming a code should return its data exactly once."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
|
||||
code = service.create_code(user, client_id="my-app")
|
||||
|
||||
assert service.consume_code(code) == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": "my-app",
|
||||
}
|
||||
# Single use: a second consumption fails
|
||||
assert service.consume_code(code) is None
|
||||
|
||||
|
||||
def test_consume_code_unknown_or_empty():
|
||||
"""Unknown or empty codes should not be consumable."""
|
||||
service = TransitCodeService()
|
||||
|
||||
assert service.consume_code("unknown-code") is None
|
||||
assert service.consume_code("") is None
|
||||
assert service.consume_code(None) is None
|
||||
|
||||
|
||||
@patch("core.services.transit_code.cache.delete", return_value=False)
|
||||
def test_consume_code_returns_none_when_delete_loses_the_race(mock_delete):
|
||||
"""If the code was already deleted by a concurrent request, consumption fails."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
code = service.create_code(user, client_id="my-app")
|
||||
assert service.consume_code(code) is None
|
||||
mock_delete.assert_called_once()
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Tests for the generic send_email task."""
|
||||
|
||||
# pylint: disable=unused-argument,redefined-outer-name
|
||||
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tasks.email import send_email
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_settings(settings):
|
||||
"""Branding settings shared by every email."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.EMAIL_DOMAIN = "acme.com"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
return settings
|
||||
|
||||
|
||||
@mock.patch("core.tasks.email.send_mail")
|
||||
@mock.patch("core.tasks.email.render_to_string", side_effect=["<p>html</p>", "text"])
|
||||
def test_send_email_renders_template_and_sends(
|
||||
mock_render, mock_send_mail, email_settings
|
||||
):
|
||||
"""Both template variants are rendered with base + caller context, then sent."""
|
||||
send_email(
|
||||
template="some_template",
|
||||
subject="Any subject",
|
||||
recipients=["a@test.com", "b@test.com"],
|
||||
context={"foo": "bar", "brandname": "Overridden"},
|
||||
)
|
||||
|
||||
expected_context = {
|
||||
"brandname": "Overridden",
|
||||
"support_email": "support@acme.com",
|
||||
"logo_img": "https://acme.com/logo",
|
||||
"domain": "acme.com",
|
||||
"foo": "bar",
|
||||
}
|
||||
assert mock_render.call_args_list == [
|
||||
mock.call("mail/html/some_template.html", expected_context),
|
||||
mock.call("mail/text/some_template.txt", expected_context),
|
||||
]
|
||||
mock_send_mail.assert_called_once_with(
|
||||
"Any subject",
|
||||
"text",
|
||||
"notifications@acme.com",
|
||||
["a@test.com", "b@test.com"],
|
||||
html_message="<p>html</p>",
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.tasks.email.send_mail")
|
||||
def test_send_email_translates_subject_in_requested_language(
|
||||
mock_send_mail, email_settings
|
||||
):
|
||||
"""The subject msgid is translated in the worker, in the recipient language."""
|
||||
send_email(
|
||||
template="screen_recording",
|
||||
subject=gettext_noop("Your recording is ready"),
|
||||
recipients=["franc@test.com"],
|
||||
context={"room_name": "Room", "link": "https://acme.com/r/1"},
|
||||
language="fr-fr",
|
||||
)
|
||||
|
||||
subject, body, *_ = mock_send_mail.call_args[0]
|
||||
assert subject == "Votre enregistrement est prêt"
|
||||
assert "Votre enregistrement est prêt !" in body
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.tasks.email.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
|
||||
)
|
||||
@mock.patch("core.tasks.email.render_to_string", return_value="content")
|
||||
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
|
||||
"""SMTP errors propagate so Celery can retry the task."""
|
||||
with pytest.raises(smtplib.SMTPException):
|
||||
send_email(
|
||||
template="some_template",
|
||||
subject="Any subject",
|
||||
recipients=["a@test.com"],
|
||||
)
|
||||
@@ -1,244 +0,0 @@
|
||||
"""
|
||||
Tests for user access JWT authentication on the core API.
|
||||
|
||||
The token authenticates the user on the whole API, exactly like a session
|
||||
cookie would (similar to lib-jitsi-meet's token authentication): the
|
||||
existing role-based permissions apply unchanged. Room endpoint coverage
|
||||
with a user access token lives in the room test files.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.models import ApplicationScope, RoleChoices
|
||||
from core.tests.utils import generate_user_access_token
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_user_access_token_users_me():
|
||||
"""A user access token should authenticate the user on /users/me/."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["email"] == user.email
|
||||
|
||||
|
||||
def test_user_access_token_expired():
|
||||
"""An expired user access token should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = generate_user_access_token(
|
||||
user,
|
||||
iat=now - timedelta(hours=3),
|
||||
exp=now - timedelta(hours=1),
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "token expired" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_wrong_token_type():
|
||||
"""A verified token with the wrong 'token_type' claim should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, token_type="addons")
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token type" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_invalid_signature():
|
||||
"""A token signed with the wrong key should defer and end unauthenticated."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=600),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_token",
|
||||
"client_id": "test-app",
|
||||
},
|
||||
"wrong-secret-key-padded-for-minimum-len!",
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# UserAccessJWTAuthentication defers, session auth finds no session
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_missing_client_id_claim():
|
||||
"""A token without the issuance-audit claim should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, client_id=None)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_inactive_user():
|
||||
"""A user access token for an inactive user should be rejected."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_feature_disabled(settings):
|
||||
"""When the feature is disabled, user access tokens should be ignored."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_does_not_break_session_authentication():
|
||||
"""A session-authenticated user should keep full access to the API."""
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
|
||||
|
||||
def test_user_access_token_application_jwt_not_accepted_on_core_api():
|
||||
"""An application-delegation JWT must not authenticate on the core API."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=600),
|
||||
"user_id": str(user.id),
|
||||
"client_id": "some-client",
|
||||
"delegated": True,
|
||||
"scope": "rooms:retrieve",
|
||||
},
|
||||
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# The user token backend must defer (wrong signature) and the request
|
||||
# must end up unauthenticated.
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_application_scope_revoked():
|
||||
"""Revoking the application's grant invalidates its outstanding tokens."""
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
|
||||
token = generate_user_access_token(user, application=application)
|
||||
|
||||
application.scopes = []
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "application access revoked" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_application_deactivated():
|
||||
"""Deactivating the application invalidates its outstanding tokens."""
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
|
||||
token = generate_user_access_token(user, application=application)
|
||||
|
||||
application.is_active = False
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "application access revoked" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_unknown_application():
|
||||
"""A token whose client_id matches no application is refused."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, client_id="not-an-application")
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "application access revoked" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_does_not_override_existing_session():
|
||||
"""A Bearer token must not override the identity of a live session."""
|
||||
session_user = UserFactory()
|
||||
token_user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(session_user)
|
||||
client.credentials(
|
||||
HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(token_user)}"
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["email"] == session_user.email
|
||||
@@ -1,262 +0,0 @@
|
||||
"""
|
||||
Test users API endpoints in the Meet core app: exchange transit code.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
import secrets
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import ApplicationFactory, UserFactory
|
||||
from core.models import ApplicationScope
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def decode_user_access_token(token, settings):
|
||||
"""Decode a user access token with the token secret."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
)
|
||||
|
||||
|
||||
def generate_unknown_code(settings):
|
||||
"""Generate a well-formed code that was never stored."""
|
||||
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Return an anonymous API client with a random source IP.
|
||||
|
||||
A fresh IP per test isolates the anonymous throttle history, both
|
||||
between the tests of this module and between test runs.
|
||||
"""
|
||||
# `secrets` rather than `random`: the global random module is seeded
|
||||
# deterministically by the factories, its sequence repeats across runs.
|
||||
remote_addr = (
|
||||
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
|
||||
f".{secrets.randbelow(254) + 1}"
|
||||
)
|
||||
return APIClient(REMOTE_ADDR=remote_addr)
|
||||
|
||||
|
||||
def test_exchange_access_token_missing_code(client):
|
||||
"""The exchange endpoint should validate its input."""
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "code" in response.data
|
||||
|
||||
|
||||
def test_exchange_access_token_get_method(client):
|
||||
"""The exchange endpoint should not accept GET."""
|
||||
|
||||
response = client.get("/api/v1.0/users/exchange-access-token/")
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_exchange_access_token_malformed_code(client):
|
||||
"""A code whose length cannot match a generated one should be a 400."""
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": "not-a-valid-code"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invalid transit code format" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_unknown_code(client, settings):
|
||||
"""A well-formed but unknown code should be denied."""
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "invalid, expired or already used" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_success(client, settings):
|
||||
"""A valid transit code should be exchangeable for an access token."""
|
||||
user = UserFactory()
|
||||
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
|
||||
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
|
||||
assert response.data["scope"] == "user:access"
|
||||
|
||||
payload = decode_user_access_token(response.data["access_token"], settings)
|
||||
assert payload["user_id"] == str(user.id)
|
||||
assert payload["client_id"] == application.client_id
|
||||
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
|
||||
|
||||
|
||||
def test_exchange_access_token_single_use(client):
|
||||
"""A transit code should be exchangeable exactly once."""
|
||||
user = UserFactory()
|
||||
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Replaying the same code must be denied
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 403
|
||||
assert "invalid, expired or already used" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_inactive_user(client):
|
||||
"""A code minted for a now-inactive user should be denied."""
|
||||
user = UserFactory()
|
||||
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||
|
||||
user.is_active = False
|
||||
user.save()
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "no longer access" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_feature_disabled(client, settings):
|
||||
"""The exchange endpoint should return 404 when the feature is disabled."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_exchange_access_token_throttled(client, settings):
|
||||
"""Anonymous exchange attempts should be rate limited."""
|
||||
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
|
||||
initial_rate = throttle_rates["exchange_access_token"]
|
||||
# The rates dict is mutated in place: restore it explicitly, the
|
||||
# `settings` fixture only rolls back attribute assignments.
|
||||
throttle_rates["exchange_access_token"] = "2/minute"
|
||||
|
||||
try:
|
||||
for _ in range(2):
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
finally:
|
||||
throttle_rates["exchange_access_token"] = initial_rate
|
||||
|
||||
|
||||
def test_exchange_access_token_refused_when_already_authenticated(client):
|
||||
"""A session-authenticated browser must not exchange a transit code."""
|
||||
user = UserFactory()
|
||||
session_user = UserFactory()
|
||||
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||
|
||||
client.force_login(session_user)
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "already authenticated" in str(response.data).lower()
|
||||
|
||||
# The code was not consumed: it stays valid for its intended,
|
||||
# cookieless embedded context.
|
||||
client.logout()
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_exchange_access_token_application_scope_revoked(client):
|
||||
"""A code is refused once the application's grant is revoked."""
|
||||
user = UserFactory()
|
||||
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||
|
||||
application.scopes = []
|
||||
application.save()
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "no longer create user sessions" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_application_deactivated(client):
|
||||
"""A code is refused once the application is disabled."""
|
||||
user = UserFactory()
|
||||
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||
|
||||
application.is_active = False
|
||||
application.save()
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "no longer create user sessions" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_unknown_application(client):
|
||||
"""A code whose client_id matches no application is refused."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user, client_id="not-an-application")
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "no longer create user sessions" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_end_to_end(client):
|
||||
"""A token obtained from the exchange must authenticate on the core API.
|
||||
|
||||
Regression test: token issuance and token validation must stay in
|
||||
sync on the claims they set and require (e.g. 'token_type').
|
||||
"""
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 200
|
||||
|
||||
api_client = APIClient()
|
||||
api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {response.data['access_token']}")
|
||||
me = api_client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert me.status_code == 200
|
||||
assert me.data["email"] == user.email
|
||||
@@ -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/",
|
||||
@@ -87,73 +91,21 @@ def test_api_applications_generate_token_success(settings):
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"restricted", [True, False], ids=["restricted", "unrestricted"]
|
||||
)
|
||||
def test_api_applications_generate_token_success_query_count(
|
||||
restricted, django_assert_num_queries
|
||||
):
|
||||
"""An existing user needs one query each for application, domains, and user."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret)
|
||||
if restricted:
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
client = APIClient()
|
||||
with django_assert_num_queries(3):
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.data
|
||||
|
||||
|
||||
def test_api_applications_generate_token_invalid_credentials_query_count(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""An invalid secret must be rejected before querying domains or users."""
|
||||
application = ApplicationFactory(client_secret="test-secret-123")
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
client = APIClient()
|
||||
with django_assert_num_queries(1):
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": "wrong-secret",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "user@example.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_form_urlencoded(settings):
|
||||
"""The token endpoint should accept "application/x-www-form-urlencoded"
|
||||
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
|
||||
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/",
|
||||
@@ -220,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(
|
||||
@@ -245,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/",
|
||||
@@ -324,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(
|
||||
@@ -413,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(
|
||||
@@ -435,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/",
|
||||
@@ -458,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/",
|
||||
@@ -484,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(
|
||||
@@ -508,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/",
|
||||
@@ -559,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/",
|
||||
@@ -615,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/",
|
||||
@@ -666,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"
|
||||
|
||||
@@ -719,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(
|
||||
@@ -740,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)
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"""
|
||||
Tests for external API /users endpoints (transit codes)
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import ApplicationFactory, UserFactory
|
||||
from core.models import ApplicationScope
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_addons_test_token(user, scopes):
|
||||
"""Generate a valid JWT token signed with the addons secret for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": django_settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.ADDONS_TOKEN_TTL),
|
||||
"scope": " ".join(scopes),
|
||||
"user_id": str(user.id),
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def generate_test_token(user, scopes, application=None):
|
||||
"""Generate a valid application JWT token for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
if application is None:
|
||||
application = ApplicationFactory(scopes=scopes)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now
|
||||
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_users_transit_code_requires_authentication():
|
||||
"""Minting a transit code without authentication should return 401."""
|
||||
client = APIClient()
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_users_transit_code_missing_scope():
|
||||
"""A token without the 'users:session' scope should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "users:session" in str(response.data)
|
||||
|
||||
|
||||
def test_api_users_transit_code_success(settings):
|
||||
"""A delegated user with the scope should be able to mint a transit code."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
|
||||
|
||||
code = response.data["transit_code"]
|
||||
# Opaque, high-entropy random string
|
||||
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||
|
||||
# The code is stored server-side and references the delegated user
|
||||
code_data = TransitCodeService().consume_code(code)
|
||||
assert code_data == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": mock.ANY,
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_transit_code_scope_claim_exceeding_db_grant():
|
||||
"""A 'users:session' claim beyond the grant recorded in database is refused."""
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
token = generate_test_token(
|
||||
user, [ApplicationScope.USERS_SESSION], application=application
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "not granted" in str(response.data)
|
||||
|
||||
|
||||
def test_api_users_transit_code_get_forbidden():
|
||||
"""Minting a transit code with a GET should not be allowed."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_api_users_transit_code_resource_server_not_supported():
|
||||
"""A resource server token must not be able to mint a transit code."""
|
||||
user = UserFactory()
|
||||
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication,
|
||||
"authenticate",
|
||||
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
|
||||
) as mock_rs_authenticate:
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_not_called()
|
||||
|
||||
|
||||
def test_api_users_transit_code_feature_disabled(settings):
|
||||
"""Minting a transit code should return 404 when the feature is disabled."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_users_transit_code_inactive_user():
|
||||
"""An inactive user should not be able to mint a transit code."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_users_transit_code_rejects_addons_token():
|
||||
"""An addons token must not be able to mint a transit code.
|
||||
|
||||
The token carries the 'users:session' scope and is signed with the addons
|
||||
secret, so only the missing backend stands between it and a transit code.
|
||||
"""
|
||||
user = UserFactory()
|
||||
token = generate_addons_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication, "authenticate", return_value=None
|
||||
):
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth", [None, {}, {"client_id": ""}, {"client_id": None}])
|
||||
def test_api_users_transit_code_missing_client_id(auth):
|
||||
"""No transit code should be minted without a client_id in the token."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer token")
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"core.external_api.authentication.ApplicationJWTAuthentication.authenticate",
|
||||
return_value=(user, auth),
|
||||
),
|
||||
mock.patch(
|
||||
"core.external_api.permissions.HasRequiredUserScope.has_permission",
|
||||
return_value=True,
|
||||
),
|
||||
mock.patch.object(
|
||||
TransitCodeService, "create_code", return_value="code"
|
||||
) as mock_create_code,
|
||||
):
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Invalid application token."}
|
||||
mock_create_code.assert_not_called()
|
||||
@@ -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():
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Shared helpers for tests in the Meet core application"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import jwt
|
||||
|
||||
from core.factories import ApplicationFactory
|
||||
from core.models import ApplicationScope
|
||||
|
||||
|
||||
def generate_user_access_token(user, application=None, **overrides):
|
||||
"""Generate a valid user access JWT signed with the token secret.
|
||||
|
||||
Claims can be overridden through keyword arguments; passing None for a
|
||||
claim removes it from the payload.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if application is None:
|
||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||
|
||||
payload = {
|
||||
"iss": settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": settings.USER_ACCESS_TOKEN_TYPE_CLAIM,
|
||||
"client_id": application.client_id,
|
||||
"scope": "user:access",
|
||||
}
|
||||
payload.update(overrides)
|
||||
payload = {key: value for key, value in payload.items() if value is not None}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
@@ -48,11 +48,6 @@ external_router.register(
|
||||
external_viewsets.RoomViewSet,
|
||||
basename="external_room",
|
||||
)
|
||||
external_router.register(
|
||||
"users",
|
||||
external_viewsets.UserViewSet,
|
||||
basename="external_user",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -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",
|
||||
@@ -370,7 +373,6 @@ class Base(Configuration):
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"core.authentication.backends.SessionAuthenticationWith401",
|
||||
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
@@ -384,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",
|
||||
@@ -400,11 +409,6 @@ class Base(Configuration):
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"exchange_access_token": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"creation_callback": values.Value(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
@@ -986,6 +990,11 @@ class Base(Configuration):
|
||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LOBBY_COOKIE_NAME = values.Value(
|
||||
"lobbyParticipantId",
|
||||
environ_name="LOBBY_COOKIE_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
@@ -1071,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,
|
||||
)
|
||||
@@ -1108,66 +1117,6 @@ class Base(Configuration):
|
||||
environ_name="APPLICATION_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# User access tokens (embedded frontend / iframe support)
|
||||
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
|
||||
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
|
||||
)
|
||||
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
|
||||
)
|
||||
USER_ACCESS_TOKEN_ALG = values.Value(
|
||||
"HS256",
|
||||
environ_name="USER_ACCESS_TOKEN_ALG",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_ISSUER = values.Value(
|
||||
"lasuite-meet",
|
||||
environ_name="USER_ACCESS_TOKEN_ISSUER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
|
||||
None,
|
||||
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Lifetime of the user access token obtained through the exchange
|
||||
# endpoint. It never transits through a URL, so it can cover a full
|
||||
# meeting (default: 2 hours).
|
||||
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||
7200,
|
||||
environ_name="USER_ACCESS_TOKEN_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Lifetime of the single-use transit code handed to the frontend
|
||||
# through a URL fragment. Kept very short by design: it must only
|
||||
# survive the redirect and the exchange call.
|
||||
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
|
||||
60,
|
||||
environ_name="TRANSIT_CODE_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
TRANSIT_CODE_CACHE_PREFIX = values.Value(
|
||||
"transit-code",
|
||||
environ_name="TRANSIT_CODE_CACHE_PREFIX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
|
||||
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
|
||||
48,
|
||||
environ_name="TRANSIT_CODE_NBYTES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_TYPE = values.Value(
|
||||
"Bearer",
|
||||
environ_name="USER_ACCESS_TOKEN_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_TYPE_CLAIM = values.Value(
|
||||
"user_token",
|
||||
environ_name="USER_ACCESS_TOKEN_TYPE_CLAIM",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||
@@ -1271,6 +1220,31 @@ 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
|
||||
)
|
||||
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",
|
||||
@@ -1288,6 +1262,9 @@ class Base(Configuration):
|
||||
"format": "{asctime} {name} {levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"audit_json": {
|
||||
"()": "core.audit.formatter.AuditJsonFormatter",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"silence_expected_401": {
|
||||
@@ -1300,6 +1277,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": {
|
||||
@@ -1332,6 +1314,11 @@ class Base(Configuration):
|
||||
),
|
||||
"propagate": False,
|
||||
},
|
||||
"audit": {
|
||||
"handlers": ["audit_console"],
|
||||
"level": AUDIT_LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1445,6 +1432,18 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _check_audit_log_data_stream_namespace(cls):
|
||||
"""Ensure the audit data stream namespace contains no ``-``.
|
||||
|
||||
ECS splits data stream names ``<type>-<dataset>-<namespace>`` on -
|
||||
"""
|
||||
if "-" in cls.AUDIT_LOG_DATA_STREAM_NAMESPACE:
|
||||
raise ValueError(
|
||||
"AUDIT_LOG_DATA_STREAM_NAMESPACE "
|
||||
f"'{cls.AUDIT_LOG_DATA_STREAM_NAMESPACE}' must not contain '-'."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def post_setup(cls):
|
||||
"""Post setup configuration.
|
||||
@@ -1459,6 +1458,7 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
cls._check_recording_encoding_maps()
|
||||
cls._check_audit_log_data_stream_namespace()
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
@@ -1493,20 +1493,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(
|
||||
@@ -1526,6 +1512,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):
|
||||
@@ -1577,22 +1565,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
|
||||
@@ -1613,9 +1615,6 @@ class Test(Base):
|
||||
ADDONS_ENABLED = True
|
||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
USER_ACCESS_TOKEN_ENABLED = True
|
||||
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
|
||||
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
|
||||
|
||||
CONNECTION_TEST_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" },
|
||||
|
||||
@@ -49,7 +49,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
|
||||
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",
|
||||
|
||||
+17
-24
@@ -12,7 +12,6 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||
|
||||
@@ -25,29 +24,23 @@ function App() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TransitCodeGate>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
component={route.Component}
|
||||
/>
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</TransitCodeGate>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import { apiUrl } from './apiUrl'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
export const fetchApi = async <T = Record<string, unknown>>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> => {
|
||||
const csrfToken = getCsrfToken()
|
||||
// Embedded (iframe) mode: the user access token obtained through the
|
||||
// transit code exchange authenticates requests in place of the session
|
||||
// cookie, which is blocked in third-party contexts.
|
||||
const accessToken = getAccessToken()
|
||||
const response = await fetch(apiUrl(url), {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { setAccessToken } from '@/stores/accessToken'
|
||||
import {
|
||||
consumeTransitCodeFromFragment,
|
||||
isEmbedded,
|
||||
} from '../utils/transitCode'
|
||||
|
||||
type ApiAccessToken = {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
scope: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a single-use transit code for a user access token.
|
||||
*
|
||||
* The endpoint is unauthenticated: the code itself is the credential.
|
||||
*/
|
||||
export const exchangeAccessToken = (code: string): Promise<ApiAccessToken> => {
|
||||
return fetchApi<ApiAccessToken>('/users/exchange-access-token/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
|
||||
const runInitialization = async (): Promise<void> => {
|
||||
const code = consumeTransitCodeFromFragment()
|
||||
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmbedded()) {
|
||||
console.warn('Transit code ignored outside an embedded context')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { access_token } = await exchangeAccessToken(code)
|
||||
setAccessToken(access_token)
|
||||
} catch (error) {
|
||||
console.warn('Transit code exchange failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
let initialization: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Bootstrap the embedded (iframe) authentication, if applicable.
|
||||
*
|
||||
* When, and only when, a transit code is present in the URL fragment,
|
||||
* exchange it for a user access token and keep it in the in-memory
|
||||
* accessToken store: fetchApi then sends it as a Bearer header on every
|
||||
* api call, authenticating the user exactly like a session cookie would.
|
||||
*
|
||||
* Must complete before anything fires an authenticated query, which the
|
||||
* TransitCodeGate component guarantees by gating the app tree on it.
|
||||
*
|
||||
* Memoized: the fragment is consumed and the code exchanged exactly once,
|
||||
* however many times this is called (StrictMode double-invoked effects,
|
||||
* among others). Subsequent calls await the same promise.
|
||||
*
|
||||
* A failed exchange (expired or already used code) is not fatal: the app
|
||||
* starts unauthenticated, falling back to the regular session flow.
|
||||
*/
|
||||
export const initializeAccessTokenFromFragment = (): Promise<void> => {
|
||||
if (!initialization) {
|
||||
initialization = runInitialization()
|
||||
}
|
||||
return initialization
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user