Compare commits

..

6 Commits

Author SHA1 Message Date
briquet 284902c788 fixup! 🔧(compose) share the backend redis with the summary stack 2026-10-09 18:13:07 +02:00
briquet 547c371dba ✨(backend) audit recordings, transcripts and summaries
Emit audit events across the recording lifecycle: recording.start,
recording.stop and recording.delete from the API, recording.end when
LiveKit reports the egress ended, recording.transcript.request when the
backend sends a recording to the summary service, and
recording.transcript.report and recording.summary.report when that
service calls back.

recording.end has an unknown outcome when LiveKit reports an egress
status the backend does not map, and recording.transcript.request names
the summary service as service.target.

Small bugfix: AuditViewMixin keeps a copy of a deleted target, since deleting an
instance clears its primary key.
2026-10-09 17:58:09 +02:00
briquet 7fad537aad 🔒️(backend) audit writes made through the Django admin
Every ModelAdmin now emits an ECS audit event when an object is created,
changed or deleted, and when a bulk action runs. Actions are templated
as admin.<target>.<verb>, after the model name. A signed-in account
without staff access reaching the admin is recorded as a denied
admin.access.

The wiring is a custom AdminSite installed through AdminConfig.default_site:
it mixes the auditing into every admin class at registration, including the
ones Django declares, so a new ModelAdmin is covered without doing anything.
It hangs off log_addition, log_change, log_deletions and get_actions, which
Django calls in every path, rather than off save_model. Deletions noted by
log_deletions are emitted from delete_model and delete_queryset, once their
outcome is known.

Changed field names are always reported; their values only for the
admin_values each model is registered with, and never for anything that
looks like a secret. An account acted upon is reported as user.target:
the target itself when it is a user, else the account its model
registers as user_target, as the user an access grants a role to. The
emitter derives it for every event, so user.provision names the user it
creates as well.
2026-10-09 17:57:58 +02:00
briquet 5f1eced645 📝(docs) document audit logging
Describe the audit event shape, the catalogue, how views and other code
emit events, the environment variables configuring them, how the
project registers its actions, models and authentication classes.
2026-10-09 17:57:32 +02:00
briquet 12f58dfc09 ✨(backend) audit external API token and room operations
Emit audit events on the most exposed authentication surface: every
request to the client-credentials token endpoint, issued or refused, as
application.token.issue + provisional user creation + room create,
update, retrieve and list through the delegated rooms API.

Events identify the application by client id, once its credentials are
verified, and the delegated user by id, OIDC sub and email domain
2026-10-09 17:57:07 +02:00
briquet 3af2f2f774 ✨(backend) add structured audit logging facility
Add a core.audit package emitting one Elastic Common Schema JSON line per
security-relevant action on a dedicated "audit" logger. Only lasuite.*
and entity.target.raw.* extends on ECS core fields. Each event names
the data stream it belongs to (data_stream.*, event.dataset) and carries
a unique event.id.

The audit logs reads the actor, auth method, tenant and network fields from
the request it is given, else from the request being served, which
AuditLogMiddleware keeps in a context variable so that a service needs
no request argument.

A person is identified by primary key, OIDC sub when the account has one
and email domain, with its privileges as user.roles; an internal peer
calling the backend by service.origin.name. The tenant is the
application client id, else that domain. The client IP is DRF's throttle
ident, which now honours a NUM_PROXIES setting.

The middleware also replaces the inbound X-Request-ID with a fresh id,
unless REQUEST_ID_TRUST_HEADER says the ingress overwrites it, and echoes
it in the response and the gunicorn access log. Audit events carry it as
http.request.id, next to the client's user agent.

An action is defined with a dotted name with its default ECS category
and types, validated at import against the types ECS expects in that
category, so a call site only names what was attempted. A category or
types given to audit.log win over them, and a bare dotted name is
accepted too.

 Meet declares all of the events core/auditing.py:
- audit.register lists the fields describing a model as a target, an
  ECS entity.target, and its ECS entity type. Every target carries its
  model name and primary key, so an unregistered one is still
  identified, and a user target its OIDC sub.
- audit.register_auth_method names the lasuite.auth.method of a DRF
  authentication class

AuditViewMixin audits every response of the CRUD actions a view maps in
audit_actions, and of the extra actions naming theirs with
@action(audit_action=...), from finalize_response: the outcome, reason
and status come from the response. An exception DRF does not handle is recorded
as an internal error before it propagates. The core.audit app also
records Django's login, failed login and logout signals.
2026-10-09 17:56:59 +02:00
126 changed files with 6758 additions and 4372 deletions
+6 -21
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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"
)
-31
View File
@@ -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
+304
View File
@@ -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`.
+3 -4
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.34.0"
version = "1.33.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
-5
View File
@@ -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):
-1
View File
@@ -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
-38
View File
@@ -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
+11 -27
View File
@@ -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"
+39 -105
View File
@@ -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(
+34
View File
@@ -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",
]
+30
View File
@@ -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
+187
View File
@@ -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,
}
+361
View File
@@ -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
+29
View File
@@ -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"
+187
View File
@@ -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
+130
View File
@@ -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},
}
+231
View File
@@ -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
+79
View File
@@ -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"
+39
View File
@@ -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=(",", ":")
)
+116
View File
@@ -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)
+114
View File
@@ -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
+81
View File
@@ -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")
+89
View File
@@ -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
+62
View File
@@ -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))
+52
View File
@@ -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
+109
View File
@@ -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")
+2 -9
View File
@@ -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.
+27 -95
View File
@@ -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."""
+59 -87
View File
@@ -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,
)
-10
View File
@@ -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(
-46
View File
@@ -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
View File
@@ -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(
+28 -2
View File
@@ -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."""
+62 -48
View File
@@ -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
-74
View File
@@ -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
-2
View File
@@ -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",
)
-74
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
"""Tests for the audit logging facility."""
+447
View File
@@ -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") == []
+371
View File
@@ -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 == []
+171
View File
@@ -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
+23
View File
@@ -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"]
+187 -237
View File
@@ -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)
+260 -278
View File
@@ -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():
-41
View File
@@ -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,
)
-5
View File
@@ -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(
+85 -86
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1297,7 +1297,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.34.0"
version = "1.33.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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>
)
}
-6
View File
@@ -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